diff --git a/hub/internal/api/pbsdr_test.go b/hub/internal/api/pbsdr_test.go index e7ef0f5..b4f062e 100644 --- a/hub/internal/api/pbsdr_test.go +++ b/hub/internal/api/pbsdr_test.go @@ -15,7 +15,7 @@ import ( func TestConsumePBSToken_OnceThen404(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) - if err := st.SaveHostPBSSecret("h1", "tok-secret-1"); err != nil { + if _, err := st.SaveHostPBSSecret("h1", "tok-secret-1"); err != nil { t.Fatalf("seed secret: %v", err) } @@ -43,7 +43,7 @@ func TestConsumePBSToken_ForeignKeyForbiddenAndSecretSurvives(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c2", APIKey: "HKEY2"}) - if err := st.SaveHostPBSSecret("h1", "tok-secret-1"); err != nil { + if _, err := st.SaveHostPBSSecret("h1", "tok-secret-1"); err != nil { t.Fatalf("seed secret: %v", err) } @@ -61,8 +61,7 @@ func TestConsumePBSToken_ForeignKeyForbiddenAndSecretSurvives(t *testing.T) { func TestConsumePBSToken_AuthMatrix(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) - st.SaveHostPBSSecret("h1", "tok-secret-1") - + _, _ = st.SaveHostPBSSecret("h1", "tok-secret-1") if rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "", ""); rr.Code != http.StatusUnauthorized { t.Errorf("unauthenticated = %d, want 401", rr.Code) } diff --git a/hub/internal/store/host_delete_test.go b/hub/internal/store/host_delete_test.go index 76b5fcc..3df96ca 100644 --- a/hub/internal/store/host_delete_test.go +++ b/hub/internal/store/host_delete_test.go @@ -35,7 +35,7 @@ func seedHostWithArtifacts(t *testing.T, s *Store, hostID, customerID string) { if err := s.SaveHostRecoveryCredential(hostID, "root@pam", "recovery-secret"); err != nil { t.Fatal(err) } - if err := s.SaveHostPBSSecret(hostID, "pbs-secret"); err != nil { + if _, err := s.SaveHostPBSSecret(hostID, "pbs-secret"); err != nil { t.Fatal(err) } // Agent-scoped log bundle (scope_id = host_id) + a pending request. Order matters: diff --git a/hub/internal/store/pbsdr.go b/hub/internal/store/pbsdr.go index 2d17377..409f1c4 100644 --- a/hub/internal/store/pbsdr.go +++ b/hub/internal/store/pbsdr.go @@ -12,13 +12,39 @@ import ( // SaveHostPBSSecret stores (last-write-wins) the one-time PBS token secret for a host, resetting // the consumed flag (a re-issue supersedes any prior unconsumed value). Never logged. -func (s *Store) SaveHostPBSSecret(hostID, value string) error { - _, err := s.db.Exec(` - INSERT INTO host_pbs_secrets (host_id, value, created_at, consumed_at) - VALUES (?, ?, datetime('now'), NULL) - ON CONFLICT(host_id) DO UPDATE SET value = excluded.value, created_at = datetime('now'), consumed_at = NULL`, - hostID, value) - return err +// +// It returns the host's new secret GENERATION — a monotonic counter advanced by exactly this +// mint. The caller stamps it into the pbs_dr descriptor, which is what makes a re-key visible to +// the agent: without it the descriptor is byte-identical across a re-issue (only the side-table +// secret rotates), the converged agent short-circuits on its content hash, and the fresh secret is +// never consumed — the R-39 failure. See the column's note in store.go. +// +// The UPSERT and the read are one statement (RETURNING), so two concurrent mints cannot both +// report the same generation. +func (s *Store) SaveHostPBSSecret(hostID, value string) (int64, error) { + var gen int64 + err := s.db.QueryRow(` + INSERT INTO host_pbs_secrets (host_id, value, created_at, consumed_at, generation) + VALUES (?, ?, datetime('now'), NULL, 1) + ON CONFLICT(host_id) DO UPDATE SET + value = excluded.value, + created_at = datetime('now'), + consumed_at = NULL, + generation = host_pbs_secrets.generation + 1 + RETURNING generation`, + hostID, value).Scan(&gen) + return gen, err +} + +// HostPBSSecretGeneration returns the host's current secret generation (0 = no secret ever stored). +// Read-only; used when refreshing a descriptor without minting. +func (s *Store) HostPBSSecretGeneration(hostID string) (int64, error) { + var gen int64 + err := s.db.QueryRow(`SELECT generation FROM host_pbs_secrets WHERE host_id = ?`, hostID).Scan(&gen) + if err == sql.ErrNoRows { + return 0, nil + } + return gen, err } // ConsumeHostPBSSecret returns the host's one-time PBS token secret and marks it consumed in the diff --git a/hub/internal/store/pbsdr_test.go b/hub/internal/store/pbsdr_test.go index 22fce65..a7b49ec 100644 --- a/hub/internal/store/pbsdr_test.go +++ b/hub/internal/store/pbsdr_test.go @@ -16,7 +16,7 @@ func TestRestageHostPBSSecret(t *testing.T) { } // Store + consume, then re-stage: the SAME value is served once more. - if err := s.SaveHostPBSSecret("h1", "the-secret"); err != nil { + if _, err := s.SaveHostPBSSecret("h1", "the-secret"); err != nil { t.Fatalf("save: %v", err) } if _, err := s.ConsumeHostPBSSecret("h1"); err != nil { @@ -48,7 +48,7 @@ func TestRestageHostPBSSecret_NoGenerationBump(t *testing.T) { if err != nil { t.Fatalf("set desired: %v", err) } - if err := s.SaveHostPBSSecret("h1", "s"); err != nil { + if _, err := s.SaveHostPBSSecret("h1", "s"); err != nil { t.Fatalf("save secret: %v", err) } if _, err := s.RestageHostPBSSecret("h1"); err != nil { @@ -125,7 +125,7 @@ func TestHostPBSSecret_ConsumeOnce(t *testing.T) { t.Fatalf("consume with nothing stored = %v, want sql.ErrNoRows", err) } - if err := s.SaveHostPBSSecret("h1", "secret-1"); err != nil { + if _, err := s.SaveHostPBSSecret("h1", "secret-1"); err != nil { t.Fatalf("save: %v", err) } got, err := s.ConsumeHostPBSSecret("h1") @@ -139,7 +139,7 @@ func TestHostPBSSecret_ConsumeOnce(t *testing.T) { } // Re-issue path: a fresh save resets consumption and serves the NEW value once. - if err := s.SaveHostPBSSecret("h1", "secret-2"); err != nil { + if _, err := s.SaveHostPBSSecret("h1", "secret-2"); err != nil { t.Fatalf("re-save: %v", err) } got, err = s.ConsumeHostPBSSecret("h1") @@ -152,3 +152,70 @@ func TestHostPBSSecret_ConsumeOnce(t *testing.T) { t.Fatalf("foreign host consume = %v, want sql.ErrNoRows", err) } } + +// R-39 — the secret GENERATION is what makes a re-key visible to the agent. +// +// A re-issue rotates only the side-table secret: token_id, fingerprint, datastore and namespace all +// come back byte-identical, so without this counter the descriptor never moves, the converged agent +// short-circuits on its content hash, and the fresh secret is never consumed. That is the 2026-07-18 +// N100 failure. These assertions are the store half of the guarantee. +func TestSaveHostPBSSecret_GenerationIsMonotonicPerMint(t *testing.T) { + s := newTestStore(t) + + // No secret ever stored → generation 0 (not an error). + if g, err := s.HostPBSSecretGeneration("h1"); err != nil || g != 0 { + t.Fatalf("generation with nothing stored = (%d, %v), want (0, nil)", g, err) + } + + g1, err := s.SaveHostPBSSecret("h1", "secret-1") + if err != nil { + t.Fatalf("first mint: %v", err) + } + if g1 != 1 { + t.Fatalf("first mint generation = %d, want 1", g1) + } + + // THE FIX: a re-key with an identical descriptor still advances the generation. + g2, err := s.SaveHostPBSSecret("h1", "secret-2") + if err != nil { + t.Fatalf("re-key mint: %v", err) + } + if g2 != 2 { + t.Fatalf("re-key generation = %d, want 2 — a re-issue MUST advance it or the agent never re-applies", g2) + } + if g2 <= g1 { + t.Fatalf("generation went backwards or stalled: %d -> %d", g1, g2) + } + if got, err := s.HostPBSSecretGeneration("h1"); err != nil || got != g2 { + t.Fatalf("read-back generation = (%d, %v), want (%d, nil)", got, err, g2) + } + + // Per-host, not global: another host starts at 1. + if g, err := s.SaveHostPBSSecret("h2", "other"); err != nil || g != 1 { + t.Fatalf("second host first mint = (%d, %v), want (1, nil) — the counter is per-host", g, err) + } +} + +// A RE-STAGE must NOT advance the generation: it re-arms the SAME secret, so the descriptor content +// genuinely has not changed and a bump would cause a pointless agent refetch loop. This is the +// counterpart to TestRestageHostPBSSecret_NoGenerationBump, one level down. +func TestRestageHostPBSSecret_LeavesSecretGenerationAlone(t *testing.T) { + s := newTestStore(t) + g1, err := s.SaveHostPBSSecret("h1", "secret-1") + if err != nil { + t.Fatalf("mint: %v", err) + } + if _, err := s.ConsumeHostPBSSecret("h1"); err != nil { + t.Fatalf("consume: %v", err) + } + if restaged, err := s.RestageHostPBSSecret("h1"); err != nil || !restaged { + t.Fatalf("restage = (%v, %v), want (true, nil)", restaged, err) + } + g2, err := s.HostPBSSecretGeneration("h1") + if err != nil { + t.Fatalf("read generation: %v", err) + } + if g2 != g1 { + t.Fatalf("restage moved the secret generation %d -> %d; a re-stage changes no descriptor content", g1, g2) + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 1bdf227..c40bf1e 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -486,13 +486,35 @@ func (s *Store) migrate() error { host_id TEXT PRIMARY KEY, value TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT (datetime('now')), - consumed_at DATETIME + consumed_at DATETIME, + generation INTEGER NOT NULL DEFAULT 0 ); `) if err != nil { return err } + // R-39 fleet fix (v0.68.0) — the PBS secret GENERATION: a monotonic per-host counter advanced by + // every fresh MINT and by nothing else. Must be declared AFTER the CREATE above (an ALTER placed + // earlier in this function silently no-ops, because the table does not exist yet). + // + // Why it has to exist. The agent's re-apply trigger is a change in the DESCRIPTOR CONTENT HASH + // (`felhom-agent internal/pbsdr/manager.go` descriptorHash). An ep0 credential re-issue re-keys + // the SECRET of an existing token, so token_id, fingerprint, datastore and namespace all come + // back byte-identical — the descriptor does not move, the converged agent short-circuits, the + // fresh secret is never consumed, and the box serves a revoked credential while reporting + // `applied`. That is the 2026-07-18 N100 failure exactly (R-39). Stamping this counter into the + // descriptor is what finally makes a re-key LOOK different to the agent. + // + // It is deliberately NOT created_at (two mints inside one second collide) and there is no row id + // to borrow: this table is keyed by host_id and UPSERTed last-write-wins, so a "new row" never + // exists. A counter column is the only monotonic source available here. + // + // RestageHostPBSSecret must NOT touch it: a re-stage re-arms the SAME secret, the descriptor + // content genuinely has not changed, and bumping would trigger a pointless agent refetch loop + // (that method's own contract says so). + s.db.Exec(`ALTER TABLE host_pbs_secrets ADD COLUMN generation INTEGER NOT NULL DEFAULT 0`) + // v0.50.0 — customer-claim password arc (DRILL-day0-vm F-4): one row per customer holding the // ACTIVE claim/reset code state. code_hash is bcrypt(code) — the plaintext exists ONLY inside // the email send (same custody rule as the retrieval passphrase). generation is monotonic: a @@ -1560,6 +1582,7 @@ type ManagedFloorDecision struct { // - manifest MinAgent "" → UNCOUPLED release: serve the floor as-is (no agent gating); // - agent_version known AND ≥ MinAgent → serve the floor; // - agent_version below MinAgent, OR unknown/unparseable → HOLD (serve no directive) + flag. +// // A held box is VISIBLE (the dashboard renders the reason), never silently stale. func (s *Store) ResolveManagedFloor(customerID string) ManagedFloorDecision { d := ManagedFloorDecision{Floor: s.EffectiveMinControllerVersion(customerID)} @@ -2042,7 +2065,7 @@ func (s *Store) CountHostArtifacts(hostID string) (HostArtifacts, error) { // DeleteHost removes a host and every host-scoped artifact in ONE transaction (v0.47.0 // stale host removal). The online-gate lives in the web handler — the store deletes what // it is told to. Guards: -// - empty hostID → refused (would DELETE the '' scope rows); +// - empty hostID → refused (would DELETE the ” scope rows); // - escrow present without deleteEscrow → ErrHostEscrowPresent, the tx never starts. // // The wg_peers delete is INSIDE the tx on purpose — a crash between a host delete and a diff --git a/hub/internal/web/pbsdr.go b/hub/internal/web/pbsdr.go index a22c4df..9e30f39 100644 --- a/hub/internal/web/pbsdr.go +++ b/hub/internal/web/pbsdr.go @@ -58,6 +58,19 @@ type pbsDRDescriptor struct { Namespace string `json:"namespace,omitempty"` TokenID string `json:"token_id,omitempty"` Fingerprint string `json:"fingerprint,omitempty"` + // SecretGeneration is the monotonic per-host counter advanced by every fresh secret MINT + // (store.SaveHostPBSSecret). It carries no secret material — only the fact that one rotated. + // + // It exists because a re-key changes NOTHING else in this struct: an ep0 re-issue re-keys the + // secret of an EXISTING token, so token_id/fingerprint/datastore/namespace stay byte-identical. + // The agent re-applies on the descriptor's content hash, so without this field a re-issue is + // invisible to a converged box — it short-circuits, never consumes the fresh secret, and serves a + // revoked credential while reporting `applied` (R-39, N100 2026-07-18). + // + // MUST stay field-exact with felhom-agent internal/hub.WirePBSDR. Agents below 0.91.0 do not + // carry the field, drop the unknown JSON key, and behave exactly as they do today — the field is + // inert to them, not breaking (Scenario C); the re-arm guarantee needs agent >= 0.91.0. + SecretGeneration int64 `json:"secret_generation,omitempty"` } // defaultPBSStorageID is the storage-entry id the agent bridge creates on a customer box. The DEMO @@ -254,17 +267,19 @@ func (s *Server) pbsdrProvisionAtom(ctx context.Context, customerID string, host } // The atom: secret first (consume-once custody), then descriptor+bump (the agent's signal). - if err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret); err != nil { + secretGen, err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret) + if err != nil { return "", fmt.Errorf("pbsdr: store one-time token secret: %w", err) } desc := &pbsDRDescriptor{ - Enabled: true, - StorageID: storageID, - PBSTunnelIP: ep.PBSTunnelIP, - Datastore: res.Datastore, - Namespace: res.Namespace, - TokenID: res.TokenID, - Fingerprint: res.Fingerprint, + Enabled: true, + StorageID: storageID, + PBSTunnelIP: ep.PBSTunnelIP, + Datastore: res.Datastore, + Namespace: res.Namespace, + TokenID: res.TokenID, + Fingerprint: res.Fingerprint, + SecretGeneration: secretGen, } merged, err := mergePBSDR(host.DesiredJSON, desc) if err != nil { @@ -316,8 +331,14 @@ func (s *Server) PBSDRAutoProvision(ctx context.Context, customerID string) { // ReissuePBSDR re-keys the customer's ep0 PBS token and re-arms the agent — the non-HTTP core shared // by the operator button (handlePBSDRReissue) and the pbsdrheal self-heal reconciler (the escalation // path when no stored secret is re-stageable, or the agent burned one and reports consumed_failed). -// tenantsync reissue → fresh consume-once secret (SaveHostPBSSecret) → descriptor refresh with the -// NEW token_id/fingerprint + generation bump (the agent's re-consume signal). This reuses the same +// tenantsync reissue → fresh consume-once secret (SaveHostPBSSecret) → descriptor refresh + host +// generation bump (the agent's re-consume signal). +// +// CORRECTION (R-39, v0.68.0): this comment used to claim the refresh carried "the NEW +// token_id/fingerprint". That is FALSE for a re-key — ep0 rotates the SECRET of an existing token, +// so token_id and fingerprint come back byte-identical and the re-assignments below are no-ops. That +// false belief is the whole reason the descriptor never moved and the agent never re-consumed +// (N100, 2026-07-18). The thing that actually changes the descriptor is SecretGeneration. This reuses the same // reissue op the handler does — it is NOT a re-run of pbsdrProvisionAtom (which refuses ErrTokenExists // and would not re-key). The secret value is never logged. Keep this in lockstep with the tail of // handlePBSDRReissue. @@ -343,13 +364,18 @@ func (s *Server) ReissuePBSDR(ctx context.Context, customerID string) error { if err != nil { return fmt.Errorf("pbsdr reissue for %s: %w", customerID, err) } - if err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret); err != nil { + secretGen, err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret) + if err != nil { return fmt.Errorf("pbsdr reissue for %s: store secret: %w", customerID, err) } + // These four are re-assigned for completeness but are byte-identical on a re-key (see the + // correction above). SecretGeneration is the field that actually moves the descriptor hash and + // therefore re-arms the agent. cur.TokenID = res.TokenID cur.Fingerprint = res.Fingerprint cur.Datastore = res.Datastore cur.Namespace = res.Namespace + cur.SecretGeneration = secretGen merged, err := mergePBSDR(host.DesiredJSON, cur) if err != nil { return fmt.Errorf("pbsdr reissue for %s: merge descriptor: %w", customerID, err) @@ -395,7 +421,8 @@ func (s *Server) handlePBSDRReissue(w http.ResponseWriter, r *http.Request, cust http.Error(w, "PBS credential re-issue failed: "+err.Error(), http.StatusBadGateway) return } - if err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret); err != nil { + secretGen, err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret) + if err != nil { s.logger.Printf("[ERROR] pbsdr reissue for %s: secret store: %v", customerID, err) http.Error(w, "Re-issued on the endpoint but storing the secret failed — re-issue again", http.StatusInternalServerError) return @@ -404,6 +431,7 @@ func (s *Server) handlePBSDRReissue(w http.ResponseWriter, r *http.Request, cust cur.Fingerprint = res.Fingerprint cur.Datastore = res.Datastore cur.Namespace = res.Namespace + cur.SecretGeneration = secretGen // the ONLY field a re-key actually moves — see ReissuePBSDR merged, err := mergePBSDR(host.DesiredJSON, cur) if err == nil { _, err = s.store.SetHostDesired(host.HostID, []byte(merged)) @@ -422,8 +450,8 @@ func (s *Server) handlePBSDRReissue(w http.ResponseWriter, r *http.Request, cust // cascade stages (host → WG peer → descriptor → escrow) — one flag, ordered rollout, honest // intermediate states (scenario D). type pbsDRView struct { - Supported bool // tenantsync configured on this hub - NoHost bool // no enrolled host for the customer (cascade stage 1 waiting) + Supported bool // tenantsync configured on this hub + NoHost bool // no enrolled host for the customer (cascade stage 1 waiting) HostID string DRTier bool // the stored per-customer flag (operator INTENT) Enabled bool // descriptor enabled (host-side reality) diff --git a/hub/internal/web/pbsdr_generation_test.go b/hub/internal/web/pbsdr_generation_test.go new file mode 100644 index 0000000..0954f01 --- /dev/null +++ b/hub/internal/web/pbsdr_generation_test.go @@ -0,0 +1,196 @@ +package web + +import ( + "context" + "encoding/json" + "net/url" + "testing" +) + +// R-39, Scenario B — the hub half of the fix: a re-issue must change the DESCRIPTOR BYTES. +// +// The agent re-applies only when the descriptor's content hash moves (felhom-agent +// internal/pbsdr/manager.go descriptorHash marshals the parsed struct). An ep0 re-key rotates the +// secret of an EXISTING token, so token_id / fingerprint / datastore / namespace all come back +// byte-identical — which is precisely why the 2026-07-18 N100 box short-circuited forever. The only +// field that moves is SecretGeneration. +// +// COMPANION RED-PROOF (run + recorded in REPORT.md): delete the `SecretGeneration` field from +// pbsDRDescriptor (or stop setting it in the re-issue path) → the two marshals below become +// byte-identical and this test FAILS, reproducing the defect exactly. +func TestPBSDRDescriptor_ReissueChangesTheBytes(t *testing.T) { + // The descriptor as it stands after the FIRST provision. + before := &pbsDRDescriptor{ + Enabled: true, + StorageID: "felhom-pbs", + PBSTunnelIP: "10.77.0.1", + Datastore: "felhom-offsite", + Namespace: "demo-felhom", + TokenID: "felhom@pbs!demo-felhom", + Fingerprint: "c6:07:28:3f", + SecretGeneration: 1, + } + // After a RE-KEY: everything the endpoint returns is identical — only the secret rotated. + after := *before + after.SecretGeneration = 2 + + b1, err := json.Marshal(before) + if err != nil { + t.Fatalf("marshal before: %v", err) + } + b2, err := json.Marshal(&after) + if err != nil { + t.Fatalf("marshal after: %v", err) + } + if string(b1) == string(b2) { + t.Fatalf("a re-issue left the descriptor BYTE-IDENTICAL — the agent will short-circuit and never "+ + "consume the fresh secret (this is the R-39 defect).\n bytes: %s", b1) + } + + // And the difference must be exactly the generation — not an accident of field ordering. + var m1, m2 map[string]any + if err := json.Unmarshal(b1, &m1); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b2, &m2); err != nil { + t.Fatal(err) + } + for k, v1 := range m1 { + if k == "secret_generation" { + continue + } + if v2, ok := m2[k]; !ok || string(mustJSON(t, v1)) != string(mustJSON(t, v2)) { + t.Errorf("a re-key must change ONLY secret_generation, but %q moved: %v -> %v", k, v1, m2[k]) + } + } + if m2["secret_generation"] == m1["secret_generation"] { + t.Error("secret_generation did not advance") + } +} + +// The field must be OMITTED when zero, so a hub that has never minted for a host does not start +// emitting a new key into every legacy descriptor (which would itself be a spurious re-apply). +func TestPBSDRDescriptor_ZeroGenerationIsOmitted(t *testing.T) { + b, err := json.Marshal(&pbsDRDescriptor{Enabled: true, StorageID: "felhom-pbs"}) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if _, present := m["secret_generation"]; present { + t.Errorf("zero generation must be omitted (omitempty) — emitting it would move the hash of every "+ + "pre-existing descriptor and cause a fleet-wide spurious re-apply. got: %s", b) + } +} + +// An UNKNOWN key must survive a round-trip through readPBSDR/mergePBSDR untouched — the Scenario-C +// compatibility direction, asserted from the hub side: an old agent's descriptor is not corrupted by +// a hub that now writes the new field. +func TestPBSDRDescriptor_RoundTripPreservesOtherKeys(t *testing.T) { + desired := `{"operator":{"note":"keep me"},"pbs_dr":{"enabled":true,"storage_id":"felhom-pbs","secret_generation":7}}` + cur := readPBSDR(desired) + if cur == nil { + t.Fatal("readPBSDR returned nil") + } + if cur.SecretGeneration != 7 { + t.Fatalf("secret_generation round-trip = %d, want 7", cur.SecretGeneration) + } + cur.SecretGeneration = 8 + merged, err := mergePBSDR(desired, cur) + if err != nil { + t.Fatalf("merge: %v", err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal([]byte(merged), &m); err != nil { + t.Fatal(err) + } + if _, ok := m["operator"]; !ok { + t.Error("merge dropped a sibling key in desired_json") + } + if got := readPBSDR(merged); got == nil || got.SecretGeneration != 8 { + t.Errorf("merged descriptor lost the advanced generation: %+v", got) + } +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +// R-39, Scenario B at the FLOW level — the assertion that actually guards the shipped behaviour. +// +// The struct test above proves the field moves the bytes; this proves ReissuePBSDR *stamps* it. +// fakeTenancy returns an identical TokenID / Fingerprint / Datastore / Namespace on every call and +// rotates only the secret — which is exactly what an ep0 re-key does, and exactly why the descriptor +// used to come back byte-identical. +// +// COMPANION RED-PROOF (run + recorded): comment out `cur.SecretGeneration = secretGen` in +// ReissuePBSDR (keep the field declared so it still compiles) → the pbs_dr block is unchanged across +// the re-issue and this test FAILS with both identical blocks printed. That is the 2026-07-18 N100 +// behaviour reproduced in a unit test. +func TestReissuePBSDR_ChangesTheStoredDescriptor(t *testing.T) { + fake := &fakeTenancy{secret: "OLD"} + s, st, _ := newPBSDRServer(t, fake) + postUpdate(t, s, url.Values{"dr_tier": {"on"}}) // provision → descriptor + generation 1 + + pbsBlockOf := func(what string) string { + t.Helper() + h, err := st.GetHost("peti-01") + if err != nil || h == nil { + t.Fatalf("%s: get host: %v", what, err) + } + var doc map[string]json.RawMessage + if err := json.Unmarshal([]byte(h.DesiredJSON), &doc); err != nil { + t.Fatalf("%s: parse desired_json: %v", what, err) + } + return string(doc["pbs_dr"]) + } + + before := pbsBlockOf("before") + if before == "" { + t.Fatal("no pbs_dr descriptor after provisioning") + } + + // The agent consumes it and converges — the box is now pinned to this exact descriptor hash. + if _, err := st.ConsumeHostPBSSecret("peti-01"); err != nil { + t.Fatalf("consume: %v", err) + } + + fake.secret = "FRESH" // the re-key: a new secret behind an unchanged token + if err := s.ReissuePBSDR(context.Background(), "peti"); err != nil { + t.Fatalf("ReissuePBSDR: %v", err) + } + after := pbsBlockOf("after") + + if after == before { + t.Fatalf("the re-issue left the pbs_dr descriptor BYTE-IDENTICAL — a converged agent will "+ + "short-circuit on its content hash and never consume the fresh secret (R-39).\n block: %s", before) + } + + // The fresh secret must genuinely be re-armed for consumption, and the generation advanced. + var b, a pbsDRDescriptor + if err := json.Unmarshal([]byte(before), &b); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(after), &a); err != nil { + t.Fatal(err) + } + if a.SecretGeneration <= b.SecretGeneration { + t.Errorf("secret_generation did not advance across a re-issue: %d -> %d", b.SecretGeneration, a.SecretGeneration) + } + // Everything else is identical — proving the generation is the ONLY thing carrying the signal. + if a.TokenID != b.TokenID || a.Fingerprint != b.Fingerprint || a.Namespace != b.Namespace || a.Datastore != b.Datastore { + t.Errorf("this fake models a re-key, so these must be unchanged; if they differ the test is no "+ + "longer exercising the defect shape.\n before=%+v\n after=%+v", b, a) + } + got, err := st.ConsumeHostPBSSecret("peti-01") + if err != nil || got != "FRESH" { + t.Errorf("post-reissue consume = (%q, %v), want (FRESH, nil) — the fresh secret must be consumable", got, err) + } +}