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