A ref points at an element on a page. It is only meaningful for the run of the computer that produced it, so the gateway checks the run before resolving one. That check does not run on a replica that did not take the snapshot, and on a single process it runs one action too late. Either way a ref from a computer that has since been replaced still resolves, and the click goes through.
What it looks like
Three clicks, all carrying a ref from a computer that no longer exists:
cold replica click: {"action":"click","element":{"role":"button","name":"Submit order"}}
first click after replacement: {"action":"click","element":{"role":"button","name":"Submit order"}}
second click after replacement: {"name":"StaleSnapshotError"}
The third one is what should happen every time. The first two are allowed, and the audit row for each records Submit order — a button on a page that is gone.
Why it happens
The server remembers which run of a computer it last saw in a plain Map inside one process (supervisor.ts:25). locate writes to that map (:172) and sessionOf reads from it (:164) without asking the supervisor anything. So:
- Another replica has nothing in its map for this Bot, and
sessionOf returns undefined. An unknown run is not treated as a mismatch (gateway.ts:386), so the check is skipped.
- On the process that does have a value, the value is old.
govern reads it (gateway.ts:421) before this action's own locate has run, so it sees the run from the previous action. The next action sees the right one, which is why the second click is refused.
This is structural. The snapshot was moved into Postgres so any replica could resolve a ref against it. The run, which is what makes the snapshot's generation mean anything, stayed in one process's memory.
Reproduction
Against d293f23, as a Bun test using the real createDockerSupervisorProvider with a stubbed supervisor and computer.
- Build a gateway on the real provider, with
/ensure reporting startedAt: "…T10:00:00.000Z".
await gateway.snapshot("bot-a").
- Change the reported
startedAt to "…T11:30:00.000Z". This is the supervisor replacing the container because the image tag moved, which it already does without telling the server.
await gateway.click("bot-a", actor, { ref: "e9", snapshotId: 7 }). It should raise StaleSnapshotError. It is allowed instead, and audited as the old run's Submit order. Repeat the same click and it is refused.
- On a process that has never called
locate for bot-c, save a snapshot for bot-c carrying the older run into the store, then click e9 at generation 7. sessionOf returns undefined, the check is skipped, and the click is allowed.
Step 5 is what every replica that did not take the snapshot does, until it has located that Bot itself.
Why it matters
A generation only counts within one run. A replaced container starts again at one and reaches generation 7 again, so the same ref resolves on both sides — to two different pages. A rule like "never click Confirm transfer" can then miss a click that is on it, and the audit row names an element the Bot never touched.
One decision this needs
What should an unknown run mean? Right now it means "carry on as before", which is what lets a replica skip the check. Treating it as a mismatch would refuse instead — safer, but it would also refuse for the single shared computer, which has no run to report at all. Worth deciding deliberately rather than leaving it to fall out of a truthiness test.
Severity
Moderate, and it fails open rather than closed. Nothing is wrong on a single process that never replaces a computer mid-conversation. It bites in the two cases this project treats as normal: more than one server replica, and an upgrade that moves the computer image while a conversation is open.
I have a reproduction as a Bun test and am happy to open a PR once there is a view on the question above.
A ref points at an element on a page. It is only meaningful for the run of the computer that produced it, so the gateway checks the run before resolving one. That check does not run on a replica that did not take the snapshot, and on a single process it runs one action too late. Either way a ref from a computer that has since been replaced still resolves, and the click goes through.
What it looks like
Three clicks, all carrying a ref from a computer that no longer exists:
The third one is what should happen every time. The first two are allowed, and the audit row for each records
Submit order— a button on a page that is gone.Why it happens
The server remembers which run of a computer it last saw in a plain
Mapinside one process (supervisor.ts:25).locatewrites to that map (:172) andsessionOfreads from it (:164) without asking the supervisor anything. So:sessionOfreturnsundefined. An unknown run is not treated as a mismatch (gateway.ts:386), so the check is skipped.governreads it (gateway.ts:421) before this action's ownlocatehas run, so it sees the run from the previous action. The next action sees the right one, which is why the second click is refused.This is structural. The snapshot was moved into Postgres so any replica could resolve a ref against it. The run, which is what makes the snapshot's generation mean anything, stayed in one process's memory.
Reproduction
Against
d293f23, as a Bun test using the realcreateDockerSupervisorProviderwith a stubbed supervisor and computer./ensurereportingstartedAt: "…T10:00:00.000Z".await gateway.snapshot("bot-a").startedAtto"…T11:30:00.000Z". This is the supervisor replacing the container because the image tag moved, which it already does without telling the server.await gateway.click("bot-a", actor, { ref: "e9", snapshotId: 7 }). It should raiseStaleSnapshotError. It is allowed instead, and audited as the old run'sSubmit order. Repeat the same click and it is refused.locateforbot-c, save a snapshot forbot-ccarrying the older run into the store, then clicke9at generation 7.sessionOfreturnsundefined, the check is skipped, and the click is allowed.Step 5 is what every replica that did not take the snapshot does, until it has located that Bot itself.
Why it matters
A generation only counts within one run. A replaced container starts again at one and reaches generation 7 again, so the same ref resolves on both sides — to two different pages. A rule like "never click Confirm transfer" can then miss a click that is on it, and the audit row names an element the Bot never touched.
One decision this needs
What should an unknown run mean? Right now it means "carry on as before", which is what lets a replica skip the check. Treating it as a mismatch would refuse instead — safer, but it would also refuse for the single shared computer, which has no run to report at all. Worth deciding deliberately rather than leaving it to fall out of a truthiness test.
Severity
Moderate, and it fails open rather than closed. Nothing is wrong on a single process that never replaces a computer mid-conversation. It bites in the two cases this project treats as normal: more than one server replica, and an upgrade that moves the computer image while a conversation is open.
I have a reproduction as a Bun test and am happy to open a PR once there is a view on the question above.