Skip to main content
Version: v5.6.1

Change governance — the approval-policy funnel

packs:change-governance

The packs:change-governance pack's model: CAB-style approvals as policy, not authoring. A change manager imposes approval on a path; no workflow author can code around it. The API surface is ChangeGovernanceService (see opschain docs api-v2); every event the funnel emits is catalogued on opschain docs events; the per-RPC determination × pack × step-up table is opschain docs governance-matrix. Hands-on guides: opschain learn governance-approvals and opschain learn governance-asset-owners.

Approval policies (the matrix)​

An ApprovalPolicy is one matrix row: path_pattern (node paths it covers — a trailing /** covers the named node and its subtree on segment boundaries), optional action_patterns (name globs; empty covers every action), optional metadata_predicates (the shared selector grammar, evaluated against effective node metadata — a predicate-carrying policy matches nothing without a classification, the fail-closed posture), approvers (named users and groups, and/or the kinds resolution families — impacted_asset_owners, resolved per change; a policy must carry at least one source), integer quorum counting distinct identities, self_approval (default false: neither the requester's nor the change owner's approval ever counts), strict (never subsumed; refuses covered creates on entitlement lapse), require_window (covered subjects start only inside an open admitting maintenance window), require_step_up (a matched create demands an elevated session before any approval gate is evaluated — customer policy only ever ADDS step-up over the platform floors), require_change_reference, and the two clocks (quorum TTL default 7d, approval validity default 14d).

Policy reads are open to any authenticated caller behind the pack (the funnel explains itself: the rules that hold a change are never a secret — maintenance-window listing follows the same principle). ListApprovalPolicies/GetApprovalPolicy carry an authorisations block reporting the caller's authoring standing (the list's on the root scope, each row's on its own scope), so read-only surfaces can drop their edit affordances honestly.

Policy writes require the delegable change-governance determination — updatable on /governance/change<scope>, an ordinary path-RBAC grant on the reserved rule-path family (a role grant with project delegation) — plus a stepped-up session (the policy-write floor). The compiled policy matcher is precompiled and write-invalidated (the authz-snapshot shape), so the create funnel never parses patterns per create.

Impacted asset owners​

Ownership is an authz-shaped fact declared per subtree on the policy fabric: an asset owner declaration names a subtree root and its owner identities (users and groups). A declaration covers its node and whole subtree on segment boundaries, the deepest covering declaration wins (inheritance), and the subtree is immutable once declared — move ownership by delete + create so the audit trail shows the handover. Declaring is delegable exactly like the metadata-writer role: writes are decided updatable on the reserved /governance/owners<node path> rule-path family (plus the step-up floor) and audited; reads ride readable on the same family, 404-on-deny.

A policy carrying the impacted_asset_owners approver kind resolves these declarations at the create funnel, over the units the policy itself matched in the frozen target set: each impacted target resolves to its covering declaration, and the checklist gains one all-of line per resolved owner — quorum 1 per line, every owner line must satisfy (distinct from the per-line n-of-m quorum), with the resolved subtree root frozen onto the line (owner_path). Self-approval exclusion and distinct-identity counting apply per owner line, so an owner who requested the change cannot wave their own asset through. An impacted target no declaration covers refuses the create loudly, naming the policy and the uncovered paths — a covered change is never created approver-less. The canonical policy shape: production scope × destroy* → require_step_up + approval of each impacted asset owner.

The funnel — pending_approval​

Every top-level create door (direct changes, workflow runs, retries, trigger and schedule lineage) matches its frozen targets — the subject's own target+action plus every frozen step target (ANY frozen target under a pattern matches) — against the enabled policy set, after the maintenance stamp. A matched subject is created normally (its tree freezes, the content fingerprint is taken) and HELD: pending_approval in every list, a layered checklist opened, nothing driven, no runner, no queue slot, no workspace. Children a held run's parent spawns never re-gate (no double-gating); every retry re-gates as a new subject (nothing grandfathers).

The checklist carries one frozen line per matched policy (approvals bind to what the approver saw, never to later policy edits), per-line quorum over distinct identities, and the MIN-across-lines clocks. The fingerprint covers the frozen composition — target, action, version pins, masked inputs, skip mask, match units — and stamps every approval row and export; the governance block is deliberately outside it (never fingerprinted).

Reviewer verbs are user-plane: the inbox (ListPendingApprovals) and checklist reads ride node read authz; approve/reject require approver-list membership on a line, with the self-approval exclusion applied per line.

What an approver may read — the approver-scoped subject read. An approval without sight of the subject is a rubber stamp, so being a named approver on an OPEN checklist (any line, group membership resolved exactly as the inbox resolves it) — or its requester — admits the read of THAT checklist's subject without the reviewer or admin role that otherwise gates it: for a trust raise, the agent whose trust is being raised (AgentService.GetAgent and GetAgentTrustReview — its identity, disclosures, release and the trust review listing the pending grant with its tier, scope, actions and caps); for a release promotion, that release (AgentReleaseService.GetAgentRelease); for a transport grant, that grant (TransportGrantService.GetTransportGrant). The scope is the subject only — never the fleet list, another agent, the release catalog, the grant inventory or the agent's history — and the admission lapses the moment the checklist closes (the decided row keeps its decision-history view). The same rule admits the checklist itself when its target is no node a project approver could hold a read on: a release promotion targets the platform root / and a lane rollover the agent's own path, so the inbox lists them, approvals show reads them and approvals approve/reject decide them for the approvers they name (and their requester) without a read grant on / — the notification engine tells those approvers by the same rule. The funnel's own determinations decide first from the compiled rule trie; only a denial consults the caller's open checklists, read set-based and cached for a few seconds so the inbox's click-through costs one lookup. The /governance/agents reviewer role itself is unchanged.

The decision history (ListApprovalChecklists; CLI opschain approvals history; the GUI's Decided tab beside the inbox) answers "what did I approve in the past?" and "who decided what?": the decided checklists — approved (quorum complete), rejected, timed_out (quorum TTL lapsed), break_glass — newest decision first, each row carrying the full checklist with its approval rows (who, when, comment) and the finaliser's reason. A checklist is listed to a caller who may read its target, who decided it (an approver always sees what they decided, even after their read on the target is withdrawn), or who holds the oversight determination — readable on /governance/change, or updatable on /admin — which admits every checklist in the estate: the governance administrator's "approvals have been performed by other users" view.

A checklist read explains itself: each line carries waiting_on (the named users plus the named groups' known members, minus identities already recorded and — on a self_approval: false line — the requester and change owner) and its policy's delegation scope (policy_scope_path); the checklist carries the caller's own standing (caller_eligibility: eligible, self_approval_excluded, already_approved or not_approver, plus caller_may_reject), the held subject itself (subject_kind/subject_id — a grant-shaped checklist names the grant's subject so review surfaces can deep-link it), and, when a line requires a window, the maintenance-window coverage of the target path (window.state: active/scheduled/none with the covering window). The standing fields are affordance honesty for the GUI's approve/reject verbs — the server-side refusals stay the enforcement, and the release gate's admission decision stays authoritative over the coverage summary. A checklist also explains WHY its subject was raised — the approval context (context on the checklist; opschain docs resources/approval): the requester's kind and stated rationale (the create request's rationale attribute — optional for a person, mandatory for an agent principal), the originating agent with its trust tier at creation and owner, the recommendation the subject implements, the redacted adapter transcript when one was sealed, and the correlation chain's recent hops in order — the incident or operator request that invoked the agent, each governed read, the recommendation, the subject. The context is served to the checklist's approvers (any line, group membership resolved), its requester and agent-governance reviewers, read set-based once per inbox page; opschain approvals show <run-id> prints it and the inbox's WHY column carries the rationale's first line. An approver who cannot see why a change was raised is being asked to rubber-stamp it; this is the platform explaining itself. The inbox is ONE surface for every held subject kind (never two parallel approval systems): beside change and workflow subjects it carries trust_grant checklists — agent trust raises held by the ceremony, whose approval activates the grant instead of releasing a run. Trust-raise reviewer verbs are admitted by packs:change-governance OR packs:agent-governance; with only the agent pack the inbox lists the trust-raise rows alone. Rejection finalises the subject as rejected with a mandatory reason and sweeps its frozen tree aborted. The quorum TTL expiring does the same with an audited timeout. A completed checklist's validity lapsing re-gates: every approval row voids and the subject returns to pending_approval.

Release — approved ≠ run-now​

Checklist completion attempts release: the maintenance gate re-runs at start, and a require_window line holds the subject until an open window admits its declared work (the refusal names the next scheduled window). Held subjects release automatically on the trigger engine's leader tick when a window opens, or explicitly via StartApprovedRun. Break-glass is the only discretionary lever: the change-governance determination on the subject's target, a mandatory reason, the loud warn:approvals:break_glass event — and it implies the audited maintenance override. It is deliberately NOT on the step-up floor (break-glass is the exemption).

Governance metadata​

Each funnel subject carries a governance block — change owner (defaults to the requester; zero authz powers), category (standard | normal | emergency), typed external references ({system, record_type, id, url?, relationship}) — mutable through the whole lifecycle, audited with who/when/before/after (43), never fingerprinted. require_change_reference checks presence at approval. The CLI writes the block with opschain approvals set-metadata (manual: opschain docs cli/approvals-set-metadata).

Submission export​

ExportSubmission renders the fingerprint-stamped markdown packet from authoritative rows: subject identity, fingerprint, governance block, checklist with approval rows and strict lines flagged, clocks, window stamp, prior-run evidence (informs, never gates; zero history shows the first-run-anywhere flag) and the subject's audit slice. Inputs render only in their masked recorded form — the export path never surfaces a resolved secret.

Converting the retired settings approvers​

The Rails-era node-settings requires_approval_from entries (stored in the global and per-node settings documents) are retired: this server never enforced them, so they presented an approval gate that did not exist. The converge, the node compare and the settings editor no longer serve the key, and approval policies are the enforced successor.

opschain approval-policies convert-settings-approvers is the operator conversion (ChangeGovernanceService.ConvertSettingsApprovers): it scans every stored settings document and creates one policy per entry at the owning node's path — path pattern <node path>/** (a global entry becomes /**), the change_filter.actions as action patterns ("all" → *), the entry's user_names/ldap_groups as named approvers, quorum 1 with self-approval allowed (the old gate was satisfied by any one named approver, the change creator included). The run answers a report naming every entry — converted, already converted (re-runs are idempotent by shape) or unconvertible (placeholder approvers, created_by filters, extended action globs, template-owned documents), the last left in place to recreate by hand. --dry-run reports without writing; a writing run is audited as api:settings_approvers:converted. The manual page is cli/approval-policies-convert-settings-approvers.

Converting the step-level wait gates​

The step-level requires_approval_from: wait gate in workflow and composed-action YAML is a different, fully live mechanism: it parks the run mid-flow until any one named user or group member approves, and it keeps enforcing exactly as documented. It is nonetheless superseded as the way to get governed sign-off — governance and orchestration are orthogonal, and governance-grade approval lives in approval policies on the estate paths, not in workflow YAML.

opschain approval-policies convert-step-gates is the operator absorption (ChangeGovernanceService.ConvertStepApprovalGates): it scans the stored workflow catalog, reports every gate by name (workflow, version, step, approvers) and creates one policy per distinct approver set at the owning project's path — the workflow's code as the action pattern, so the policy holds that workflow's runs for approval before they start; quorum 1 with self-approval allowed (the step gate is satisfied by any one named approver, the run's creator included). Only the latest published version converts; gates on drafts and superseded versions are reported as superseded, placeholder approvers and archived workflows as unconvertible. Converting never edits or removes workflow YAML — the step gate keeps enforcing beside the new policy until the workflow's authors delete the key at their own pace. --dry-run reports without writing; a writing run is audited as api:step_approval_gates:converted. The manual page is cli/approval-policies-convert-step-gates.

Estate usage is visible without the verb: composing a stored version that carries the gate records one throttled warn:workflows:deprecated_approval_gate event per version per day, naming the workflow, version and gate-carrying steps.

Entitlement​

Everything on this page is the packs:change-governance pack (see entitlements). The gated surface, exactly:

  • Approval policies — reads and writes alike (ListApprovalPolicies/GetApprovalPolicy and the create/update/ delete verbs), plus the settings-approvers and step-gate conversions.
  • Asset owners — the declaration reads and writes (ListAssetOwners/GetAssetOwner and create/update/delete).
  • The reviewer surfaces — the inbox, checklist reads, approve/reject, break-glass, StartApprovedRun, the governance metadata verbs and ExportSubmission — gated per held-subject kind: change and workflow checklists require this pack; trust-raise and agent-release checklists are admitted by packs:agent-governance on its own, transport-grant checklists by packs:rpc, and this pack admits every kind. The inbox lists exactly the kinds the caller's entitlements admit.
  • The create funnel itself — policy matching at the create doors is what the lapse posture below governs; an unentitled estate's creates never enter the funnel.

Each refusal is the typed permission_denied with ERROR_REASON_PACK_ENTITLEMENT_REQUIRED naming the pack and the tier.

What stays base: creating and running changes and workflows, the authored step-level requires_approval_from: wait gate, and maintenance windows (their own maintenance feature, opschain docs maintenance-windows). On lapse the posture is funnel-shaped: strict policies refuse covered creates with the named pack refusal; non-strict matches degrade loudly (warn:approval_policies:degraded) and the create passes ungated — never a silently-dropped hold. A never-entitled estate has no policies and keeps today's base behaviour; authored requires_approval_from wait steps stay ungated. Hands-on: opschain learn governance-approvals and opschain learn governance-asset-owners.