Skip to content

feat(#575): the audience floor and capability grants (phase 2) - #729

Merged
Weegy merged 1 commit into
mainfrom
feat/575-audience-floor
Aug 18, 2026
Merged

feat(#575): the audience floor and capability grants (phase 2)#729
Weegy merged 1 commit into
mainfrom
feat/575-audience-floor

Conversation

@Weegy

@Weegy Weegy commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Unblocked by #726. The Phase-0 spec sequences this explicitly — "#333 must land before #575 Phase 2", because the floor intersects entitlements and ChatParticipant carries none. #333 phases 1–3 are on main, 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:

What is guarded Where it must be evaluated Why not per-turn
Egress (tool calls) per call a turn-start snapshot is a TOCTOU hole — the audience can change before the call fires
Context / memory retrieval per retrieval, per recipient the rendered context differs per recipient by definition
File / credential handles at handle resolution the handle outlives the turn

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:

ChatParticipantsProvider: "Returning an empty array is a valid unknown / unavailable state"

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:

  • an unknown audience — including an empty roster — permits nothing;
  • one unresolvable participant closes the whole room, because bounding only the people you 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. 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 as partial on #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 a Set.

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)

  1. Org security postures with provenance-labelled input screening #579 inbound screening — on the way in, before anything is trusted
  2. Audience floor — before an effect is produced or context rendered
  3. Privacy Shield — at the data-plane boundary, on whatever survives

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

Surface Effect
Published plugin contract none — additive exports only
Runtime behaviour none — no guard is wired yet; the three guard sites are the next cut
Database none — InMemoryGrantStore ships; a persistent store is separate

Verification

Mutation checks

Mutation Result
remove the empty-members guard killed — 1
ignore unresolved members killed — 2
treat an empty roster as a known empty room killed — 1
drop the partial-role guard in grants killed — 2, incl. the end-to-end case

The 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


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

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
@Weegy
Weegy merged commit a9ac791 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-audience-floor 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