feat(#575): the audience floor and capability grants (phase 2) - #729
Merged
Conversation
The first module in this cluster that decides something. #333 (phases 1-3, merged) produces Principals and says what they are entitled to; this consumes them and answers the question the Phase-0 spec §6 assigns to #575: "given who is present, what may happen in this room?". ONE INTERSECTION FUNCTION, THREE GUARDS. Spec §5.2 warns that planning "the audience floor" as a single interception point is the most common way to get it wrong, because what it guards has three different correctness requirements: egress must be evaluated PER TOOL CALL (a turn-start snapshot is a TOCTOU hole — the audience can change before the call fires), context PER RETRIEVAL PER RECIPIENT, and file/credential handles AT HANDLE RESOLUTION. So the intersection ships as a pure, cheap function the three guards share, not as a hook. It is cheap precisely so per-call evaluation is affordable. Mid-turn joiners follow spec §5.3 / D4 — split by reversibility. A floor is a value, not a subscription: rendered context cannot be un-sent so the context guard snapshots, an unfired call can still be refused so the egress guard re-computes. EVERYTHING FAILS CLOSED, BECAUSE THE INTERSECTION OF NOTHING IS EVERYTHING. - An `unknown` audience permits nothing. This is not hypothetical: the `ChatParticipantsProvider` contract says "returning an empty array is a valid unknown / unavailable state", so an empty roster is `unknown` and never "the room is empty". Reading it the other way is spec §5.1's "silent full-permission grant". - One unresolvable participant closes the whole room. Bounding only the people we could identify is not bounding the room. - A `known` audience with no members is refused explicitly rather than left to a reduce that would yield "no constraints". `closed` is deliberately NOT the same as `open` with an empty capability set, even though both permit nothing: the first is an outage, the second is policy, and an operator staring at a blocked workflow needs to tell them apart. Same reasoning as `partial` on #333's role lookups — which is also carried through: a partial role lookup yields no capability set at all, because a lower bound is indistinguishable from a deliberate policy once it is just a `Set`. Capabilities are opaque strings and deliberately NOT roles. Intersecting role labels would be wrong in a way that looks right: two people with different roles may well share a right, and `{'admin'} ∩ {'approver'}` is empty while both can do the thing. Within one principal capabilities UNION (two roles give you both); across the audience they INTERSECT. The two directions live in separate modules with the reasoning stated in both. Ordering relative to the two gates already on the path is stated per spec §5.4: #579 inbound screening → audience floor → Privacy Shield. The floor precedes Privacy Shield because it decides WHETHER an effect happens; Privacy Shield decides what a permitted effect may carry. Mutation-checked: removing the empty-members guard kills 1 test; ignoring `unresolved` members kills 2; treating an empty roster as a known empty room kills 1; dropping the partial-role guard kills 2 — including the end-to-end "closes the floor with a reason", whose healthy-source control twin still opens. Full suite 6625 tests / 0 fail / 0 cancelled. Typecheck, lint, the #470 decoupling ratchet (3294) and the #573 test-typecheck ratchet (406/406) green. Refs #575
This was referenced Aug 18, 2026
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.
Unblocked by #726. The Phase-0 spec sequences this explicitly — "#333 must land before #575 Phase 2", because the floor intersects entitlements and
ChatParticipantcarries none. #333 phases 1–3 are onmain, so this is now buildable.This is the first module in the cluster that decides something. #333 produces Principals and says what they are entitled to; this consumes them and answers "given who is present, what may happen in this room?".
One intersection function, three guards
Spec §5.2 warns that planning "the audience floor" as a single interception point is the most common way to get it wrong, because what it guards has three different correctness requirements:
So the intersection ships as a pure, cheap function the three guards share — cheap precisely so per-call evaluation is affordable — rather than as a hook.
Mid-turn joiners follow §5.3 / D4, split by reversibility: a floor is a value, not a subscription. Rendered context cannot be un-sent, so the context guard snapshots; an unfired call can still be refused, so the egress guard re-computes.
Everything fails closed, because the intersection of nothing is everything
This is the trap the whole design is shaped around, and it is live today, not hypothetical:
An empty roster already means unknown. Reading it as "the room is empty, so nothing is restricted" is what §5.1 calls a silent full-permission grant. So:
unknownaudience — including an empty roster — permits nothing;knownaudience with no members is refused explicitly rather than left to a reduce that would yield "no constraints".closedis deliberately not the same asopenwith an empty capability set. Both permit nothing, but the first is an outage and the second is policy — an operator staring at a blocked workflow needs to tell them apart. Same reasoning aspartialon #333's role lookups, which is carried through: a partial role lookup produces no capability set at all, because a lower bound is indistinguishable from a deliberate policy once it is just aSet.Capabilities are not roles
Intersecting role labels would be wrong in a way that looks right: two people with different roles may well share a right, and
{'admin'} ∩ {'approver'}is empty while both can do the thing. Capabilities are opaque tokens; the role→capability mapping is the grant store's.Within one principal capabilities union (two roles give you both); across the audience they intersect. The two directions live in separate modules with the reasoning stated in both, because confusing them is a privilege bug either way.
Ordering relative to the two gates already on the path (§5.4)
The floor precedes Privacy Shield because it decides whether an effect happens; Privacy Shield decides what a permitted effect may carry. Reversing them would mean minimizing data for a call that should never have been made.
Blast radius
InMemoryGrantStoreships; a persistent store is separateVerification
npm run typecheck✅ ·npm run lint✅npm run typechecknever typechecksmiddleware/test/#573 ratchet 406/406 ✅Mutation checks
unresolvedmembersThe end-to-end pair is the one that matters: a directory outage closes the floor with a reason, while its healthy-source control twin still opens it — so the test measures the guard rather than an unrelated side effect.
Refs #575
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.