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) {
|
func TestCountHostArtifacts(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
seedHostWithArtifacts(t, s, "impact-host", "cust-e")
|
seedHostWithArtifacts(t, s, "impact-host", "cust-e")
|
||||||
|
|||||||
@@ -555,6 +555,27 @@ func (s *Store) migrate() error {
|
|||||||
return err
|
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
|
// 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).
|
// it touches (hosts, customer_configs) exists on a fresh DB too (where it finds nothing).
|
||||||
if drTierAlterErr == nil {
|
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
|
// 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
|
// 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.
|
// (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 {
|
func (s *Store) DeleteHost(hostID string, deleteEscrow bool) error {
|
||||||
if hostID == "" {
|
if hostID == "" {
|
||||||
return fmt.Errorf("DeleteHost: empty host_id")
|
return fmt.Errorf("DeleteHost: empty host_id")
|
||||||
}
|
}
|
||||||
if !deleteEscrow {
|
var escrowPresent int
|
||||||
var n int
|
if err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM host_escrow WHERE host_id = ?)`, hostID).Scan(&escrowPresent); err != nil {
|
||||||
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)
|
||||||
return fmt.Errorf("DeleteHost %s: escrow check: %w", hostID, err)
|
}
|
||||||
}
|
if !deleteEscrow && escrowPresent != 0 {
|
||||||
if n != 0 {
|
return ErrHostEscrowPresent
|
||||||
return ErrHostEscrowPresent
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
@@ -1913,6 +1937,25 @@ func (s *Store) DeleteHost(hostID string, deleteEscrow bool) error {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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{
|
stmts := []string{
|
||||||
`DELETE FROM guests WHERE host_id = ?`,
|
`DELETE FROM guests WHERE host_id = ?`,
|
||||||
`DELETE FROM host_reports 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()
|
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
|
// 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
|
// 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
|
// NOT touch the reality columns (agent_version/last_report_at) or the inert intent
|
||||||
|
|||||||
Reference in New Issue
Block a user