Skip to content

revert(engine): hold #360 from the next release - #364

Merged
khaliqgant merged 1 commit into
mainfrom
codex/relaycast-release-0902
Sep 2, 2026
Merged

revert(engine): hold #360 from the next release#364
khaliqgant merged 1 commit into
mainfrom
codex/relaycast-release-0902

Conversation

@miyaontherelay

@miyaontherelay miyaontherelay commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Release gate

Hold #360 out of the next engine cut while keeping merged fixes #355 and #358 releasable.

#360 merged with four unresolved functional review threads and no author response:

  • legacy client-supplied cloud:* tags can become trusted and preserved indefinitely;
  • workspace-key enrollment and node registration use different serialization, so a stale registration snapshot can erase or resurrect enrollment tags;
  • reserving the entire cloud:* namespace breaks the documented non-repo: client-tag round trip.

Source inspection confirms POST /v1/nodes calls createNodeToken outside the per-node serializeNodeOp used by node-control registration. Publishing the current main would therefore ship a known stale-write race as the cure for tag churn.

This is the reasoned exclusion allowed by the release brief. Follow-up #365 restores the behavior with explicit tag authority/provenance, shared node mutation ordering that survives the hosted runtime, and deterministic interleaving controls.

#363 interaction

#363 does not close this hole. Its machine enrollment queue uses ['machine', workspaceId, machineId]; registration uses the deliberately separate ['node', workspaceId, nodeId] queue, and enrollment without machine_id takes no machine queue. Both queues are process-local. A synthetic three-way composition check against current main produced the same final tree in either merge order: #360's authority marker/test are absent while #363's machine lock and migration 0043 remain present.

Verification

No merge, release, or deploy has been performed.

PR #360 merged with unresolved functional findings: enrollment can race registration and lose authoritative tags; legacy client-supplied cloud tags become trusted indefinitely; and reserving the entire cloud namespace breaks client tag round-tripping. Keep #355 and #358 releasable while the tag-authority contract is hardened separately.

Session-Id: 01a060f2-87c5-7d21-bf8f-515f3e9cbfdd
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 7e1a3bff-0b82-4a26-9bbd-202540640c28


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@khaliqgant
khaliqgant merged commit f04cde8 into main Sep 2, 2026
8 checks passed
@khaliqgant
khaliqgant deleted the codex/relaycast-release-0902 branch September 2, 2026 09:25
khaliqgant added a commit that referenced this pull request Sep 2, 2026
Two from cubic review on #363, plus a composition guard for the post-#364 tree.

Serialization was keyed on `machine_id` alone, so a direct-node enrollment
carrying one queued behind every other enrollment on that machine even though
it never consults the machine lookup. A box running many direct node-of-one
delivery hosts would serialize enrollments that are genuinely independent.
Gate the queue on `requestedNodeRole(...) === 'broker'`, matching the condition
that actually reaches the lookup.

The concurrency test asserted only the roster length, so it would have passed
if one enrollment had failed outright. Both enrollments now have to return 201
before the row count means anything; same for the two direct hosts.

Also pin the one real interaction between this feature and the node.register
path, now that #364 has removed server-authoritative tag preservation: a broker
that enrolls with a `machine_id` and then registers WITHOUT one must keep the
stored value. `recomputeNodeAggregate` writes `machineId` only when non-null,
and this feature depends on that — if a register could null it, the node's next
boot under a fresh name would mint a new row and the dedupe would silently stop
working. The test asserts the value survives and that dedupe still holds after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978
khaliqgant added a commit that referenced this pull request Sep 3, 2026
* feat(engine): dedupe node enrollment on machine_id

`nodes` rows were only ever keyed on `node_id` then `name`. A fleet host that
persists no `node_id` enrolls under a fresh name on every boot, so each boot
minted a brand-new roster row and nothing ever reclaimed the old one. That is
why roster cleanup does not stick: the roster refills from enrollment.

`machine_id` already existed as a column but was written only on the WebSocket
`node.register` path — `POST /v1/nodes` did not even accept it, so zod stripped
it and every enrolled row stored NULL.

Enrollment now resolves `node_id`, then `name`, then `machine_id`, and persists
the value it was given. Re-enrollment under a fresh name rotates the machine's
existing node instead of inserting another.

The `machine_id` step is scoped to `broker` nodes. A broker is the node-of-many
fleet host and a machine runs one; a `direct` node is a node-of-one delivery
host and a single machine legitimately runs many, so collapsing those would
strand agents. An explicit `node_id` still wins, which is how to run two brokers
on one machine. The lookup resolves oldest-first so a roster already holding
several rows for one machine converges on its earliest rather than picking
arbitrarily.

Migration 0043 indexes `nodes(workspace_id, machine_id)` — the lookup runs on
every enrollment. Non-unique: the existing roster already holds many rows per
machine, and direct nodes are meant to repeat.

Verified on a local dev server (SQLite), before and after, with the same probe:
two enrollments, same `machine_id`, different names.
  before: roster rows for one machine_id: 2, machine_id NULL on both
  after:  roster rows for one machine_id: 1, same node id, machine_id recorded
Reverting the two source files makes 3 of the 8 new conformance tests fail.

Complements relaycast-cloud#91, which reaps existing rows; this stops the
refill. Rollout to the hosted gateway needs the companion relaycast-cloud
change: the D1 index migration, the `@relaycast/engine` bump, and the bundled
engine-version marker bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

* fix(engine): infer the requested role before matching on machine_id

Codex review on #363 found a P1 in the machine_id dedupe, and it reproduces.

The machine lookup skipped only an *explicit* `role: "direct"`. But the role
default depends on `kind`: `http_push` and `poll` nodes default to `direct`,
and a caller that omits `role` left `data.role` undefined. So an http_push node
enrolling on a machine that already had a broker matched that broker, and the
route then inherited the broker's role, rotated its token and rewrote its
transport to http_push. Silently moving a live node's identity is a worse
failure than the roster growth this dedupe exists to stop.

`requestedNodeRole` now derives the role from the request alone, mirroring the
route's own default, and only a broker request reaches the machine lookup. Two
tests cover it — http_push and poll — and both fail without the change:
  before: rows: 1, broker-host rotated to kind=http_push
  after:  rows: 2, broker-host ws/broker, push-host http_push/direct

Also from that review:

- Enrollments keyed on one machine are serialized in-process
  (`serializeMachineEnroll`, alongside the existing `serializeNodeOp`). Resolve
  and insert are not one step and the index is deliberately non-unique, so
  concurrent first-enrollments of one machine could each miss and each insert.
  This closes the race within an isolate, not across them; a duplicate that
  still slips through self-corrects, since the lookup resolves oldest-first.
- `machine_id` added to the `NodeRosterEntry` response schema in `openapi.yaml`.
  The endpoint description already promised it and `publicNode` already emitted
  it, so OpenAPI-generated clients were missing a field the TypeScript SDK had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

* docs: align changelog heading link and tighten the engine entry

Both from cubic review on #363.

Raising the root heading to `[Unreleased - Minor]` left the reference-style
link definition as `[Unreleased - Patch]:`, matching no in-text label —
`cut-changelog.mjs` does not rewrite it.

The engine entry carried design backstory that AGENTS.md asks changelogs to
omit. Trimmed to the API and migration detail a package changelog is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

* fix(engine): only broker enrollments take the machine queue

Two from cubic review on #363, plus a composition guard for the post-#364 tree.

Serialization was keyed on `machine_id` alone, so a direct-node enrollment
carrying one queued behind every other enrollment on that machine even though
it never consults the machine lookup. A box running many direct node-of-one
delivery hosts would serialize enrollments that are genuinely independent.
Gate the queue on `requestedNodeRole(...) === 'broker'`, matching the condition
that actually reaches the lookup.

The concurrency test asserted only the roster length, so it would have passed
if one enrollment had failed outright. Both enrollments now have to return 201
before the row count means anything; same for the two direct hosts.

Also pin the one real interaction between this feature and the node.register
path, now that #364 has removed server-authoritative tag preservation: a broker
that enrolls with a `machine_id` and then registers WITHOUT one must keep the
stored value. `recomputeNodeAggregate` writes `machineId` only when non-null,
and this feature depends on that — if a register could null it, the node's next
boot under a fresh name would mint a new row and the dedupe would silently stop
working. The test asserts the value survives and that dedupe still holds after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

* fix(engine): never adopt a live broker on a machine_id match

CodeRabbit, Major, on the current head: the dedupe tests covered a renamed
reboot but not a second broker presenting a live incumbent's machine_id.

That case was a silent hijack. Enrollment matched the incumbent, renamed it to
the claimant's name and rotated its token — a running broker losing its
identity and its credential with no error raised, the same shape as burning an
agent name. Reproduced before fixing:

  expected 'node_live' not to be 'node_live'

The match now skips any node that `isNodeLive`. The reasoning is that a live
incumbent cannot be the host re-enrolling, because that host has not left; only
a node that is gone can be the one coming back. Liveness is read through
`isNodeLive` rather than reproduced in SQL so it cannot drift from the
definition placement and the roster already use.

Declining the match rather than rejecting the enrollment is deliberate. A
shared machine_id is not automatically hostile: a VM cloned from a snapshot, or
containers baked from a single image, carry the same id and run concurrently —
they are genuinely separate nodes and each deserves its own row. Rejecting
would break an entire fleet booted from one image; declining costs one extra
roster row, which is exactly the pre-existing behaviour. The roster-refill fix
is untouched, because a rebooting host's incumbent is offline by then.

Three tests, all failing without the change or pinning its edges:
- a live incumbent keeps its id, name AND token hash while the claimant gets
  its own row
- the same claim against a stale incumbent still collapses, so the guard is
  liveness and not identity
- the register/enroll composition test now takes the node offline first, which
  is the real sequence a re-enroll follows

Full engine suite 711/711.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

* docs: document that a live broker is never adopted on machine_id

AGENTS.md keeps README.md and openapi.yaml aligned with behavior, and the
liveness gate narrowed what enrollment does. Both now state that only an
offline broker is reused, and that a caller presenting a live broker's
machine_id gets its own node with the incumbent's id, name and token left
untouched — including why that is the right answer for cloned VMs and
containers baked from one image rather than a rejection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

* fix(engine): filter live brokers before bounding the machine scan

Two from cubic on the liveness gate.

The candidate scan bounded before the liveness filter, so a machine with more
live brokers than the limit could hide its one offline row past the bound and
enrollment would insert another — the roster growth this feature exists to stop,
reappearing precisely on the busiest machines. Live rows are now excluded in
SQL, so the bound counts only reusable candidates.

`isNodeLive` stays authoritative; the SQL predicate is a pre-filter. The one
case where they can disagree is a heartbeat timestamped in the future, which
`isNodeLive` treats as not live and SQL treats as live. SQL is the stricter
side, so the row is skipped and a new node is created — an extra roster row,
never an adopted live broker.

New test builds 25 live brokers on one machine ahead of a single offline row
and asserts the offline row is reused. It fails without the pre-filter:
  expected 'node_220881473274068992' to be 'node_reusable'

Also: the docs said a "running" broker is never adopted, which overstates the
guard. It checks liveness — `status: online` with a heartbeat inside the node
liveness TTL — so a broker whose process is up but whose heartbeat has lapsed
is reusable. Reworded across CHANGELOG, engine CHANGELOG, README and openapi.

Full engine suite 712/712.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

* refactor(engine): name the machine-match reusability rule

cubic's second-round P2 on the future-heartbeat case. The behaviour it flags is
intended, but it was only expressed as a `lte` in SQL plus a code comment, with
the TS side still checking the looser `!isNodeLive`. Two predicates that
disagree is a drift hazard whatever the intent, and the intent was not written
anywhere a reviewer would read as a decision.

`isReusableForMachineMatch` is now the single definition, sitting beside
`isNodeLive` and composing it. SQL mirrors it exactly, and the TS re-check runs
the same rule rather than a more permissive one.

The rule keeps treating an `online` row with a future heartbeat as live.
`lastHeartbeatAt` is always stamped server-side, so a future value means the
server clock moved backwards; `isNodeLive` requires `age >= 0` and therefore
reports such a node as not live while it may still be heartbeating normally.
Deferring to it there would hand a running broker's row and token to the next
caller — the hijack the liveness gate exists to prevent, in the one window
where `isNodeLive` cannot see it.

The cost is a duplicate row bounded at one per machine per rollback, not one
per enrollment: the row enrollment creates instead carries a null heartbeat, so
the next enrollment reuses that one. It also self-heals — once the clock passes
the stale timestamp the original becomes reusable and oldest-first convergence
collapses back onto it. The new test asserts both halves.

Full engine suite 713 tests; two http_push redrive sweep tests flaked under
load and pass in isolation (unrelated to enrollment).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

* fix(engine): require a prior heartbeat before reusing a machine_id row

P1 from independent review at 40866bf, reproduced here before fixing.

Two hosts cold-booting from one snapshot or baked image enroll moments apart.
At the instant the second enrolls, the first has enrolled but not yet
registered, so its row is `offline` with a null heartbeat — which the old
predicate called reusable. The second adopted it, overwriting `tokenHash`, and
the first host was left holding a 201 and a credential that silently stopped
authenticating. Neither side saw an error. Reproduced at head:

  expected 'node_2209...' not to be 'node_2209...'

The liveness guard did not help: it protects a broker that is heartbeating, and
clones have not heartbeated yet. This was also a regression against main, where
zod stripped machine_id and both clones kept their own row and token.

The deeper problem is that "one host enrolling twice" and "two clones enrolling
once each" are the same request sequence — enrollment has no information that
separates them. So reuse now requires proof that a host held the row and left:
the row must have heartbeated at least once, and that heartbeat must be older
than the liveness TTL. Live rows and future-dated heartbeats stay excluded for
the reasons already documented.

This narrows the feature, and the trade should be explicit: a host that enrolls
and never connects is no longer deduped, so this bounds the roster for hosts
that actually join the fleet. Never-connected rows have no working credential to
protect and are reclaimed by the roster reaper (relaycast-cloud#91) instead.

The probe changed with it. Its old scenario — enroll twice, never connect — was
demonstrating the unsafe path, so it now covers both a reboot that had connected
and a clone cold-boot, and asserts the first host's row and name survive:

  Scenario 1 reused_same_row: true   roster_rows: 1   PASS
  Scenario 2 vmB_took_vmA_row: false roster_rows: 2   vmA_row_name_now: clone-a   PASS

Tests rewritten against the safe semantics; the clone case fails without this
change. Full engine suite 711/711.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

* fix(engine): gate machine_id reuse on proven liveness, not lastHeartbeatAt

Both P1s from the 17:50 review. They share one root, so they are fixed together
rather than patched separately.

`lastHeartbeatAt` does not mean "this node proved it was alive". Enumerating
every writer of the column on `nodes`:

  PROOF OF LIFE (a frame arrived)
    heartbeatNode            node.ts:598    node.heartbeat, direct branch
    heartbeatProvider        nodeProvider.ts:162  node.heartbeat, broker branch
  REGISTRATION (the node connected; it never sent a heartbeat)
    registerNode             node.ts:523
    upsertProvider           nodeProvider.ts:114,133
    recomputeNodeAggregate   nodeProvider.ts:352  (`lastHeartbeatAt ?? new Date()`)
  DISCONNECT CLEANUP (proves the opposite)
    markNodeOffline          node.ts:675,684
    markProviderOffline      nodeProvider.ts:177
    markDirectNodeOfflineForAgent  node.ts:1103
  IMPLICIT DIRECT-NODE LIFECYCLE
    ensureDirectNodeForAgent node.ts:1003,1034,1078,1086
    agent.ts:225 (null)

So a node that registered and never heartbeated satisfied the old gate once the
TTL passed — P1 (confidence 9). And `createNodeToken` rotated `tokenHash` while
preserving the stale timestamp, so a row reused once still looked proven and
could be taken again immediately, revoking the credential just issued — P1
(confidence 10).

Migration 0044 adds `proven_live_at`, written ONLY by the two heartbeat paths
and cleared in `createNodeToken`'s update branch when a row's token is
re-issued. `isReusableForMachineMatch` reads that column; the SQL pre-filter
mirrors it.

Three tests, each failing against the specific thing it guards:
- reverting only the clear-on-rotation fails the double-reuse test
- reverting only the predicate to lastHeartbeatAt fails the registered-only test
  and the double-reuse test
- an end-to-end test asserts registration leaves provenLiveAt null while a
  heartbeat frame sets it

Probe unchanged in shape, re-run on this build:
  Scenario 1 reused_same_row true, roster_rows 1                          PASS
  Scenario 2 vmB_took_vmA_row false, roster_rows 2, vmA name clone-a      PASS

Full engine suite 714/714.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

* fix(engine): mirror the full reusability predicate in SQL

Six threads from review run a9cf7c6a. The three substantive ones are one
defect, and it is a regression I introduced in 20cbb08: moving the gate from
lastHeartbeatAt to provenLiveAt, I REPLACED the SQL predicate instead of
extending it, dropping two guards I had added earlier for exactly these cases.

CodeRabbit (Major) and cubic (P2), node.ts:402 — `provenLiveAt < liveCutoff`
does not mean "not live". A broker that registered a moment ago has a fresh
lastHeartbeatAt and is live, while its last real proof can be hours old. Those
rows were admitted, filled the scan window ahead of the reusable row, and
enrollment created another. The TS re-check rejected them, but only after the
LIMIT had spent the window.

cubic (P1), placement.ts:111 — a future lastHeartbeatAt makes isNodeLive return
false on the negative age, so a stale proof read as reusable while the broker
was still running. The future check moved to provenLiveAt and was not kept on
lastHeartbeatAt.

The SQL now mirrors isReusableForMachineMatch completely — stale proof, not
live, and no future-dated heartbeat — so the bound counts only reusable rows.

cubic (P2) on the fixture was the sharpest of the six: the crowded-machine test
had no provenLiveAt on its 25 live rows, so they never survived the proof filter
and never reached the scan window. The test was passing for the wrong reason and
had never exercised the regression it was named for. They now carry a stale
proof with a fresh heartbeat, the exact shape that must be admitted by the proof
filter and rejected as live.

Reverting each fix alone fails its own test:
  SQL liveness clauses  -> × finds the reusable row even when many live brokers…
  future-heartbeat check -> × does not reuse a row with a stale proof but a future heartbeat

Two minor, both fair: the engine changelog entry had become a design note
against the AGENTS.md concision rule, and the README stated the no-double-reuse
guarantee unqualified when serializeMachineEnroll is isolate-local. The README
now scopes it to one process and names the boundary.

Machine-id suite 18/18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978

* test(engine): pin the SQL future-heartbeat clause with a crowding case

cubic P3: the future-heartbeat test used an `online` row, which the SQL
not-live clause already excludes, so the separate future-heartbeat clause could
be deleted without failing anything.

Their suggested fix — switch the row to `offline` — does not pin it either, and
I checked before applying it. An offline row with a future heartbeat is admitted
by SQL and then rejected by `isReusableForMachineMatch`, so the test still
passes with the SQL clause removed. For a single row the JS check is the real
guard and the SQL clause is only a pre-filter.

What the clause actually buys is scan-window headroom, so only crowding shows
its absence: 25 future-dated rows ahead of one reusable row. Without the clause
they fill the window and the reusable row is never seen.

  removed SQL future clause -> × finds the reusable row behind many future-dated rows

Kept the `offline` status change as well, since it exercises the JS future check
on a non-live row rather than duplicating the live case.

Machine-id suite 19/19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgtVrPUzGDZwa89Bg4deaL

Session-Id: c39a612d-f08b-44de-837c-dfcd1819a978
@miyaontherelay
miyaontherelay restored the codex/relaycast-release-0902 branch September 3, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants