diff --git a/hub/internal/store/host_delete_test.go b/hub/internal/store/host_delete_test.go index c30b4c0..00446b2 100644 --- a/hub/internal/store/host_delete_test.go +++ b/hub/internal/store/host_delete_test.go @@ -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") diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index a65b64e..815fec5 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -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