Skip to content

Admission gate has no migration path: pre-#1438 records can never reclaim their name #1452

Description

@khaliqgant

Summary

The v11.4.2 admission gate (#1438, 5c2ad8ee3) is correct and should not be weakened. It has one gap: it has no migration path for records created before it shipped, so a broker that registered under a name before 2026-08-06 can never reclaim that name after an unclean shutdown. The name is stranded permanently.

// crates/broker/src/relaycast/auth.rs:990 — admit_agent_registration
let reclaims_same_work_unit = matches!(
    (identity_key, existing_identity),
    (Some(ours), Some(theirs)) if hash_identity_key(ours) == theirs
);

Pre-#1438 records carry no stamped identity_key, so existing_identity is None and the arm is unsatisfiable for every possible caller key. No RELAY_AGENT_IDENTITY_KEY value recovers such a name. This is not a matter of finding the right key: stable_node_identity_key() derives it deterministically from the broker's state-directory path, and the stored side simply is not there.

This stranded the name chief during the 2026-08-07 node outage. It is unrecoverable until AgentWorkforce/relaycast#309 (name release without history loss) ships.

Read this before designing the fix: the gate covers one of three registration paths

The incident brief this came from compared two agent records — one refused, one that reclaimed its id twice with no identity_key — and treated the difference as gate behaviour. It is not. They went through different code and were never subject to the same rule. Anyone fixing this needs the full picture, because a fix built on the two-record comparison would be built on a false model.

Path 1 — broker self-registration. GATED.
Rust AuthClient::startup_session_*register_agent_with_workspace_key (crates/broker/src/relaycast/auth.rs:725) → admit_agent_registration (:959) → POST /v1/agents → 409 → the check above. This is the only caller of admit_agent_registration in the tree. This is the path with the migration gap.

Path 2 — broker-spawned worker. Gated differently, server-side.
Node-control agent.register → relaycast registerAgentViaNode (packages/engine/src/engine/node.ts:1118). An onConflictDoUpdate on (workspace_id, name) that overwrites tokenHash, guarded by:

setWhere: or(
  ne(agents.status, 'active'),
  and(
    eq(agents.locationType, 'via_node'),
    or(
      eq(agents.locationNodeId, nodeId),
      sql`${agents.locationNodeId} = 'node_direct_' || ${agents.id}`,
    ),
  ),
),

Reclaim here is proved by node identity, not by a caller-supplied key. It never consults identity_key.

Path 3 — HTTP fallback when path 2 is unavailable. UNGATED.
ws.rs::register_agent_token (crates/broker/src/relaycast/ws.rs:129) → SDK registerOrRotate (relaycast packages/sdk-typescript/src/relay.ts:485) → on 409, agents.get(name) + agents.rotateToken(name), returning the incumbent's id and a fresh token. The name alone suffices. Taken as the documented fallback at crates/broker/src/runtime/relaycast_events.rs:493-510.

Live evidence that the two populations are path-separated, read from the workspace on 2026-08-07:

agent type metadata path
chief human {} 1 — refused, stranded
chief-broker human identityKey present 1 — created post-gate
restart-probe-0807 human identityKey present 1 — created post-gate
chief-khaliq agent fleet.nodeId, nodeId, registeredAt: 09:47:06.932Z 2 — reclaimed
marketing-lead agent fleet.nodeId, nodeId, registeredAt: 09:47:09.421Z 2 — reclaimed

The registeredAt stamps on the path-2 records are the reclaims themselves. Those agents never reached the gate.

The trap: status is a staleness flag, not a liveness signal — and the API view disagrees with the column

Any adopt-on-not-live design needs a signal for "the incumbent is provably gone". status is not it, for two separate reasons.

1. Nothing ever clears it. sweepStaleAgents (packages/engine/src/engine/agent.ts:298) is written to flip 'active''offline' after STALE_THRESHOLD_MS (5 minutes, :11) — but it has no caller. The only other references in the tree are its own .d.ts declarations under dist/. The loop that drove it was removed in the Fly.io → Cloudflare Workers migration and has not been restored; AgentWorkforce/relaycast#306 tracks re-wiring it and is still open.

Confirmed against the live workspace rather than inferred from the missing caller: of the 329 records whose stored status is 'active', 305 have a lastSeen older than five minutes, and the oldest has been 'active' for 23.9 days. If a five-minute sweep were running, that count would be 24.

So 'active' is not a stale-for-under-five-minutes signal. It means "was alive at some point and never explicitly said otherwise" — it is never revoked on a timer. The 536 'offline' rows come from explicit disconnect and teardown paths (a2a.ts:433, action.ts:1170, inboundWebhook.ts:35, and the node lifecycle in node.ts), not from a sweep, so any code branching on 'offline' is branching on whether something once took the trouble to say so.

2. The serialized value does not match the stored value. Measured against a live workspace of 864 agents on 2026-08-07:

probe result
GET /v1/agents?status=active (SQL filter on the stored column) 329 rows
GET /v1/agents?status=offline 536 rows
GET /v1/agents?status=online / ?status=unknown 0 rows
status field in the response body for those same 329 rows "unknown", all 329

The filter matches 'active' in the column (listAgents does eq(agents.status, status) before any serialization, packages/engine/src/engine/agent.ts:109-113), so the stored value is 'active' — but every one of those records serializes as "unknown". Two agents confirmed present in the ?status=active set were actively exchanging messages at the time of the read.

Nothing in the engine ever writes 'unknown' ('active' at :59 and :294, 'offline' at :303), so the divergence is introduced on the way out, in the deployed build. It could not be located in this checkout, which may not match what is deployed.

Consequences for anyone building on this field:

  1. Read the column, not the response body. A liveness rule written against the serialized value would see zero live agents in a fully healthy workspace.

  2. Validate any candidate signal against the living population, not just a known-dead record. A signal that correctly identifies chief as dead is worthless if it also identifies every running agent as dead. Testing against chief alone would have shown offline and looked correct.

  3. Path 2's guard is real and, as deployed, permanent. Its setWhere first disjunct is ne(agents.status, 'active'). The stored column genuinely holds 'active' for any agent that ever came up, and — because nothing sweeps — it keeps holding it indefinitely. So for those records the disjunct is permanently false and the node-identity condition always does the work. Path 2 is more strongly gated than its source suggests, not less.

    The cost is the mirror image, and it belongs with Freeing an agent name requires deleting its history — and the delete is refused for any agent that has ever spoken relaycast#309: an agent that dies uncleanly stays 'active' forever, so its name can only ever be reclaimed by the same node. If that node is gone or its id changes, the name is stranded permanently — the same failure that stranded chief, reached by a different route, on the path the entire resident roster registers through. Releasing the name is the only recovery on either path.

    Note also that restoring the sweep (relaycast#306) would change this security property, not just fix presence reporting: every record it flips to 'offline' becomes claimable by any node, by name alone. That coupling should be deliberate rather than discovered later.

Options, and the one worth arguing for

Option 1 — do not adopt; release instead. Land relaycast#309 and let an operator explicitly release a stranded name. The next honest registration then takes the create branch and stamps identity_key naturally. No inference, no change to the boundary #1438 just established, auditable, and the recovery is a documented command.

Option 2 — adopt-and-stamp on provable non-liveness. When existing_identity is None and the incumbent is provably not live, adopt the record and stamp the hash, converting legacy records on first honest restart. No operator action.

The honest case against Option 2: a legacy path-1 record carries no ownership proof of any kind. chief has empty metadata, and post-gate path-1 records (chief-broker) carry no node binding either, so there is no locationNodeId to fall back on the way path 2 does. Adoption therefore cannot verify ownership — it can only infer it from absence. That is the weakest possible foundation for a change to the exact boundary that exists to stop AR-448 duplicate-agent takeover, and it buys automation of a recovery that Option 1 already makes safe and explicit.

Sizing, since it bears on whether the manual path is tolerable: 226 records in this workspace are type: human with no identityKey — the shape a legacy path-1 record has. How many are broker self-registrations rather than genuine human registrations is not determined by that view and should be established before assuming the migration is small. In practice, though, only one name per node needs reclaiming after any given incident.

Recommendation: Option 1, with Option 2 held until #309 is in and the population is actually characterized. If Option 2 is taken anyway, it needs the most review of anything in this cluster — getting it wrong reopens AR-448.

If Option 2 proceeds, the minimum bar:

  • liveness from lastSeen age plus absence of a live node connection, never from status
  • the signal demonstrated against currently-running agents, with the false-positive rate stated
  • adoption stamped and logged as a distinct, greppable event, so a wrong adoption is visible after the fact rather than silent
  • a test that a live incumbent is never adopted away, exercised against the live-population signal rather than a fixture

Related

Verification

Rust line references read from origin/main (the gate is not in every local checkout — HEAD at 08c2f290d predates it). Relaycast references from main at 08ddec7. Agent records and status distribution read live from the workspace on 2026-08-07, not from a persisted state file.

Context

Filed from an incident brief covering the 2026-08-07 node outage. Do not merge without Khaliq — he owns the merge gate.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions