Skip to content

Trust module stable

Purpose & Scope

The Trust module owns CORA's authorization topology. Every command that crosses the system is evaluated against this topology before it reaches a decider, and the evaluator that performs that check is a pure function on Policy state. Four aggregates carry the responsibility: Zone groups principals and assets that share a trust posture, Conduit is a governed communications path between two Zones, Surface is the process-level arrival point through which a request entered CORA, and Policy is an authorization rule attached to a specific Conduit and Surface.

Trust is the what you may do layer. Identity (who you are) lives in Access; agent-specific configuration (tool allowlists, budgets, suspended state) lives in Agent. The cross-module Authorize port carries an Actor id resolved by Access, a Conduit id resolved by the entry adapter, and a Surface id resolved by the transport adapter, and answers Allow or Deny by consulting Policy state.

Out of scope

  • Lifecycle FSMs on Zone, Conduit, and Policy. All three follow an additive-state pattern: today they are immutable-once-defined, and the future Defined → Active → Modified → Archived (Zone, Conduit) and Drafted → Approved → Active → Superseded (Policy) transitions land additively when commands that exercise them ship. Adding fields to a state record that gets defaulted in the evolver is the documented forward-compatible change shape.
  • Operator-defined Surfaces and a Surface list endpoint. Today the three Surfaces (HTTP, MCP stdio, MCP streamable HTTP) are seeded at boot from constants. Operators cannot define new Surfaces and there is no GET /surfaces listing. Surface ships a status enum so the version and deprecate slices can land additively, but only Defined is emitted today.
  • Attribute-based and relationship-based widening. The Policy decider is pure allow-list over (principal, command, conduit, surface). The AuthZEN-shaped widening that adds subject attributes, resource attributes, and a context bag is captured as a target shape and is deferred until the trigger fires.
  • Cross-policy combining rules. Today the Authorize port resolves one Policy per (conduit, surface) pair. Multi-policy union or intersection logic is deferred until a real combining rule arrives.
  • Per-principal permission cache. list_permissions enumerates from Policy state on every call. A materialized "what can this principal do" projection is deferred until p95 read latency demands it.
  • Cross-facility federation. Policy resolution across facilities is out of scope. Today each deployment owns its own Trust streams.

Aggregates

Name Identity State summary FSM
Zone id: UUID id, name: ZoneName additive, no transitions today
Conduit id: UUID id, name: ConduitName, source_zone_id, target_zone_id, logbooks: dict[str, UUID] additive, no transitions today
Surface id: UUID id, name: SurfaceName, kind: SurfaceKind, status: SurfaceStatus additive, only Defined emitted today
Policy id: UUID id, name: PolicyName, conduit_id, permitted_principals: frozenset[UUID], permitted_commands: frozenset[str], surface_id additive, no transitions today

A Zone is a trust-requirement-homogeneous grouping of principals and assets, defined by trust posture rather than physical location. A Conduit is the governed comms path between two Zones; the source-target naming is for clarity at the API layer, since the conduit itself is undirected per the topology standard. A Surface is the process-level arrival socket the request crossed: the protocol-bound endpoint, not the inter-zone path. A Policy is the explicit allow-list that gates a (principal, command) pair on a specific Conduit and Surface.

Conduit.logbooks maps logbook kind to the currently-open logbook id. Today the only logbook kind is traversals, opened automatically at conduit-creation, and the state encodes the at-most-one-open-per-kind invariant directly: opening a second logbook of the same kind raises rather than orphaning the first. Logbook entries themselves live in a separate typed table and do not fold into Conduit state.

Policy.surface_id defaults to a nil sentinel UUID. The sentinel is reserved exclusively for one legacy compatibility fold: pre-Surface PolicyDefined events on disk lack the surface_id field and fold to nil, and the evaluator treats nil-surface policies as matching any caller's surface_id. Once those legacy streams are drained the wildcard branch and the sentinel default will be removed in the same change.

Value Objects

Name Shape Where used
ZoneName trimmed string, 1-200 chars Zone.name
ConduitName trimmed string, 1-200 chars Conduit.name
SurfaceName trimmed string, 1-200 chars Surface.name
SurfaceKind closed StrEnum: http | mcp_stdio | mcp_streamable_http Surface.kind
SurfaceStatus closed StrEnum: defined | versioned | deprecated Surface.status
PolicyName trimmed string, 1-200 chars Policy.name
LogbookKind snake_case string discriminator; today only "traversals" keys of Conduit.logbooks
AuthzResult tagged union Allow() | Deny(reason: str) return shape of evaluate(policy, ...)

SurfaceKind is a closed enum on purpose: adding a new arrival kind (gRPC, websocket, agent-to-agent, batch) requires a code release. The kept-narrow operational vocabulary is the same discipline applied to executor shapes in Recipe and affordances in Equipment.

AuthzResult.Deny.reason is a diagnostic string meant to flow into logs and API responses for debugging. It is not intended for end-user display and not part of the authorization contract that callers depend on.

FSM

Three of the four aggregates run no FSM today. Zone, Conduit, and Policy are immutable-once-defined: the genesis event is the only event, and the additive-state pattern keeps the door open for the lifecycle transitions captured in the out-of-scope aside. Surface ships a status enum but only the genesis transition into Defined is exposed. None of the four aggregates ships a version_* or deprecate_* slice today.

stateDiagram-v2
    [*] --> Defined: define_zone | define_conduit | define_surface | define_policy
    Defined --> [*]
From To Command Event
[*] Defined define_zone ZoneDefined
[*] Defined define_conduit ConduitDefined (auto-opens the traversals logbook via ConduitLogbookOpened in the same transaction)
[*] Defined define_surface SurfaceDefined
[*] Defined define_policy PolicyDefined

The logbook sub-lifecycle on Conduit is captured in the events table below; today the open transition fires at conduit-creation and the close transition has no command path (it lands additively when conduit-archive ships).

Events

Zone emits one event type. Conduit emits three. Surface emits one. Policy emits one.

Event Payload sketch When emitted
ZoneDefined zone_id, name, occurred_at define_zone succeeds (genesis)
ConduitDefined conduit_id, name, source_zone_id, target_zone_id, occurred_at define_conduit succeeds (genesis)
ConduitLogbookOpened conduit_id, logbook_id, kind, schema, occurred_at a logbook of kind is attached to the Conduit; carries the per-entry schema for audit
ConduitLogbookClosed conduit_id, logbook_id, occurred_at a previously-opened logbook is terminated (additive; no current command path emits it)
SurfaceDefined surface_id, name, kind, occurred_at define_surface succeeds (genesis); today only the three seeded Surfaces ever emit this
PolicyDefined policy_id, name, conduit_id, surface_id, permitted_principals, permitted_commands, occurred_at define_policy succeeds (genesis); permitted sets serialize as sorted lists for deterministic payloads

ConduitLogbookOpened.schema declares the per-row column shape of the entries that will land in the typed entries table for this logbook kind. Carrying the schema on the open event means the Conduit lifecycle audit captures the schema as of the moment the logbook was opened, which supports per-logbook schema evolution by opening a new logbook with an updated schema.

PolicyDefined payloads carry the permission lists sorted by string form. Same logical permission set, same payload bytes, same idempotency hash.

Slices

Command Category REST MCP tool Idempotency
DefineZone NEW POST /zones define_zone required
ListZones QUERY GET /zones list_zones none
DefineConduit NEW POST /conduits define_conduit required
ListConduits QUERY GET /conduits list_conduits none
DefineSurface NEW POST /surfaces define_surface required
GetSurface QUERY GET /surfaces/{target_surface_id} get_surface none
DefinePolicy NEW POST /policies define_policy required
ListPolicies QUERY GET /policies list_policies none
EvaluatePolicy QUERY GET /policies/{policy_id}/evaluate evaluate_policy none
ListPermissions QUERY GET /policies/{policy_id}/permissions list_permissions none

define_surface is reachable today only for the bootstrap path that seeds the three system Surfaces. The route exists for operational symmetry and is exercised by the bootstrap routine; there is no operator-facing path for minting a fourth Surface.

EvaluatePolicy and ListPermissions are the two query slices that expose the Policy decider. EvaluatePolicy answers the yes-no question for a single (principal, command, conduit) tuple. ListPermissions enumerates the commands a principal may execute against a Policy via a Conduit and returns an incomplete: bool flag (always False today; reserved for the attribute-widened future where enumeration may be lossy). Both endpoints exist for UX and debugging; only the Authorize port wired by the kernel authorizes actual commands.

Errors per slice. Beyond Pydantic boundary 422s, each slice raises:

DefineZone
InvalidZoneName, ZoneAlreadyExists, Unauthorized
ListZones
(boundary 422 only)
DefineConduit
InvalidConduitName, ConduitAlreadyExists, ConduitLogbookAlreadyOpen (defensive; the genesis-time auto-open cannot collide on a fresh stream), Unauthorized
ListConduits
(boundary 422 only)
DefineSurface
InvalidSurfaceName, SurfaceAlreadyExists, Unauthorized
GetSurface
SurfaceNotFound
DefinePolicy
InvalidPolicyName, PolicyAlreadyExists, Unauthorized
ListPolicies
(boundary 422 only)
EvaluatePolicy
PolicyNotFound (the Deny and Allow outcomes are normal 200 responses, not errors)
ListPermissions
PolicyNotFound, Unauthorized (on-behalf queries where evaluated_principal_id differs from the caller require a separate permission and are denied by default)

Storage & Projections

Four read-side artefacts back the Trust module: three summary projections (one per identity-bearing aggregate that supports list queries) and one typed entries table for the per-decision authorization audit log.

proj_trust_zone_summary
CREATE TABLE proj_trust_zone_summary (
    zone_id     UUID        PRIMARY KEY,
    name        TEXT        NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX proj_trust_zone_summary_keyset_idx
    ON proj_trust_zone_summary (created_at, zone_id);
proj_trust_conduit_summary
CREATE TABLE proj_trust_conduit_summary (
    conduit_id      UUID        PRIMARY KEY,
    name            TEXT        NOT NULL,
    source_zone_id  UUID        NOT NULL,
    target_zone_id  UUID        NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL,
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX proj_trust_conduit_summary_keyset_idx
    ON proj_trust_conduit_summary (created_at, conduit_id);

CREATE INDEX proj_trust_conduit_summary_source_zone_idx
    ON proj_trust_conduit_summary (source_zone_id);

CREATE INDEX proj_trust_conduit_summary_target_zone_idx
    ON proj_trust_conduit_summary (target_zone_id);
proj_trust_policy_summary
CREATE TABLE proj_trust_policy_summary (
    policy_id   UUID        PRIMARY KEY,
    name        TEXT        NOT NULL,
    conduit_id  UUID        NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX proj_trust_policy_summary_keyset_idx
    ON proj_trust_policy_summary (created_at, policy_id);

CREATE INDEX proj_trust_policy_summary_conduit_idx
    ON proj_trust_policy_summary (conduit_id);
entries_conduit_traversals
CREATE TABLE entries_conduit_traversals (
    event_id        UUID         PRIMARY KEY,
    conduit_id      UUID         NOT NULL,
    logbook_id      UUID         NOT NULL,
    actor_id        UUID         NOT NULL,
    command_name    TEXT         NOT NULL,
    decision        TEXT         NOT NULL CHECK (decision IN ('Allow', 'Deny')),
    reason          TEXT,
    correlation_id  UUID         NOT NULL,
    causation_id    UUID,
    occurred_at     TIMESTAMPTZ  NOT NULL,
    recorded_at     TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE INDEX entries_conduit_traversals_conduit_time_idx
    ON entries_conduit_traversals (conduit_id, occurred_at DESC);

CREATE INDEX entries_conduit_traversals_logbook_idx
    ON entries_conduit_traversals (logbook_id);

CREATE INDEX entries_conduit_traversals_recorded_at_brin_idx
    ON entries_conduit_traversals USING BRIN (recorded_at);

entries_conduit_traversals is the first concrete entries-table observation logbook in CORA. Per-decision authorization records are high-cardinality (one row per Authorize port call across every command in production) and must not fold into Conduit state or bloat the main events table. The event_id primary key doubles as the idempotency and dedup key. The (conduit_id, occurred_at DESC) btree supports the primary read pattern of paging the latest decisions for a Conduit. The logbook_id btree supports per-logbook session reads. The recorded_at BRIN index supports retention sweeps and time-range analytics at a fraction of a btree's storage cost.

ConduitLogbookOpened and ConduitLogbookClosed events are intentionally not subscribed by proj_trust_conduit_summary: they are internal logbook bookkeeping that does not mutate the summary's columns. The same precedent holds in Decision's summary projection.

Policy.permitted_principals and Policy.permitted_commands are intentionally not projected as filter columns on proj_trust_policy_summary: they are list-shaped, and a future join projection covers "all policies allowing principal X" if that read pattern crystallizes.

Cross-Module boundaries

Module Relationship What's exchanged
Access reads-from Policy.permitted_principals contains Actor.id values; the Authorize port carries actor_id resolved by the Access layer
All BCs provides-port every write-side decider behind the kernel's PEP is gated by the Authorize port, which resolves a Policy and calls the pure evaluate(policy, ...)
Conduit / Surface ids on inbound calls reads-from the HTTP and MCP entry adapters set conduit_id from the entry topology and surface_id from the transport, then pass both on the Authorize call
Equipment aligns-with every Asset is conventionally a member of exactly one Trust Zone for security policy; the link is read-time, not stored on either aggregate
Decision shared-id-with Decision.actor_id is an Actor id that may appear in Policy.permitted_principals; the link is read-time

The Authorize port is the only path through which Trust state influences command execution. Deciders never read Policy state directly; the kernel's PEP holds the only reference and answers a single Allow or Deny. This keeps every BC's write path pure and makes the Policy decider trivially testable as a function from (policy, principal, command, conduit, surface) to AuthzResult.

Cross-aggregate references inside Trust are bare UUIDs and are not verified at write time. A Conduit can be defined with a source_zone_id that does not resolve, a Policy can be defined against a Conduit id that does not exist; the mismatch surfaces at evaluate time, which is the natural enforcement point. This is the same eventual-consistency posture used everywhere else.

Examples

The five examples below cover the canonical Trust authoring and evaluation flow: define a Zone, define a Conduit between two Zones, define a Policy that gates one command on that Conduit, evaluate that Policy for a (principal, command) pair, and enumerate the commands a principal may execute. The caller's principal goes on the X-Principal-Id header. For the REST and MCP equivalence, auth, and idempotency conventions these examples share, see Reading the examples on the Modules landing page.

Define a Zone

POST /zones
Content-Type: application/json
Idempotency-Key: 2a7d5f0c-1b2e-3c4d-5e6f-7a8b9c0d1e2f
X-Principal-Id: 11111111-2222-3333-4444-555555555555

{
  "name": "Beamline 35-BM Operators"
}

Returns 201 Created with the newly-assigned zone_id. A second call with the same idempotency key returns the same id.

mcp.call_tool(
    "define_zone",
    {"name": "Beamline 35-BM Operators"},
)

Define a Conduit between two Zones

POST /conduits
Content-Type: application/json
Idempotency-Key: 7b3e9a1c-2d4f-5e6a-7b8c-9d0e1f2a3b4c
X-Principal-Id: 11111111-2222-3333-4444-555555555555

{
  "name": "Operator → Detector Control",
  "source_zone_id": "<operator-zone-id>",
  "target_zone_id": "<detector-zone-id>"
}

Returns 201 Created with conduit_id. The traversals logbook opens automatically in the same transaction, so the very first authorization decision routed through this Conduit lands a row in entries_conduit_traversals without a separate setup step.

mcp.call_tool(
    "define_conduit",
    {
        "name": "Operator → Detector Control",
        "source_zone_id": "<operator-zone-id>",
        "target_zone_id": "<detector-zone-id>",
    },
)

Define a Policy gating one command on a Conduit and Surface

POST /policies
Content-Type: application/json
Idempotency-Key: 9c2a8e4f-3b5d-6c7e-8f9a-0b1c2d3e4f5a
X-Principal-Id: 11111111-2222-3333-4444-555555555555

{
  "name": "Operators may start runs over the operator → detector conduit",
  "conduit_id": "<conduit-id>",
  "surface_id": "<seeded-http-surface-id>",
  "permitted_principals": ["<operator-actor-id>"],
  "permitted_commands": ["StartRun"]
}

Returns 201 Created with policy_id. The permitted sets are stored as frozenset on the aggregate and serialized as sorted lists in the payload for deterministic bytes. An empty permitted_principals or permitted_commands is allowed and yields a deny-all policy by construction.

mcp.call_tool(
    "define_policy",
    {
        "name": "Operators may start runs over the operator → detector conduit",
        "conduit_id": "<conduit-id>",
        "surface_id": "<seeded-http-surface-id>",
        "permitted_principals": ["<operator-actor-id>"],
        "permitted_commands": ["StartRun"],
    },
)

Evaluate a Policy

GET /policies/<policy-id>/evaluate?evaluated_principal_id=<operator-actor-id>&evaluated_command_name=StartRun&evaluated_conduit_id=<conduit-id>
X-Principal-Id: 11111111-2222-3333-4444-555555555555

Returns 200 OK with {"decision": "Allow", "reason": null} for the matching tuple, or {"decision": "Deny", "reason": "<diagnostic>"} if any of the four checks (conduit, surface, principal, command) fails. 404 Not Found if no Policy exists at the given id. The evaluated_ prefix on the query params disambiguates them from the caller's own principal_id header.

mcp.call_tool(
    "evaluate_policy",
    {
        "policy_id": "<policy-id>",
        "evaluated_principal_id": "<operator-actor-id>",
        "evaluated_command_name": "StartRun",
        "evaluated_conduit_id": "<conduit-id>",
    },
)

Enumerate a principal's permitted commands for a Conduit

GET /policies/<policy-id>/permissions?evaluated_principal_id=<operator-actor-id>&evaluated_conduit_id=<conduit-id>
X-Principal-Id: 11111111-2222-3333-4444-555555555555

Returns 200 OK with {"policy_id", "evaluated_principal_id", "evaluated_conduit_id", "permitted_commands": [...], "incomplete": false}. The command list is the sorted intersection of Policy.permitted_commands and what the policy actually grants for the supplied principal. The incomplete flag is always false today; it is reserved for the attribute-widened future where enumeration may not be lossless. The returned set is intended for UX and debugging only; it must not drive authorization decisions, since only the kernel's Authorize port authorizes commands.

mcp.call_tool(
    "list_permissions",
    {
        "policy_id": "<policy-id>",
        "evaluated_principal_id": "<operator-actor-id>",
        "evaluated_conduit_id": "<conduit-id>",
    },
)