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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user