hub: R-39 core — stamp a secret GENERATION into the pbs_dr descriptor
The fleet half of R-39. 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 agent re-applies on the descriptor's CONTENT HASH, so a re-issue was invisible to a converged box: it short-circuited, never consumed the fresh secret, and served a revoked credential while reporting `applied` — the N100 failure of 2026-07-18. host_pbs_secrets gains a monotonic per-host `generation`, advanced by every fresh MINT and by nothing else, stamped into the descriptor as `secret_generation`. That is now the only field a re-key moves, and it is what re-arms the agent. DEVIATION FROM SPEC, deliberate: the brief said to return "the new row's id (int64) … no schema change". There is no row id — host_pbs_secrets is keyed by host_id and UPSERTed last-write-wins, so a new row never exists, and created_at collides for two mints in the same second. An additive counter column is the only monotonic source; it uses the repo's existing idempotent ALTER-TABLE idiom. RestageHostPBSSecret deliberately does NOT advance it: a re-stage re-arms the SAME secret, the descriptor content genuinely has not changed, and a bump would cause a pointless agent refetch loop (that method's own contract says so). Also corrects a comment that asserted the re-issue refreshes the descriptor "with the NEW token_id/fingerprint". That is false for a re-key, and believing it is why the descriptor was never expected to be identical in the first place. omitempty is load-bearing: a zero generation must not start emitting a new key into every pre-existing descriptor, which would itself be a fleet-wide spurious re-apply. Compatibility: agents below 0.91.0 drop the unknown JSON key and behave exactly as today — inert, not breaking (Scenario C). Tests: store-level monotonicity + per-host isolation + restage-leaves-it-alone; descriptor byte-change, omitempty, and sibling-key round-trip; and a FLOW-level test driving ReissuePBSDR against a fake that models a real re-key. Red-proof run at the assertion level (not the compiler): commenting out the stamp makes the flow test fail with both byte-identical blocks printed.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user