From e98a43167eb684a2b36b824f079f8eec646b48c2 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 18 Aug 2026 08:55:30 +0200 Subject: [PATCH] feat(#575): the audience floor and capability grants (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/CHANGELOG.md | 34 +++ .../harness-channel-sdk/src/audienceFloor.ts | 230 +++++++++++++++++ .../harness-channel-sdk/src/grants.ts | 162 ++++++++++++ .../packages/harness-channel-sdk/src/index.ts | 27 ++ middleware/test/audienceFloor.test.ts | 236 ++++++++++++++++++ 5 files changed, 689 insertions(+) create mode 100644 middleware/packages/harness-channel-sdk/src/audienceFloor.ts create mode 100644 middleware/packages/harness-channel-sdk/src/grants.ts create mode 100644 middleware/test/audienceFloor.test.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 08f31dfd..c9c9cdda 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,40 @@ entry. See `CONTRIBUTING.md` § Releases & changelog. ## [Unreleased] +### Added — the audience floor and capability grants (#575, phase 2) + +- **The first module in this 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.** The spec is emphatic that the + floor is not a single interception point: egress must be checked **per tool + call** (a turn-start snapshot is a TOCTOU hole), 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 rather + than as a hook. +- **Everything fails closed, because the intersection of nothing is + everything.** An audience that cannot be established permits nothing rather + than everything, and that trap is live today: `ChatParticipantsProvider`'s own + contract says "returning an empty array is a valid unknown/unavailable state", + so an empty roster is `unknown`, never "the room is empty". One participant + who cannot be resolved to a Principal closes the whole room — bounding only + the people you could identify is not bounding the room. +- **`closed` and `open`-with-nothing are different answers.** Both permit + nothing, but the first is an outage and the second is policy. An operator + looking at a blocked workflow needs to tell them apart. +- **Grants: capabilities union within a principal, intersect across the room.** + A principal's capabilities are their direct grants plus the grants of every + role they hold; the room's floor is the intersection of everyone's. The two + directions live in separate modules because confusing them is a privilege bug + either way. +- **A partial role lookup never becomes a capability set.** #333 phase 2 made + "we could not read a role source" distinct from "no roles"; that distinction + survives into the floor, which closes with a diagnosable reason instead of + quietly applying a stricter policy nobody chose. +- Capabilities are deliberately **not** roles: intersecting role labels would be + wrong in a way that looks right, since two people with different roles may + well share a right. + ### Fixed — an approval quorum could complete with too few approvals (#333, phase 3) - **Conductor's role→holder resolution is now pluggable — and two decisions built diff --git a/middleware/packages/harness-channel-sdk/src/audienceFloor.ts b/middleware/packages/harness-channel-sdk/src/audienceFloor.ts new file mode 100644 index 00000000..32362844 --- /dev/null +++ b/middleware/packages/harness-channel-sdk/src/audienceFloor.ts @@ -0,0 +1,230 @@ +/** + * #575 Phase 2 — the audience floor: "given who is present, what may happen in + * this room?" + * + * `specs/575-scope-and-identity-foundation/spec.md` §6 draws the line this file + * sits on: **#333 produces Principals, #575 consumes them and produces + * decisions.** Phase 1 gave the typed scope; #333 phases 1-3 gave Principals and + * the sources that say what they are entitled to. This is the first module that + * decides something. + * + * ## The floor is one function, not one interception point + * + * Spec §5.2 is emphatic that "the audience floor" is three guards with three + * different correctness requirements, and that planning it as a single + * interception point is the most common way to get it wrong: + * + * | 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 check must ride with it | + * + * So this module exports the *intersection*, and the three guards share it. + * {@link audienceFloor} is pure and cheap precisely so calling it per tool call + * is affordable. + * + * ## Mid-turn joiners — split by reversibility (spec §5.3, decision D4) + * + * A floor is a value, not a subscription, and that is deliberate. Context that + * has already been rendered cannot be un-sent, so re-filtering it mid-turn is + * theatre: the context guard **snapshots** its floor. An outbound call that has + * not fired yet *can* still be refused, so the egress guard **re-computes** + * before each call. Any single answer for both is wrong in one direction. + * + * ## Where this sits relative to the two gates already on the path (spec §5.4) + * + * Every turn already passes Privacy Shield v4 (data minimization *toward* the + * model) and #579's inbound screening (untrusted content coming *from* outside). + * The floor is a third gate, and the ordering is not arbitrary: + * + * 1. **#579 inbound screening** — on the way in, before anything is trusted. + * 2. **Audience floor** — before an effect is produced or context is rendered. + * 3. **Privacy Shield** — at the data-plane boundary, on whatever survives. + * + * The floor runs before Privacy Shield because it decides *whether* an effect + * happens at all; Privacy Shield decides what a permitted effect may carry. + * Reversing them would mean minimizing data for a call that should never have + * been made. + * + * ## Everything here fails CLOSED, and that is the whole point + * + * "The intersection of the rights of everyone present" has a trap in it: the + * intersection of *nothing* is *everything*. An empty audience must therefore + * never be read as "nobody is here, so nothing is restricted" — spec §5.1 calls + * getting this backwards "a silent full-permission grant". + * + * That trap is live today, not hypothetical: `ChatParticipantsProvider`'s own + * contract says "returning an empty array is a valid **unknown / unavailable** + * state". An empty roster already means *unknown*, so this module refuses to + * build a floor from a bare participant list at all — {@link Audience} makes the + * caller state which it is. + */ + +import type { Principal } from './principal.js'; + +/** + * An opaque capability token — `'tool:web_search'`, `'memory:read:/notes'`. + * + * Deliberately a string and deliberately NOT a role. Intersecting role *labels* + * would be wrong in a way that looks right: two people with different roles may + * well share a right, and intersecting `{'admin'} ∩ {'approver'}` yields nothing + * while both principals can in fact do the thing. The floor intersects what + * people MAY DO, and the mapping from roles to capabilities belongs to the grant + * store, not here. + */ +export type Capability = string; + +/** Why an audience could not be established. Never means "nobody is present". */ +export type AudienceUnknownReason = + /** No participant provider is installed — HTTP and web turns (spec §5.1). */ + | 'no_provider' + /** The provider threw or timed out. */ + | 'provider_failed' + /** + * The provider returned an empty roster. Its documented contract treats that + * as "unknown / unavailable", NOT as "the room is empty", so it lands here. + */ + | 'empty_roster'; + +/** One participant, once we have tried to turn them into a Principal. */ +export type AudienceMember = + | { + readonly kind: 'resolved'; + readonly principal: Principal; + /** What this principal may do. Empty is a real answer: they may do nothing. */ + readonly capabilities: ReadonlySet; + } + | { + readonly kind: 'unresolved'; + /** Operator-readable. Belongs in logs, never in an HTTP body. */ + readonly reason: string; + }; + +/** + * Who is in the room. + * + * `known` may legitimately contain `unresolved` members — a guest with no + * directory record is present even though we cannot say what they may do. That + * case closes the floor rather than being silently dropped, which is the + * difference between "we bounded the room" and "we bounded the part of the room + * we could see". + */ +export type Audience = + | { readonly kind: 'known'; readonly members: readonly AudienceMember[] } + | { readonly kind: 'unknown'; readonly reason: AudienceUnknownReason }; + +/** + * The computed floor. + * + * `closed` is not the same as `open` with an empty capability set, even though + * both permit nothing. `open` means "we know exactly what this room allows and + * it is nothing"; `closed` means "we could not establish the room". The first is + * a policy outcome, the second is an outage — and an operator staring at a + * blocked workflow needs to tell them apart. Same reasoning as `partial` on + * #333's role lookups. + */ +export type AudienceFloor = + | { readonly outcome: 'open'; readonly capabilities: ReadonlySet } + | { readonly outcome: 'closed'; readonly reason: string }; + +/** What #333's join hands back for one participant. */ +export interface ResolvedAudienceMember { + readonly principal: Principal; + readonly capabilities: ReadonlySet; +} + +/** + * Turn a roster into an {@link Audience}, refusing to invent one. + * + * `participants` is what `ChatParticipantsProvider` returned, or `undefined` + * when no provider is installed. `resolve` is #333's join — participant to + * Principal plus capabilities — and returns `undefined` for anyone it cannot + * place. + * + * The empty-roster case is the load-bearing one, and it is why this helper + * exists rather than callers assembling an `Audience` by hand. + */ +export async function resolveAudience( + participants: readonly TParticipant[] | undefined, + resolve: (participant: TParticipant) => Promise, +): Promise { + if (participants === undefined) return { kind: 'unknown', reason: 'no_provider' }; + if (participants.length === 0) return { kind: 'unknown', reason: 'empty_roster' }; + + const members = await Promise.all( + participants.map(async (participant): Promise => { + try { + const resolved = await resolve(participant); + return resolved + ? { kind: 'resolved', principal: resolved.principal, capabilities: resolved.capabilities } + : { kind: 'unresolved', reason: 'no principal could be resolved for this participant' }; + } catch (err) { + return { + kind: 'unresolved', + reason: `resolution threw: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }), + ); + + return { kind: 'known', members }; +} + +/** + * The intersection every guard shares: what may happen with THIS audience + * present. + * + * Three ways it closes, and all three are refusals to guess: + * + * 1. The audience is `unknown` — including an empty roster, which the provider + * contract already defines as unknown. + * 2. A member is `unresolved` — somebody is in the room whose rights we cannot + * bound, so the room cannot be bounded either. + * 3. `known` with no members at all — a shape callers should not build, but + * intersecting it would yield "everything", so it is refused explicitly + * rather than left to the reduce. + * + * Otherwise the floor is the set intersection across resolved members. An empty + * intersection is `open` with nothing in it — a real answer, not a failure. + */ +export function audienceFloor(audience: Audience): AudienceFloor { + if (audience.kind === 'unknown') { + return { outcome: 'closed', reason: `audience unknown (${audience.reason})` }; + } + + const unresolved = audience.members.filter((m) => m.kind === 'unresolved'); + if (unresolved.length > 0) { + return { + outcome: 'closed', + reason: `${unresolved.length} participant(s) could not be resolved to a principal`, + }; + } + + const resolved = audience.members.filter( + (m): m is Extract => m.kind === 'resolved', + ); + if (resolved.length === 0) { + // The intersection of nothing is everything. Never return that. + return { outcome: 'closed', reason: 'audience is known but has no members' }; + } + + let capabilities = new Set(resolved[0]?.capabilities ?? []); + for (const member of resolved.slice(1)) { + capabilities = new Set([...capabilities].filter((c) => member.capabilities.has(c))); + if (capabilities.size === 0) break; + } + + return { outcome: 'open', capabilities }; +} + +/** + * Whether `capability` is permitted under `floor`. + * + * The single predicate the three guards call. A `closed` floor permits nothing — + * stated here once so no guard has to remember to check `outcome` first, which + * is exactly the check that gets forgotten. + */ +export function floorPermits(floor: AudienceFloor, capability: Capability): boolean { + return floor.outcome === 'open' && floor.capabilities.has(capability); +} diff --git a/middleware/packages/harness-channel-sdk/src/grants.ts b/middleware/packages/harness-channel-sdk/src/grants.ts new file mode 100644 index 00000000..637725ca --- /dev/null +++ b/middleware/packages/harness-channel-sdk/src/grants.ts @@ -0,0 +1,162 @@ +/** + * #575 Phase 2 — grants: the mapping from a Principal to the capabilities the + * audience floor intersects. + * + * The floor (`audienceFloor.ts`) intersects capability *sets*; this is where a + * set comes from. Two independent ways to hold a capability, deliberately kept + * separate: + * + * - a **direct grant** to a `user:` principal, and + * - a **role grant**, held by everyone who currently holds that role. + * + * Role grants are why #333 had to land first. A principal's capabilities are + * the union of their direct grants and the grants of every role they hold, and + * "every role they hold" is exactly what `RoleSourceRegistry` answers. + * + * ## Union here, intersection there — and both are deliberate + * + * Within one principal, capabilities UNION: holding two roles gives you the + * powers of both. Across the audience they INTERSECT: a room may only do what + * everyone in it may do. Getting these backwards in either direction is a + * privilege bug, so they live in separate modules with the reasoning stated in + * both. + * + * ## A partial role lookup must not produce a capability set + * + * #333 phase 2 made "we could not read a role source" a distinct outcome rather + * than an empty list. That distinction has to survive the trip here, because a + * capability set built from partially-known roles is a LOWER BOUND — and the + * floor cannot tell a lower bound from a real answer once it is just a `Set`. + * + * Reading it as a real answer fails in the direction that matters: + * + * - too few capabilities for a member → the intersection is too small → the + * room is over-restricted. Annoying, and safe. + * - but the same shrunken set is indistinguishable from a deliberate policy, + * so an operator sees "the floor forbids it" and never learns a directory + * was down. + * + * So {@link resolveCapabilities} refuses: a partial role lookup yields + * `undefined`, which `resolveAudience` turns into an `unresolved` member, which + * closes the floor with a reason an operator can act on. + */ + +import type { Capability, ResolvedAudienceMember } from './audienceFloor.js'; +import { canonicalizePrincipalRef, principalRef, type Principal } from './principal.js'; +import type { RoleSourceRegistry } from './roleSource.js'; + +/** + * Where capability grants are read from. + * + * Intentionally two narrow lookups rather than one "give me everything" call: + * the role side is fanned out over however many roles a principal holds, and a + * store backed by SQL wants to see them as separate, cacheable questions. + * + * Implementations live outside this package. A store that cannot answer must + * **throw** — {@link resolveCapabilities} converts that into a closed floor + * rather than a silently smaller one. + */ +export interface GrantStore { + /** Capabilities granted directly to this principal. */ + directGrants(principal: Principal): Promise; + /** Capabilities granted to a role, held by whoever currently holds it. */ + roleGrants(roleKey: string): Promise; +} + +/** + * Resolve one principal into the audience member the floor consumes. + * + * Returns `undefined` — meaning "unresolved", which closes the floor — when the + * answer would be a lower bound rather than a fact: + * + * - the principal's role lookup came back `partial` (a role source was down), + * - or the grant store threw. + * + * Both are outages, and an outage must not read as policy. + * + * A `role:` principal is not an audience member: rooms contain people, and + * #333's registry already refuses to resolve roles-of-a-role. Passing one is a + * caller error, so it resolves to `undefined` rather than being quietly + * expanded into its holders — expansion is `RoleHolderRegistry`'s job and doing + * it here would hide which of the two happened. + */ +export async function resolveCapabilities( + principal: Principal, + roles: RoleSourceRegistry, + grants: GrantStore, +): Promise { + if (principal.kind !== 'user') return undefined; + + try { + const roleLookup = await roles.resolveRoles(principal); + // A lower bound is not an answer. See the module header. + if (roleLookup.partial) return undefined; + + const capabilities = new Set(); + for (const capability of await grants.directGrants(principal)) { + const trimmed = capability.trim(); + if (trimmed.length > 0) capabilities.add(trimmed); + } + + // Role keys keep their case (#333 phase 1: `createRole` writes them + // verbatim), so they are canonicalized with the ROLE rule before lookup — + // lowercasing here would miss every mixed-case grant row. + const perRole = await Promise.all( + roleLookup.roles.map((role) => grants.roleGrants(canonicalizePrincipalRef('role', role))), + ); + for (const granted of perRole) { + for (const capability of granted) { + const trimmed = capability.trim(); + if (trimmed.length > 0) capabilities.add(trimmed); + } + } + + return { principal, capabilities }; + } catch { + // Deliberately swallowed here and surfaced as `unresolved` by the caller: + // the floor's `closed` reason is the operator-facing signal, and letting + // this reject would take down the turn instead of restricting it. + return undefined; + } +} + +/** + * An in-memory {@link GrantStore}, for tests and for deployments that configure + * grants declaratively rather than in a database. + * + * Direct grants are keyed by the principal's canonical wire form so a + * differently-cased id cannot miss its own grants; role grants are keyed by the + * role key with its case intact, matching `conductor_roles`. + */ +export class InMemoryGrantStore implements GrantStore { + private readonly direct = new Map>(); + private readonly byRole = new Map>(); + + grantToPrincipal(principal: Principal, ...capabilities: Capability[]): this { + const key = principalKey(principal); + const set = this.direct.get(key) ?? new Set(); + for (const c of capabilities) set.add(c); + this.direct.set(key, set); + return this; + } + + grantToRole(roleKey: string, ...capabilities: Capability[]): this { + const key = canonicalizePrincipalRef('role', roleKey); + const set = this.byRole.get(key) ?? new Set(); + for (const c of capabilities) set.add(c); + this.byRole.set(key, set); + return this; + } + + async directGrants(principal: Principal): Promise { + return [...(this.direct.get(principalKey(principal)) ?? [])]; + } + + async roleGrants(roleKey: string): Promise { + return [...(this.byRole.get(canonicalizePrincipalRef('role', roleKey)) ?? [])]; + } +} + +function principalKey(principal: Principal): string { + return `${principal.kind}:${canonicalizePrincipalRef(principal.kind, principalRef(principal))}`; +} diff --git a/middleware/packages/harness-channel-sdk/src/index.ts b/middleware/packages/harness-channel-sdk/src/index.ts index 52092b0c..8775901c 100644 --- a/middleware/packages/harness-channel-sdk/src/index.ts +++ b/middleware/packages/harness-channel-sdk/src/index.ts @@ -279,3 +279,30 @@ export { type HolderLookupUnavailableCode, type RoleHolderSource, } from './roleHolderSource.js'; + +// #575 Phase 2 — the first module 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?". Everything fails +// closed, because the intersection of nothing is everything and an empty roster +// already means "unknown" in `ChatParticipantsProvider`'s own contract. +export { + audienceFloor, + floorPermits, + resolveAudience, + type Audience, + type AudienceFloor, + type AudienceMember, + type AudienceUnknownReason, + type Capability, + type ResolvedAudienceMember, +} from './audienceFloor.js'; + +// #575 Phase 2 — where a principal's capabilities come from. Capabilities UNION +// within one principal (two roles give you both) and INTERSECT across the +// audience (a room may only do what everyone may do); the two live apart so the +// directions cannot be confused. +export { + InMemoryGrantStore, + resolveCapabilities, + type GrantStore, +} from './grants.js'; diff --git a/middleware/test/audienceFloor.test.ts b/middleware/test/audienceFloor.test.ts new file mode 100644 index 00000000..3ad9b2e4 --- /dev/null +++ b/middleware/test/audienceFloor.test.ts @@ -0,0 +1,236 @@ +/** + * #575 Phase 2 — the audience floor and the grants behind it. + * + * "The intersection of the rights of everyone present" contains a trap: the + * intersection of NOTHING is EVERYTHING. Almost every test below exists to pin + * one of the ways that trap could be sprung, because each of them looks like a + * reasonable default right up until it hands out a silent full grant. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + audienceFloor, + floorPermits, + resolveAudience, + type Audience, + type AudienceMember, + type Capability, +} from '../packages/harness-channel-sdk/src/audienceFloor.js'; +import { + InMemoryGrantStore, + resolveCapabilities, +} from '../packages/harness-channel-sdk/src/grants.js'; +import type { Principal } from '../packages/harness-channel-sdk/src/principal.js'; +import { + RoleSourceRegistry, + type RoleLookup, + type RoleSource, +} from '../packages/harness-channel-sdk/src/roleSource.js'; + +const alice: Principal = { kind: 'user', userId: 'alice' }; +const bob: Principal = { kind: 'user', userId: 'bob' }; + +const member = (principal: Principal, ...caps: Capability[]): AudienceMember => ({ + kind: 'resolved', + principal, + capabilities: new Set(caps), +}); + +const known = (...members: AudienceMember[]): Audience => ({ kind: 'known', members }); + +describe('the intersection of nothing is never everything', () => { + it('an unknown audience closes the floor', () => { + for (const reason of ['no_provider', 'provider_failed', 'empty_roster'] as const) { + const floor = audienceFloor({ kind: 'unknown', reason }); + assert.equal(floor.outcome, 'closed', reason); + assert.equal(floorPermits(floor, 'tool:web_search'), false); + } + }); + + it('a known audience with NO members closes rather than permitting everything', () => { + // Reducing an empty list would yield "no constraints", i.e. a full grant. + const floor = audienceFloor({ kind: 'known', members: [] }); + assert.equal(floor.outcome, 'closed'); + }); + + it('one unresolvable participant closes the whole room', () => { + // A guest with no directory record is present. Bounding only the people we + // could identify is not bounding the room. + const floor = audienceFloor( + known(member(alice, 'tool:web_search'), { kind: 'unresolved', reason: 'guest' }), + ); + assert.equal(floor.outcome, 'closed'); + assert.match(floor.outcome === 'closed' ? floor.reason : '', /could not be resolved/); + }); +}); + +describe('closed and empty-but-open are different answers', () => { + it('an empty intersection is OPEN with nothing in it', () => { + // Both permit nothing, but this one is a policy outcome and `closed` is an + // outage. An operator staring at a blocked workflow needs to tell them apart. + const floor = audienceFloor(known(member(alice, 'a'), member(bob, 'b'))); + assert.equal(floor.outcome, 'open'); + assert.equal(floor.outcome === 'open' ? floor.capabilities.size : -1, 0); + assert.equal(floorPermits(floor, 'a'), false); + }); +}); + +describe('the intersection itself', () => { + it('keeps only what everyone present may do', () => { + const floor = audienceFloor( + known(member(alice, 'read', 'write', 'admin'), member(bob, 'read', 'write')), + ); + assert.deepEqual( + floor.outcome === 'open' ? [...floor.capabilities].sort() : null, + ['read', 'write'], + ); + }); + + it('a single participant keeps their own capabilities', () => { + const floor = audienceFloor(known(member(alice, 'read'))); + assert.equal(floorPermits(floor, 'read'), true); + }); + + it('adding a less-privileged person can only shrink the floor', () => { + const before = audienceFloor(known(member(alice, 'read', 'write'))); + const after = audienceFloor(known(member(alice, 'read', 'write'), member(bob, 'read'))); + assert.equal(floorPermits(before, 'write'), true); + assert.equal(floorPermits(after, 'write'), false, 'a joiner must never widen the floor'); + assert.equal(floorPermits(after, 'read'), true); + }); +}); + +describe('resolveAudience refuses to invent a room', () => { + const join = async (p: string) => + p === 'unknown-person' ? undefined : { principal: { kind: 'user' as const, userId: p }, capabilities: new Set(['read']) }; + + it('no provider installed → unknown, not an empty room', async () => { + // HTTP and web turns install no participant provider (spec §5.1). + assert.deepEqual(await resolveAudience(undefined, join), { kind: 'unknown', reason: 'no_provider' }); + }); + + it('an EMPTY roster → unknown, because that is what the provider contract says', async () => { + // `ChatParticipantsProvider`: "returning an empty array is a valid + // unknown / unavailable state". Reading it as "the room is empty" is the + // silent full grant. + assert.deepEqual(await resolveAudience([], join), { kind: 'unknown', reason: 'empty_roster' }); + }); + + it('a participant the join cannot place becomes unresolved, not dropped', async () => { + const audience = await resolveAudience(['alice', 'unknown-person'], join); + assert.equal(audience.kind, 'known'); + assert.equal(audienceFloor(audience).outcome, 'closed'); + }); + + it('a throwing join becomes unresolved rather than failing the turn', async () => { + const audience = await resolveAudience(['alice'], async () => { + throw new Error('graph down'); + }); + assert.equal(audience.kind, 'known'); + const floor = audienceFloor(audience); + assert.equal(floor.outcome, 'closed'); + }); +}); + +// ─── grants ──────────────────────────────────────────────────────────────── + +function rolesReturning(lookup: RoleLookup): RoleSourceRegistry { + const reg = new RoleSourceRegistry(); + const src: RoleSource = { id: 's', displayName: 's', rolesFor: async () => lookup }; + reg.register(src); + return reg; +} + +describe('capabilities union within one principal', () => { + it('direct grants and every role grant are unioned', async () => { + const grants = new InMemoryGrantStore() + .grantToPrincipal(alice, 'direct:1') + .grantToRole('Approver', 'role:approve') + .grantToRole('Reviewer', 'role:review'); + const roles = rolesReturning({ outcome: 'resolved', roles: ['Approver', 'Reviewer'] }); + + const resolved = await resolveCapabilities(alice, roles, grants); + assert.deepEqual([...(resolved?.capabilities ?? [])].sort(), [ + 'direct:1', + 'role:approve', + 'role:review', + ]); + }); + + it('role grants are looked up with the role key’s case intact', async () => { + // `createRole` writes keys verbatim; lowercasing the lookup would miss + // every mixed-case grant row. + const grants = new InMemoryGrantStore().grantToRole('Head-Of-Sales', 'role:sign'); + const roles = rolesReturning({ outcome: 'resolved', roles: ['Head-Of-Sales'] }); + const resolved = await resolveCapabilities(alice, roles, grants); + assert.deepEqual([...(resolved?.capabilities ?? [])], ['role:sign']); + }); + + it('a principal with no grants resolves to an empty set — a real answer', async () => { + const resolved = await resolveCapabilities( + alice, + rolesReturning({ outcome: 'resolved', roles: [] }), + new InMemoryGrantStore(), + ); + assert.ok(resolved, 'no grants is not the same as unresolvable'); + assert.equal(resolved?.capabilities.size, 0); + }); +}); + +describe('a lower bound is not an answer — the chain from #333 to the floor', () => { + it('a PARTIAL role lookup makes the principal unresolvable', async () => { + // The capability set would be a lower bound, and the floor cannot tell a + // lower bound from policy once it is just a Set. + const roles = rolesReturning({ outcome: 'unavailable', code: 'source_error', message: 'entra down' }); + const resolved = await resolveCapabilities(alice, roles, new InMemoryGrantStore()); + assert.equal(resolved, undefined); + }); + + it('and that closes the floor end-to-end, with a reason', async () => { + // The whole point of the chain: a directory outage surfaces as a closed + // room an operator can diagnose, not as a quietly stricter policy. + const roles = rolesReturning({ outcome: 'unavailable', code: 'source_error', message: 'entra down' }); + const grants = new InMemoryGrantStore().grantToPrincipal(alice, 'tool:web_search'); + + const audience = await resolveAudience([alice], (p) => resolveCapabilities(p, roles, grants)); + const floor = audienceFloor(audience); + assert.equal(floor.outcome, 'closed'); + assert.equal(floorPermits(floor, 'tool:web_search'), false); + }); + + it('the same setup with a healthy source DOES open the floor', async () => { + // Control twin: without it the refusal above could pass for an unrelated + // reason and nobody would notice. + const roles = rolesReturning({ outcome: 'resolved', roles: [] }); + const grants = new InMemoryGrantStore().grantToPrincipal(alice, 'tool:web_search'); + + const audience = await resolveAudience([alice], (p) => resolveCapabilities(p, roles, grants)); + assert.equal(floorPermits(audienceFloor(audience), 'tool:web_search'), true); + }); + + it('a throwing grant store also makes the principal unresolvable', async () => { + const broken = { + directGrants: async () => { + throw new Error('pg down'); + }, + roleGrants: async () => [], + }; + const roles = rolesReturning({ outcome: 'resolved', roles: [] }); + assert.equal(await resolveCapabilities(alice, roles, broken), undefined); + }); +}); + +describe('a role principal is not an audience member', () => { + it('resolves to undefined rather than being expanded into its holders', async () => { + // Expansion is RoleHolderRegistry's job (#333 phase 3). Doing it here would + // hide which of the two actually happened. + const resolved = await resolveCapabilities( + { kind: 'role', roleKey: 'approver' }, + rolesReturning({ outcome: 'resolved', roles: [] }), + new InMemoryGrantStore(), + ); + assert.equal(resolved, undefined); + }); +});