Skip to content

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

Draft
Maximo-Guk wants to merge 13 commits into
mainfrom
observer-session-restart
Draft

Observer verification: fix coverage bookkeeping, restart sessions when scope widens#380
Maximo-Guk wants to merge 13 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
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note that, obviously, any writes have to happen before the abort or they will be lost. Moreover, the grace period is important to make sure writes actually settled. An abort() breaks the output gate, causing existing in-flight writes never to land. At the very least, we have to await ctx.storage.sync(), but that also gives a chance for a client to jump in with a request.

Perhaps the easiest solution is to use blockConcurrencyWhile().

await this.ctx.blockConcurrencyWhile(async () => {
  await this.ctx.storage.sync();

  // Throwing from blockConcurrencyWhile also resets the DO.
  throw new Error("Gadget restarted because a new connection was added.");
});

Another option would be to store in-memory that the given gatekeeper should be blocked from being accessed -- the mark naturally goes away after restarting. But, this seems more complicated, and blockConcurrencyWhile() should do the trick I think.

#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[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

// whoever can't will simply be denied their next open. This cannot loop: a second identical
// failure finds the entry already scrubbed, so no flag and no restart. A re-prompt that
// repairs the failure never reaches here, and step 6 re-persists full coverage.
if (scrubbedCoverage) {

@ask-bonk ask-bonk Bot Aug 31, 2026

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] Invalidate existing sessions before awaiting repair UI

By this point a failed addObserver() may already have scrubbed persisted coverage, but the restart is not scheduled until this catch runs. A configured open first awaits the collaborator-controlled configureCb.configure() retry (and later diagnostic/rollback RPCs), so a callback that never settles leaves other previously admitted sessions usable indefinitely after re-verification confirms the collaborator no longer has access. Revoke the old session generation, or at least schedule/coalesce the restart, immediately when the persisted choice is scrubbed and before any repair callback is awaited.

// exists by then, so the client's retry is clean.
#restartIfShared(reason: string): void {
this.getSharingManager().then(sharing => {
if (sharing.listCollaborators().length === 0) return;

@ask-bonk ask-bonk Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Restart only when the affected role is present

This checks for any collaborator even though the callers widen different scopes: addGatekeeper() widens only build, while binding/promotion widens only use. Adding an unbound connection to a workspace with only use collaborators, or binding one when all collaborators are build, therefore aborts every in-flight session although nobody has new verification requirements. Pass the affected role into this helper and schedule only when listCollaborators() contains that role.

@ask-bonk

ask-bonk Bot commented Aug 31, 2026

Copy link
Copy Markdown

Submitted 2 actionable inline review findings.

github run

@ask-bonk

This comment was marked as resolved.

@kentonv kentonv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I started reviewing this but I see you're making other changes so decided to post what I have and come back later.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note that, obviously, any writes have to happen before the abort or they will be lost. Moreover, the grace period is important to make sure writes actually settled. An abort() breaks the output gate, causing existing in-flight writes never to land. At the very least, we have to await ctx.storage.sync(), but that also gives a chance for a client to jump in with a request.

Perhaps the easiest solution is to use blockConcurrencyWhile().

await this.ctx.blockConcurrencyWhile(async () => {
  await this.ctx.storage.sync();

  // Throwing from blockConcurrencyWhile also resets the DO.
  throw new Error("Gadget restarted because a new connection was added.");
});

Another option would be to store in-memory that the given gatekeeper should be blocked from being accessed -- the mark naturally goes away after restarting. But, this seems more complicated, and blockConcurrencyWhile() should do the trick I think.

// exists by then, so the client's retry is clean.
#restartIfShared(reason: string): void {
this.getSharingManager().then(sharing => {
if (sharing.listCollaborators().length === 0) return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of checking whether any collaborators exist on the sharing graph, we should check whether anyone is actually connected. We can keep a count of the number of live OverseerClientInterface and UseOverseerInterface objects (increment count in constructor, decrement in disposer) in order to decide whether anyone is connected. (We can even count connections by the owner separately from collaborators.)


let fail = (reason: string, err?: unknown) => {
failures.set(gk.id, {accountId, reason});
// The persisted record is what asserts this collaborator was verified for this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this is incorrect. The existence of an entry in accountChoices doesn't assert anything about the user's verification status. It simply records the user's choice, so that we don't have to ask them again.

A secondary, though less important, role of accountChoices is to record which gatekeepers may have already had the user registered via a successful addObserver(). Those gatekeepers potentially need later removeObserver() calls to clean up. However, this isn't strictly necessary -- if we accidentally leave a user registered on removeObserver() when they no longer have access, then when they show up in excludeObservers in the future, we can detect that the observer is no longer present and remove them at that time. So existence in accountChoices is more of a hint for this purpose.

But regardless, nothing should be assuming that an entry in accountChoices means the user is actually verified.

Under this understanding, I believe this commit can be removed, since there's not necessarily any need to delete a choice just because it failed re-verification. If we do want to delete it, though, we would also need to call removeObserver() on the gatekeeper to fully remove this observer. But if we do that, we also need to make sure that the observer doesn't have any other live connections open, because if they do, those connections would then be able to bypass excludeObservers.

So I think we should actually just remove this commit.

let pruned = false;
for (let key of Object.keys(record.accountChoices)) {
if (!inScopeIds.has(Number(key))) {
delete record.accountChoices[Number(key)];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Similar to the other comment -- this seems to misunderstand accountChoices.

I think we can leave the record alone. If the gatekeeper ever becomes in-scope again, then the user will probably want to keep the same choice they made earlier.

Note, though, that in the meantime, the user is still registered as an observer on the gatekeeper (and even with the change written here, they still would be -- there's no call to gatekeeper.removeObserver() here). This means they may show up in excludeObservers spuriously. We should have the excludeObservers handling detect that the gatekeeper is no longer in-scope for the user, and then remove the observer from the gatekeeper, even though the observer is still in the workspace's observers table. Currently (unless it changed elsewhere in this PR that I haven't seen yet), when the user shows up in excludeObservers it will actually cause the observation to be blocked, which isn't right if the gatekeeper is no longer in-scope for the user...

Maximo-Guk and others added 12 commits August 31, 2026 15:36
`ensureObserver` acquired the chosen account's verifier *outside* the per-gatekeeper
`try`, so a rejection from the client's User DO -- the deterministic vendor-mismatch
throw, or any cross-worker transport failure -- escaped its `Promise.all` instead of
being collected like every other refusal. Two consequences: the user saw the transport
error rather than the message naming the connection and account to fix, and there was no
repair prompt for a failure that a re-authenticate would have cleared.

It also raced the rollback. A mid-flight `Promise.all` rejection abandons the siblings
still in flight, so the `newlyAdded` snapshot the terminal catch rolls back could be
missing a registration one of them was about to add.

Move the `getVerifier` call inside the `try`, where `fail()` already handles a settled
denial and an operational failure identically.

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, not just the ones the call created. 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 reachable sequence is a collaborator
whose upstream access is revoked, 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 the failed bindings 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.

`invalidated` is the set that distinguishes them -- a binding that failed and has not
verified since, so a repaired pass takes its entry back out.

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
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>
scheduleAccessRestart() syncs, waits ~100ms, then calls ctx.abort(), which
ignores the output gate. The sync covered the write that triggered *this*
restart, but the timer runs concurrently with everything else: a
removeCollaborator() that has already written its sharing edge and is still
awaiting tearDownLostObservers()/refreshAffectedCollaboratorListings() gets
aborted with the revocation still buffered. The DO comes back with the
collaborator still a collaborator, while the owner's UI says the removal
succeeded. That is not a delayed revocation, it's a lost one, so unlike the
abort delay itself it isn't inside any tolerance.

So sync again immediately before the abort, with nothing awaited in between,
and coalesce concurrent triggers onto one timer instead of racing several.

This makes the storage write safe, which is the unbounded part. The awaited
external effects can still be cut short: a skipped removeObserver() leaves an
orphaned registration (harmless -- the gate is admission, and every open
re-runs addObserver), and the listing refresh self-heals on reconnect.

Also record, beside the existing residual note in docs/observers.md, that the
tolerance for an access change taking effect is 5s -- which is what makes the
abort delay an accepted residual rather than a defect, and this lost write not.
bindWorkpiece()'s guard asked whether the edge was permanent and its target
account-requiring, but never whether that target was already in "use" scope. So
binding a second name onto an already-bound connection severed every session on
a shared workspace for a widening that didn't happen.

Replace the condition with the before/after comparison mergeChatChanges()
already uses. #accountRequiringUseScope() filters on `pending` and
visibleBindings (subsuming the chatId check) and on gatekeeperVendorId
(subsuming the creationSpec/vendorId check), so this is a smaller condition
built from an existing helper, and both widening triggers now read identically.
two roles widen independently: a new connection enters every "build"
collaborator's scope at once and no "use" collaborator's until a gadget binds
it, and binding one enters "use" scope having been in "build" scope since it
was created. So adding an unbound connection severed every session on a
use-only workspace, and binding one severed every session on a build-only
workspace, in both cases without changing anyone's verification requirements.

Pass the affected role and skip when no collaborator holds it. The scrub
trigger passes none: a failed re-verification isn't a widening and must sever
regardless of role.

Also record two claims where the next reader will look for them, both raised
in review:

- Why the pre-abort sync is sufficient rather than merely a narrower window.
  The input gate is closed for the duration of a storage operation, so nothing
  can interleave a mutation between that sync and the abort; the window it
  closes is the open one before it, across scheduler.wait(100).
- Why the scrub trigger schedules its restart in the catch rather than at the
  scrub, leaving a delay the failing collaborator controls. Stalling the
  re-prompt preserves exactly the sessions that never re-opening would, so it
  is a way to decline to leave rather than a way in -- and scheduling at the
  scrub would cut off every repair before a human could answer it.

Tests cover both directions of the role filter; covering one would leave half
the condition unexercised.
A sync() followed by ctx.abort() only narrows the window it was meant to
close: a request delivered after the sync resolves can still write a
mutation that the abort then discards. Run the flush and the reset
together inside blockConcurrencyWhile() instead, throwing out of the
callback to reset the object -- nothing is delivered to the object for
the duration of the block, so there is no moment between "everything is
durable" and "the object is gone" at which anything can run.

The two sync() calls collapse into the one inside the block, which
covers every write issued before it (including a concurrent access
mutation's). The scheduler.wait() stays outside, since blocking
concurrency across the whole delay would stall unrelated requests. The
rejection is swallowed: unlike ctx.abort(), this form rejects, and all
three direct callers fire and forget.

This retracts the input-gate argument the previous commit rested on:
the gate is not what makes the barrier airtight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A restart's whole effect is to sever live sessions, so the sharing table
is the wrong thing to consult: a collaborator who isn't connected has no
session to cut, and an entry for one bought a workspace-wide reset that
reached only the owner.

OverseerImpl now counts live sessions by the capability each holds,
through a joinSession() pairing modelled on joinPresence(). Both client
interfaces join synchronously in their constructor and leave in
[Symbol.dispose], and #restartIfShared -- renamed #restartIfSessionsAffected,
since it no longer asks about sharing -- gates on a live non-owner session
of the affected role.

Being synchronous is the point: the old form reached the sharing manager
over RPC, so a widening could be missed outright when that lookup failed,
and the restart was scheduled some unbounded time after the change.

The counter is deliberately not derived from #presence, which looks like
it already holds this: a session joins presence only once its
fetchProfile() resolves, so a just-opened session is briefly invisible
there -- fine for a roster, fail-open for an access decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`excludeObservers` blocked whenever a named observer was still authorized
in the sharing graph, without asking whether that observer could actually
see the connection naming them. A "use" collaborator's opens only verify
them against gadget-bound connections, so unbinding one leaves their
registration on it untouched -- their next open neither re-registers nor
removes it -- and the gatekeeper goes on naming them forever, blocking
observations from a connection they have no way to reach.

`#enforceExcludeObservers` now takes the gatekeeper id its only caller
already has, and blocks only when the observer is still authorized *and*
that gatekeeper is still in their role's verification scope. Otherwise the
observation proceeds and they are de-registered from that one gatekeeper;
the record stays, since they are still a collaborator and a rebind must
put them back in scope rather than start them from scratch.

The scope test is fail-closed and narrow: out of scope means only "role is
`use`, the connection requires an account, and no gadget binds it". It
reuses #accountRequiringUseScope() rather than #inScopeGatekeepers, whose
observerVendorId() throws on a legacy record with no creationSpec -- an
unrelated legacy connection must not turn the observation path into an
error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Maximo-Guk
Maximo-Guk force-pushed the observer-session-restart branch from 9e5ab69 to a33a8e8 Compare August 31, 2026 21:02
@Maximo-Guk
Maximo-Guk marked this pull request as draft August 31, 2026 21:02
@ask-bonk

This comment was marked as resolved.

// workspace the first open after an ambient capsule appears bounces itself once; the capsule
// exists by then, so the client's retry is clean.
#restartIfSessionsAffected(reason: string, affectedRole?: CollaboratorRole): void {
if (!this.#hasCollaboratorSession(affectedRole)) return;

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] Count admissions before observer verification yields

ensureObserver() snapshots inScope before awaiting the modal/verifier, but this check only sees sessions whose interface constructors have already run. If this is a collaborator’s only open, the owner can add a gatekeeper while configure() is blocked; the count is still zero, so no reset is scheduled, and the old open then returns a build capability that can access the new gatekeeper without any addObserver() check. receiveExternalMessage() never joins this count and has the same interleaving before starting its chat. Hold an admission lease/generation across authorization, or recheck scope immediately before exposing the capability/starting the chat.

#leaveOutputsFanout: () => void;

[Symbol.dispose]() {
this.#leaveSession();

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 descendant capabilities represented after parent disposal

Capabilities returned by Cap’n Web are independently owned. A use collaborator can retain the UseGadgetClientInterface returned by getGadget(), dispose only this parent, and make the live-session count reach zero; notifyClosed() merely marks that disposal as expected and does not revoke the retained child. The owner can then bind a new restricted resource without scheduling a reset, after which the retained child can call connectToGadget() against the gadget’s current bindings. The build parent at line 9357 has the same issue. Keep the authorization lease alive for descendant capabilities or make every descendant enforce the admitted generation.

await this.#removeObserverFromGatekeepers(observerId, gatekeeperIds);
}
for (let observerId of outOfScope) {
await this.#removeObserverFromGatekeepers(observerId, [gatekeeperId]);

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] Revalidate scope before removing each observer

outOfScope is classified once, but these removals await one by one. With two excluded observers, the first removeObserver() opens the input gate; while it is pending, the owner can bind this gatekeeper and the second observer can open and successfully re-register. This stale second removal then deletes that fresh registration and the method admits the excluded observation even though the observer can now reach it; later exclusions may omit them too. Recheck the current role/scope before each removal (or serialize cleanup with scope changes).

@ask-bonk

ask-bonk Bot commented Aug 31, 2026

Copy link
Copy Markdown

Submitted 3 actionable inline review findings.

github run

@kentonv kentonv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Bugfix: Don't let a delayed abort swallow a concurrent access change.

This commit is suspicious. It seems to be complaining that a randomly-placed abort() could leave the DO in an inconsistent state, if something else is happening in the meantime that gets interrupted in the middle.

But if that's the case, then that something else is the buggy bit. Every piece of code should be tolerant of a random crash interrupting it. Usually, DO's atomicity guarantees take care of it: any writes performed without an intervening await are automatically coalesced into a transaction. But anything that does writes around an async operation needs to consider what happens if the async operation never returns (due to a crash).

Of course, adding and removing observers from a gatekeeper is inherently async, and we can't even wrap it in a transaction since the gatekeeper itself is modifying its own storage. (I do want to create cross-facet transactions at some point... but we don't have them yet.)

The rule to follow here is: Ensure the gatekeeper overestimates. That is:

  • addObserver() must succeed before the observer is granted any access.
  • remoteObserver() must not be called until the observer's access has been revoked.

Since it's an over-estimate, in the event of a badly-timed crash, a gatekeeper can end up having an observer registered who doesn't actually have access. That is OK: it only causes that observer to possibly show up in excludeObservers in the future. At that point, the overseer checks and discovers that the excluded observer doesn't have access in the first place, and can then remove them from the gatekeeper, thus bringing it into sync.

In any case, though, I don't quite understand the situation that the first paragraph of the commit description is worried about. If removeCollaborator() has already removed the sharing edge (and the observer has also been removed from the sharing table), then it doesn't really matter if we fail to finish removing the observer from all gatekeepers -- the gatekeepers can be left over-estimating.

Five fixes for the races the PR review surfaced, all in the same spirit:
the session count must cover everything a collaborator holds a role's
access through, and observer bookkeeping must only ever err toward
over-registration (a spurious registration blocks fail-closed; a lost
one fails open).

- addGatekeeper publication window: the record must be durable before
  the scheduled reset, but the severed sessions stay live for the
  reset's ~100ms response-delivery delay and ids are guessable. When a
  restart was scheduled, the new id is marked in the in-memory
  #gatekeepersPendingRestart set -- in the same synchronous block as
  the put -- and getGatekeeperById/openSession refuse it with a
  retryable error until the reset destroys the mark with the sessions.

- In-flight authorization: authorizeCollaborator holds a joinSession
  lease for the resolved role across ensureObserver, so an open parked
  on the configuration prompt or verifier RPCs counts as a session and
  a widening restarts it. receiveExternalMessage holds a "build" lease
  for a non-owner caller across the whole call, since it produces a
  reply from workspace data without ever constructing a counted
  interface.

- Retained descendant capabilities: GadgetClientImpl,
  UseGadgetClientInterface, and GatekeeperClientImpl now count toward
  #hasCollaboratorSession for their own lifetime when minted into a
  collaborator session, since a client can dispose the parent interface
  while retaining a child stub. Owner mints and internal constructions
  don't count.

- excludeObservers teardown: each observer is re-classified against
  current state adjacent to their own awaited removal. An observer put
  back in scope by a bind + fresh open mid-teardown blocks the
  observation instead of having their fresh registration deleted by the
  stale removal.

- Returning-observer rollback: a failed open no longer rolls back any
  of a returning observer's registrations. The persisted observerId is
  shared with concurrent opens, so the rollback could delete a
  registration a concurrent successful open just made and persisted --
  under-registering, the fail-open direction. Only a first-ever
  verification (whose observerId is private to the call) still rolls
  back.

Also fixes the TS18047 null guard in observer-role-scope.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ask-bonk

ask-bonk Bot commented Sep 1, 2026

Copy link
Copy Markdown
  • [P1] overseer.ts:2270: Binding or promoting a connection schedules a delayed restart but does not quarantine the widened gadget. During the 100 ms delay, an existing UseGadgetClientInterface can call connectToGadget() and load a fresh facet containing the unverified binding. Block affected gadgets until reset, as addGatekeeper() blocks new IDs.

  • [P1] overseer.ts:9426: Disposing the parent removes the build-session count even when independently retained chat/action/console subscriptions remain. Those subscriptions can later receive data from newly added connections without triggering a restart. Each data-bearing subscription needs its own session lease.

  • [P1] overseer.ts:4452: #gatekeepersPendingRestart does not cover slash-command access. listSlashCommands() and #prepareChatMessage() access pending gatekeepers directly, allowing a build collaborator to list or invoke a newly added Context skill before observer registration. Filter/assert pending IDs on both paths.

  • [P2] overseer.ts:8748: Every non-owner external-message request is counted as build before authorization. A stranger or use collaborator racing a connection addition therefore causes an unnecessary workspace reset. Acquire the long-lived lease only after authorizeCollaborator() returns build; its internal lease already protects verification.

github run

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

2 participants