hub: F-14 part 1 — host-deletion provenance (host_deletions, in-tx)
Every DeleteHost writes a provenance row INSIDE the cascade tx: host_id,
customer_id, deleted_at, escrow_acked. escrow_acked = ack given over a
PRESENT escrow row (acknowledged destruction, not a vacuous checkbox).
LatestHostDeletion(customer) serves the F-14 gate — newest record only,
so an old acked deletion never whitelists a newer un-acked one. No
backfill by design: pre-record deletions stay on the manual path.
Red-proof: dropping the in-tx INSERT fails TestDeleteHost_ProvenanceRecord
("no deletion record written") + the part-2 scenario-A test.
This commit is contained in:
@@ -174,6 +174,89 @@ func TestDeleteHost_EscrowFlagSemantics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.53.0 F-14 provenance — the deletion record is written IN the delete tx, with
|
||||
// escrow_acked reflecting an ACTUAL acknowledged destruction (ack over a present escrow).
|
||||
// RED-PROOF (Part 1): dropping the provenance INSERT from DeleteHost fails the acked case
|
||||
// (LatestHostDeletion returns nil — the gate finds nothing).
|
||||
func TestDeleteHost_ProvenanceRecord(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
// Escrow-ack delete → record with escrow_acked = true.
|
||||
seedHostWithArtifacts(t, s, "prov-acked", "cust-f14")
|
||||
if err := s.DeleteHost("prov-acked", true); err != nil {
|
||||
t.Fatalf("DeleteHost: %v", err)
|
||||
}
|
||||
rec, err := s.LatestHostDeletion("cust-f14")
|
||||
if err != nil {
|
||||
t.Fatalf("LatestHostDeletion: %v", err)
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatal("no deletion record written by the escrow-ack delete")
|
||||
}
|
||||
if rec.HostID != "prov-acked" || rec.CustomerID != "cust-f14" || !rec.EscrowAcked {
|
||||
t.Errorf("record = %+v, want host=prov-acked customer=cust-f14 escrow_acked=true", rec)
|
||||
}
|
||||
if rec.DeletedAt.IsZero() {
|
||||
t.Error("deleted_at not populated")
|
||||
}
|
||||
|
||||
// Delete WITHOUT escrow (none present) → record exists but escrow_acked = false, even
|
||||
// though deleteEscrow=true was passed: ticking the box over NOTHING is not an
|
||||
// acknowledged destruction.
|
||||
if err := s.UpsertHost(&Host{HostID: "prov-noescrow", CustomerID: "cust-noesc", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DeleteHost("prov-noescrow", true); err != nil {
|
||||
t.Fatalf("DeleteHost: %v", err)
|
||||
}
|
||||
rec, err = s.LatestHostDeletion("cust-noesc")
|
||||
if err != nil || rec == nil {
|
||||
t.Fatalf("LatestHostDeletion = %+v, %v; want a record", rec, err)
|
||||
}
|
||||
if rec.EscrowAcked {
|
||||
t.Error("escrow_acked = true for a host with NO escrow row — vacuous ack must record false")
|
||||
}
|
||||
|
||||
// The refused delete (escrow present, no ack) writes NOTHING — the tx never ran.
|
||||
seedHostWithArtifacts(t, s, "prov-refused", "cust-refused")
|
||||
if err := s.DeleteHost("prov-refused", false); !errors.Is(err, ErrHostEscrowPresent) {
|
||||
t.Fatalf("expected escrow refusal, got %v", err)
|
||||
}
|
||||
if rec, _ := s.LatestHostDeletion("cust-refused"); rec != nil {
|
||||
t.Errorf("refused delete wrote a provenance record: %+v", rec)
|
||||
}
|
||||
|
||||
// Customer with no deletions ever → nil, nil (the pre-v0.53.0 shape — manual path).
|
||||
if rec, err := s.LatestHostDeletion("cust-never"); err != nil || rec != nil {
|
||||
t.Errorf("LatestHostDeletion(no deletions) = %+v, %v; want nil, nil", rec, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The gate reads the MOST RECENT record: an old acked deletion must not whitelist a newer
|
||||
// un-acked one (the F-14 law is about the deletion that orphaned the CURRENT tenancy).
|
||||
func TestLatestHostDeletion_NewestWins(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
seedHostWithArtifacts(t, s, "gen1-host", "cust-seq")
|
||||
if err := s.DeleteHost("gen1-host", true); err != nil { // acked
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.UpsertHost(&Host{HostID: "gen2-host", CustomerID: "cust-seq", APIKey: "k2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DeleteHost("gen2-host", false); err != nil { // no escrow → un-acked record
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, err := s.LatestHostDeletion("cust-seq")
|
||||
if err != nil || rec == nil {
|
||||
t.Fatalf("LatestHostDeletion = %+v, %v", rec, err)
|
||||
}
|
||||
if rec.HostID != "gen2-host" || rec.EscrowAcked {
|
||||
t.Errorf("latest record = %+v, want the NEWER un-acked gen2-host row", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHostArtifacts(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedHostWithArtifacts(t, s, "impact-host", "cust-e")
|
||||
|
||||
@@ -555,6 +555,27 @@ func (s *Store) migrate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// v0.53.0 — host-deletion provenance (F-14, operator ruling 2026-07-13): one row per DeleteHost,
|
||||
// written INSIDE the delete transaction. escrow_acked records whether the host was removed
|
||||
// through the escrow-ack flow (the operator explicitly acknowledged destroying a PRESENT escrow
|
||||
// row — acknowledged key destruction). The PBS-DR enable path may auto-re-issue a surviving ep0
|
||||
// tenancy ONLY when the customer's most recent record here has escrow_acked=1; no record (all
|
||||
// pre-v0.53.0 deletions — deliberately NO backfill) or an un-acked record keeps the manual
|
||||
// re-issue path the only one (never-silently-re-key law).
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS host_deletions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id TEXT NOT NULL,
|
||||
customer_id TEXT NOT NULL,
|
||||
deleted_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
escrow_acked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_host_deletions_customer ON host_deletions(customer_id, id DESC);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// v0.51.0 dr_tier one-time legacy backfill — see the ALTER above; runs last so every table
|
||||
// it touches (hosts, customer_configs) exists on a fresh DB too (where it finds nothing).
|
||||
if drTierAlterErr == nil {
|
||||
@@ -1893,18 +1914,21 @@ func (s *Store) CountHostArtifacts(hostID string) (HostArtifacts, error) {
|
||||
// reconciler's 5-minute declarative full-list push converges the endpoint after the row
|
||||
// disappears — no bump, no reconciler change. log_bundle rows die by scope_id == host_id
|
||||
// (agent channel); customer-scoped bundles (scope_id == customer_id) are NOT touched.
|
||||
//
|
||||
// v0.53.0 (F-14 provenance): every delete also writes a host_deletions row IN THE SAME tx.
|
||||
// escrow_acked = deleteEscrow AND an escrow row was actually present — "removed through the
|
||||
// escrow-ack flow" means an acknowledged destruction happened, not merely that the checkbox
|
||||
// was ticked over nothing.
|
||||
func (s *Store) DeleteHost(hostID string, deleteEscrow bool) error {
|
||||
if hostID == "" {
|
||||
return fmt.Errorf("DeleteHost: empty host_id")
|
||||
}
|
||||
if !deleteEscrow {
|
||||
var n int
|
||||
if err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM host_escrow WHERE host_id = ?)`, hostID).Scan(&n); err != nil {
|
||||
return fmt.Errorf("DeleteHost %s: escrow check: %w", hostID, err)
|
||||
}
|
||||
if n != 0 {
|
||||
return ErrHostEscrowPresent
|
||||
}
|
||||
var escrowPresent int
|
||||
if err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM host_escrow WHERE host_id = ?)`, hostID).Scan(&escrowPresent); err != nil {
|
||||
return fmt.Errorf("DeleteHost %s: escrow check: %w", hostID, err)
|
||||
}
|
||||
if !deleteEscrow && escrowPresent != 0 {
|
||||
return ErrHostEscrowPresent
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
@@ -1913,6 +1937,25 @@ func (s *Store) DeleteHost(hostID string, deleteEscrow bool) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Provenance first (reads the host row this tx is about to delete). A host_id that has no
|
||||
// row deletes nothing anyway — skip the record rather than inventing an empty customer_id.
|
||||
var customerID string
|
||||
switch err := tx.QueryRow(`SELECT customer_id FROM hosts WHERE host_id = ?`, hostID).Scan(&customerID); err {
|
||||
case nil:
|
||||
acked := 0
|
||||
if deleteEscrow && escrowPresent != 0 {
|
||||
acked = 1
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO host_deletions (host_id, customer_id, escrow_acked) VALUES (?, ?, ?)`,
|
||||
hostID, customerID, acked); err != nil {
|
||||
return fmt.Errorf("DeleteHost %s: provenance record: %w", hostID, err)
|
||||
}
|
||||
case sql.ErrNoRows:
|
||||
// no host row — fall through, the deletes below are no-ops
|
||||
default:
|
||||
return fmt.Errorf("DeleteHost %s: customer lookup: %w", hostID, err)
|
||||
}
|
||||
|
||||
stmts := []string{
|
||||
`DELETE FROM guests WHERE host_id = ?`,
|
||||
`DELETE FROM host_reports WHERE host_id = ?`,
|
||||
@@ -1935,6 +1978,40 @@ func (s *Store) DeleteHost(hostID string, deleteEscrow bool) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// HostDeletion is one host-removal provenance record (v0.53.0, F-14). EscrowAcked means the
|
||||
// operator removed the host through the escrow-ack flow — an acknowledged destruction of the
|
||||
// host's key custody, the ONLY state that permits the PBS-DR auto-re-issue.
|
||||
type HostDeletion struct {
|
||||
HostID string
|
||||
CustomerID string
|
||||
DeletedAt time.Time
|
||||
EscrowAcked bool
|
||||
}
|
||||
|
||||
// LatestHostDeletion returns the customer's MOST RECENT host-deletion record (nil when the
|
||||
// customer has none — every pre-v0.53.0 deletion, by design: no backfill invents provenance).
|
||||
// The latest record is the one that orphaned a surviving ep0 tenancy, so the F-14 gate reads
|
||||
// exactly this row — an older acked record must not whitelist a newer un-acked deletion.
|
||||
func (s *Store) LatestHostDeletion(customerID string) (*HostDeletion, error) {
|
||||
var d HostDeletion
|
||||
var deletedAt string
|
||||
var acked int
|
||||
err := s.db.QueryRow(`
|
||||
SELECT host_id, customer_id, deleted_at, escrow_acked
|
||||
FROM host_deletions WHERE customer_id = ?
|
||||
ORDER BY id DESC LIMIT 1`, customerID,
|
||||
).Scan(&d.HostID, &d.CustomerID, &deletedAt, &acked)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.DeletedAt = parseSQLiteTime(deletedAt)
|
||||
d.EscrowAcked = acked != 0
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// UpsertHost creates or updates a host identity (used by the admin mint). On
|
||||
// conflict it updates only operator-settable identity fields + updated_at; it does
|
||||
// NOT touch the reality columns (agent_version/last_report_at) or the inert intent
|
||||
|
||||
Reference in New Issue
Block a user