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
22 changes: 22 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,28 @@ entry. See `CONTRIBUTING.md` § Releases & changelog.

## [Unreleased]

### Added — audience-floor grants survive a restart, and an operator can see them

- **The audience floor had no durable store (#575).** `InMemoryGrantStore` was
the only implementation, so a deployment that switched the floor on lost every
grant on restart. Because the floor fails closed, that did not degrade the
feature — an empty grant table means "nobody may do anything", so a restart
shut every room until someone re-seeded by hand.
- **New:** `AUDIENCE_FLOOR_ENABLED` (default off) plus Postgres-backed grants
(migration `0035_audience_grants.sql`) and an operator surface at
`/api/v1/admin/audience-grants` (cookie auth, like the other admin routers).
- **The admin surface is available whenever Postgres is, independently of
enforcement** — grants have to be seedable and reviewable *before* the floor
starts enforcing, or the only way to populate the table would be to switch the
floor on against an empty one.
- **Enabling the floor without Postgres refuses to boot.** The alternative is
worse than a crash: every lookup would throw, every room would refuse every
tool, recall nothing and read no attachment, and the deployment would look
configured while behaving as though someone had forbidden everything.
- Role grants additionally need a role source registered (#333 phase 2); direct
grants work on their own, because an empty role registry is a complete answer
rather than a partial one.

### Fixed — a withheld answer no longer ships its full reasoning to the channel

- **`NO_REPLY` stopped suppressing delivery the moment the AI-Act Art. 50
Expand Down
68 changes: 68 additions & 0 deletions middleware/migrations/0035_audience_grants.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
-- ── Audience-floor capability grants (#575 phase 2) ────────────────────────
-- The durable backing for `GrantStore`. Until now the only implementation was
-- `InMemoryGrantStore`, which means a deployment that switched the audience
-- floor on lost every grant on restart — and because the floor fails closed, an
-- empty grant table is not "no policy", it is "nobody may do anything". A
-- restart therefore did not degrade the feature, it shut the rooms.
--
-- Two tables rather than one with a nullable discriminator: a direct grant and
-- a role grant are looked up by different keys on different code paths
-- (`directGrants(principal)` vs `roleGrants(roleKey)`), and the SDK keeps them
-- separate for exactly that reason. One table with a half-empty key column
-- would need a partial index per path and could express a row that is neither.
--
-- Capability strings are opaque here on purpose. The floor intersects sets of
-- them; what they mean (`tool:send_email`, `memory:recall`, `attachment:read`)
-- is the guards' business, and a CHECK constraint listing today's namespaces
-- would have to be migrated every time a guard learns a new one.

-- ---------------------------------------------------------------------------
-- Direct grants: a Principal holds a capability in their own right.
--
-- `principal_kind` is always 'user' today — `resolveCapabilities` refuses a
-- `role:` principal outright, because a role is an indirection over holders and
-- not a subject with entitlements. The column exists anyway so the table does
-- not have to be migrated if #333 ever grows a third kind; a row with any other
-- kind is simply never read.
--
-- `principal_ref` holds the CANONICAL form (`canonicalizePrincipalRef`), which
-- for a user means lower-cased. Writing the raw form here would let the same
-- person miss their own grants depending on how a channel spelled their id.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS audience_direct_grants (
principal_kind TEXT NOT NULL,
principal_ref TEXT NOT NULL,
capability TEXT NOT NULL,
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (principal_kind, principal_ref, capability)
);

-- ---------------------------------------------------------------------------
-- Role grants: everyone who currently holds the role holds the capability.
--
-- DELIBERATELY NO FOREIGN KEY to conductor_roles(key).
--
-- #333 phase 2 made role membership answerable by a registry of sources, and a
-- source may be an external directory this deployment has no local row for. A
-- foreign key would make "grant a capability to the Entra group everyone in
-- support belongs to" unrepresentable — the exact case the role-source registry
-- exists to serve. The cost is that a typo'd role key is accepted and silently
-- grants nothing; that fails in the safe direction, and the admin surface lists
-- what is stored so the typo is visible.
--
-- Role keys keep their case: `conductor_roles.key` is written verbatim by
-- `createRole`, so lower-casing here would miss every mixed-case role.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS audience_role_grants (
role_key TEXT NOT NULL,
capability TEXT NOT NULL,
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (role_key, capability)
);

-- The hot path reads every capability for one key, which the primary key's
-- leading column already serves. No secondary index is added for it.

-- rollback: DROP TABLE audience_role_grants; DROP TABLE audience_direct_grants;
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* than once in one process.
*/

import type { ChatAgent, DisclosureSeenStore } from '@omadia/channel-sdk';
import type { ChatAgent, DisclosureSeenStore, GrantStore } from '@omadia/channel-sdk';
import type { EmbeddingClient } from '@omadia/embeddings';
import type { LlmProvider } from '@omadia/llm-provider';
import type {
Expand Down Expand Up @@ -201,6 +201,18 @@ export interface OrchestratorDeps {
* `OrchestratorOptions.securityScreener` / `securityAuditSink`.
*/
readonly securityPosture?: SecurityPostureSetup;
/**
* #575 — durable capability grants for the audience floor.
*
* Present ONLY when the operator enabled the floor: the kernel publishes the
* `audienceGrants` service behind `AUDIENCE_FLOOR_ENABLED`. Absent ⇒ the
* orchestrator installs no audience provider and the three guards
* short-circuit, which is the "not enforced ≠ closed" rule they are built on.
* Passing a store is therefore the switch, and the reason it is a switch
* rather than a default is that the floor fails closed — an empty grant table
* bounds every room to nothing.
*/
readonly audienceGrants?: GrantStore;
/** #133 E0 — side-channel turn-hook runner, fired during each turn. */
readonly turnHookRegistry?: TurnHookRunner;
/**
Expand Down Expand Up @@ -416,6 +428,8 @@ export function buildOrchestratorForAgent(
// orchestrator applies the shipping default (`auto`); the screener + sink
// are always wired (inert unless screening is enabled for the posture).
...(deps.securityPosture ? { securityPosture: deps.securityPosture } : {}),
// #575 — supplying this is what makes the audience guards non-inert.
...(deps.audienceGrants ? { audienceGrants: deps.audienceGrants } : {}),
securityScreener,
securityAuditSink,
...(deps.turnHookRegistry
Expand Down
12 changes: 12 additions & 0 deletions middleware/packages/harness-orchestrator/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
POSTURE_ORDER,
type ChatAgent,
type AiDisclosureLevel,
type GrantStore,
type SecurityPosture,
} from '@omadia/channel-sdk';
import type {
Expand Down Expand Up @@ -621,6 +622,13 @@ export async function activate(
const securityPosture = resolveSecurityPostureSetup((key) =>
ctx.config.get<unknown>(key),
);
// #575 — the audience floor's capability grants. Published by the kernel
// BEFORE plugin activation (as a late-bound wrapper, because the Postgres
// pool it needs is published by the knowledge-graph plugin during this same
// pass) and ONLY when `AUDIENCE_FLOOR_ENABLED` is set. Undefined is the
// ordinary case: the orchestrator then installs no audience provider at all
// and every guard short-circuits, leaving behaviour unchanged.
const audienceGrants = ctx.services.get<GrantStore>('audienceGrants');
// #648 — publish the RESOLVED posture so `/health` and the operator
// dashboard can read what this instance actually does, and warn once at boot
// when it deviates from the delivered state. A reduced marking is a
Expand Down Expand Up @@ -837,6 +845,10 @@ export async function activate(
// #579 — org security posture (org floor + optional scope tighten + mode +
// screen URL). Undefined → the orchestrator's shipping default (`auto`).
...(securityPosture ? { securityPosture } : {}),
// #575 — the audience floor's grant store, published by the kernel only
// when the operator enabled the floor. Absent is the normal case and means
// the guards stay inert; see `OrchestratorDeps.audienceGrants`.
...(audienceGrants ? { audienceGrants } : {}),
// #644 — one fold-dedup store for the whole process, shared by every Agent
// the registry builds (same lifetime rationale as `directLineStickyStore`
// below): a per-instance store would re-fold the marking into a live
Expand Down
69 changes: 69 additions & 0 deletions middleware/src/audience/lateBoundGrantStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* #575 phase 2 — a {@link GrantStore} that resolves its backing store later.
*
* ## Why this indirection has to exist
*
* The orchestrator plugin reads the services it consumes at **activation**, and
* `index.ts` publishes those before `toolPluginRuntime.activateAllInstalled()`
* for exactly that reason. But `graphPool` is published BY a plugin — the
* knowledge-graph one — during that same activation pass, and is only readable
* afterwards. So at the moment the grant store must be published, the pool it
* needs does not exist yet.
*
* The codebase already solves this shape with a forward reference (see
* `conductorTemplateRegistrarRef` in `index.ts`): publish something now, point
* it at a holder, fill the holder in once the dependency resolves.
*
* ## Not-yet-hydrated must THROW, not return an empty list
*
* This is the whole reason the file has a header rather than being three lines
* inline. `GrantStore`'s contract says a store that cannot answer must throw,
* and `resolveCapabilities` turns that throw into an `unresolved` audience
* member — which closes the floor **with a reason an operator can act on**.
*
* Returning `[]` instead would be catastrophic in a way that looks harmless:
* an empty capability list is a perfectly well-formed answer, so the floor
* would intersect it into a smaller set and refuse things silently. The room
* would behave as though an operator had decided to forbid everything, and the
* actual cause — the store was consulted before it was ready — would leave no
* trace anywhere.
*
* Between "closed, and here is why" and "closed, cause unknown", only the first
* is a system anyone can operate.
*/

import type { Capability, GrantStore, Principal } from '@omadia/channel-sdk';

export class GrantStoreNotReadyError extends Error {
constructor() {
super(
'audience grant store is not hydrated yet — the audience floor was consulted before Postgres resolved. ' +
'The floor is closed until the store is available; this is not a policy decision.',
);
this.name = 'GrantStoreNotReadyError';
}
}

/**
* Wrap a holder that is filled in later.
*
* @param resolve returns the real store, or `undefined` while still unhydrated.
*/
export function createLateBoundGrantStore(
resolve: () => GrantStore | undefined,
): GrantStore {
const target = (): GrantStore => {
const store = resolve();
if (!store) throw new GrantStoreNotReadyError();
return store;
};

return {
async directGrants(principal: Principal): Promise<readonly Capability[]> {
return target().directGrants(principal);
},
async roleGrants(roleKey: string): Promise<readonly Capability[]> {
return target().roleGrants(roleKey);
},
};
}
Loading
Loading