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:
2026-07-13 14:44:42 +02:00
parent 74fa61c7bc
commit 2321077800
2 changed files with 168 additions and 8 deletions
+85 -8
View File
@@ -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