Skip to content

feat(#575): wire the audience floor's handle-resolution guard - #733

Merged
Weegy merged 1 commit into
mainfrom
feat/575-handle-guard
Aug 18, 2026
Merged

feat(#575): wire the audience floor's handle-resolution guard#733
Weegy merged 1 commit into
mainfrom
feat/575-handle-guard

Conversation

@Weegy

@Weegy Weegy commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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, ingestAttachments and anything added next are covered by construction.

This closes a path guard 1 did not

read_attachment is a tool, so it 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. 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

Surface Effect
Deployments without an audience source none
Deployments with one attachment redemption is refused when the room may not jointly read stored attachments
Published plugin contract none
Database none

Verification

Where #575 stands after this

Piece State
ScopeId (phase 1) merged
Audience floor + grants (phase 2) merged, #729
Guard 1 — egress merged, #731
Guard 2 — context recall merged, #732
Guard 3 — handle resolution this PR
Persistent grant store still to come
Handle bound at minting needs channel-plugin storage

Refs #575


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

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
Weegy merged commit 2560c90 into main Aug 18, 2026
9 checks passed
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
@Weegy
Weegy deleted the feat/575-handle-guard branch August 19, 2026 06:59
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.

1 participant