Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,32 @@ entry. See `CONTRIBUTING.md` § Releases & changelog.

## [Unreleased]

### Added — the audience floor can now be switched on (#575)

- **The piece that makes the three guards non-inert.** Until now the floor, the
grants and all three guards were merged but unreachable, because nothing
installed an audience source. Passing `audienceGrants` to the orchestrator now
builds one per turn, and enforcement begins.
- **It is an explicit opt-in, not a default.** The floor fails closed by design,
so a deployment that has not decided who may do what would otherwise find its
rooms bounded by an empty grant table. Omit the option and every guard
short-circuits exactly as before.
- **The chain runs end to end**: roster → Principal per participant (via the
same knowledge-graph join `resolveTurnOwnerIdentity` uses) → roles → grants →
the intersection. Every failure along it was already made explicit by the
layer that owns it, so this adds no policy of its own — an unreadable role
source, an unplaceable participant or an empty roster each close the room,
with a reason.
- **It deliberately does not cache.** The egress guard re-evaluates per tool
call so a mid-turn joiner narrows the floor before the next call fires;
memoizing the roster here would hand it the turn's opening answer every time.
Caching stays where the `ChatParticipantsProvider` contract already puts it —
with the channel adapter, which knows when its roster goes stale.
- A turn that did not arrive through a channel resolves to no principals rather
than defaulting to a plausible-looking channel kind: a wrong kind resolves to
a *different* identity cluster, which would hand the room somebody else's
grants.

### Added — the audience floor now guards attachment-handle resolution (#575)

- **The floor's third and last guard**, completing the trio the spec names. A
Expand Down
118 changes: 118 additions & 0 deletions middleware/packages/harness-orchestrator/src/audienceFloorProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
audienceFloor,
makePrincipal,
resolveAudience,
resolveCapabilities,
type AudienceFloor,
type GrantStore,
type Principal,
type RoleSourceRegistry,
} from '@omadia/channel-sdk';
import type { ChannelKind, KnowledgeGraph } from '@omadia/plugin-api';

import type { AudienceFloorProvider } from './audienceFloorGuard.js';
import type { ChatParticipant, ChatParticipantsProvider } from './chatParticipants.js';

/**
* #575 — assembles the audience floor from the pieces #333 and #575 phase 2 put
* in place. This is the module that makes the three guards non-inert: until
* something installs one of these on `turnContext.audienceFloor`, every guard
* short-circuits and the deployment behaves exactly as before.
*
* The chain it runs, per evaluation:
*
* roster (ChatParticipantsProvider)
* → Principal per participant (#333 phase 1, via the KG join)
* → capabilities per Principal (#333 phase 2 roles + #575 grants)
* → Audience (#575, fails closed on any gap)
* → the intersection (#575)
*
* Every failure mode along that chain has already been made explicit by the
* layers below — an unreadable role source yields no capability set, an
* unplaceable participant yields `unresolved`, an empty roster yields
* `unknown` — so this module adds no policy of its own. 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 (spec §5.2,
* TOCTOU). Memoizing the roster for the duration of 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 simply somebody else's job: the
* `ChatParticipantsProvider` contract already says the roster accessor is
* "expected to be cheap (cached by the implementer)". A channel adapter knows
* when its roster can go stale; this module does not.
*/
export interface AudienceFloorProviderDeps {
/** The turn's roster accessor. `undefined` ⇒ the audience is unknowable. */
readonly participants: ChatParticipantsProvider | undefined;
/** #333's join: one chat participant to one platform Principal. */
readonly resolvePrincipal: (participant: ChatParticipant) => Promise<Principal | undefined>;
/** #333 phase 2 — what roles a Principal holds. */
readonly roles: RoleSourceRegistry;
/** #575 phase 2 — what those roles, and the Principal directly, are granted. */
readonly grants: GrantStore;
}

export function createAudienceFloorProvider(deps: AudienceFloorProviderDeps): AudienceFloorProvider {
return async (): Promise<AudienceFloor> => {
let roster: readonly ChatParticipant[] | undefined;
if (deps.participants) {
try {
roster = await deps.participants();
} catch {
// A roster accessor that blew up has not told us who is present. The
// reason string is built by `audienceFloor` so every closed floor reads
// the same way regardless of which step failed.
return audienceFloor({ kind: 'unknown', reason: 'provider_failed' });
}
}

const audience = await resolveAudience(roster, async (participant) => {
const principal = await deps.resolvePrincipal(participant);
if (!principal) return undefined;
return resolveCapabilities(principal, deps.roles, deps.grants);
});

return audienceFloor(audience);
};
}

/**
* The participant → Principal join, over the knowledge graph.
*
* Mirrors `resolveTurnOwnerIdentity` (#568/#333) — the same
* `resolveOrCreateChannelIdentity` call, applied to everyone in the room rather
* than only the caller. Idempotent: re-resolving the same
* `(channelKind, channelUserId)` pair returns the same id.
*
* Returns `undefined` — which the floor turns into an `unresolved` member and
* therefore a closed room — rather than falling back to the channel-native id.
* That fallback would be worse than useless here: a Teams AAD object id is not
* a principal in omadia's id space, so grants keyed on it would silently never
* match, and the room would look bounded while being bounded by nothing.
*/
export function knowledgeGraphPrincipalResolver(
knowledgeGraph: KnowledgeGraph | undefined,
channelKind: ChannelKind | undefined,
): (participant: ChatParticipant) => Promise<Principal | undefined> {
return async (participant) => {
// No channel kind means this turn did not arrive through a channel, so
// there is no `(channelKind, channelUserId)` pair to resolve against. Left
// unresolved on purpose rather than defaulted to some plausible-looking
// kind: a wrong kind resolves to a DIFFERENT identity cluster, which would
// hand the room somebody else's grants.
if (!knowledgeGraph || !channelKind) return undefined;
try {
const { omadiaUserId } = await knowledgeGraph.resolveOrCreateChannelIdentity({
channelKind,
channelUserId: participant.channelUserId,
});
return omadiaUserId ? makePrincipal('user', omadiaUserId) : undefined;
} catch {
return undefined;
}
};
}
49 changes: 49 additions & 0 deletions middleware/packages/harness-orchestrator/src/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ import {
type TurnContextValue,
} from './turnContext.js';
import { guardContextRecall, guardToolEgress } from './audienceFloorGuard.js';
import {
createAudienceFloorProvider,
knowledgeGraphPrincipalResolver,
} from './audienceFloorProvider.js';
import { resolveTurnOwnerIdentity } from './resolveTurnOwnerIdentity.js';
import { isMcpServerPrivacyBypassed } from './mcpPrivacyBypass.js';
import { isMcpServerKgIngest } from './mcpKgIngest.js';
Expand All @@ -224,6 +228,9 @@ export type {
VerifierResultSummary,
} from '@omadia/channel-sdk';
export { toSemanticAnswer } from '@omadia/channel-sdk';
// #575 — the audience floor's inputs, supplied by the deployment.
import type { GrantStore, RoleSourceRegistry } from '@omadia/channel-sdk';
import { RoleSourceRegistry as RoleSourceRegistryImpl } from '@omadia/channel-sdk';

/**
* Kernel-owned native-tool names. Registered into the Orchestrator's
Expand Down Expand Up @@ -418,6 +425,24 @@ export interface OrchestratorOptions {
* Callers that don't want context-retrieval just omit this.
*/
contextRetriever?: ContextRetriever;
/**
* #575 — capability grants. Supplying this is what TURNS THE AUDIENCE FLOOR
* ON: with it, every turn resolves who is present and the three guards
* (tool egress, context recall, attachment handles) start enforcing the
* intersection of what those people may do. Omit it and all three
* short-circuit, which is every deployment's behaviour today.
*
* It is an explicit opt-in rather than a default because the floor fails
* closed by design: a deployment that has not decided who may do what would
* otherwise find its rooms bounded by an empty grant table.
*/
audienceGrants?: GrantStore;
/**
* #575 / #333 — role sources feeding the floor. Only consulted when
* `audienceGrants` is set. Defaults to an empty registry, which means
* principals hold no roles and therefore only their direct grants.
*/
audienceRoleSources?: RoleSourceRegistry;
/**
* OB-75 (Palaia Phase 6) — Session-Continuity Briefings. When set,
* the orchestrator prepends a session-summary + open-tasks block to
Expand Down Expand Up @@ -1693,6 +1718,9 @@ export class Orchestrator {
private readonly entityRefBus: EntityRefBus | undefined;
private readonly knowledgeGraphTool: KnowledgeGraphTool | undefined;
private readonly contextRetriever: ContextRetriever | undefined;
/** #575 — set only when the deployment opted the audience floor in. */
private readonly audienceGrants: GrantStore | undefined;
private readonly audienceRoleSources: RoleSourceRegistry;
private readonly sessionBriefing: SessionBriefingService | undefined;
private readonly factExtractor: FactExtractor | undefined;
/** #133 E0 — optional side-channel turn-hook runner (see OrchestratorOptions). */
Expand Down Expand Up @@ -1861,6 +1889,8 @@ export class Orchestrator {
this.sessionLogger = options.sessionLogger;
this.entityRefBus = options.entityRefBus;
this.contextRetriever = options.contextRetriever;
this.audienceGrants = options.audienceGrants;
this.audienceRoleSources = options.audienceRoleSources ?? new RoleSourceRegistryImpl();
this.sessionBriefing = options.sessionBriefing;
this.turnHookRegistry = options.turnHookRegistry;

Expand Down Expand Up @@ -3166,6 +3196,25 @@ export class Orchestrator {
// every call as `unresolved` and then fails closed. See the W4-1 block
// above for where the value comes from.
...(mcpUserKey ? { mcpUserKey } : {}),
// #575 — installed ONLY when the deployment supplied a grant store.
// Without it the three guards short-circuit and behaviour is unchanged,
// which is the "not enforced ≠ closed" rule the guards are built on.
// Deliberately not memoized: the egress guard re-evaluates per tool
// call so a mid-turn joiner narrows the floor, and caching here would
// hand it the turn's opening answer every time.
...(this.audienceGrants
? {
audienceFloor: createAudienceFloorProvider({
participants: parent?.chatParticipants,
resolvePrincipal: knowledgeGraphPrincipalResolver(
this.knowledgeGraph,
input.channelIdentity?.channelKind,
),
roles: this.audienceRoleSources,
grants: this.audienceGrants,
}),
}
: {}),
...(privacyHandle ? { privacyHandle } : {}),
...(parent?.captureRawToolResult
? { captureRawToolResult: parent.captureRawToolResult }
Expand Down
Loading
Loading