Skip to main content

Policy and rulesets

The policy kernel evaluates a frozen resolved request against immutable configuration. Its results are DENY, REQUIRE_APPROVAL and ELIGIBLE. It has no HTTP/MCP route, consumes no human approval and grants no dispatch ownership.

Bundles and lifecycle

BundleContent contains tenant/bundle identity, Cedar schema, separate authorization and approval text, exact mapping references, typed limits, optional context_settings (see Context contracts) and optional quorum rules (design). A quorum rule names an approval policy id (policy:<id>) or a REQUIRE_APPROVAL limit rule (limit:<rule_id>) of the same bundle and requires two to five distinct human approvers; rule ids and obligations are unique, a rule naming an unknown obligation fails validation with its field location, and the effective requirement of a decision is the largest over its obligations, or one when no rule matches. The quorum never adds or removes an obligation. A mapping reference may set observe_only (design): requests for that operation are resolved and evaluated but only observed, never dispatched, while the rest of the bundle stays enforced; the flag serializes only when set, so existing content hashes are unchanged. It may instead set external_executor (design, guide): requests for that operation are resolved, evaluated, reserved and claimed exactly as an enforced one, and then answered with a grant instead of a dispatch, because the caller's own platform holds the vendor connector. That flag serializes only when set too. The two are mutually exclusive — the model refuses the pair at construction and the store refuses it again with its field location — and a mapping carrying external_executor is refused unless its provider, action and provider operation are a released binding of the action catalog and the tenant has at least one unrevoked executor key, since without one an unreported grant would have no exit. Both checks run where every other component check runs, at validation and at activation; admission does not repeat them, because it reads the active bundle through the execution store without revalidating components, so two later guards carry the key requirement instead: revoke_executor_key refuses to retire a tenant's last live key while its active bundle declares an external executor, and admission itself refuses to issue a grant the tenant's key ring could not attest. Reporting a key compromised deliberately bypasses the first, which is why the second exists. Placement is bundle content and never a request field: no header, credential attribute or client capability moves an operation into or out of a checkpoint. Every component except context_settings and quorum must be present; an explicitly empty approval set is allowed. Schema and policy text have byte bounds, limit identities are unique, and unsupported rule fields/types fail validation. Content hashes include the entire canonical bundle; plan hashes also bind component versions and the resolved evidence.

A current ADMIN manages the lifecycle from the policy pages (/policy: list and create a draft from a JSON document; /policy/{bundle_id}: inspect components, validate, read the 24-hour preview, activate with an explicit confirmation, retire) or the bundle-* commands; both go through the same audited store functions. Content is stored separately from DRAFT, VALIDATED, ACTIVE and RETIRED lifecycle state. The runtime has no content update/delete/truncate privilege. Administrative state transitions must validate the current credential and role, verify referenced mapping/account versions, and append an audit event in the same transaction. A per-tenant control row serializes activation, including simultaneous first activation. Since migration 0041_spaces the selection is per space: space_policy_selection holds at most one bundle per space as its primary key, ACTIVE means "selected by at least one space of this tenant", activation returns the previously selected bundle to VALIDATED only when no other space still selects it (audited as policy.bundle_deselected), and retiring a bundle any space selects is refused. The fence does not move — activation still locks tenant_policy_control first and then writes the space's selection, so two activations in two spaces serialize on the row they always serialized on and admission reads the space's selection under the lock it already takes. A space with no selection fails closed exactly as a tenant with none did.

The synthetic endpoint_bundle() pack authorizes workstation isolation only with a linked open, critical incident. It forbids domain controllers and criticality 4 resources, requires approval for production, sets a principal autonomous threshold of ten targets per rolling hour, and a tenant total ceiling of twenty per rolling fifteen minutes. These are mock demonstration rules.

execbound_harness.soc_demo.soc_bundle() is a second synthetic pack covering all six catalog actions on both mocks. It permits workstation isolation and standard-identity control with an open, critical linked incident, permits a reset only for a standard identity with an open high or critical incident, and permits lifting isolation when the incident is CLOSED or the caller states context.arguments.reason == "false_positive". It forbids domain controllers, privileged and tier-0 identities and criticality 4. Approval is always required to enable an identity, for any production target, for a lift whose reason is remediated and for a reset that does not force a change at next sign-in. Each action gets a principal autonomous threshold of ten targets per rolling hour and a tenant total ceiling of twenty per rolling fifteen minutes. seed-validation installs it as VALIDATED beside the active demo bundle; Action catalog records the rules and the ADMIN activation step. These are mock demonstration rules.

Bundles may include context_settings: per contract and source kind, a max age (up to the contract ceiling) and a permitted writer allowlist. Unset settings use the contract defaults and are omitted from the stored bytes, so earlier bundle hashes are unchanged. Validation and activation require every mapping's contract to exist in the catalog and serve that mapping, the Cedar entity for each contract to declare exactly tenant, account and the contract's facts with their types, and every setting to name a used contract and source kind within its ceiling. They also check each mapping against the action catalog: the Cedar schema must declare the mapped action, its appliesTo.context record must declare exactly that action's arguments with their Cedar types (an empty record for an action with none), and every action a limit rule names must be a catalog action, because a rule for an unknown action would neither charge nor cap anything. Contract validation reads the declared entity attributes, so a bundle's Cedar schema must use Cedar's JSON schema format. A bundle whose schema uses Cedar's human-readable schema format is rejected at validation and activation.

A limit rule names an action, a scope, a rolling window_seconds, a max_targets ceiling, a budget (autonomous or total) and an on_exceed effect (DENY or REQUIRE_APPROVAL). scope is one of principal, tenant, group, account, selector and space (design, spaces): a group rule carries group_id, an account rule account_id, a space rule space_id, and a selector rule selector, which is {"fact": <name>, "equals": <string | integer | boolean>} over one scalar trusted fact of the frozen plan. A scope without its qualifier, a qualifier without its scope or two qualifiers at once fail validation, so a rule can never silently widen to the tenant. An optional actions list of 1 to 8 catalog actions, sorted, unique and containing action, makes the rule count impact of every listed action and charge a candidate whose action is in the list; without it the rule counts action alone. A space rule is the per-customer ceiling an MSSP wants inside one tenant: it counts over the retained space_id of the impact row, never over where the agent is placed now, so moving an agent later never rewrites what past impact counted for. A tenant rule still counts across every space, which is the founder's decision working and, named plainly, a cross-space denial channel. The five qualifiers serialize only when set, so the content hash, security_versions.limits and every plan hash of a bundle written before they existed are byte-identical. The store validates each rule with a field location: every name in the action list must be a catalog action (limits.<i>.action), group_id an existing principal group of the tenant (limits.<i>.group_id), account_id an active connector account of the tenant (limits.<i>.account_id), space_id an existing space of the tenant (limits.<i>.space_id), and a selector must name a fact that every context contract of the bundle's mappings serving one of the rule's actions declares, with a value inside each such contract's domain, and at least one contract must serve those actions (limits.<i>.selector). These checks run at validation and at activation, and load_active_bundle repeats them for a caller that wants a revalidated snapshot; admission does not, because it reads the active bundle through the execution store without re-running the component checks, so a group deleted after activation is not refused there. A group can only be deleted while it has no members, since the membership table's foreign key has no cascade. The shipped demo, identity and SOC packs carry only principal and tenant rules; group administration is described in Operations.

Rules

A ruleset is a bundle's source: allow, deny and review rules over the facts and arguments the catalog declares, each with typed conditions and values the declared domain admits, plus budgets and a quorum, compiled deterministically into the Cedar sets, limits and quorum the kernel already executes. The kernel never reads a ruleset; it reads the compiled bundle, and validation and activation recompile the ruleset and refuse a bundle whose components disagree with it, naming the component. A ruleset cannot express anything a bundle cannot, so hard-denial precedence and default deny are untouched by construction.

The pages show a rule as one sentence, labelled as how the rules read and never as the decision, above the compiled policy it produced; the preview names the rule behind each decision that would change, and marks a candidate that would owe a review as unresolved, because a replay cannot know a person's answer. A note on a rule is inert: shown as text, compiled nowhere.

A rule can also be drafted from a sentence. The console's assistant (#330) reads the draft an administrator is editing, with that administrator's own authority, and answers with one Rule in the rule model's own JSON schema, validated by the same validator the add-a-rule form uses and compiled with the draft's other rules against the draft's mappings before it is offered. What comes back is the rule, its sentence, and its conditions in the words the editor parses, which the page uses to pre-fill the form beside what the person said. The assistant writes nothing: it creates no draft, adds no rule and activates nothing. The administrator submits the form, and the draft then goes through validation and preview like any other, whose changed decisions are the check that the rule does what was meant. A proposal the compiler will not take is refused with the field that caused it rather than offered and failing later.

The welcome page and an agent's page read the rules the same way. project() in policy/rulesets.py says what the rules about one action say over one set of facts, following the compiled bundle's own precedence (a denial wins, an allow is needed, an applicable review means approval), names the rules the reading rests on, and names any fact it was not given instead of guessing. It is a reading for a person, never a decision: limits, quorum and people are outside it, every page labels it so, and tests/test_pack_rulesets.py holds it equal to the kernel's policy decision over every input of every pack.

Cedar boundary

The pinned cedarpy==4.12.0 community binding provides the Cedar engine. It is not an AWS-supported Python SDK. Both policy sets are parsed and validated against the supplied schema. Authorization supports static permits and forbids; approval supports static permits only. Templates and unsupported effects are rejected through structured parsing.

The adapter checks the native JSON response rather than the convenience result accessors because those accessors default missing diagnostics to empty values. Missing or malformed decisions/diagnostics, unknown policy IDs, inconsistent effects and dependency exceptions deny. Runtime authorization and approval sets are both evaluated even if the first denies or errors. Native API use is deliberately narrow and must be reviewed when changing the pinned binding. The adapter calls cedarpy._internal.is_authorized and cedarpy._internal.validate_policies directly. As of 4.12.0 (checked September 15, 2026) the public is_authorized wraps the same native call, but its Diagnostics accessors substitute empty lists for missing errors and reason keys, which would turn an incomplete engine response into an apparent clean allow; no public entry point exposes the raw response, so the private call stays. tests/test_cedar.py asserts that the private entry points exist and return the expected shapes, so a binding upgrade that changes them fails with an explicit review message instead of silently altering decisions. Binding documentation, Cedar error semantics

Only authenticated identity and validated resolved/derived facts enter Cedar entities. Principal and resource identifiers include tenant/account scope; caller claims never become entity attributes. The request context is empty for an action that declares no arguments; for an action that declares them, evaluation rechecks the frozen arguments against the action spec and sends them as the single record context.arguments, denying with INVALID_ARGUMENTS when they no longer match. Arguments are caller intent, so they stay in the context and never become resource attributes. Missing or inconsistent provenance, writers outside the bundle's effective allowlist, invalid derivations, stale mappings and expired evidence deny. Evidence expiry is checked again after policy evaluation. Writer allowlists should name only trusted writer labels. Admitting a writer label that an agent can write (the tests use agent) removes the scenario T protection against agent-written context.

Decision composition

Any policy error, missing base authorization, applicable forbid, invalid input/dependency or exceeded DENY limit yields DENY. Policy and limit obligations are considered only when no denial exists. Engine policy IDs are namespaced with policy: and stable rule IDs with limit:; an obligation is meaningful only within its tenant, principal and bundle/plan binding.

UsageSnapshot is an immutable internal input scoped to tenant, principal, bundle, the frozen intent's account (account_id) and the memberships the coordinator read inside the fence for this decision (groups, at most 64, sorted and unique). It must contain exactly one count for every rule of the bundle, and a snapshot whose tenant, principal, bundle or account differs from the frozen plan is INVALID_USAGE. A rule applies to a candidate through LimitRule.applies_to: the frozen action must be in the rule's action list, and a group rule needs the group among the snapshot's memberships, an account rule the frozen intent's account, and a selector rule the selected fact in the frozen plan with exactly the selected value and type (1, true and "1" are three different values); a rule that does not apply neither charges nor waives anything, exactly like a rule for another action. Each projection adds confirmed targets, unresolved reservations and the proposed target's applicable contribution. A scoped internal VerifiedApproval exempts the candidate from autonomous impact while preserving total impact. Exceeded REQUIRE_APPROVAL rules add obligations before satisfaction; exceeded applicable DENY limits still deny. Rule order does not alter the effect.

Required execution integration

The pure evaluator does not query active configuration, refresh credentials, reserve capacity or establish that supplied usage is current. The execution coordinator authenticates, resolves trusted evidence, loads the active bundle and derives usage from retained impact, in each rule's scope, while holding the tenant fence. It rechecks authority before commitment.

The coordinator validates and consumes scoped server-side approval, acquires the resource/idempotency claims and persists dispatch intent. There is no caller-supplied approval boolean. Activating a bundle changes the plan binding; old approval cannot authorize a new active bundle. History and uncertainty survive rule changes.

The decision is the same for an externally executed operation: the same resolution freezes the same plan, the same evaluation composes the same Cedar sets and limit rules, DENY still wins, and an unsatisfied obligation returns the same approval flow with its window, quorum and continuation. A denied checkpoint grants nothing, reserves nothing and claims nothing. What differs is only what follows an ELIGIBLE decision, which External execution checkpoint describes; an issued grant is not recalled by a later bundle edit, and the report route reads the bundle the execution was frozen under rather than the active one.

Policy tests cover the decision portions of acceptance N/R; coordinator tests add approval consumption, live concurrent budgets and precommit configuration rechecks. Separate provider, surface, identity and replay tests now cover the controlled mock system. See current build status and Validation for complete release evidence and the still-unperformed named-host/vendor trials.

Verification

At policy PR #5 completion, the full local PostgreSQL suite passed 462 tests. That unit added 57 Cedar adapter tests, 33 model tests, 38 decision tests, 64 store tests and one resolution/activation/evaluation integration test. Migration round-trip, lint/format, strict type checks and package builds passed. Independent policy-core and store/migration reviews found no material issues. Later unit evidence is recorded in BUILD_STATUS.

Hosted smoke verification exercised the ADMIN lifecycle and active-bundle resolution through the restricted role: a synthetic production workstation required approval, an agent could not activate configuration, and a second tenant could not read the bundle. These are bounded development checks; destructive regression tests run only against the guarded local test database.