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
+83
View File
@@ -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")