feat(#575): wire the audience floor's handle-resolution guard - #733
Merged
Conversation
The floor's third and last guard (spec §5.2), completing the trio. A storage key is a handle: minted in one turn, redeemable later, and — being just a string — pasteable into a different room. THE CHECK RIDES WITH THE HANDLE, NOT WITH THE CALL SITES. Spec §5.2 says the check "must ride with" the handle because it outlives the turn. Adding a call to every resolution site works exactly until somebody adds the next site and forgets, so enforcement lives in a wrapper around `AttachmentReader`, applied at its SINGLE construction site in `plugin.ts`. `read_attachment`, `ingestAttachments` and anything added next are covered by construction. This closes a path guard 1 did not. `read_attachment` is a tool and so already passed `dispatchTool`, but `ingestAttachments` resolves storage keys straight off the inbound turn with no tool call in sight — and that is the path a caller actually controls. A refusal returns `undefined`, the reader's existing "unavailable" contract, so no caller needs new error handling. That makes a denial indistinguishable from "unknown key" TO THE CALLER, which is deliberate rather than sloppy: a message confirming the key exists but is off-limits would leak the document's existence to a room that may not know it. The reason goes to the operator log instead, where it is actionable and not a side channel. The inner reader is never reached, so a refused redemption does not even touch the store — a store hit is itself observable. Redeeming a handle and invoking the read tool are separate capabilities; neither grants the other. REMAINING GAP, named rather than assumed closed: this checks the floor at REDEMPTION, not at MINTING. It stops a room from redeeming a handle that room may not read, but cannot yet stop a handle minted in a narrow room from being redeemed in a wider one that happens to hold the capability. Binding the minting audience to the handle needs the attachment store to persist it, and that store lives in the channel plugins. Mutation-checked: removing the guard from `readByStorageKey` kills three tests — "both methods are guarded", "indistinguishable from unknown key", and "the inner reader is never even reached". Full suite 6652 tests / 0 fail / 0 cancelled, none of which installs an audience provider — the evidence for inertness. Typecheck, lint, the #470 decoupling ratchet (3294) and the #573 test-typecheck ratchet (406/406) green. Refs #575
Weegy
added a commit
that referenced
this pull request
Aug 18, 2026
The piece that makes the rest reachable. The floor (#729), the grants (#729) and all three guards (#731, #732, #733) are merged but INERT: nothing installs an audience source, so every guard short-circuits. Passing `audienceGrants` to the orchestrator now builds a provider per turn and enforcement begins. An explicit opt-in rather than a default, and deliberately so: the floor fails closed, so a deployment that has not yet decided who may do what would find its rooms bounded by an empty grant table. Omit the option and behaviour is unchanged — which the full suite demonstrates, since none of its 6662 tests passes one. The chain, per evaluation: roster (ChatParticipantsProvider) -> Principal per participant (#333 phase 1, via the same knowledge-graph join `resolveTurnOwnerIdentity` uses) -> roles (#333 phase 2) -> capabilities (#575 grants) -> the intersection (#575 floor) This module adds NO policy of its own. Every failure mode along that chain was already made explicit by the layer that owns it — an unreadable role source yields no capability set, an unplaceable participant yields `unresolved`, an empty roster yields `unknown` — and each of them closes the room with a reason. It only wires. IT DELIBERATELY DOES NOT CACHE. The egress guard re-evaluates per tool call precisely so a participant who joins mid-turn narrows the floor before the next call fires. Memoizing the roster for a turn would make that re-evaluation theatre — the guard would keep re-asking and keep getting the turn's opening answer. Caching is not forbidden, it is somebody else's job: `ChatParticipantsProvider` already documents its accessor as "expected to be cheap (cached by the implementer)", and a channel adapter knows when its roster goes stale. This module does not. A turn with no `channelIdentity` resolves to no principals rather than defaulting to a plausible channel kind. A wrong kind resolves against a DIFFERENT identity cluster, which would hand the room somebody else's grants — worse than refusing. Mutation-checked: memoizing the provider kills both no-cache tests, including "a mid-turn joiner narrows the floor on the NEXT evaluation". The #573 ratchet caught 10 new type errors in the new test on the first run and they were FIXED, not baselined: the test imported the SDK through the source barrel while the provider resolves it through `dist`, and TypeScript treats the two as nominally different classes. Full suite 6662 tests / 0 fail / 0 cancelled. Typecheck, lint, the #470 decoupling ratchet (3294) and the #573 ratchet (406/406, unchanged) green. Refs #575
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The floor's third and last guard (spec §5.2), completing the trio after #731 (egress) and #732 (context recall).
A storage key is a handle: minted in one turn, redeemable later, and — being just a string — pasteable into a different room.
The check rides with the handle, not with the call sites
The spec says the check "must ride with" the handle because it outlives the turn. Adding a call at every resolution site works exactly until somebody adds the next site and forgets. So enforcement lives in a wrapper around
AttachmentReader, applied at its single construction site.read_attachment,ingestAttachmentsand anything added next are covered by construction.This closes a path guard 1 did not
read_attachmentis a tool, so it already passeddispatchTool. ButingestAttachmentsresolves storage keys straight off the inbound turn, with no tool call in sight — and that is the path a caller actually controls. Guarding only the tool would have left it open while looking complete.Why a refusal looks like "unknown key"
A refusal returns
undefined, the reader's existing "unavailable" contract, so no caller needs new error handling. That makes a denial indistinguishable from a missing key to the caller — deliberate, not sloppy: a message confirming the key exists but is off-limits would leak the document's existence to a room that may not know it. The reason goes to the operator log, where it is actionable and not a side channel.The inner reader is never reached on a refusal, so a denied redemption does not even touch the store — a store hit is itself observable.
Redeeming a handle and invoking the read tool are separate capabilities; neither grants the other.
Remaining gap — named, not assumed closed
This checks the floor at redemption, not at minting. It stops a room from redeeming a handle that room may not read, but cannot yet stop a handle minted in a narrow room from being redeemed in a wider one that happens to hold the capability. Binding the minting audience to the handle needs the attachment store to persist it, and that store lives in the channel plugins rather than here.
Blast radius
Verification
npm run typecheck✅ ·npm run lint✅ · Epic: Integrated dev platform — isolated per-job runners that clone, develop, test, and ship PRs #470 ratchet 3294 ✅ ·npm run typechecknever typechecksmiddleware/test/#573 ratchet 406/406 ✅readByStorageKeykills three tests — "both methods are guarded", "indistinguishable from unknown key", and "the inner reader is never even reached".Where #575 stands after this
ScopeId(phase 1)Refs #575
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.