Skip to content

Keep the snapshot a ref resolves against where every replica can read it - #46

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
kevin9327:fix/gateway-snapshot-cross-replica
Aug 21, 2026
Merged

Keep the snapshot a ref resolves against where every replica can read it#46
davidmckayv merged 2 commits into
CopilotKit:mainfrom
kevin9327:fix/gateway-snapshot-cross-replica

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

What this changes

The gateway resolves the opaque ref in an acting call into the element it points at, from the
snapshot this server took, and then decides and audits against that element — step 1 of
resolve → decide → record → act, the one the file header calls "fatal to skip". That mapping lived
in a Map in the process.

With more than one server process the snapshot lands on one and the click that uses its refs on
another, so the Map is empty on the replica that answers the click: the ref resolves to nothing,
the policy decides with no element in its context, and the audit row can only record
"not in the current snapshot". A deny rule written against the element then fails closed and
refuses every click on those replicas, which reads as a flaky computer; a rule that does not name the
element lets the click through unresolved and unrecorded, which reads as the boundary being quiet
because nothing matched. Either way the boundary stops doing what the operator wrote, and nothing
says so. This is the cache #21 flagged as still having the same problem, after taking two features
back for exactly this shape.

This moves the snapshot into Postgres — one row per computer (computer_snapshot), upserted on every
snapshot, read on the action path — so a ref resolves on whichever replica the click lands on. A ref
resolves only when the snapshotId it carries matches the stored generation, so a ref from a
superseded page resolves to nothing rather than to whatever now holds it: the concern that a
persisted cache would "decide on fiction" is answered by the generation, not by keeping the cache in
memory. The agent-computer still makes the same generation check when the action arrives; this keeps
the policy decision and the audit row honest first, on whichever replica the action landed.

Where it runs

  • New state that outlives a request? The last snapshot per computer. It lives in Postgres,
    table computer_snapshot, keyed by computer_id — not a Map or a closure.
  • What happens on the second replica? The click loads the snapshot from Postgres and resolves
    the ref, so the policy sees the real element and the audit row names it. Before this change the
    ref resolved to nothing there and the boundary was blind.
  • Anything serialised? One row per computer, primary key computer_id, written with
    INSERT … ON CONFLICT (computer_id) DO UPDATE. Two processes snapshotting the same computer:
    last write wins, which is the correct meaning, because the newest generation is the current
    page. No check-then-write.
  • Anything fanned out to a browser? No. This is server-side resolution state, read on the
    action path, never pushed to a socket.
  • New listener, port, or schedule? None. Same ingress, same Postgres.

Boundary and audit

  • Every acting call still goes through the gateway: resolve, decide, audit, then act. Only
    resolve changed — it reads the shared snapshot and matches the generation.
  • New refusals and new failures each write a row. A superseded ref is unchanged: no element
    resolves, the row says so, and the computer refuses it on arrival.
  • Nothing new is trusted from the client. The snapshotId only narrows resolution: it yields the
    true element of the current snapshot or nothing, never a different element than the ref already
    names. The ref-to-element mapping stays server-held.

Proof

Local (Windows, Bun 1.3.14, no Postgres):

  • bun run typecheck across app/server/worker — clean.
  • biome check (format, lint, organizeImports) on every changed file — clean.
  • bun test server/tests/computer-gateway.test.ts server/tests/computer-snapshot-store.test.ts
    — 24 pass. The three new gateway tests build two gateways over one shared store (two replicas, one
    database) and prove a click is resolved and refused on the replica that never took the snapshot,
    that the resolved element label is available there, and that a ref only resolves against its own
    generation.
  • The rest of the non-integration suite passes. The only local failures are the channel-store and
    credential tests that need Postgres and the agent-computer symlink tests that need Windows symlink
    permission — both environmental, neither in the changed code.

CI covers what this machine cannot: drizzle-kit migrate applies 0001_computer_snapshot, and
computer-snapshot-store.integration.test.ts (modelled on policy-durability.integration.test.ts)
proves a snapshot saved by one store is resolvable by a second store on the same database, that a
newer snapshot supersedes the last with one row per computer, and that the JSON round-trip keeps each
element's type.

This is independent of my open #45 (fleet listing); it touches computer/gateway.ts, a new
computer/snapshot-store.ts, the computer_snapshot table, and the composition root only.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@kevin9327
kevin9327 force-pushed the fix/gateway-snapshot-cross-replica branch from c6f0d42 to 7ebe503 Compare August 21, 2026 10:06
The gateway turns the opaque ref in an acting call into the element it points at, from the snapshot
this server took, and then decides and records against that element. That mapping lived in a Map in
the process. OpenBot runs several server processes behind a load balancer, and the process that
answers a snapshot is rarely the one that answers the click that uses its refs, so on every other
replica the Map was empty: the ref resolved to nothing, the policy decided with no element in front
of it, and the audit row could not name what was touched.

Neither outcome is loud. A deny rule written against the element fails closed and refuses every
click on the replicas that did not snapshot, which reads as the computer being flaky. A rule that
does not name the element lets the click through unresolved and unrecorded, which reads as the
boundary being quiet because nothing matched. Either way the boundary is not doing what the operator
wrote, and nothing says so. CopilotKit#21 took two features back for this exact shape and noted this cache had
it too.

So the snapshot goes through Postgres, the way channel activity and the policy already do: one row
per computer, upserted on every snapshot, read on the action path. A ref resolves on whichever
replica the click lands on.

Staleness, the reason a persisted snapshot cache is rightly regarded with suspicion, is answered by
the generation the far-side computer stamps on every snapshot. A ref resolves only when its
generation matches the stored one, so a ref from a superseded page resolves to nothing rather than
to whatever now holds it: it cannot resolve to a name that is no longer on screen, because a ref
from an old screen no longer matches. The computer makes the same generation check when the action
reaches it; this keeps the policy decision and the audit row honest first, on whichever replica the
action landed.

The client-supplied snapshotId only ever narrows resolution. It can yield the true element of the
current snapshot or nothing, never a different element than the ref already names, so nothing new is
trusted from the client that the server does not resolve itself.

Without a database the gateway still keeps snapshots in memory, so a single-process test does not
need Postgres, exactly as the policy store does not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@davidmckayv
davidmckayv force-pushed the fix/gateway-snapshot-cross-replica branch from 7ebe503 to ca79016 Compare August 21, 2026 14:46
Two things the cross-replica move needs that a single process did not.

The upsert was last-write-wins. Two replicas snapshotting the same computer is the case this table
exists for, and Postgres has no say in which of their writes arrives second, so the older generation
could overwrite the newer one and every ref the model was holding would stop resolving. The
generation decides instead: it is stamped by the computer and increases by one per snapshot, so a
lower number is an older page. Clocks cannot decide it, because there are two of them.

Wiping a computer now clears the row. A fresh computer counts generations from one again, so a row
left behind lets a ref from the previous session match the new session's first snapshot, and the
policy decides against an element from a page that is gone. The generation only tells snapshots apart
within a session, so the row has to go when the session does.

Tests for both, and for clear on each store. The ordering one fails without the guard.
@davidmckayv
davidmckayv force-pushed the fix/gateway-snapshot-cross-replica branch from ca79016 to f0e1785 Compare August 21, 2026 15:52

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one that actually changes what the product is, and I proved the failure it fixes rather than reasoning about it.

Two replicas on one Postgres, same deployment, same deny rule contains(element.name, "submit"), same ref. Replica A snapshotted; replica B, which never did, handled the click.

With the store in Postgres: B resolved f1e11 to "Submit order" and refused it by name, and allowed the Cancel button beside it with the element correctly resolved.

With the store back in process memory, which is what main does: B submitted the order. The URL moved to /thanks, and the audit row recorded it as allowed, with element "not in the current snapshot" and no rule. Not a refusal, not a flake. The boundary was simply not there, and the trail said the action was fine.

That is worth stating plainly, because it is a sharper failure than the PR body claims. The body says a deny rule fails closed and refuses everything on the other replicas. It does not: govern binds every context field to a neutral value when nothing resolves, so contains("", "submit") is false, the deny does not match, and the click goes through. It fails open, quietly, on every replica but one.

The generation check is safe. asRef requires ref and snapshotId together on all three acting routes, so a ref never reaches the gateway without the generation it came from, and the far side already made the same check.

Two things I added on top, in their own commit:

  • The upsert was last-write-wins. Two replicas snapshotting the same computer is the case this table exists for, and Postgres has no say in which write lands second, so an older generation could overwrite a newer one and every ref the model was holding would stop resolving. It is now forward-only on snapshot_id, which is stamped by the computer and increases by one. Clocks cannot decide it; there are two of them. The test fails without the guard.
  • Wiping a computer now clears the row. main gained snapshots.delete(botId) on reset after this branch was written. A fresh computer counts generations from one again, so a row left behind lets a ref from the previous session match the new session's first snapshot, and the policy decides against a page that is gone.

I also rebased onto main, which had moved a long way: the migration renumbered to 0005 (regenerated, not hand-edited), the gateway is now behind the ComputerProvider seam and keyed on botId, and the cross-replica tests needed porting to that harness.

48 tests pass across the three files.

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