Skip to content

Observer verification: fix coverage bookkeeping, restart sessions when scope widens - #380

Open
Maximo-Guk wants to merge 7 commits into
mainfrom
observer-session-restart
Open

Observer verification: fix coverage bookkeeping, restart sessions when scope widens#380
Maximo-Guk wants to merge 7 commits into
mainfrom
observer-session-restart

Conversation

@Maximo-Guk

@Maximo-Guk Maximo-Guk commented Aug 28, 2026

Copy link
Copy Markdown
Member

Why:

Before this PR: We didn't sever existing observers, this was just actually documented in the original plan "existing observers see an incremental modal for just the new binding on their next open..."

The main gap we have right now is adding a binding does not restart live sessions, so an already-open collaborator is only verified against it at their next open.

What:

authorizeCollaborator() is now responsible for being the single gate for checking whether observers have access to underyling data. Everything routes through here, including receiveExternalMessage() which previously only checked the role and never checked whether the observer had access to the underlying data.

We also decided to abort the DO in order to revoke the RPC capabilities, we already use the same mechanism when revoking collaborators and we can just the same mechanism here. We made this a little less disruptive by checking graph first to see if there's any other collaborators before aborting the DO

Testing:

I added extensive integration tests in observer-role-scope.test.ts, observer-reverification.test.ts and external-message-verification ( even though it's actually used yet in any gatekeepers ) for testing the observer verification logic

@github-actions github-actions Bot added the kernel Changes to the Workshop kernel label Aug 28, 2026
Maximo-Guk and others added 7 commits August 28, 2026 16:44
The persisted observer record is the standing claim that a collaborator was verified
for a producer -- `ensureObserver` reads it back on their next open and re-registers
them off the account choice it holds, and `authorizeObservation` reads it from other
turns. So a collaborator whose live re-verification just failed must not keep an entry
saying they are covered: until now a revoked collaborator stayed "verified" until their
next *successful* open.

`fail()` now drops the failed gatekeeper from the persisted `accountChoices`
synchronously with the failure determination, `getVerifier` moves inside the
per-gatekeeper `try` so a verifier-acquisition rejection scrubs like any other refusal
(and surfaces the descriptive denial rather than the raw RPC error, with no mid-flight
`Promise.all` rejection to stale the rollback snapshot), and the terminal catch
de-registers invalidated registrations alongside newly-added ones.

That last part is a fail-open regression for a *returning* observer, marked with a TODO
here and fixed in the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The persisted observer record is meant to state what a collaborator's most recent open
verified, and `ensureObserver` re-registers them off the account choices it holds. But
a choice for a gatekeeper outside their current verification scope survived every open
that could not check it: a "use" collaborator who opens while a connection is unbound
from every gadget verifies nothing against it, yet their stale entry stays -- and the
moment the connection is rebound (rebinding keeps the same gatekeeper id) the next open
silently re-registers them off a choice made for a scope the workspace no longer has,
instead of asking them again.

Step 2 now drops every account choice for a gatekeeper outside the collaborator's live
verification scope, even when the remaining scope is empty (that is exactly the
everything-unbound open). The gatekeeper-side registration is deliberately kept: it
preserves forward exclusion via `byObserverId`, and the next successful open's
`addObserver` overwrites the verifier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n fails.

`ensureObserver`'s rollback removed the gatekeeper registrations of every binding that
failed the call (`invalidated`), not just the ones the call created (`newlyAdded`). For a
collaborator who was already an admitted observer, that de-registered them from a
gatekeeper they had previously been verified against -- and a de-registered observer is
one the gatekeeper stops naming in `ObservationDescription.excludeObservers`, so an
observation it would have excluded them from is admitted with nothing left to block it.

The coverage scrub this rollback accompanies is not a substitute for the registration.
They cover different sets: `gatekeeper-confluence` -- the only in-repo producer of
`excludeObservers` -- never marks an observation `prohibitAllSharing`, so for it the
scrub covers none of the affected reads. The reachable sequence is a collaborator whose
Confluence access is revoked upstream, whose re-open therefore fails, and whose
pre-existing live session then watches the owner's agent read a page they cannot access.

So roll back `invalidated` only on a first-ever verification, where the minted
observerId is discarded along with the unpersisted record and a registration left behind
would linger unresolvable. A returning observer's id is already persisted, so keeping
their registrations is fail-closed (a registration can only add exclusion names) and the
next successful open's `addObserver` overwrites the verifier. This restores the invariant
`registeredBeforeCall` was introduced to state: roll back only what this call added.
Coverage is still scrubbed either way, so a revoked collaborator's record stops claiming
they were verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ens.

Authorization and observer verification run only at open(). Nothing re-ran
them when the set of gatekeepers a collaborator must be verified against
*grew* mid-session -- adding a connection, or binding one into a gadget --
so a collaborator who opened before the growth kept a live session holding
access they were never verified for.

Fix it with the mechanism already used to revoke a collaborator: generalize
scheduleRevocationRestart() to scheduleAccessRestart(reason) and add
#restartIfShared(), which flushes, waits 100ms and aborts the DO so every
client reconnects and re-opens against the new scope. It is a no-op when the
workspace has no collaborators, so a solo workspace is never disturbed.

Four sites widen scope and now restart: addGatekeeper, a permanent
bindWorkpiece, a merge that promotes a binding edge into "use" scope, and a
denied re-verification that scrubbed a persisted account choice. The merge
case compares the effective account-requiring "use" scope before and after
promotion rather than restarting on any promotion, since most merges promote
neither a gadget with bindings nor an edge to a connection anyone is verified
against. It reads the scope through the non-throwing gatekeeperVendorId()
rather than #inScopeGatekeepers("use"), whose observerVendorId() throws on a
legacy record with no creationSpec -- an unrelated legacy connection must not
turn an accepted merge into an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`addGatekeeper` published the gatekeeper record before awaiting the gatekeeper's
`describe()`, because `getGatekeeperFacet(id)` resolved the class from that record. The DO's
input gate is open across the await and ids are allocated sequentially, so a live `build`
session could guess the id and `getGatekeeperById()`/`openSession()` on the owner's
brand-new connection -- which gates on nothing but record existence -- for as long as
`describe()` took, all of it before `#restartIfShared` severed it.

`getGatekeeperFacet` now optionally takes the class directly, so the record is published
exactly once, after `describe()` resolves. Nothing a gatekeeper's `describe()` can reach
calls back into the overseer to resolve itself by record, so no caller needs the early put.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
receiveExternalMessage() checked only the caller's role. Observer
verification -- which is how a collaborator earns the right to see what the
workspace has read -- runs at open(), so a "build" collaborator who never
opened the workspace, or whose upstream access was since revoked, could still
drive the agent and have it answer out of chat history and gadget storage.

Extract the gate open() applies into authorizeCollaborator(): resolve the
effective role from the permission graph, then run ensureObserver for that
role. Both entry points call it. The external path passes requireRole:
"build", so an insufficient role is denied before verification runs -- a "use"
collaborator would otherwise be verified, or told to go fix a verification
failure, for access this path can never grant them. It also passes no
configureCb, since there is no channel to prompt on: an unverified caller is
told to open the workspace in a browser, which is where configuration happens.

roleRank is exported for the requireRole comparison, so it ranks rather than
string-compares.

Also has open() await ambient reconciliation before authorizing rather than
between the role check and verification, which is where the two halves now
join.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/observers.md gains a "Restarting when verification scope widens"
subsection: the four triggers, why the merge trigger compares scopes rather
than firing on any promotion, why shrinking scope and role rises are
deliberately not triggers, why addGatekeeper's publication order is
load-bearing under the restart, and where the enforcement moment actually
falls for each trigger. Step 3 gains the record prune, the scrub-and-restart
failure path and the returning-observer rollback rule; edge cases 3 and 5 are
rewritten around them, and Step 6's justification for an orphaned entry is
corrected -- a registration is what admits an open, so a stale one grants
nothing on its own.

docs/sharing.md renames scheduleRevocationRestart and documents the abort's
second purpose, whose trigger is a grant rather than a revocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Maximo-Guk
Maximo-Guk force-pushed the observer-session-restart branch from db563f7 to 4aa84ec Compare August 28, 2026 21:51
@github-actions

Copy link
Copy Markdown

Preview: pr380-observer-sess-c41973bd

https://pr380-observer-sess-c41973bd-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

gatekeeperRecord.resourceTitle = description.title;
gatekeeperRecord.resourceUrl = description.url;
gatekeeperRecord.hasSlashCommands = description.hasSlashCommands;
this.storage.gatekeepers.put(gatekeeperRecord);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Keep the widened capability hidden until old sessions are invalidated

The record becomes collaborator-visible here, but the restart below still has to resolve getSharingManager(), sync storage, and sleep for 100 ms. During that interval an existing build collaborator can use the sequential ID to pipeline getGatekeeperById(id).openSession() and read from this brand-new resource without ever passing observer verification. Unlike the revocation delay, this is a first-time grant of access, so the grace window violates the observer invariant. Please gate access by the session generation / verified scope until reconnect, or otherwise invalidate collaborator sessions before publishing the record.

#restartIfShared(reason: string): void {
this.getSharingManager().then(sharing => {
if (sharing.listCollaborators().length === 0) return;
return this.scheduleAccessRestart(reason);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Do not let this delayed abort overtake a later access mutation

A widening call can sync and enter the 100 ms wait, then a concurrent owner request can remove a collaborator: the permission edge is written synchronously, but that request awaits observer teardown/listing refresh before reaching its own scheduleAccessRestart(). The earlier timer can therefore call ctx.abort() first; because abort ignores the output gate, the later revocation write may be lost and the collaborator can become authorized again after restart. Coalesce these restarts and perform a final sync while preventing new access mutations immediately before aborting.

// trip happened, since nothing persisted in the Workshop contains it. An implementation
// that merely checked the record would keep accepting him here.
await setVerifyOutcome(accountLabel(bobAccount), { allow: false, reason: DENIED_REASON });
const revoked = await submitExternalMessage({ callerEmail: bob, gadgetKey, prompt: "hi" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Wait for the restart triggered by this denial

This failed re-verification scrubs Bob's persisted choice and schedules ctx.abort() about 100 ms later, but the test returns immediately and disposes its workspace/session. observer-reverification.test.ts documents that an abort landing after the last client leaves crashes local workerd; because these tests are concurrent, this can fail an unrelated sibling. Keep a probe alive and wait until it observes the disconnect (or otherwise settle the restart) before leaving the test.

@ask-bonk

ask-bonk Bot commented Aug 28, 2026

Copy link
Copy Markdown

Submitted 3 actionable inline review findings.

github run

@Maximo-Guk
Maximo-Guk marked this pull request as ready for review August 28, 2026 22:11

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Devin Review

Comment on lines +2230 to +2232
if (chatId === undefined && targetRecord.creationSpec &&
"vendorId" in targetRecord.creationSpec) {
this.#restartIfShared("Gadget restarted because a connection was bound to a gadget.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Rebinding an in-scope connection needlessly restarts the workspace

A permanent bind of a vendor-backed connection restarts every session, even when that connection is already in the use scope. Rebinding it into another gadget or under another name widens no one's scope, yet still severs the shared workspace — unlike mergeChatChanges, which compares scope before and after.

Prompt for agents
In bindWorkpiece (packages/workshop-backend/src/overseer.ts), the permanent-bind branch unconditionally calls #restartIfShared when the target gatekeeper has a vendorId. But a gatekeeper can already be bound by another gadget or under another name, in which case its id is already in #gadgetBoundGatekeeperIds()/#accountRequiringUseScope() and binding it again widens no 'use' collaborator's verification scope. The mergeChatChanges path avoids this by snapshotting #accountRequiringUseScope() before promotion and only restarting when the after-set contains a new id. Apply the same guard here: before the bind, check whether the target id was already present in the account-requiring use scope, and only call #restartIfShared if the bind actually adds it. This prevents needlessly severing every session on a shared workspace for a bind that changed nobody's scope.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kernel Changes to the Workshop kernel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant