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:
@@ -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