API v2 (/api/v2)
Included from Basic tier
OpsChain API v2 is the schema-first, Connect-RPC surface mounted at
/api/v2, served beside the contract-frozen JSON:API v1. The
schema is the protobuf package opschain.api.v2 (proto/opschain/api/v2/);
every procedure is a stable URL of the form
POST /api/v2/opschain.api.v2.<Service>/<Method> taking a JSON (or binary
protobuf) request body with a bearer token — the same tokens, users and
authorisation rules as v1. Procedures the schema declares side-effect free
are additionally served over HTTP GET, and pass the system-quiesce write
barrier while the estate is read-only.
The conventions and the first RPC:
-
InfoService.GetInfo— the composed server's build and cluster identity (version, commit, build date, cluster name), equivalent to the composition half of the v1GET /api/inforead, served under both the embedded SQLite store and PostgreSQL:curl -X POST https://host/api/v2/opschain.api.v2.InfoService/GetInfo \-H "authorization: Bearer $TOK" -H "content-type: application/json" -d '{}' -
RequestMetadata(correlation_id,idempotency_key) — the per-call metadata convention every mutating v2 RPC will carry. -
The typed error model — Connect's closed code set plus
ErrorInfodetails carrying a named reason from the frozenErrorReasoncatalogue (first entry:ERROR_REASON_SYSTEM_QUIESCED),FieldViolationsfor validation andRetryInfofor retry hints.
The read lane and the streams. Every read is declared
side-effect free (HTTP GET also works; the quiesce barrier admits it) and
applies the same resource-level authorization v1 applies: a record the
principal cannot read never appears in a list, and an unreadable get
answers not_found (denied and missing are indistinguishable).
ChangeService.ListChanges/GetChange— changes with a typedChangeFilter(status codes, node-path subtree, the step-search terms, creator, creation window);GetChangewith"view": "CHANGE_VIEW_FULL"adds the change's step rows.WorkflowRunService.ListWorkflowRuns/GetWorkflowRun— workflow runs (project/workflow codes, status codes, creation window); the get carries the run's step-tree document.ActivityService.ListActivities— the merged change/workflow-run activity feed (the v1/api/activitiesunion) with typed node scopes, status/creator/free-text filters and a selectable sort column.EventService.ListEvents/GetEvent— events with typed type/ type-prefix sets, username, node path, system flag, creation window and thefor_change_idscope.AuditService.ListAuditEntries— the server-shaped audit-history read: each event row arrives with its source context (kind, id, display name, action, node path, project, owning change/run) already resolved set-based on the server — the replacement for the v1 audit screens' 12-pathinclude=chain and client-side denormalisation.
Lists paginate with opaque cursor tokens: send page_size (server-capped
at 1000; default 100, audit 50) and the previous response's
next_page_token; an empty token means the listing is complete.
Two server streams ride the same h2c plumbing as the agent lane:
LogService.FollowLogs— one step attempt's log lines: persisted history fromfrom_cursor, then the live tail, ending when the log seals. Reconnect with the last line's cursor to resume. Authorised against the owning change or workflow run. An aggregate target — an emptystep_idfor the whole run, orinclude_child_stepsfor a step's subtree — streams the run's interleaved multi-source log instead (every step of a change, a workflow run's own lines plus its spawned changes' step lines, or a node background task's full source set), each line labelled withsource_id/source_type/group_id(andworkflow_step_id/workflow_step_pathon workflow aggregates), stably ordered by the(logged_at, line id)keyset. The aggregate stream tails the store on the WatchEvents pacing and ends once the owning run is terminal and the log is drained; it is PostgreSQL-only (ERROR_REASON_STORE_POSTGRES_REQUIREDelsewhere).EventService.WatchEvents— events created after the stream opens that match the typed filter, oldest first (the server tails the store on a short server-side poll; the stream shape is the contract). Resumable: see the UI-3 enablers below.
The watch streams — EventService.WatchEvents and
NotificationService.WatchNotifications — acknowledge on open (corrected 2026-10-20): the first response message is sent as soon as the
subscription is established, before any event or row exists (an event-less
WatchEvents message carrying the current resume cursor, a count-only
WatchNotifications unread tick), so a client can tell a quiet live
stream from a dead connection. LogService.FollowLogs sends no open
acknowledgement: every FollowLogs frame carries a line with its resume
cursor — the raw-consumer wire contract — and its envelope has no
line-less shape that can carry a cursor without rendering as a log row,
so FollowLogs opens without an ack message. Log streams are
user-foreground and unmount-bounded, so their socket-budget exposure is
the smallest; a designed cursored ack shape can return in a future release.
On the embedded SQLite store the server is the run engine, the runner
and agent fleet, licensing and the local secret store behind the API —
nothing more. The rule: every estate, identity, governance,
workflow-catalog, notification and provider family needs the PostgreSQL
store. What the SQLite store serves: changes and workflow runs, steps
(except name suggestions), per-step logs and FollowLogs on one step,
runners, runner groups, the fleet queue and the agent plane, licence and
entitlements, encryption keys and the system verbs. Everything else
answers unimplemented with a typed ErrorInfo detail naming
ERROR_REASON_STORE_POSTGRES_REQUIRED (the v1 501 posture, typed): the
estate (nodes and node lifecycle, entities, infrastructure, compare,
node artifacts and artifact uploads, properties history, version
history, the actions catalog, documents and their versions), identity
(users, groups, identity writes, authorisation rules), templates and git
remotes, the workflow catalog, triggers and scheduled activities,
maintenance windows, change governance (approval policies, checklists,
step-gate conversion), notifications (connectors, teams, mandates,
preferences, governance, the watch stream), activities, events and the
audit history, chains, dashboards and pack dashboards, pack
re-resolution, MintModels, node background tasks, user preferences and
the aggregate log stream. The provider families — secrets providers,
transport providers, image registries and build providers, identity
providers, model providers, SIEM connections, GitHub and JSM
connections — are registered only on a PostgreSQL store, so under SQLite
their verbs answer a plain not_found rather than the typed reason.
Rate limits
The interceptor spine enforces built-in, enforced-but-generous per-principal limits — the no-gateway baseline:
- Request budget — a token bucket per principal (anonymous callers are
keyed by client address): 600 requests/minute steady state, bucket
capacity 600, by default. Every v2 response carries the standard
RateLimit-Limit,RateLimit-RemainingandRateLimit-Resetheaders. - Stream cap — at most 25 concurrent server streams (
FollowLogs,WatchEvents) per principal, by default; a slot frees when the stream ends.
A call over either limit answers resource_exhausted with a typed
ErrorInfo naming ERROR_REASON_RATE_LIMITED (metadata says which limit:
requests or streams), a RetryInfo detail, and a Retry-After header.
Request size. Every v2 request message is read under a 10 MiB
bound — measured after any Content-Encoding is undone, so a small
compressed body cannot inflate past it — and a larger one answers
resource_exhausted before any handler or interceptor runs, with
nothing beyond the bound ever buffered. ArtifactService.UploadArtifact
is the one message that legitimately carries a file inline: it reads
under a bound derived from artifacts.max_artifact_mb (the budget in its
base64 JSON form plus the shared bound), which is also the hard cap on
the whole v2 request stream.
Both limits are deploy-tunable through the server's api.rate_limit
config block (opschain docs config); a deployment whose API gateway owns
rate limiting sets enabled: false or a key
to 0 — effectively-off. Budgets are in process memory, per server
process: under HA each replica grants each principal its own budget, so
the effective estate-wide ceiling is the per-replica limit times the
replica count.
OpenAPI
The v2 OpenAPI 3.1 document is generated from the proto
(api/openapi-v2.yaml, via the pinned protoc-gen-connect-openapi in
make proto) and never hand-edited; make openapi-v2-drift (part of
make check) and an in-process drift test keep it exactly in step with
the schema. The composed server serves it stamped with the build identity
at GET /api/v2/openapi.yaml and GET /api/v2/openapi.json, beside v1's
GET /api/openapi.yaml|json.
v1-usage telemetry
Every v1 request (anything under /api/ except /api/v2/) is counted
per UTC day, principal, token and route pattern — opaque row ids only,
never usernames — the usage evidence for the v1
deprecation-and-removal horizon. Counters persist in the
api_usage_counters table on PostgreSQL (low cardinality: one row per
counter key, folded by upsert, flushed every 30 s) and fall back to
process memory on the embedded store. Read them at
GET /api/admin/v1_usage[?day=YYYY-MM-DD] with an admin token; the
response names its store (postgres or memory).
Schema evolution is additive-only within opschain.api.v2 and gated by
buf breaking (make proto-breaking); anything non-additive is
opschain.api.v3.
/api/v1
/api/v1 is the canonical spelling of the existing v1 API; /api remains a
permanent alias answering byte-identically (links in responses keep spelling
/api). Nothing changes for existing consumers; no client is ever required
to move.
Writes
Every mutating v2 RPC crosses the same engine-backed write seams, the same
authorization and the same audit spine as its v1 twin — one domain layer,
two facades. Writes return the written resource's basic
view and take a requestMetadata object:
correlation_id— joins the write to an existing chain: a change or workflow run created with it lands in that chain exactly as trigger-fired and nested runs do, and the audit event every other verb records is stamped with it. Must be a UUID.idempotency_key— makes retries safe: the first arrival writes and the key remembers the written resource; a repeat of the same (principal, procedure, key) within the 24-hour window answers that resource's current basic view and never writes again. Keys persist in theapi_idempotency_keystable on PostgreSQL (in process memory on the embedded store); a repeat racing its first arrival answersabortedwithERROR_REASON_IDEMPOTENT_CALL_IN_FLIGHT.
The write RPCs, each authorised exactly as its v1 twin (404-on-deny — a
denied write answers not_found):
ChangeService.CreateChange— the typed v1 create contract: the targetnode_path,action, git identity, overrides/metadata/notify/typed inputs as JSON text, skip/starting-step and wait-step options, and theoverride_maintenanceescape hatch (honoured only with update on the window node's maintenance rule path). Authorised as read on the target node plus execute on its action path.ChangeService.PauseChange/ResumeChange/CancelChange— the run verbs (cancel is v1's DELETE); a finalised run answersfailed_preconditionwithERROR_REASON_CANNOT_PROGRESS. The change view's typedpausablefield is true exactly whilePauseChangeis valid (not finalised, not paused).WorkflowRunService.CreateWorkflowRun/PauseWorkflowRun/ResumeWorkflowRun/CancelWorkflowRun— project read plus execute on the workflow's path.StepService.ApproveStep/ContinueStep/RejectStep— the change step gates (v1'sPOST /api/steps/{id}/…): execute on the step's action path; continue on an approval-gated wait answerspermission_denied.PropertiesService.UpdateProperties/SettingsService.UpdateSettings— the versioned-document writes: the full replacement document as JSON text, with optional compare-and-set (version); a moved version answersfailed_preconditionwithERROR_REASON_VERSION_MISMATCHcarryingcurrent/supplied. Authorised as read + update on the owner node.FleetQueueService— the admin dispatch-plane service, in its own service behind the function-level admin gate (the/adminupdatable determination; a caller without it answerspermission_denied, never mixed into a user-privilege service — API5):ListQueueEntries(NO_SIDE_EFFECTS) serves every queue entry with its owning work item (change / workflow run / node background task), the owner's status, target node path and correlation chain;TerminateQueueEntrycancels an entry's owner through its own lane (run cancel / task abort — the queue row is never deleted directly; a finalised owner answersfailed_precondition);SweepQueueOrphansdiscards parked entries whose owner is already finalised. Terminations and sweeps audit asapi:admin_queue:terminate/api:admin_queue:sweep.AgentService— the agent-fleet admin plane behind the same function-level admin gate asFleetQueueService:ListAgents/GetAgent(NO_SIDE_EFFECTS— the fleet view and the full detail with host inventory and the manifest disclosures (purpose, model, adapter, tool surface, custody mode;from_releasewhen rendered from the enrolment-bound release's frozen manifest), the key fingerprinted, never raw),SetAgentDraining,ForgetAgent(an online agent answersfailed_preconditionby name),RevokeAgent,HaltAgent/ResumeAgentHalt(the per-agent emergency scalpel; a halt demands a break-glassreason),HaltAgentGroup/ResumeAgentGroupHalt(the group scalpel, reporting the severed member count), and the decision-support readsGetAgentAssignments,GetAgentActivity,GetAgentTrustReviewandGetAgentGovernanceDashboard(NO_SIDE_EFFECTS— the pack's dashboard panel set: the governed request lane over a window with its applied-tier mix and refusals, the recommendation funnel, the halt picture and the fleet accountability counters, behind thepacks:agent-governanceentitlement plus the funnel read determination). The lifecycle writes record the same customer-visibleinfo:agent:*/warn:agent:*events the REST verbs record — inside the shared serving core, so the two planes cannot drift; the REST/api/agentsroutes stay mounted for the GUI and the static-token lane.TrustService— the trust plane's human half behind the same function-level admin gate:ListTrustGrants(NO_SIDE_EFFECTS— the full grant history: active, pending and revoked, with the decision attributions),ApproveTrustGrant/RejectTrustGrant(the human decisions on agent proposals, disambiguated by the grant's state — a pending grant activates or is refused, an active grant's pending revoke proposal applies or clears; a grant held by an open trust-raise ceremony answersfailed_preconditionnaming the approvals inbox), and the assets-under-management assignment verbsCreateTrustAssignment,ListTrustAssignments(NO_SIDE_EFFECTS) andDeleteTrustAssignment. The grant surfaces ride the agent-governance pack gate exactly as the REST routes do; the decisions and assignment writes record the same customer-visibleinfo:trust:*events inside the shared serving core. The agent-credential lane — grant creation and revoke proposals by agents, and the human create/revoke verbs sharing those routes — stays on REST (POST /api/trust/grants,POST /api/trust/grants/{id}/revoke) by construction: the v2 plane authenticates users, never agent keypairs.
Validation failures answer invalid_argument with a FieldViolations
detail naming every invalid field.
Audit: every successful v2 mutation records the same api:* event the
v1 lane records (api:changes:pause, api:steps:approve,
api:properties:update, …), through the same recorder, correlation-stamped
when the request carried one; the create verbs record nothing extra because
the run engine appends api:changes:create / api:workflow_runs:create
itself, whichever facade the run came through. A failed call records
nothing.
Quiesce: the mutating RPCs are not NO_SIDE_EFFECTS, so the
system-quiesce write barrier refuses them with the typed
ERROR_REASON_SYSTEM_QUIESCED 503 while the POST-shaped v2 reads keep
answering (the barrier keys on the schema's idempotency level, never
the HTTP verb).
CLI: opschain audit (the audit-history read on the generated Go
client), opschain admin queue list|terminate|sweep (the admin
queue verbs on FleetQueueService),
opschain admin trust list|approve|reject|assign|assignments|unassign
(the trust plane's admin half on TrustService; grant, revoke and the
ceiling verbs stay on the REST routes agents share) and
opschain api v2 call <Service/Method> -d <json> (the typed escape
hatch) are the v2-lane CLI verbs; opschain runs retry's
server lane rides the v2 retry verbs; the remaining existing verbs stay on the frozen v1 lane until a
later release moves them.
Surface completion
Across the whole surface — every
mutating RPC carries requestMetadata (idempotency + correlation), records
its api:* audit event through the registry-tested seam, and authorises
exactly as its v1 twin:
ChangeService.RetryChange/WorkflowRunService.RetryWorkflowRun— the v2 twins of v1's retry POSTs (attributes.retry): duplicate an unsuccessful (error/cancelled/aborted/rejected) run and start driving it. The duplicate re-parks every wait and approval gate — approval is never inherited — and the verb crosses the same retry funnel as v1: the retryable-state validation, the licence work gate and the pack entitlement gate all bite, answeringinvalid_argumentwith the named refusal. Authorised as read on the original's target (project) plus execute on its action (workflow) path. No handler audit event: the run engine appendsapi:changes:create/api:workflow_runs:createfor the duplicate itself.WorkflowStepService.ApproveWorkflowStep/ContinueWorkflowStep/RejectWorkflowStep— the workflow-step gates (v1'sPOST /api/workflow_steps/{id}/…), recorded asapi:workflow_steps:<verb>. Authorised exactly as v1: read on the owning run's project — the v1 lane applies no further action check, and the v2 twin matches it. Continue on an approval-gated wait answerspermission_denied; the response carries the step's shared fields plus its full per-type attribute document as JSON text.PropertiesService.GetProperties/SettingsService.GetSettings— side-effect-free document reads (HTTP GET works; the quiesce barrier admits them), addressed by documentid(the v1GET /api/properties|settings/{id}twin) or by the owner node'snode_path(by-path addressing — the read resolves the node's own document, so a client needs no id round-trip). Exactly one of the two; read on the owner node, 404-on-deny.LicenceService.GetLicenceStatus— the resolved entitlement status (tier, expiry state, limits, features, unknown features, grace days, per-document states, invalid/denied counts, the per-feature grants and theregistrationnotice while the server runs on the unregistered evaluation footprint), field for field what v1'sGET /api/infoserves inlicence_status, with the same exposure: any authenticated principal, no admin gate.LogService.FollowLogs— additionally resolves a node background task: pass the task id as bothrun_idandstep_id(the task's log is keyed run_id == step_id == task id, attempt 1) to stream a generation task's output live, mid-derive. Authorised at the authenticated-user level only, exactly as the v1node_background_task_log_linesread.
GUI dashboard and search reads
The read surface the GUI's Dashboard and CMD-L Search screens consume —
every RPC NO_SIDE_EFFECTS, cursor-paginated where it lists, authorised
per row exactly as the v1 reads are (unreadable rows never appear;
unreadable gets answer not_found):
DashboardService.GetDashboard— the server-shaped dashboard aggregate: the client names the sections it needs (totals,status_breakdown,timeline,durations, the rankedtop_*sections,hot_commits,in_flight) over a requiredcreated_after/created_beforewindow plus theListActivitiesnarrowing vocabulary, with a timelineinterval(hour/day/week/month, refused over 1000 buckets), an IANAtime_zonefor bucket boundaries and a ranked-rowlimit(default 10, max 50). Authz is pushed down: figures count only activities the caller may read, so two principals may see different totals, by design. Every breakdown/ranked row carries a typedActivityFilterdrilldownthat reproduces it throughActivityService.ListActivities— the drill-down contract is the schema. PostgreSQL-backed. The sections are aggregated in SQL over the full window —GROUP BYbuckets, nearest-rank (percentile_disc) duration percentiles, set-based top-N — with the caller's read scope compiled into the query predicates, so figures are exact on estates of any size (previously the fold ran over an at-most-1,001-row fetch). A principal whose scope has no exact literal-prefix form (a read-denying rule, a wildcard/regex grant) is served by the previous decide-filtered path over the capped fetch.
The dashboard insight reads extend the dashboard surface with
the event-backed panels — all NO_SIDE_EFFECTS, PostgreSQL-backed,
set-based SQL over the events table (window-bounded, never a row fetch),
and admin-scoped: estate-wide counts are the admin plane's view, so
each requires the /admin updatable determination (permission_denied on
deny) rather than per-row pushdown. They are licence-free base; only the
pack panel sets below gate:
EventService.GetEventStats— the dedicated event aggregation verb on the event domain: time-bucketed counts by event class (the type's leading segment) over a requiredcreated_after/created_beforewindow, the window totals, the top event types and acting usernames, and an optionalpayload_facet(counts bydata->>'<field>', e.g.applied_tierorfeature). The filter is theListEventsvocabulary verbatim — a panel's drill-down passes the same filter toListEvents. Interval,time_zoneand the 1000-bucket cap followGetDashboard; ranked lists takelimit(default 10, max 50). CLI:opschain events stats.DashboardService.GetSecurityPulse— the security-pulse panel's counters over afrom/towindow: authz denials (warn:authz:denied), sign-in failures and throttles, refresh reuse, provider outages, step-up passes/refusals, break-glass, refused webhooks and GitHub exchanges, licence refusals and refused git remotes — every counter present even at zero, each naming the event types that reproduce it throughListEvents. Under thepacks:security-governanceentitlement the response adds apostureblock (the control/attestation subset of the continuous-control-monitoring metrics, computed from the control catalog and the latest hot-window attestations); without the pack the block is absent, never an error — the full metric set, findings and chain-verify tile stay onGovernanceService.GetGovernancePostureReport.DashboardService.GetConfigChangeAudit— the configuration-change audit panel:api:<family>:*write events folded by configuration family (policies, credentials, remotes, triggers, workflows, templates, settings, providers, identity, licence, …) with per-family counts and last-write instants, the top acting usernames, the window total and the sensitivecredential_extractionscounter (api:git_credentials:extract). Each family row carries itstype_prefix— theListEventsdrill-down. The sign-in lane (api:auth:*) is deliberately not a configuration family.
The governance packs' panel sets ride their own domains:
ChangeGovernanceService.GetChangeGovernanceDashboard and
AgentService.GetAgentGovernanceDashboard (both below), while the
packs:security-governance set is served by the existing
GovernanceService.GetGovernancePostureReport — control coverage by
domain, verdict breakdown, the chain-verify tile, campaigns, waivers and
legal holds need no second read.
ActivityServiceadditions — the filter gainsids(exact-id any-of: uuid lookup and batched trails),actionandcommit_sha(the dashboard drill-down vocabulary); rows gainworkflow_name; and"view": "ACTIVITY_VIEW_WITH_PARENTS"adds each row's parent trail (project → environment → asset, with template identity), resolved server-side in the same pass.ActivityService.ListWaitingGates— "waiting for my approval" across both arms (change steps + workflow steps) merged newest first, derived from the signed-in principal (a caller can never ask for someone else's queue), each gate carrying its owning run's context (display name, node path, project) resolved server-side.StepService.GetStep— one change step by id (the search uuid lookup), read-authorised on the step's change target.StepService.SuggestStepNames— distinct step actions and step names starting with a prefix (the v1ui_support/step_actionscontract, typed): a blank prefix yields nothing, limit default 50 / max 100. PostgreSQL-backed.ScheduledActivityService.GetScheduledActivity— one scheduled change/workflow by id (the search uuid lookup), authorised on the owning node's scheduled_activities path.NodeService.GetNodeHierarchy— the estate tree (projects → environments → assets, nested, name-sorted, template identity resolved), pruned to what the principal may read (an unreadable node survives only as context above a readable descendant);root_pathselects a subtree andinclude_archivedrestores archived nodes.include_entities(default false — the response is otherwise unchanged) nests the entities under the root into the tree at any depth, read in one indexed subtree query and pruned per read authorisation andinclude_archivedlike every other node; entity rows carrykind: "entity"with id, code, name, path and archived only. It requiresroot_path— without one the call is refusedinvalid_argumentnamingroot_path("include_entities requires root_path — …"), so there is no estate-wide entity read — and the root may itself be an entity path. Under the embedded storeinclude_entitiesanswersERROR_REASON_STORE_POSTGRES_REQUIRED. Replaces the GUI'sui_support/node_hierarchyread and serves the dashboard's project picker.AuthorisationPolicyService.ListAuthorisationPolicies— policy summaries (id,name,description) for the search index, visible per v1's policy-read scope: a principal without policy-read authority simply lists none.UserService.GetCurrentUser/ListUsers— the minimal users/identity read: the signed-in principal (username, provider, groups, superuser, break-glass) and a picker listing (synthetic internal accounts hidden, the v1 posture).UserService.CreateUser/UpdateUser/ResetUserPassword— the v1/api/admin/userswrite surface's twins, local accounts only (a directory identity is managed in the directory). Admin-determination gated; the server's security posture (break-glass-only local-user mode, the collision defence and break-glass cap, password policy and reuse history) is enforced in the shared serve-side seam so both planes carry one rulebook, with the v1 messages verbatim. A generated password is answered exactly once (never on an idempotent replay) and no password value ever reaches an audit event or log; audited asapi:users:create/api:users:update/api:users:reset_passwordwith value-free payloads.
With these replacements live, the v1 GUI-only routes
GET /api/ui_support/node_hierarchy and GET /api/ui_support/step_actions
are marked deprecated: true in the served v1 OpenAPI document, and the no-caller ui_support routes —
preferences (GET and PATCH), group_members/{group_name} and
clear_cache — carry the same mark. Their v1 behaviour is unchanged — v1
never grows, every marked route keeps serving, and the ui_support
family's audience only shrinks.
Per-user preferences
PreferencesService.GetPreference/ListPreferences/PutPreference— per-user named preference documents: GUI state that follows the user instead of living in per-browser localStorage. Strictly the caller's own rows — no request names a user and no caller (superuser included) reaches another user's documents; reads areNO_SIDE_EFFECTS, the put carries idempotency keys and correlation like every v2 write and audits asapi:preferences:create|update(name and supplied version only — document bodies never reach the event lane). Writes are compare-and-set (expected_version0 creates; a stale version answers the typedERROR_REASON_VERSION_MISMATCH), documents are opaque JSON bounded per document and per user (ERROR_REASON_PREFERENCE_TOO_LARGE,ERROR_REASON_PREFERENCE_LIMIT_EXCEEDED), and the listing serves metadata only. PostgreSQL-backed — the embedded SQLite store answers the typed postgres posture, so clients keep a localStorage fallback. Seeopschain docs resources/preference.
GUI cutover completion
Five further additions close the remaining v2 cutover
gaps — all additive, every write still carrying requestMetadata and its
api:* audit row:
- Cancel reason —
CancelChangeandCancelWorkflowRuntake an optionalreason(max 1000 characters; beyond answersinvalid_argumentwith areasonfield violation). A supplied reason is recorded on the verb'sapi:changes:destroy/api:workflow_runs:destroyaudit event (request_body.reason) and appended to every cancelled step'saudit:steps:canceltrail message (cancelled via API: <reason>), so the cancelled activity's Events view shows it where operators already look; an absent reason stays absent and the trail message stayscancelled via API. The step/workflow-step reject verbs already carry the equivalentmessageattribute. - Retry at the moved rev —
RetryChangeRequest.refresh_git_rev(bool) re-resolves the original change'sgit_rev(branch/tag) to its current target and pins the duplicate to that commit; a sha rev re-resolves to itself. Honoured only for project/environment git changes: an asset-change retry supplying it answersinvalid_argumentwith the create lane's named refusal (the asset's template supplies the commit). v1'srefresh_sharetry attribute is the upstream-parity alias driving the same engine option (RetryOptions.RefreshGitRev) — it was previously accepted and silently ignored.build_without_cacheis DEPRECATED architecture-wide (no step-image builds): v1 keeps accepting it as a documented no-op; it has no v2 retry equivalent, and the v2 create field is marked deprecated in the schema. AuditFilter.for_change_id— scopesListAuditEntriesto one change's audit trail (the change's own events plus its steps' events — the samefor_changepredicate the v1 change Events tab queries with); must be a UUID, combines with the other filter fields, and the per-row node-path authorization still applies.- GetInfo instance facts —
GetInfoResponsegainsmaintenance_mode,banners_jsonandinstance_info_json, field for field what the v1GET /api/inforead serves (defaultsfalse/false/[]/{}on compositions without a stored configuration), so the GUI boots off one v2 read. Secrets authz metadata deliberately waits for the secrets service's v2. LogService.ReadLogLines— the cursor-paged poll twin ofFollowLogs(NO_SIDE_EFFECTS, HTTP GET works): up topage_sizepersisted lines (server default 100, cap 1000) afterfrom_cursor, no live tail, over the same sources (change step logs, workflow step logs, node background task logs via run_id == step_id == task id), the same authorization and the same masked line pipeline.next_cursoris the last line's cursor — or the request's own cursor when no new lines exist — so a poll loop passes it straight back. It serves the same aggregate target as FollowLogs (emptystep_id/include_child_steps.4), keeping poll-twin parity for the aggregate stream behind stream-hostile proxies. The GUI's streaming log viewers ride this lane.
ui_support per-object replacements
The remaining GUI-only v1 ui_support routes gain native v2 reads, each
on the service owning its object (there is deliberately no monolithic UiSupport service):
GroupService.ListGroups— the group catalog, typed and cursor-paginated: every provider connection's snapshot groups (the distinctfederated_group_snapshotsnames, each row sourced by its connection key with the member usernames/accounts whose snapshot names it, the retired directory mirror's replacement) merged with the local groups, snapshot rows first. Any authenticated caller, the v1 posture; PostgreSQL-backed (the embedded store answers the typed postgres posture).GroupService.CreateGroup/DeleteGroup/AddMember/RemoveMember— local group management, typed: group CRUD and per-user-id membership writes, so ldap and federated shadow accounts are addressable without username/provider disambiguation (the membership resolves through the shared group resolver on the member's next request). Snapshot groups are read-only provider facts (a local group may share a bare name — snapshot catalog ids are namespaced by connection key);DeleteGroupis refused while policy assignments reference the group, naming the referents; membership writes are idempotent (a repeated add or a non-member remove is a no-op). All four sit behind the/adminupdatable determination (permission_deniedon deny) and audit asapi:groups:create|destroy|member_added| member_removed. Local rows inListGroups(and the membership verbs' responses) carrymember_accounts— each membership's user id, username and provider — the members-editor payload.IdentityProviderService.SweepIdentityProvider— the directory sweep on an ldap-kind connection (the retiredGroupService.RefreshDirectorymirror refresh's successor): one service-bind read serving eager re-key of legacyauth_provider='ldap'rows, import of directory users ahead of their first sign-in, and leaver detection (disable + credential revocation,warn:auth:leaver_disabled), reporting counts, unresolved legacy usernames and skipped entries. Behind the/adminupdatable determination; an unreachable directory answersunavailablehaving changed nothing; audited asapi:identity_providers:sweep.UserService.ListUsersadditions — rows carryemail_address(unset when the account holds none) and the request takesinclude_ghosts, the escape hatch restoring hidden ghost rows exactly as the v1 listings honour it. Dormant identities — users whose provider can no longer sign them in — are excluded by default;include_dormantrestores them withdormant: trueset (opschain docs identity, "Dormant identities"). The request'sproviderfield keeps only one provider instance's rows, matched case-insensitively as an open string over theauth_providervocabulary (local,ldap,oidc:<code>,github:<code>, and any future kind; an instance with no rows is an empty listing, never an error), composing with the dormancy default — the same open match the v1 admin listing takes as?provider=.NodeService.GetNodeHierarchyenrichment to v1 payload parity for the GUI shell (all additive): projects carrytemplates[](the project template catalog with each version'sdescription/version/sha/rev, served when the caller may read the project's templates path) andworkflows[](the runnable workflows — unarchived with a published version, readable at each workflow's own path); projects reportworkflow_admin(update on<path>/workflows); every node reportschanges_execute(execute on<path>/changes— the summary v1's changes list served asmeta.authorisations.execute); andpolicy_editor: truereproduces the v1 mode — the tree narrows to the caller's policy-admin scope with ancestors kept as context, nodes carrypolicy_admininstead ofworkflow_admin, and the response carriespolicy_update(v1'smeta.authorisations.update).WorkflowService.GetWorkflowSchemaandSystemConfigurationService.GetSystemConfigurationSchema— the GUI's Monaco JSON Schema documents, byte-identical to what v1GET /api/ui_support/json_schemaserves forworkflowandsystem_configuration(ECMA-262-safe patterns), each on its object's own service (schema-only services for now — the estate CRUD/admin waves extend them). The properties and node/project settings schema reads land onPropertiesService/SettingsServicebeside the documents lane.
UI-3 server enablers
The server half of the GUI's UI-3 slice (events push, long chains, the estate artifacts view), built v2-first per the 2026-10-18 boarding (v1 never grows; every capability the screens need exists on v2):
- G13 — events keyset cursor: already the
ListEventscontract — opaquepage_tokencursors keyed on(created_at, id), stable under concurrent inserts (new rows never shift an already-minted page). - G14 — resumable events stream:
WatchEventsRequest.from_cursorresumes a droppedWatchEventsstream: stored events after the cursor replay oldest first (gap-safe across read windows, however wide the backlog), then the live tail follows. Every delivered event carries thecursorthat resumes exactly after it, and aListEventsnext_page_tokenis also accepted — the list-snapshot-to-live-stream handover. The same per-event node-path authorization applies to the replay as to the tail; a garbage cursor answersinvalid_argument. - G18 — chain pagination:
ChainService.ListChainHopsreads one correlation chain — the events, changes and workflow runs sharing the id, chronological, the v1GET /api/chains/{id}row contract typed — keyset-paginated over(created_at, id)with the cursor pushed into the union query, lifting the v1 read's 1001-hop cap: arbitrarily long chains page through completely. Hops are RBAC-redacted per viewer exactly as on v1 (an unreadable hop serves as a stub keeping id, kind, depth and timestamp), and because a redacted hop still occupies its row, page tokens are identical for every principal. PostgreSQL-backed. - G22 — estate-wide artifacts index:
ArtifactService.ListArtifacts(artifact-spelled) lists artifact metadata across the whole estate (v1 lists per node, change or step only), newest first, keyset-paginated — every row carrying itskindand, for akind: imagerecord, theimagefacts (identifier, digest, provider, registry, source commit;opschain docs artifacts),filter.kind: imagekeeping the image records alone — with thenode_path_startprefix filter pushed into the query. Rows are the v1 artefact serialization typed plus the owningnode_pathandstep_id; purged rows list with theirpurged_at. Authz is pushed down per row (an artifact on an unreadable node path never appears and never consumes a page slot); a thin grant over a large estate answers a possibly-short page with a continuation token rather than an unbounded scan. PostgreSQL-backed, served by a dedicated(created_at, id)index.
GUI reads beyond the v1 surface
The reads whose absence kept GUI screens on v1 —
every RPC NO_SIDE_EFFECTS, authorised exactly as its v1 twin:
ChangeService.GetConvergedProperties/GetConvergedSettingsandStepService.GetConvergedProperties— the converged (merged, git-inclusive) document views of v1'sGET /api/changes|steps/{id}/converged_properties|converged_settings: the mergeddata_json, the per-leaf source attribution (by_property_json/by_setting_json), the contributing sources in merge order and the available layers with their backed repository/database halves. The change/step properties reads take the v1action_stage(pre/post, default post — the step's stage-pinned versions, falling back to the change's current path-node properties when the step holds no pins) andexclude_layers(a layer name, optionally suffixed_repository/_database; unknown values answerinvalid_argumentwith the v1 message). Read on the change's target node, 404-on-deny.PropertiesService.GetConvergedProperties/SettingsService.GetConvergedSettings— the node-level converge (v1's node-path…/converged_properties|converged_settingsreads), addressed bynode_path; the properties read also takes the v1converge_dateinstant. Read on the node path plus read on its/converged_properties(/converged_settings) rule path, 404-on-deny.PropertiesService.ListPropertiesVersions/GetPropertiesVersionandSettingsService.ListSettingsVersions/GetSettingsVersion— the stored version history (v1'sGET /api/properties|settings/{id}/versionsand…/versions/{version}): cursor-paged listings inversion(default) orcreated_atdescending order, the row view omitting each version's data document (askDOCUMENT_VERSION_VIEW_FULLto inline it), the get always carrying it. Authorised like the document read: read on the owner node, 404-on-deny.PropertiesService.GetPropertiesSchema/SettingsService.GetSettingsSchema— the JSON Schemas the GUI validates properties and project/node settings documents against, byte-faithful to v1'sGET /api/ui_support/json_schema(model_name=properties,project_settings,node_settings— the settings read selects bykind); ECMA-262-safe patterns, any authenticated principal. The workflow/system-configuration schema homes ship separately.WorkflowStepService.GetWorkflowStep/ListWorkflowSteps— one workflow step by id, and one run's step rows whole (resolved per the run's own step refs, unpaged — the GUI resolves the run'sstep_treeagainst them, the v1include=stepsread). Read on the owning run's project, 404-on-deny.ScheduledActivityService.ListScheduledActivities— the scheduled-activities index (v1'sGET /api/scheduled_activitiesand its node-path variant): a typed filter (node_path,action,workflow_code), cursor-paged newest first, each row carrying the typed index fields plus its full stored attribute document asattributes_json(the per-type payload — the WorkflowStep precedent). Rows the caller may not read (read on the owning node'sscheduled_activitiespath) never appear.
GUI writes beyond the v1 surface
The writes whose absence kept GUI screens on v1 — every verb carries
requestMetadata, authorises exactly as its v1 twin and records its v1
audit event:
ScheduledActivityService.CreateScheduledActivity/UpdateScheduledActivity/DeleteScheduledActivity— the scheduled workflow writes (v1'sPOST|PATCH|DELETE /api/scheduled_activities). The schedule is a typedScheduleSpec(exactly one of a cron expression or a one-offrun_at, plusrepeat): supplying it replaces both halves, the v1 explicit-null XOR switch, typed. The version pin isworkflow_version("latest"/""clears back to latest-at-firing); on an update, present fields replace and absent fields preserve, and the writer re-validates the merged state. Scheduled-change writes are refused by name, exactly as v1: a create carryingactionorsettings_overrides_jsonanswers the v1 validation message. A metadata document carrying the reserved top-levelopschainkey is refused with the v1 message. Authorised like v1: create needs read on the parent node plus update on itsscheduled_activitiesrule path; update/delete need read plus the verb's action on the owning node'sscheduled_activitiesrule path — 404-on-deny. Audited per type:api:scheduled_workflows:create|update|destroy.AuthorisationPolicyService.GetAuthorisationPolicy— one policy with its rules and assignments (the v1?include=authorisation_rules,policy_assignmentsread, server-shaped), visible per the policy-read verdict;ListAuthorisationPoliciesrows gaincreated_by/system/timestamps, the response gainscan_manage(the v1meta.authorisations.updateflag) andAUTHORISATION_POLICY_VIEW_WITH_ASSIGNMENTSadds each row's assignments (the users-tab read).AuthorisationPolicyService.CreateAuthorisationPolicy/UpdateAuthorisationPolicy/DeleteAuthorisationPolicy/ReplacePolicyAssignments— the manage-security writes, gated like every v1 authz-config write on thesecurity_administratorrole (a superuser or a global grant writes any policy, a project delegate only the policies whose rule paths all sit under its project; system-policy assignments are superuser-only), 404-on-deny and audited aswarn:authorisation_policies:refused; policy and rule reads carry awritableflag the GUI gates its controls on. An update replaces the policy's rule set wholesale only when the request carries one (AuthorisationRuleSetpresent; an empty set clears every rule; absent leaves the links alone), andReplacePolicyAssignmentsreplaces the full assignment set, the v1 contract (the GUI removes an assignment by resending the remainder). Assignments are per-identity:PolicyAssignmentInputtakes auser_idbesideusername(a username held by accounts of more than one provider is refused as ambiguous), and the servedPolicyAssignmentcarries the storeduser_idwithusername/auth_providerfor display — the GUI's identity pickers key on it. System policies refuse modification by name. Audited asapi:authorisation_policies:create| update|destroyandapi:policy_assignments:update.AuthorisationPolicyService.ListAuthorisationRules/GetAuthorisationRule— the standalone rule catalog (v1'sGET /api/authorisation_rules[/{id}]), id-ascending, visible per rule (the v1AuthorisationRule.authorizedscope — an unreadable rule never lists, an unreadable get answersnot_found); standalone rule rows carry their linkedpolicy_ids.CreateAuthorisationRule/UpdateAuthorisationRule(wholesale replace, the v1 PATCH/PUT contract) /DeleteAuthorisationRuleare the standalone rule writes, gated like every authz-config write on thesecurity_administratorrole, 404-on-deny, audited asapi:authorisation_rules:create|update| destroy. A rule may carry the step-up bits —require_step_updemands an elevated session on the rule's matched write-shaped actions (update/delete/execute) across the whole v2 surface,step_up_on_readopts its reads in too; setting a bit requirespacks:security-governance, and an unmet demand refuses with the floor's typedERROR_REASON_STEP_UP_REQUIREDnaming the rule. The composition is monotonic: the hard-coded step-up verb floor always applies first and no rule can exempt it.AuthorisationPolicyService.ListPolicyRules/AttachPolicyRule/DetachPolicyRule— the policy-rule link lane (v1's/api/authorisation_policies/{id}/rules): the link listing serves only a readable policy's rows; attach links one or more existing rules by id, detach removes one link row by its link id (the rule itself remains). Writes gated on the management verdict, audited asapi:policy_rules:create|destroy.AuthorisationPolicyService.ListRulePathCatalog— the platform rule-path vocabulary: every special (non-node) rule-path family the authz layer evaluates, each entry carrying the path (or a parameterised pattern such as/admin/groups/<group>), a one-line author-facing description, whether it takes an appended node-path scope suffix (/governance/change,/governance/owners,/metadata,/git/content— the repository-content family acontent()/list()read by expression requires beside the node read, grantable down to one folder by glob), its owning area and an honest enforcement class —ENFORCED(the server denies without the grant) orADVISORY(the verdict gates GUI controls only; the server does not deny on it today). Readable by any authenticated principal: authoring a policy needs the vocabulary, while authz-config writes stay gated. The census test beside the authz registry pins this catalog to the authz layer's actual rule-path surface, so a family that forgets to register fails CI.LicenceService.UploadLicence— the licence upload (v1'sPUT /api/licencemultipart, carried asbytes, 1 MiB cap): an Ed25519 envelope verifies and stores through the entitlement service (which appends its ownapi:licence:appliedevent) answering the applied document plus the post-applylicence_status; a legacy RSA file validates against the embedded key and stores into the licence setting. Verification refusals carry the v1 titles verbatim. Authorised as update on the/licencerule path — the one v1 licence lane that denies 403, carried over aspermission_deniedwith the v1 message.LicenceService.RemoveLicence— removes one stored entitlement document by licence id (v1'sDELETE /api/licence/{licence_id}): unlike expiry, which never reverts a licensed system, removing the last enterprise document reverts the server to the Basic tier. Answers the post-removallicence_status; an unknown id answersnot_found. Authorised like the upload (update on/licence,permission_deniedwith the v1 message); the entitlement seam appends its ownapi:licence:removedevent, exactly as v1.LicenceService.StoreLicenceCredential— stores one online installation's phone-home credential in the platform secret vault underlicence/<installation_id>/credentialand answers thesecret-vault://reference, never the value — the last step ofopschain admin licence register, so the server's verification rounds find the credential withoutOPSCHAIN_LICENCE_CREDENTIAL(which still wins for an installation it names). Authorised like the upload (update on/licence, thelicence.applystep-up); a composition without a secret vault answersnot_found.PropertiesService.UploadFileProperties— the file-properties upload (upstream'sPOST /api/properties/{id}/file_properties, a lane v1-Go never ported): the uploaded bytes serialise performat(raw / base64 / parsed JSON) into the document'sopschain.files["<file_path>"]entry, recording a new version through the shared document writer (compare-and-set against the read version). Supplyingsecret_path/secret_keystores the serialised content in the secret vault instead — gated by the v1 secret-store permission (update on the owner's secrets rule paths), refusing an existing value with the v1Vault value already exists …title (the GUI probes that prefix to offer the overwrite retry) — and records thesecret-vault://reference. Validation refusals (invalid path/mode/format, binary without base64, duplicate path withoutreplace_file) carry the upstream command's titles verbatim. Audited asapi:properties:createwith the file identity (never the content; the secret parameters mask).
Estate authoring services
The per-object services for the authoring/execution estate the GUI drives
on v1 — every RPC carries a authz-contract row with a runtime deny
proof, every list is cursor-paginated, every write accepts
requestMetadata and records its audit event, and the PostgreSQL-only
read models answer the typed STORE_POSTGRES_REQUIRED under the embedded
store:
WorkflowServicegrows the full authoring lane beside its schema read:ListWorkflows(code ascending, optionalarchivedfilter, summary/full views; each row carriessummary, the resolved one-liner — the stored explicit summary, else the description's first sentence, ellipsis-truncated at 120 characters) /GetWorkflow/CreateWorkflow/UpdateWorkflow(withcreate_new_version, the editor's "save as new version") /DeleteWorkflow(typed as the v1 204/206 split:deleted, or the surviving archived workflow), and the versions lane:ListWorkflowVersions(newest first — the order the GUI's loader takes the latest version from) /GetWorkflowVersion/UpdateWorkflowVersion(save,validate_flag,resolve_propertieswithproperty_overrides_json, publish viadraft: false) /DeleteWorkflowVersion/DeleteWorkflowDraftVersions. Authorised as v1: reads need project read; creates update on the project's/workflowscollection path and the new workflow's own path; updates/deletes update/delete on the workflow's path. Audited asapi:workflows:*/api:workflow_versions:*. The permanent-destroy lane rides beside the soft delete:GetWorkflowDestroyManifestpreviews the per-type row counts a permanent destroy would remove (the counted cascade run and rolled back — nothing touched, and the archived-first / scheduled-activity / trigger refusals name the blocker up front), andDestroyWorkflowperforms it — valid only on an archived workflow, taking runs with it only underwith_runs, events never touched (the destroy seam recordsapi:workflows:destroyeditself). Both require read on the project plus delete on the workflow's path, 404-on-deny.GitCredentialService— the shared git-credential registry:ListGitCredentials/GetGitCredential(by id or by name within the scope) /CreateGitCredential/UpdateGitCredential(resolve-at-use: referencing remotes pick the new values up on their next fetch) /DeleteGitCredential(refused while remotes reference it, naming them) /TestGitCredential(ls-remote probe, outcome in the response) /ExtractGitRemoteCredential(inline values → named credential, remote flipped to the reference). Project scope gates on the project's/git_credentialsrule path; the global scope on the/adminupdatable determination. Values are masked exactly as git remotes' are.IdentityProviderService— the federated sign-in provider registry:ListIdentityProviders/GetIdentityProvider(by id or code) /CreateIdentityProvider/UpdateIdentityProvider(a suppliedclient_secretrotates the stored one; a suppliedclient_secret_refre-pins the reference) /DeleteIdentityProvider/TestIdentityProvider(discovery + JWKS probe for the oidc kind, the API/metaread for github, a service bind + user search for ldap — reportingdetected_entry_id_attribute; outcome in the response). Every procedure gates on the/adminupdatable determination. Custody is reference-first: the record carriesclient_secret_ref, asecret-vault://reference pinned at write time (an unresolvable reference refuses by name), and an inlineclient_secretis held encrypted at rest on the record (never written into a vault; the reference is cleared); reads serveclient_secret_setplus the reference, never a value.codeis the fixed callback path segment (/auth/login/{code},/auth/callback/{code}— plain HTTP legs, not RPCs, plus the unauthenticatedGET /auth/providerssign-in button read — enabled connections ordered bydisplay_orderascending, code ascending, each row carryingcode/kind/display_name/display_order);display_orderis the admin-edited sign-in surface rank (non-negative int, default 0);kindisoidc,github(the GitHub App adapter:hostnames the instance, empty meaning github.com; a non-github.com host — GHES — is refused without the Enterprise licence;step_upis hard-wired toplatform) orldap(directory_url+require_tls— a plaintextldap://URL is refused unlessrequire_tlsis explicitly disabled —bind_dnwith the bind password on theclient_secretseam,user_base/user_attribute/users_filter,group_base/group_attribute/groups_filter,mail_attribute,entry_id_attribute— empty detectsentryUUID→objectGUID;step_uphard-wired toplatform; no callback leg — sign-in isPOST /auth/login/{code}with JSON/formusername+passwordand optionaluse_cookie, answering the session tokens as JSON). Creates and issuer changes additionally check the identity entitlement split: the packaged GitHub/Google/Entra ID (on a tenant-derivedhttps://login.microsoftonline.com/<tenant id>/v2.0issuer) buttons rideidentity:builtin(Basic defaults, baseline-unioned), any other OIDC issuer ridesidentity:sso, and theldapkind is base platform — never identity-gated, like local login; a missing feature refusespermission_deniedwithERROR_REASON_FEATURE_ENTITLEMENT_REQUIREDmetadata naming the feature and the effective tier. The client secret is write-only end to end: stored encrypted, responses carry onlyclient_secret_set. Audited asapi:identity_providers:*. Group→role mapping:ListIdentityProviderGroupMappings(optionalproviderfilter, by id or code) /CreateIdentityProviderGroupMapping/DeleteIdentityProviderGroupMapping— one entry per{provider_group → role | group_name}grant, exactly one target per entry (roleacceptsadmintoday), creates gated onidentity:ssoexcept on an ldap-kind connection, which is base platform and never identity-gated (listing and deletion stay open under a lapse — grant removal is never licence-blocked), entries cascade-deleted with their connection, and writes audited asapi:identity_provider_group_mappings:*carrying the grant. Snapshotted provider groups resolve as<provider code>:<group>effective groups, so path-RBAC policy assignments can also target them directly. Seeopschain docs identity, "Federated sign-in" and "Group→role mapping".SecretsProviderService— the secrets-backend connection registry:ListSecretsProviders(code ascending) /GetSecretsProvider(by id or code) /CreateSecretsProvider/UpdateSecretsProvider(a suppliedcredentialrotates the stored one) /DeleteSecretsProvider/TestSecretsProvider(authenticate with the stored credential per the auth method, then a token self-lookup; outcome in the response AND recorded as per-connection health —last_test_at/last_test_success/last_test_erroron every read). Every procedure gates on the/adminupdatable determination.codeis the estate identifier (never akey);kindisopenbaoorhashicorp-vault(one HTTP API today, distinct kinds by design) with a per-connectionkv_version(1|2);auth_methodistoken,userpassorapprole, the method's secret half riding the write-onlycredentialseam (encrypted at rest; reads servecredential_set) — or pinned as a provider-qualifiedsecret-vault://<code>@<path>/<key>credential_refnaming ANOTHER enabled provider, resolved at write time (a vault cannot custody its own key, so unqualified and self-qualified references refuse by name; composition resolves the reference through the named provider, failing loudly when it cannot); connection options aremount_path,ssl_verify/ssl_ca_cert,cipher_stackingandpassword_length. Multi-vault: the firstenabledprovider is base platform; enabling a second refusespermission_deniedwithERROR_REASON_FEATURE_ENTITLEMENT_REQUIREDnamingsecrets:multi-vaultunless the entitlement is granted. The binding verbs route resolution between enabled providers:ListSecretsProviderBindings(scope ascending, each row joining the bound provider's enabled state andlast_test_*health — the posture view) /SetSecretsProviderBinding(upsert byscope_path—/,/projects/<code>or/projects/<code>/environments/<code>; the provider must be enabled; gated onsecrets:multi-vault; auditedapi:secrets_provider_bindings:create|update) /DeleteSecretsProviderBinding(ungated — a lapsed estate can always unwind; audited:destroy) /ResolveSecretsProviderBinding(the provenance read: which provider governs a node path and which scope decided — the deepest covering binding,""for the single-enabled default; several enabled with no covering binding refuse by name).DeleteSecretsProviderrefusesfailed_preconditionwhile bindings reference the provider. The composition is live — provider AND binding writes re-resolve resolution with no restart. There is deliberately nodisplay_order(providers never render as an end-user picker; listings order by code). Audited asapi:secrets_providers:*, never carrying a credential. The retiredvault.*config keys seed the first connection at upgrade (opschain docs config).ModelProviderService— the model-provider catalog:ListModelProviders(code ascending) /GetModelProvider(by id or code) /CreateModelProvider/UpdateModelProvider(a suppliedcredentialrotates the stored API key) /DeleteModelProvider. Every procedure gates on the/adminupdatable determination; create and enable additionally requirepacks:ai-agents(ERROR_REASON_FEATURE_ENTITLEMENT_REQUIREDnames it; the polite-lapse posture — stored records keep serving).codeis the estate identifier agent manifests reference (model_provider) and the agent-plane model gateway path carries (/api/model/{code}/…);kindisanthropicoropenai, picking the defaultendpointand the credential injection form. Custody is reference-first: the record carriescredential_ref, asecret-vault://reference pinned at write time (an unresolvable reference refuses by name), and an inlinecredentialis held encrypted at rest on the record (never written into a vault; the reference is cleared); reads servecredential_setplus the reference, never a value. The record also carries the provider-level model budget —tokens_per_minute,tokens_per_day,tokens_per_week,tokens_per_month,tokens_per_quarter,tokens_per_year(optional on create and update;0clears, a negative value refuses by name), the ceiling the gateway enforces after the grant caps (opschain docs model-providers). Audited asapi:model_providers:*, never carrying a credential. The gateway itself is agent-authenticated (the session bearer), not part of this admin service — seeopschain docs ai-agents.InboundWebhookService— the inbound-webhook connection registry (opschain docs cli/admin-inbound-webhooks):ListInboundWebhookConnections(code ascending) /GetInboundWebhookConnection(by id or code) /CreateInboundWebhookConnection/UpdateInboundWebhookConnection(a suppliedsecretrotates the stored signing secret) /DeleteInboundWebhookConnection. Every procedure gates on the/adminupdatable determination. A connection defines the signed endpointPOST /api/hooks/<code>external systems fire triggers through:service_account(the identity every resulting trigger firing executes as),replay_window_seconds,rate_per_minute,enabledand the write-only HMACsecret(at least 16 characters; reads servesecret_setandhook_path, never a value). Audited asapi:inbound_webhooks:*, never carrying the secret. The hook endpoint itself is signature-authenticated, not part of this admin service — seeopschain docs triggers.SiemService— the SIEM forwarder connection registry (opschain docs integrations-siem):ListSiemConnections(code ascending, each with its delivery health) /GetSiemConnection(by id or code) /CreateSiemConnection/UpdateSiemConnection(a suppliedsecretrotates the stored receiver token;set_event_filterreplaces the prefix filter) /DeleteSiemConnection/TestSiemConnection(one syntheticinfo:siem:testevent through the real sender). Every procedure gates on the/adminupdatable determination. A connection pushes the event stream to a customer receiver — kindsplunk_hec(HEC JSON envelopes, write-only token; reads servesecret_set, never a value),syslog(CEF over udp/tcp/tls) orotlp_http(the correlation chain as OpenTelemetry traces to<endpoint_url>/v1/traces;protocolprotobuf|json,auth_header,ssl_verify,ssl_ca_cert) — with batched at-least-once delivery behind a durable cursor and per-connection health (last_delivered_at,lag_seconds,consecutive_failures,last_error). Audited asapi:siem_connections:*, never carrying the token. SIEM export is a base capability: no pack gate.PackStoreService— the installed-pack store (the air-gap pack lane):InstallPack(upload a signed bundle; the server verifies it offline against the embedded keyring — manifest signature, every per-file content digest, thepacks:<name>entitlement the manifest requires and its minimum platform version — refusing each by name; versions are immutable, an identical re-upload is idempotent) /ListPackVersions(paged, name ascending then newest install first;namefilters to one pack) /PinPackVersion(marks the version an unversionedpacks.yamldeclaration resolves to — rollback is a re-pin; emptyversionclears the pin, restoring highest-installed-semver resolution) /DeletePackVersion(explicit; refuses while the version is pinned or while any template version or run still references it) /ReResolvePack(the bulk upgrade verb: cuts one new template version per template whose latest version resolves the pack through an unversionedinstalled:declaration, moving only that pack pin — explicitversion:declarations and already-sealed versions never move;dry_runreports the set without cutting,queue_asset_upgradesmoves the assets through the standard assignment flow under per-asset authorisation; withoutversionthe cut versions seal the estate default — the pin, else the highest installed — and the response'sdecided_bynames its provenance (admin_pin|highest_installed); a namedversionis written as every cut version's explicit per-version pin (decided_by: explicit), which later fetches of the revision keep. The earlier refusalERROR_REASON_PACK_TEMPLATE_UNSEALEDis retired — kept in the enum for wire compatibility, never emitted). Theversioninside the signed manifest is the authoritative copy — filenames and tags are decoration. Every procedure gates on the/adminupdatable determination; writes are auditedapi:packs:*. Seeopschain docs cli/admin-packs.ImageBuildProviderServiceandImageRegistryService— the image build catalog (opschain docs image-builds):List…(code ascending) /Get…(by id or code) /Create…(abind_pathdeclares the first binding in the same call) /Update…(a suppliedcredentialrotates the stored reference) /Delete…(refused by name while bound) /Test…(the probe: credential resolves and parses as its form, the endpoint or registry answers, nothing built or pushed; the outcome lands onlast_test_*) /Bind…/Unbind…(one record, one estate path —/or any node path; the sharedImageProviderBinding). Every procedure gates on the/adminupdatable determination; both record kinds are base platform (no entitlement at create — a kind whose pack is not entitled refuses at dispatch).kindisbuildkit/packer/aws_image_builder/azure_image_builder(the reservedgcp_cloud_buildis accepted on the record and refused at dispatch) for build providers andoci/ghcr/ecr/acr/gar/gallery/amifor registries;credential_kindis validated against the kind, and aghcrregistry may name agithub_connectionin place of a stored token. Custody is reference-first:credential_refpinned at write time, inlinecredentialvaulted underimage-build-providers/<code>/credentialorimage-registries/<code>/credential; reads servecredential_set,bound_pathsand the reference, never a value. Audited asapi:image_build_providers:*/api:image_registries:*(create/update/destroy/test/bind/unbind).TransportProviderService— the transport-provider catalog:ListTransportProviders(code ascending) /GetTransportProvider(by id or code) /CreateTransportProvider/UpdateTransportProvider/DeleteTransportProvider/TestTransportProvider(record validation — config schema plus credential resolution/parse — without dialling any host; the outcome lands onlast_test_*). Every procedure gates on the/adminupdatable determination; create and enable additionally requirepacks:rpc(ERROR_REASON_FEATURE_ENTITLEMENT_REQUIREDnames it; the polite-lapse posture — stored records keep serving).kindisssh-staticorssh-ca;config_jsoncarries connection facts only (forssh-caplus the minted certificate's policy:principals/username,validity_seconds) — thessh-cacredential reference is the CA signing key, and dispatch mints a short-lived signed certificate per session rather than handing the key out. Custody is reference-first: the record storescredential_ref, asecret-vault://reference (provider-qualifiedsecret-vault://<code>@<path>/<key>included) pinned at write time — an unresolvable reference refuses by name — and an inlinecredentialis held encrypted at rest on the record (never written into a vault; the reference is cleared; reads servecredential_set). No response, audit row or error ever carries credential material. Audited asapi:transport_providers:*. Use-time resolution and session dispatch belong to the step-dispatch seam, not this service. Bindings ride the same service:ListTransportProviderBindings/SetTransportProviderBinding/DeleteTransportProviderBindingmanage the declared scope-path → provider mapping (deepest covering scope wins;/is the estate default), andResolveTransportProviderBindinganswers selection provenance for a node path (the single-enabled provider when nothing binds; several enabled and no binding refuse by name). Set gates onpacks:rpc, delete stays ungated, writes audit asapi:transport_provider_bindings:*.GitHubIntegrationService— the GitHub integration (integrations:github;opschain docs integrations-github): GitHub App connections, trust policies and the Actions OIDC token exchange. Connections:ListGitHubConnections(code ascending) /GetGitHubConnection(by id or code) /CreateGitHubConnection/UpdateGitHubConnection/DeleteGitHubConnection(removes its trust policies with it) /TestGitHubConnection(the referenced App key resolves and parses as a PEM RSA key — GitHub never called; the outcome lands onlast_test_*).hostisgithub.com(the default) or a GitHub Enterprise Server host — immutable, and self-hosted hosts gate on thegithub_self_hosted_instanceslimit (ERROR_REASON_LICENCE_LIMIT_EXCEEDED; 0 on Basic — the tier boundary). Custody is reference-first: the record storesprivate_key_ref, asecret-vault://reference pinned at write time, and an inlineprivate_keyis held encrypted at rest on the record (never written into a vault; reads serveprivate_key_set); no response, audit row or error ever carries key material. Trust policies:ListGitHubTrustPolicies/GetGitHubTrustPolicy/CreateGitHubTrustPolicy(gates on the feature and thegithub_trust_policieslimit;service_accountmust resolve to a service-account identity) /UpdateGitHubTrustPolicy/DeleteGitHubTrustPolicy(never gated). Every admin procedure sits behind the/adminupdatable determination; writes audit asapi:github_connections:*/api:github_trust_policies:*.ExchangeGitHubActionsTokenis served OPEN — the presented Actions OIDC token is the credential: verified against the connection host's issuer and the connection'soidc_audience, matched to the first enabled trust policy (name ascending) covering the token's repository/ref/environment claims, gated on the feature and thegithub_token_exchanges_per_daycap (refusals named and audit-evented aswarn:github:exchange_refused), and answered with a short-livedgithub-actions-audience access token acting as the policy's service account (no refresh — the pipeline exchanges again). Every mint landsinfo:github:token_exchangeand one exchange-register row.AttestGitHubRun(authenticated — typically the exchanged token) records a GitHub-side outcome as oneinfo:github:attestationevent against a change the caller can read (change_id,status, optional summary/repository/workflow_run_id/ evidence_urls; 404-on-deny; gated on the feature).ProvisionGitHubWebhook(admin; feature-gated) provisions GitHub webhook ingestion as one inbound-webhook connection on the generic signed lane (/api/hooks/github-<code>), bound to a service account — the write-only signing secret is generated and returned exactly once when the request supplies none, audited asapi:github_connections:provision_webhook, and a second provision refuses by name. Connections carryinstallation_id(the JITGH_TOKENmint target;0= unset, minting refuses by name) and the display-onlywebhook_connection_codejoin.JsmIntegrationService— the Jira Service Management integration (integrations:jira-service-management;opschain docs integrations-jsm): JSM connections, thejsm:request/jira:issuetyped-reference read surface and the single audited outbound write door. Connections:ListJsmConnections(code ascending) /GetJsmConnection(by id or code) /CreateJsmConnection/UpdateJsmConnection/DeleteJsmConnection(never gated) /TestJsmConnection(the base URL parses and the referenced credential resolves — JSM never called; the outcome lands onlast_test_*).base_urlis an Atlassian cloud site or Jira Data Center http(s) base URL;auth_emailset means cloud basic authentication, empty means a Data Center PAT sent as a bearer;project_keysscope outbound writes (a request outside them refuses by name). Custody is reference-first: the record storesapi_token_ref, asecret-vault://reference pinned at write time, and an inlineapi_tokenis held encrypted at rest on the record (never written into a vault; reads serveapi_token_set); no response, audit row or error ever carries token material. Every procedure exceptListJsmRequestLinkssits behind an updatable determination on its per-verb rule path under/admin(/admin/integrations/jsm/connectionsfor the connection surface;/admin/integrations/jsm/requests/comment|transition|fields|resolvefor the write door — a plain/adminrule covers them all by prefix, a narrow rule grants exactly one verb); connection writes audit asapi:jsm_connections:*.ListJsmRequestLinkslists thejsm/jiratyped references on governance metadata blocks joined to their subjects, served per row against the caller's read grant on the subject's target (the pushdown posture; optionalexternal_id/systemfilters). The write door —CreateJsmRequestComment(ALWAYS a JSM internal note; no verb on the service carries a visibility field),TransitionJsmRequest(by transition name, matched case-insensitively against the available set, or numeric id; an unknown transition refuses naming what is available),SetJsmRequestFields(label add/remove + afields_jsonobject) andResolveJsmRequest(records thejsm:requestreference with relationshipresolveson the resolving subject's governance block — merged, never dropping existing references — posts the internal evidence comment and performs the resolving transition; the subject must carry a readable governance checklist) — is feature-gated with named refusals, project-scope checked, and every write lands exactly oneinfo:jsm:*event.ProvisionJsmWebhook(admin; feature-gated) provisions Jira webhook ingestion as one inbound-webhook connection on the generic signed lane (/api/hooks/jsm-<code>), bound to a service account — the write-only signing secret is generated and returned exactly once when the request supplies none, audited asapi:jsm_connections:provision_webhook, and a second provision refuses by name.GitRemotePolicyService— Security Administration: the git identity authorisation policy and the security-role grants. The policy is a deny-all-by-default allow list{scope: global|project, remote, rev_patterns (globs, default all), principals (users/groups, default anyone-with-path-rights)}evaluated as the union of allows on the compiled authz snapshot and enforced at the use-time doors (change create, workflow change-step reach as the run's creator, therefresh_git_revretry, template-version create/sync); a denial is a named validation error plus awarn:security:git_remote_refusedevent.ListGitRemotePolicies/GetGitRemotePolicy/PutGitRemotePolicy(create-or-replace; replacing a seeded entry clears its upgrade-grandfathering marker) /DeleteGitRemotePolicyanswer to security administrators (project delegates within their projects), auditors (reads) and superusers, 404-on-deny.GetGitRemotePolicyMode/SetGitRemotePolicyModecarry the estate-wide allow-all switch (set: global security administrator or superuser only).ListSecurityRoleGrants/GrantSecurityRole/RevokeSecurityRolemanage the local (break-glass) grant lane ofsecurity_administratorandauditor— global-security-admin territory; grantingauditoradditionally requirespacks:security-governance. Writes carry the step-up floor, invalidate the authz snapshot and audit asapi:git_remote_policies:*/api:security_role_grants:*. Remote-serving reads elsewhere (GitRemoteService) annotate each remote with the caller's per-userusableverdict, and remote create/update answerwarningswhen no entry covers the configured remote.TransportGrantService— transport grants, the authority layer over the catalog (the trust-grant mould):ListTransportGrants/GetTransportGrantread the registry,CreateTransportGrantproposes a grant (subject kind/id — user, group, service_account or agent_group — provider code or glob, scope glob, action patterns, caps,require_step_up, optional expiry). With the approval fabric composed a new grant landspendingand opens atransport_grantchecklist in the one approvals inbox (approvers required); quorum completion activates it, rejection and quorum-TTL expiry revoke it, andApproveTransportGrant/RejectTransportGrantrefuse while that ceremony is open (they serve the base posture without the fabric).RevokeTransportGrantis terminal and never entitlement-gated. Every procedure gates on the/adminupdatable determination, grant writes carry the step-up session floor, create/approve requirepacks:rpc(polite lapse: stored grants keep serving), and writes audit asapi:transport_grants:*. Dispatch-time enforcement (refusal by name,warn:transport:refused/warn:transport:cap_refused/info:transport:session) lives in the step-dispatch seam, wired by the dispatch resolver.InfrastructureService— the infrastructure node kind: hosts, hypervisors, network devices and links registered as estate nodes under a project, an environment or an asset, engineered for tens of thousands of rows per instance.ListInfrastructureNodeslists a scope's subtree rolled up (or the whole estate) with keyset pagination and server-side filters over the indexed hot columns (type, lifecycle state, address, FQDN, code, name search);GetInfrastructureNodeserves one node with its own properties document.RegisterInfrastructureNodecreates one node — the type is validated against the closed registry (host,hypervisor,network_device,link) and the properties document against the type's reference vocabulary: unknown*_refkeys and credential material refuse by name (connection custody belongs to transport providers; a host carriesrpc.provider_ref, a provider reference, never a secret). References name sibling infrastructure at the same level or above (a hypervisor'shost_ref, a device'smgmt_host_ref, a link's two endpoints) and are resolved at write with named refusals. Address and FQDN are indexed but unconstrained: a same-environment duplicate address warns on the write and flags the row in lists, never refuses.BulkUpsertInfrastructureNodeslands a batch under one parent in one set-based write (sync lanes arrive in batches; per-item failures never fail the batch; the batch cap refuses by name). Lifecycle assertion follows authority: the manual lane asserts estate membership only, so its lifecycle verbs areRegisterInfrastructureNodeandDeregisterInfrastructureNode— a deregistered node is a soft-close posture (hidden from list defaults and estate reads, full history and events retained) and registering the same code again revives the same row (same id). Theprovisioned/destroyedstates stay in the schema as reserved slots for the integration that performs those operations; no RPC here writes them.RecordInfrastructureFactslands discovered facts under the properties document'sfactskey and stampslast_facts_at, so every read renders freshness as last-observed — OpsChain records what verbs did and observed; it is not a monitor. The row also carrieslast_used_at, stamped at dispatch when ahost_ref-bound step's transport session is admitted.SummarizeInfrastructureanswers the inventory's health over a scope in one set-based read (the dashboard's Infrastructure panel): the project × type × lifecycle matrix with archived rows counted apart, the exception totals (stale facts, same-environment duplicate addresses, registered-but-unused, destroyed-but-present, recently provisioned/destroyed from events) and the thresholds they were judged against (the storedinfrastructure.stale_facts_after/infrastructure.unused_aftersettings, 168h and 720h by default); an emptyscope_pathcovers everything the caller may read, and the list filter gainsduplicate_address,last_facts_before,last_used_beforeandchanged_sinceso each total lists exactly its rows. Reads are authorised per row on the node's path (an unreadable node never appears); writes take update on the node's path (or the parent'sinfrastructurerule path for creates), 404-on-deny. Auditedapi:nodes:create/updatefor CRUD andapi:infrastructure:*for the verbs. Workflow expressions read the same estate:estate('<node path>')serves a converged infrastructure node (facts, connection profile and metadata, never credentials), andestate('<scope path>', "type == 'host'")enumerates a scope's subtree as one SQL query returning selected-field projections under a bounded result budget with a named over-budget refusal.SecretServiceuser-lane additions: the interactive read lane never rides the platform vault credential —GetUserVaultCredential/SetUserVaultCredential/DeleteUserVaultCredentialmanage the CALLER's own per-provider credential (own-rows: requests carry no user; the principal is the key;tokenoruserpass, encrypted at rest, write-only — reads servecredential_set; keyed user x provider so multi-vault inherits; writes audited asapi:user_vault_credentials:*).ResolveSecretsandResolveGlobalSecret— the secrets screen's decrypt-for-display pair — sit on the step-up floor (secrets.decrypt/secrets.decrypt_global, the seam), read the vault with the caller's stored credential (a caller without one refusesfailed_preconditionwithERROR_REASON_USER_VAULT_CREDENTIAL_REQUIREDnaming the provider; the vault's own ACL denials surface as the embeddedERROR:result), and are audited (api:secrets:vault_read,api:secrets:decrypt— addresses and context, never values). Under multi-vault the pair routes by the RESOLUTION CONTEXT's node path —ResolveSecretsby its owner node,ResolveGlobalSecretby a suppliedvault_owner_id's node — through the declared scope bindings exactly as the step lane does (the owner's "inherited properties should also overlay the bindings on them" ruling): the deepest covering binding's provider serves the read with THAT provider's personal credential, an uncovered path falls to the default, a disabled bound provider refuses by name, and an explicitproviderselection wins over the binding.EncryptSecretis deliberately un-floored and un-audited. The platform lane (step-context resolution, runner env injection, step-scoped principals) is untouched. Thevault_owner_idonResolveGlobalSecretand the store verbs is optional (the generic reader submits none): absent,ResolveGlobalSecretis governed by the global/secretsexecutable rule alone (nothing ever consumed the node) and the store verbs by the global/secretsupdatable rule alone; a SUPPLIED id must still name an existing node, and owner-present stores keep the ancestor secrets-rule-path grants.ListVaultEntriesis the governed vault browser's read: one level of the KV tree under an optional prefix (KV v1/v2 per the provider'skv_version; a trailing/marks a subdirectory), and a prefix that is a secret LEAF answers its top-level field names askeysinstead (the KV model's keys, e.g.username/tokeninsidedemoenterprise/foo— names, never values; bothentriesandkeysempty withdeniedfalse means the prefix is unknown), listed AS THE CALLER through the credential lane (no credential → the same typed refusal; the vault's own ACL refusal of the prefix answersdenied: true, a verdict rather than an error) — names only, so no step-up floor, audited by prefix (api:secrets:vault_list). The optionalprovidercode must name the enabled default until multi-vault support arrives.MetadataService— the node metadata & classification framework (the model reference isopschain docs metadata, the per-RPC gate tableopschain docs governance-matrix): adoptable, schema'd metadata profiles whose typed values annotate estate nodes, structurally absent from the execution plane (invariant I1 — metadata never reaches action inputs, rendered templates or the runner's step context).ListMetadataProfiles/GetMetadataProfile(authenticated reads — the profile catalogue is vocabulary) /CreateMetadataProfile(customer profiles: the key and every field family must sit under the customer-ownedx-<org>.*namespace; non-x-families are reserved for the basecore.*themes and pack-shipped bindings) /UpdateMetadataProfile(revision semantics: any field-set change bumps the version; additive revisions are free; narrowing ones — removed field, shrunk enum — stale-flag existing values, never delete, never auto-migrate, optionally declaring per-field old→newvalue_mappings; field types never change in place; the core profile's schema is base-owned and refuses edits). The canonicalcore.*family ships as base, seeded at boot (the generic all-enterprise base vocabulary — regulatory bindings map onto it, core itself carries no regulator vocabulary):core.criticality,core.data_sensitivity,core.rto_class/core.rpo_class,core.cost_centre,core.lifecycle,core.external_exposure,core.managed_by,core.managed_by_name,core.end_of_support,core.image_provenance— all subtree-inherited.GetNodeMetadata(node read authz, 404-on-deny: the node's own values with staleness plus the EFFECTIVE view — per-fieldinherit: none|subtreeresolved with provenance naming the governing ancestor, typed values) /SetNodeMetadata(field-level writes and clears) /BulkAnnotate(the bulk adoption path: a path-glob scope AND-composed with node kinds and metadata predicates —eq/in/bool, the explicitunclassifiedselector, and the date opsbefore/after/elapsed— applied as per-node audited writes; covered nodes outside the caller's read authz are not covered, and nodes no written field applies to are skipped and counted) /ApplyMetadataProfileMigration(applies declared value mappings to stale values as per-node audited writes — migration is an act, not a side effect; unmapped stale values are counted and stay reportable) /ListMetadataRegister(node × field rows with staleness, filterable by profile/field/value/stale, read-authz filtered per row, paged, plus the unclassified-coverage counts; each row joinsowner— the node's effective owner from the/governance/ownersfabric, nearest declaration wins, empty when uncovered — never stored as metadata) /ExportMetadataRegister(the same rows as CSV — the asset-register evidence lane). Writes require the delegable metadata-writer determination — updatable on/metadata<node path>(profile writes and migrations at/metadata), a reserved rule-path family grantable per subtree through ordinary path-RBAC rules and NEVER implied by node write authz; denial ispermission_denied. Writes touching a protective field (core.criticality,core.data_sensitivity,core.external_exposure,core.image_provenance) additionally sit on the step-up floor (metadata.protective_write). Every write is audited with before/after per field (api:metadata_profiles:*/api:node_metadata:*) and invalidates the compiled effective-metadata snapshot the match paths read (62 G8) — notification metadata predicates and${{ metadata.* }}conditions see a write at the next sweep/entry, never mid-walk. PostgreSQL-backed: compositions without the metadata plane answernot_found.GitRemoteService—ListGitRemotes(name ascending, thedefault_periodic_fetch_intervalthe v1 meta reports, present when the project is readable) /GetGitRemote(by id or by name within the project) /CreateGitRemote/UpdateGitRemote/DeleteGitRemote. Credential masking is the v1 serializer's exactly: a stored sensitive field reads as{{hidden}}, an absent one is unset; password and passphrase split on whether the url is an SSH url. Write tri-state as field presence: absent preserves, a supplied empty string clears (periodic_fetch_interval: 0clears the override).add_known_hoststays superuser-gated (permission_denied, the v1 403 text). The delete's credentials-removed arm is typed (deleted: false, the surviving row, the v1 message). The keyless-write contract carries to v2 typed: a credentialed write on a server with no encryption keys answersunavailablewithERROR_REASON_SERVER_NOT_CONFIGUREDand the named v1 detail verbatim (the v1 503). EveryGitRemotecarries the periodic fetch sweep's record —last_fetched_at,last_fetch_outcome(ok|failed|skipped),last_fetch_error,consecutive_failures, the computednext_fetch_at,floating_versionsandlast_repin_at— and the ref-move watch:watch_default_branch(writable on create and update) and the computedlast_ref_move_at;ListGitRemoteRefsreads the remote's ref snapshot (GitRemoteRef:ref,sha,kind,observed_at,last_change,last_moved_at—opschain docs resources/git_remote, "Ref-move events");FetchGitRemotefetches the remote now (read plus update on the project's/git_remotesrule path; an inactive remote answersnot_found) and answers it after the fetch.SummarizeGitRemoteHealthis the operations read over that sweep, estate-wide: oneGitRemoteHealthRowper unarchived remote whose project/git_remotesrule path the caller may read (due,repins_24hadded), plusGitRemoteHealthTotalsover the visible rows (remotes,due,failing,repins_24h,floating_versions);failing_onlytrims the rows, never the totals. Compositions without the PostgreSQL git-remote estate answerERROR_REASON_STORE_POSTGRES_REQUIRED. Seeopschain docs resources/git_remote.TriggerService—ListTriggers(code ascending, each row gated on its own…/triggers/<code>rule path) /GetTrigger/CreateTrigger(the v1 defaults: name = code, enabled, throttle 60) /UpdateTrigger(merged-state validation, so a partial update cannot leave the row inconsistent) /DeleteTrigger, plusListTriggerFirings(typedstatus/sincefilters),TestTrigger(the dry run,NO_SIDE_EFFECTS) andDiscardTriggerFiring(read on the trigger plus update on the deferring window's node/maintenancepath — the v1 discard lane). Arun_aswrite re-crosses the act_as trust-grant authority on every touch; the acceptedartefactspellings normalise to the canonicalartifactat the edge. Audited asapi:triggers:create| update|destroy|discard.ActionService—ListActions(top-level catalog entries, full_path ascending, children only withinclude_children) andGetAction(one entry with its full step tree, matched case-insensitively on the action or step name). Entries carry typed identity (action,step_name,full_path,description,summary— the resolved one-liner: the entry's explicit summary, else the description's first sentence, else its first line, ellipsis-truncated at 120 characters —runner_requirement) plus the entry document asentry_json— the recursive open action-tree shape is served as stored, never lossily retyped. A non-asset node answersfailed_preconditionwith the v1 message.MintModelService—ListMintModelHistory(newest first) /GetMintModel(the stored concretised document,render_logs_json,phase_output_json) /GenerateMintModel— the v1GET …/mintmodels/generatelane declared as the mutation it is, keeping the v1 validation chain verbatim (non-asset, no-MintModel-folder, generate-actions-in-flight →failed_precondition) and the v1 read-only authorisation (a recorded parity decision), answering the accepted concretise task.enable_debugopts the one run into the debug render — the render captures the rendered template and resolved properties into the task'srender_logs/phase_outputat completion, masked; per-run, never sticky. Audited asapi:mintmodels:generate(v1's GET recorded nothing — a deliberate v2 tightening).NodeArtifactService— the scoped artifact reads beside the estate-wideArtifactServiceindex (artifact-spelled amendment 2):ListNodeArtifacts(exactly one ofnode_path,change_id,step_id; the node scope lists the node's own artifacts, not the subtree's) /GetArtifact(purged rows still show) /GetArtifactDownloadTarget(the GUI download button's contract —download_urlpoints at the authenticated v1 download endpoint; a purged artifact answersfailed_preconditionwith the v1 retention message, the 410 twin). Every row gates on the owning node's/artefactsrule path.NodeService.CompareNodes— the GUI compare dialog's read (v1POST /api/compare_nodes): both paths resolve under the caller's read scope, and the response carries the v1node_comparisonattributes document ascomparison_json(nodes/properties/settings/mintmodel/environments/ assets, each a{node_a, node_b}pair) via the sharedapiv1.NodeComparisonassembly, converge failures answeringinvalid_argumentwith the v1 titles.NodeServicelifecycle verbs — the kind-generic decommission lanes the per-object services' soft deletes feed into, each authorised read+delete on the node's path (not_found on deny, the v1nodeLifecycleAllowedpair):RestoreNode(the full undo of a soft delete, answering the restored root; the lifecycle writer appendsapi:nodes:restoreditself),GetNodeDestroyManifest(the dry-run per-type counts plus the standing export reminder, nothing touched),DestroyNode(the irreversible cascade — events excepted — answering the removed counts;api:nodes:destroyedfrom the writer), andExportNode(the credential-free export zip in-message,with_historyadding the document version trails). Restore and the destroy pair act only on a soft-deleted subtree root — a live node answersinvalid_argumentwith the v1 two-step message verbatim.ArtifactService.UploadArtifact— the v2 twin of v1's multipartPOST /api/artifacts, addressed by node path, carrying the content in-message (download bytes stay on the exempt blob lane viaGetArtifactDownloadTarget). Authorised like v1 (read on the node plus update on its/artefactsrule path, not_found on deny), validated by the shared artefact writer, auditedapi:artifacts:create.
Estate CRUD
The per-object estate services behind the GUI's project/environment/asset/
template screens (the 2026-10-18 GUI
audit is the scope ledger). Every read is NO_SIDE_EFFECTS and
cursor-paginated; every write carries requestMetadata, authorises exactly
as its v1 twin (404-on-deny) and records its v1 audit event. The estate
read model lives in the PostgreSQL store — on the embedded SQLite store
these RPCs answer the typed ERROR_REASON_STORE_POSTGRES_REQUIRED
posture.
ProjectService—ListProjects(code-ascending, per-row read pushdown, typedarchivedfilter),GetProject,CreateProject(update on/projectsplus the new path,api:projects:create),UpdateProject(unset fields preserve;archivedtoggles archive/restore with the v1 refusals),DeleteProject(the soft-delete lane: read+delete on the path, refused by name while runs are active beneath,api:projects:destroy). List and get responses carry the v1meta.authorisationspair for the governing rule path; rows carrychanges_executeand the owned properties/settings document ids.EnvironmentService—ListEnvironments(project_code)/GetEnvironment(node_path)/CreateEnvironment/UpdateEnvironment/DeleteEnvironment, the same postures over the node-path lane (auditedapi:nodes:create|update|destroy, the v1 event family).AgentPostureService— the inventory and posture of every agent enrolled in this estate (opschain docs agent-posture,resources/agent_posture).ListAgentPosture(filter?)answers oneAgentPostureRowper agent — never per instance — composed set-based: one statement over the fleet registry joined to the materialised trust assignments, the instance, channel, release and transcript tables, the identity catalog (owner standing) and a lateral last-event read off the audit ledger; then the effective tier from the compiled trust snapshot (no reads), the token caps the snapshot's budgeted grants set and the spend against them in one counter read per reporting calendar. Each row carriesagent_id,principal_id, project, code, name, type, group,owner_ref/owner_principal_id/owner_departed(a posture flag only — tier and caps unchanged),tier,assigned_scopes,release(id, name, version, stage, digest),instances/instances_connected,last_action(at, kind, outcome, correlation id, event type),token_windows(window, cap, used, grant) andover_cap,transcripts_captured,model_provider/data_handling_approved,disclosures,health,last_seen_atandstale; the response addssummary(total, by tier, stale, over cap, owner departed, without owner),stale_after(agents.stale_after, 168h by default) andcomputed_at. The filter narrows byproject_code,tier,agent_type,staleanddeparted_owner(an unknown tier or type is refused by name). A read: read on the project path (404-on-deny), read on the root for the estate; nothing is audited. Not discovery — the list is what is enrolled.ModelUsageService— the token telemetry (opschain docs provider-usage,resources/model_usage).SummarizeModelUsage(project_code?, agent_code?, since, until, grain, group_by, timezone?)answers oneModelUsageRowper bucket and group at a grain (minutestraight off the gateway's UTC buckets;hour,day,week,month,quarter,yearoff the rollups computed on the project's reporting calendar, each row naming thetimezoneandfiscal_year_startit was rolled under and carrying the peak minute, hour and day that survive aggregation) grouped onagent,provider,model,projectorgrant, withModelUsageTotals(calls, tokens, cache figures, refused calls, the cap-approaching count) and the calendar beside them;ExportModelUsageanswers the same window as CSV at the full tuple, rows rolled under a previous calendar included and flagged;RecommendCaps(grant_id, headroom?, since?, all_windows?)answers oneCapRecommendationper window (p95 × headroom over the observed per-bucket totals) beside the measuredTrustGrant. All three are reads: read on the project path (404-on-deny), read on the root for an estate-wide summary, read on the grant scope's literal prefix for a recommendation; nothing is audited.TrustGrantgains the five windows besidetokens_per_day—tokens_per_minute,tokens_per_week,tokens_per_month,tokens_per_quarter,tokens_per_year— andPOST /api/trust/grantsaccepts them (tokensstays the day alias).EntityService— the entity node kind: a minimal node with no template and no automation that lives under a project, environment, asset or another entity and parents entities only (core — no entitlement, no count limit).ListEntities(parent_path)lists the parent's own entities code-ascending in one keyset query (typedcode/search/archivedfilter, per-row read pushdown);GetEntity(node_path)carries the v1 node-showmeta(child node types["entity"], no changes, no template) beside themeta.authorisationspair and thesecrets_authorisationstriple.CreateEntity/UpdateEntity/DeleteEntitycross the shared node writers with the v1 node rules and authorisation (update on the parent's…/entitiesrule path and the new path; read+delete for the soft delete), auditedapi:nodes:create|update|destroywithkind: entityin the event data.BulkCreateEntities(parent_path, items)creates up to 1000 entities in one set-based write: each item succeeds or fails alone in itsresultsrow (a bad code, a code or name taken, a duplicate in the batch, a path a deleted entity still reserves), a larger batch is refused by name, and every created entity records its ownapi:nodes:create. A parent that cannot hold entities, and an environment or asset create under an entity, refuse by name.PromoteEntity(node_path, template_code, template_version, parent_path?, code?)converts an entity into a new asset andDemoteAsset(node_path, parent_path?, code?)an asset into a new entity: one transaction each, answeringold_node,new_node(ConvertedNode: id, path, kind, conversion state) andmoved_nodes. The destination and code default to the converted node's own. Authorised update and delete on the converted node plus update on<destination>/<assets|entities>and the new path, 404-on-deny; auditedapi:nodes:promoted|demotedinside the transaction. Promotion crosses the asset create gates (ERROR_REASON_FEATURE_ENTITLEMENT_REQUIRED,ERROR_REASON_LICENCE_LIMIT_EXCEEDED, the template version checks as field violations); a refused precondition answersERROR_REASON_CONVERSION_PRECONDITION_FAILEDwith metadataprecondition; the node left behind is archived (an ordinary archived node — editable, unarchivable, deletable), and an archived source is not converted. An entity subtree is also readable in one call throughNodeService.GetNodeHierarchywithinclude_entities, and the kind-genericNodeServicelifecycle (restore, destroy, export) and the by-path properties/settings reads accept entity paths. Field reference:opschain docs resources/entity.AssetService—ListAssets(parent_path)serves flat rows that carry the pinned template identity (template_code/template_version/ids — the v1include=template,template_versionread, server-shaped) andtask_active.5 each row also carries the pinned version'stemplate_version_state/template_version_has_mintmodeland itslatest_background_tasks(the v1 asset list'sinclude=latest_node_background_tasksread, newest first), so the assets screen's status badges need no per-row fan-out on its poll.GetAsset(node_path)is the server-shaped composite: the row plus the MintModel validity pair (mintmodel_valid/mintmodel_invalid_reason), the readable child assets and the latest background tasks (the v1 show's default include set).ListTemplateVersionHistory(node_path)lists a templated node'stemplate_version_historywindows newest-active-first — the v1 node-pathversion_historyindex with its created_by/active-range attributes, pinned-version identity and task ids; a non-templated node refuses with the v1 message, and the response carries the v1 indexmeta.authorisationsat the node's…/version_historyrule path.CreateAssetcarriestemplate_code/template_versionand keeps the v1 parent-kind gate, refused by name; every project holds assets (the former Standard/Enterprise project type is retired —CreateProjectstill acceptsproject_typeand ignores it, andProjectno longer carries it), so the licenceassetsfeature and limit are the only asset gate;UpdateAsset'stemplate_versionrepoints the pinned version through the same history-rolling writer v1 uses.TemplateService—ListTemplateVersionContent/ReadTemplateVersionContentbrowse the tree at a version's sealed commit and read one text file (authorised like the version read plus/git/content/projects/<project>/<path>—opschain docs resources/template, "Browsing a version's content");ListTemplates(project_code)(name-ascending, per-row read on each template's own rule path; the typed filter carriesarchivedandtemplate_type) andGetTemplate(id). Every template get carries its git remote relation (the equivalence ruling: the v1include=git_remotecontract, secret-bearing fields masked exactly as the v1 git_remotes show —{{hidden}}for a stored value, never the value itself);TEMPLATE_VIEW_FULLadds the version rows (newest first) on lists and gets, the pinned nodes on the get (the v1include=nodesread), and the template row'spinned_node_ids(the v1relationships.nodesrefs with unreadable nodes dropped —TemplateSerializer#authorized_nodes).CreateTemplate/UpdateTemplate/DeleteTemplatekeep the v1 authorisation split (create/delete on the project's…/templatescollection path, update on the template's own path) and refusals (in-use archive/disable, the referenced-template delete), auditedapi:templates:create|update|destroy.ListTemplateVersions(newest first; typedarchived/state/created_after— the asset screen's new-version probe) andGetTemplateVersion(template_id, version)address versions by label;UpsertTemplateVersioncreates-or-updates by label with the v1 payload (git_rev,description,archived,locked,float_git_rev), meta flags (ignore_in_use,fetch_revision) andpack_pins(repeatedTemplateVersionPackPin{name, version}— the explicit per-version pack pins; an emptyversionclears one; a pack whose declaration carriesversion:answersfailed_preconditionwithERROR_REASON_PACK_DECLARATION_SEALED, metadatapack,template,mount,declared,requested), auditedapi:template_versions:update; theTemplateVersionrow carriessealed_packs({name, version, mount, decided_by}) —opschain docs resources/template.DeleteTemplateVersion(template_id, version)(the "port both verbs" ruling —DestroyTemplateVersionCommandsemantics): an actively assigned version refuses with the v1 message; one assigned in the past — or one another record still references, the upstream InvalidForeignKey rescue — archives instead and answersarchived: truewith the row (the v1 206), otherwise the version and its owned documents are destroyed with the version's identity folded into any events that referenced it; delete on the template's rule path, auditedapi:template_versions:destroy.CancelTemplateVersionRefresh( template_id, version)(CancelTemplateVersionRefreshCommandsemantics): a version notinitializingrefuses with the v1 "No SHA refresh is in progress" message; a cancelled version drops its pending commit (destroyed when unreferenced) and returns to ready on its resolved commit — or breaks when none resolved — recording the v1info|error:template_version:refresh_cancelledevent; update on the template's rule path, auditedapi:template_versions:cancel_refresh. Revision resolution is synchronous in this server, so only a row adopted mid-refresh can be cancellable.PropertiesService.GetPropertiesHistory(node_path)— the properties-history timeline behind the GUI's asset history view (the upstreamGeneratePropertiesHistoryCommandport; a route the Go v1 facade never served). One entry per owner along the node's path (project, environment, the asset's template, asset — the template-version slot folds into the asset'stemplate_versions, solevelvalues can gap), each carrying its stored properties versions oldest first with start dates clamped to the addressed node's creation; the templated node adds the template-version windows newest first, each window's active properties versions labelled"<template version> (v<n>)"with the window's commit sha (falling back to the version's current commit). Version metadata only — no properties data travels. Read on the addressed node's path, 404-on-deny.
Detail views — nodes and templates
The additive view-shape enrichment that lets the GUI's remaining v1 detail reads swap to v2. All field additions are additive — existing fields keep their tags and meaning:
Changerows gain the v1-only detail attributes as stored JSON documents (approved_by_json,rejected_by_json,cancelled_by_json,requires_approval_from_json,initial_step_tree_json,state_timing_json,state_timing_summary_json,inputs_json— secret inputs stay masked) and the v1 include graph's related-resource ids (properties_id,root_step_id,template_version_id,workflow_id,override_properties_id,override_settings_id,git_remote_id; the project/environment/asset relations resolve throughnode_pathon the path-addressed estate services).GetChangeadditionally serves the show-onlywait_queue_json/blocking_queue_jsonmeta ("[]"when empty); list rows and write echoes leave them empty.Steprows gain the v1 step detail attributes (input_arguments_json,approved_by_json,rejected_by_json,continued_by_json,requires_approval_from_json,state_timing_json,child_execution_strategy).StepService.ListSteps— change steps newest first, the v1GET /api/stepsread typed: the filter'swaiting_for_useris the v1 scope the manage-activity screen sends, each page row's owning change folds into the response (the v1include=changegraph, deduplicated), and rows authorise per the v1 list (read on each step's change target — an unreadable step never appears).WorkflowRunrows gaininputs_json,paused_by_json,cancelled_by_jsonand the relationship ids (override_properties_id,parent_workflow_step_id,root_step_id);GetWorkflowRunserves the show-onlywait_queue_json/blocking_queue_jsonmeta ("[]"when empty — this server's v1 run show serves no computed queues, so the v2 twin carries the same).Activityrows gain the change arm's git identity (git_remote_name,git_rev,commit_sha), both arms'metadata_jsonandpaused_by_json, the workflow arm'sworkflow_versionnumber, and the background-task arm'stask_type/request_number/error_message(the row id is the task id — theReadLogLinesNBT handle). Parent template identity stays on theACTIVITY_VIEW_WITH_PARENTStrail.EventFilter.payload_equals— typed payload-field equality (the v1 dynamicdata ->> 'field'ransacker): the fetch-revision polls'template_version_id/in_progresspredicates, compared as the field's JSON text.Eventrows gainsource_url, the resolved source link the v1 event show's source relationship carries (beside the existingsource_type/source_idresource identifier).CurrentUsergains the/api/menotification-preference attributes (email_address,default_notification_methods_json,direct_message_ids_json) resolved from the principal's user record, anddirectory_shadowed(local principals only, the session fact).
Detail views — runs and steps
The four server gaps the GUI swap surfaced, closed additively (new proto tags only):
Steprows gain the orchestration attributes as stored JSON documents (condition_verdicts_json,estate_reads_json,retry_attempts_json,fan_out_json— each""when the step recorded none) and the publishedoutputsmap (sensitive values always serialize redacted). Served wherever aSteprow travels: the step show,ListStepsand theCHANGE_VIEW_FULLinclude graph.Changerows gain the v1 run-option attributes:notify_json,skip_steps_json,starting_step_json(stored JSON documents,""when absent),auto_continue_wait_stepsandpaused_at(unset when the change was never paused).WaitingGaterows gain the manage-activity detail the v1 workflow arm reads through its include chain:requires_approval_from_json(the step's stored requirement — a plain wait carries""or a document with no pending requirement, so interpret it with the v1 approval-required semantics),run_created_by(the owning change's/ run's creator), and — the workflow arm only —workflow_idand the executed version'sworkflow_versionnumber, withrun_display_namenow resolved through the version row (the v1 include chain's source; the workflow read stays as the fallback).ActivityFiltergains the main feed's remaining v1 grammar, compiled onto the same decoded filter the v1 lane pushes into SQL:git_remote_name_contains/git_rev_contains(ILIKE_cont; either excludes every workflow row, whose columns are NULL),metadata_contains(themetadata_text_contransacker), the strictupdated/started/finished_after/_beforeroot-step time windows (a NULL timestamp matches no bound),workflow_versions(theversion_inset; excludes every change row) andscheduled_activity_id(the uuid-cast_eq: case-insensitive, an unparseable value matches nothing).ListActivitiesResponsegains the v1 feed's totals meta (has_any,total_matching_filter,total_matching_filter_exact), computed over every readable matching row, not just the page — read them from the first page, since a created_at cursor bounds later pages' counts the way v1's stamps pagination does. The sort grammar needs no growth: the v1 feed accepts exactlycreated_at descandupdated_at desc(anything else is a 400), whichActivityOrderalready expresses in full.
Secrets
SecretService — the typed twins of v1's POST /api/secrets family,
served through the shared secrets engine over the same pluggable vault
composition (a composition without a configured secret vault answers
not_found). The v1 refusal semantics carry over verbatim: permission and
per-value lookup failures arrive embedded as ERROR: … result strings
on a successful response (the named-refusal contract automation parses),
never as transport codes, while owner/input validation failures are typed
invalid_argument carrying the v1 titles as field violations. Resolved
and stored values appear only in the response to the authorised caller —
never in logs, and the store verbs record no api:* audit event (the v1
posture: upstream masks secret event data, and emitting without that port
would leak vault values into the customer-visible event stream).
SecretService.ResolveSecrets— v1POST /api/secrets: decrypt values belonging to a validated owner (settings/properties/mintmodel/node/change), each value gated on membership in the owner's converged data so the RPC cannot act as an arbitrary decryption oracle. Authorised per the SecretsPolicy walk: executable on the owner node's (subpath-scoped) ancestor secrets rule paths; superusers pass.NO_SIDE_EFFECTS.SecretService.ResolveGlobalSecret— v1POST /api/secrets/global: fetch and decrypt one vault value by itssecret-vault://URL.expected_vault_valueis optional (the browser's retrieve has no stored form to pre-paste): blank reveals the resolved value outright; supplied, the stored value must match it (the verification use — compared against the STORED form either way).raw: truereturns the value AS STORED — the cipher-stacked{AES2}/T form when the write was stacked — skipping the codec's decryption (same grant, floor and audit; the mode is recorded, never a value);providerselects any enabled secrets provider under multi-vault, the default provider when blank. Superuser, or executable on/secrets.NO_SIDE_EFFECTS.SecretService.EncryptSecret— v1POST /api/secrets/encrypt: cipher-stack a string for the caller (the transportable key when configured, else the universal key); blank values andsecret-vault://references return unchanged. Superuser, or updatable on/secrets.NO_SIDE_EFFECTS(pure computation).SecretService.StoreSecret— v1POST /api/secrets/store: store a value (or a generated one whenvault_valueis unset) under asecret-vault://URL, encrypted per the vault cipher-stacking policy; an existing value refuses with the v1 "Vault value already exists" title unlessreplace_secretis set. Superuser, or updatable on the owner node's ancestor secrets rule paths (or/secrets).SecretService.StoreSecretFile— v1POST /api/secrets/store_file, carried as bytes (message-shaped per the transport-fit guardrail — bounded config files, not a byte-stream lane): binary content (a NUL byte) stores base64-encoded, text raw; the filename echoes in the result. Same authorization asStoreSecret.SecretService.GetSecretGrants— the caller's global secret authorizations (global_encrypt,global_decrypt): the exact server-side rulesEncryptSecretandResolveGlobalSecretapply, resolved against the/secretsrule paths rather than the superuser fact alone (replacing the GUI's interim/api/me-superuser derivation).NO_SIDE_EFFECTS.
Beside the service, the estate shows (GetProject/GetEnvironment/
GetAsset) gain secrets_authorisations (settings/properties/
mintmodel) — the v1 node-show meta.authorisations.secrets triple,
mirrored exactly (superuser-only), closing the gap-C node-show
bridge the GUI's change/run-details facades kept on v1.
The same three shows also carry meta: the full v1
node-show meta the layout screens consume — the node-kind shape flags
(can_have_changes/can_have_children/can_have_properties/
can_have_settings/can_have_templates/requires_template/
child_node_types/can_have_workflows/can_have_actions) and the
per-subresource authorisations sections (properties, settings,
scheduled_activities, changes, self, and the project's git_remotes/
templates/workflows/workflow_runs/environments/assets/agents), each
carrying exactly the v1 action set for its rule path. Both facades render
the meta through one shared rule (apiv1.NodeShowMetaFor), so the v1
node show and the v2 estate shows cannot drift; the secrets triple stays
in secrets_authorisations. This closes the GUI's last layout-screen v1
node-show read (the node-show meta gap).
Chains, comparisons and supporting reads
The remaining gap-B services outside the carved-out notifications family: the maintenance-window estate, the node-background-task
estate, the chain explorer's index and triage digest, and the
system-configuration singleton read. Every read is NO_SIDE_EFFECTS;
every write parses RequestMetadata and authorises exactly as its v1
twin, 404-on-deny.
MaintenanceService— the typed twins of v1GET /api/maintenance, the deferred-firings drill-down and the node-scoped window verbs, over the shared MaintenanceSource/MaintenanceWriter seams:ListMaintenanceWindows— every not-ended window, readable by any authenticated caller (when a window gates a change's start, every reader of the change may see which), plus ended ones withended(bounded byended_since);node_pathscopes the listing to exactly one node (the v1 node-scoped status read).NO_SIDE_EFFECTS.ListDeferredFirings— one window's parked trigger firings in original event order (theTriggerFiringrows), read on the window node's…/maintenancerule path.NO_SIDE_EFFECTS.StartMaintenance/ScheduleMaintenance/ExtendMaintenance/EndMaintenance/CancelMaintenance— the v1 node-scoped verbs: node readable plus update on its…/maintenancerule path; the v1 validation titles arrive as typed field violations (sharedapiv1.ValidateMaintenance*Payloadvalidators), and theapi:nodes:maintenance_*lifecycle events are emitted by the shared writer, whichever facade the verb came through.
TaskService— the typed twins of v1's node-scopednode_background_tasksendpoints (actions-refresh and MintModel-concretise task rows):ListTasks— the node's tasks newest first, narrowed by template version and task type, capped at the response window (the v1 bounded index; no cursor). Node read, 404-on-deny.NO_SIDE_EFFECTS.GetTask— one task scoped to the node, with the stored actions trees (actions_json, the v1 show-only attribute); its log lines read throughLogService.ReadLogLineswith the task id as run and step id. The Go v1 serializer never servedrender_logson a task (a failed concretise distils its cause intoerror_message), so the Task view carries none either — exact exposure parity.NO_SIDE_EFFECTS.CreateTask— the manual actions refresh (node read + update); theapi:generate_actions_request:createevent rides the shared lane.enable_debugopts the one run into the debug render (maskedrender_logs/phase_outputat completion; per-run, never sticky).CancelTask— cancels an active task and aborts its runner job (node read + delete); a finished task answers the named violation.
ChainServicegrowth:ListChains— the chain explorer's index (v1GET /api/chains): summaries inside a required, max-window-clamped time window (since, refused beyond thechains_max_window_dayssetting), narrowed by node-path prefix, origin andhas_failures(both after the scan, the v1 contract), keyset-paginated on (last_activity_at, correlation_id) descending via opaque page tokens. A chain lists only when the viewer can read at least one member;node_pathsnarrows to the readable set.NO_SIDE_EFFECTS.GetChainTriage— the server-side triage digest (v1GET /api/chains/{id}/triageG24/35), computed by the ONE shared implementation (apiv1.ChainTriageEngine— the v1 endpoint, the support bundle's lead document and this RPC all render it): assessment, failure class, runner attribution, failing hops with their log tails and stalled-member classification, and the green-with-caveats findings. Hops the viewer cannot read never appear.NO_SIDE_EFFECTS.
SystemConfigurationService.GetSystemConfiguration— the singleton read (v1GET /api/system_configuration): its identity, its settings/properties document ids and the caller's authorisation verdicts (the v1meta.authorisationsobject, typed). Any authenticated caller, the v1 posture — the documents it points at authorise their own reads and writes (SettingsService/PropertiesService), which is also where the configuration's data update already lives (v1 serves no system-configuration update route).NO_SIDE_EFFECTS.
Notifications
NotificationService — the data plane of the interest-based notification
system (see reference/resources/notification), strictly scoped to the
authenticated caller's own rows and PostgreSQL-backed:
ListNotificationSubscriptions/GetNotificationSubscription/CreateNotificationSubscription/UpdateNotificationSubscription/DeleteNotificationSubscription— subscriptions (Scope × Conditions × Route × Controls). Creating an estate scope needs read on the scope's literal prefix (404-on-deny); a platform scope needs the admin grant (permission_denied). The conditions includeattribute_predicates:{field, pattern}pairs the engine applies to already-matched candidates —action_pathis the only field supported today (an unknown field or uncompilable glob is refused as a field violation), the glob matches the full action-path string (*start*coversweb:start:run), and an event without the attribute never matches a subscription that carries a predicate.MuteNotificationSubscription/SnoozeNotificationSubscription— the quiet controls; a snooze carries an expiry so a mute never becomes a permanent blind spot.ListNotificationRoutes/CreateNotificationRoute/UpdateNotificationRoute/DeleteNotificationRoute— per-user delivery routes (in_app, orwebhookbound to an admin connector — see below; deleting a route falls its subscriptions back to the in-app default). Awebhookroute'sconfig_jsonmust name an enabled connector'sconnector_id, aurlinside that connector's allowlist, and optionally atemplate(generic,slack,teams); creates and config updates re-cross that gate, so a user can never point platform data at an arbitrary URL.ListNotifications(cursor-paged,unread_only/include_archived/include_superseded/thread_keyfilters) /GetUnreadNotificationCount/MarkNotificationsRead/ArchiveNotifications— the in-app feed, whose rows double as the delivery-evidence record. The default view hides rows a newer thread row superseded;include_superseded(or athread_keyview with it) serves the thread history.WatchNotifications— a server stream for the live bell: fresh feed rows as the dispatcher lands them, each with a resume cursor (from_cursorreplays the stored rows after it) and the current unread count, plus count-only ticks when the badge moves without a new row. Archived and superseded rows are never delivered.
NotificationService team verbs —
group-owned subscriptions and routes, delivered ONCE per event per
subscription through the group's webhook route (never per-member fan-out):
ListTeamNotificationSubscriptions/ListTeamNotificationRoutes— the caller's groups' rows (read-only "Team watches") plus every group the caller administers; each row carriescan_manage(the per-group admin determination: updatable on/admin/groups/<group>, which every/admin-plane grant covers).NO_SIDE_EFFECTS.CreateTeamNotificationSubscription/UpdateTeamNotificationSubscription/DeleteTeamNotificationSubscription— behind the group's admin determination (permission_deniedfor a member without it; a row the caller could not even see answersnot_found). The creation gate decides for the GROUP principal (the group's own path-RBAC grants, never a union of members' personal grants): an estate scope's literal prefix must be readable by the group (404-on-deny), a platform scope needs the group to hold the admin determination.mirror_in_appopts members into in-app mirror rows (off by default); a subscription without a route is mirror-only.CreateTeamNotificationRoute/UpdateTeamNotificationRoute/DeleteTeamNotificationRoute— group-owned webhook routes bound to an admin connector exactly as per-user webhook routes (connector_id+ allowlistedurl+template);webhookis the only team channel kind (there is no group in-app feed). A subscription may only reference its own group's routes.
The noise controls ride the dispatcher: each row is
stamped with its resolved cadence (default = realtime for
failures/errors and approvals-waiting, daily digest for all else; explicit
realtime/hourly/daily override per route) and, for a digest cadence,
the digest_window_ends_at whose close also schedules a queued
webhook-routed row's next_attempt_at — so the N3 delivery sweeper honours
cadence per (route, window); thread grouping keeps one updating row per
change/workflow run; and a failure flood on one thread or across one
subscription's subtree collapses to a single row whose collapsed_count
and summary name the blast radius. The in-app row itself always lands
realtime.
The member-facing governance read:
ListMandatedNotificationSubscriptions— the enabled mandated subscriptions org policy attaches to the caller's resolved groups (own-groups,NO_SIDE_EFFECTS). The subscription verbs (Update/Delete/Mute/Snooze) aimed at a mandate that binds the caller answer the typedERROR_REASON_MANDATED_SUBSCRIPTIONrefusal naming the policy and group; a mandate that does not bind the caller stays a 404.
The per-user my-activity preference:
GetNotificationPreferences(NO_SIDE_EFFECTS) /UpdateNotificationPreferences— the caller's own notification tuning.my_activity∈all(the zero-config default, served when no row is stored) |failures_only(only thefailedterminal state of the caller's own runs) |off(no my-activity rows at all); an unknown value is refused, an absent field preserves the stored value, and the update auditsapi:notification_preferences:update. The preference governs only the implicit my-activity arm — approvals-waiting notifications keep flowing even atoff, and explicit subscriptions and mandates are untouched.
Notification connectors
NotificationAdminService — the governance plane: instance-level channel
connectors (see reference/resources/notification-connector), the egress a
user's webhook route rides. Every procedure sits behind the /admin
updatable determination (permission_denied on deny) except the options
catalogue:
ListNotificationConnectors/GetNotificationConnector/CreateNotificationConnector/UpdateNotificationConnector/DeleteNotificationConnector— connector CRUD (kindwebhooktoday). A connector carries an HTTPS-only URL allowlist, optional non-secret default headers, and a write-only HMAC-SHA256 signing secret (stored encrypted; responses carry onlysigning_secret_set; a keyless server answers the typedERROR_REASON_SERVER_NOT_CONFIGUREDunavailable). A connector any route still points at refuses deletion.TestNotificationConnector— sends one signed test payload to a target inside the connector's allowlist and reports{delivered, status_code, failure_reason}; the delivery outcome rides the response, so a refusing target is a successful call withdelivered: false.ListNotificationConnectorOptions— the id/name/kind catalogue of enabled connectors any authenticated caller picks a webhook route's connector from (allowlists, headers and secrets never appear here).NO_SIDE_EFFECTS.
Notification governance
The governance arm on NotificationAdminService: every procedure here sits
behind the /admin updatable determination AND the
packs:security-governance entitlement — a missing pack answers the typed
ERROR_REASON_PACK_ENTITLEMENT_REQUIRED refusal naming the pack and tier.
Enforcement of existing mandates (the engine's matching, the member-verb
refusals) and the retention reaper never consult the pack: the controls are
free, the admin surfaces are the pack.
ListNotificationMandates/CreateNotificationMandate/UpdateNotificationMandate/DeleteNotificationMandate— mandated subscriptions: a subscription shape targeted at one group whose members cannot delete, mute, snooze or disable it. Group membership resolves through the provider-group snapshots. The scope domain is fixed at create; names are unique instance-wide (cap 500); deleting a mandate keeps its dispatched evidence rows (theirmandate_idclears). Audited asapi:notification_mandates:create|update|destroy.QueryNotificationDeliveries— the delivery-evidence query (NO_SIDE_EFFECTS, cursor-paged newest first): who was told what, when, on which channel, with what outcome — metadata only, never rendered secret content. Filters:username,channel_kind,delivery_status,subscription_id,mandate_id,from/to.GetNotificationRetention/UpdateNotificationRetention— the retention policy: Basic fixed 30d feed / 90d evidence; Enterprise defaults 90d/400d, tunable 1–3650 days with evidence ≥ feed enforced at write (the update answersfailed_preconditionon the Basic tier — its retention is fixed). The write lands a new global-settings version (notifications.feed_retention_days/evidence_retention_days); audited asapi:notification_retention:update. Seereference/resources/notificationfor the reaper mechanics.
The session lane (AuthService.1)
AuthService is the v2 session lane — the native successor to the v1
/api/tokens/* bootstrap routes, which are retired (removed at the GUI cutover, answering the standard 404 with no
deprecation shim; v1 compat JWTs are hard-cut and surviving v1 api_key
rows land on the boot-time warn:auth:v1_api_keys_retired re-mint
report). Login and Refresh are the two procedures the surface serves
without a bearer: they are how a caller becomes authenticated.
Login— username/password through the same credential chain as v1 (provider order, throttle, break-glass network check, collision defence, theapi:auth:*audit vocabulary), establishing a session row and answering the session plus its tokens. The quiesce split is preserved verb-for-verb:Loginrefuses while the system is quiesced,Refreshpasses.- Access token: a short-lived signed token on a dedicated signing key
(never
token.secret_key, so native and v1-compat tokens can never verify on each other's lane), carrying a validatedaudclaim (api; the audience vocabulary is shared with the step tokens and futuregithub-actionsexchange) plusamr/acrauthn-method and strength claims. TTL: the storedtoken.access_expirywhen set, else 15 minutes (the native default — deliberately below the v1 compat lane's 4h). Verification re-reads the session row, so a revoked session's access tokens die immediately, on every replica of every cluster. - Refresh token: opaque (
ocrt_…), hashed at rest, rotated on every use; replaying an already-rotated token revokes the whole session and emitswarn:auth:refresh_reuse. Idle expiry followstoken.refresh_expiry(7d default); the session's absolute bound istoken.session_absolute_expiry(30d default) and rotation never extends past it. Refresh— the token arrives in the request body (the CLI lane), or — for browser callers that logged in withuse_cookie— in the HttpOnly, Secure, SameSite=Strictopschain_refreshcookie scoped to the AuthService procedures (honoured whiletoken.enable_cookiesallows the channel), guarded by theX-Requested-WithCSRF header check. The response continues whichever channel the token arrived on. The Secure attribute follows the browser's TLS edge: set when the server terminates TLS or a fronting proxy declares it withX-Forwarded-Proto: https, omitted only on plain-HTTP development setups so the session lane keeps working there.Logout— revokes the presented session server-side and clears the session cookie (a sessionless credential — an api key, basic auth — is a no-op). Audited asapi:auth:logout.Introspect(NO_SIDE_EFFECTS) — the GUI shell's "who am I / what strength" read: identity, groups, superuser, the credential class, and the session with its authn method/strength and step-up elevation window (see the MFA verbs below).ListSessions/RevokeSession— concurrent sessions are allowed: the caller lists and revokes their own; a caller holding the/adminupdatable determination may list any named user's (ListSessions.user, id or username — a denied or unresolvable reference reads not_found) and revoke any user's. From the CLI:opschain admin sessions list [user] | revoke <id>. Disabling a user cascades to immediate revocation of every session and native api key. Audited asapi:auth:session_revoked.ListAPIKeys/CreateAPIKey/RevokeAPIKey— native api keys: opaque (ocak_…), hashed at rest, explicit expiry at most one year out, listed and revocable per user (closing the token-route triage rows). The token value is returned exactly once at mint. Contained break-glass principals cannot mint one. Audited asapi:auth:api_key_created/api:auth:api_key_revoked.ListFactors(NO_SIDE_EFFECTS) /EnrolTOTPFactor/ActivateTOTPFactor/RemoveFactor— the MFA factor registry: TOTP first, the kind set open for the WebAuthn fast-follow. Enrolment returns the base32 secret andotpauth://provisioning URI exactly once (encrypted at rest, never readable back); activation requires a valid code (verify-to-activate) and mints ten one-time recovery codes, returned once and hashed at rest. Enrolling a second factor and removing an active one require a live elevation. Audited asapi:auth:mfa_enrolment_started/api:auth:mfa_factor_activated/api:auth:mfa_factor_removed.Elevate— steps the session up with a platform proof (a TOTP code or an unspent recovery code), opening the non-slidingmfa.elevation_window(minutes, default 15) and raising the session'sacrtototp. Sessions of idp-mode OIDC instances elevate throughBeginIDPStepUpinstead. LikeRefresh,Elevatepasses a quiesced estate. Audited asapi:auth:step_up; failed proofs aswarn:auth:step_up_failed.BeginIDPStepUp— starts the identity-provider step-up round trip for the session the caller's own bearer rode (the only session a step-up may elevate — nothing in a link chooses it): seals that session id with the trip's nonce, PKCE verifier and a same-originreturn_to, sets the login-binding cookie that ties the trip to the calling browser, and answersauthorize_url(IdP re-auth withprompt=login/max_age=0,auth_timevalidated at the callback, which elevates the session in place).failed_preconditionfor a sessionless credential or a platform-mode instance;unavailablewhen the IdP cannot be reached;not_foundwithout federated sign-in. LikeElevate, passes a quiesced estate. The sign-in leg's former?step_up=parameter is refused. The step-up verb floor is enforced on this surface's licence apply and authorisation-policy writes (and the admin plane's platform halt/resume): without a live elevation they refusefailed_preconditionwith the typedERROR_REASON_STEP_UP_REQUIREDdetail (metadata:verb,reason,mechanism, andprovideron the idp mechanism), orERROR_REASON_MFA_ENROLMENT_REQUIREDwhen no factor is enrolled. Break-glass principals and sessionless machine credentials are exempt; every refusal is audited (warn:auth:step_up_refused). Seeopschain docs identity, "MFA factors and step-up".ChangePassword— the self-serve password change (the v1POST /api/users/me/passwordtwin, now retired from the CLI's lane): the signed-in local user proves the current password and sets a new one through the shared authn substrate (policy, reuse history, the rotate-on-first-login clear). Named refusals carry the v1 titles verbatim (Current password is incorrect, the directory-managed refusal, the policy violations) asinvalid_argument; the substrate recordsapi:users:password_changeditself (never doubled by the handler), and no password value ever reaches an event, a log line or an error message.opschain passwdrides it. It is one of the two procedures served while the caller is confined to the password-change flow (rotate-on-first-login,security.password_max_age_days) —Introspectis the other; every other call answerspermission_deniedwith the remedy namingopschain passwd, and the change lifts the confinement for the same session.
Federated sign-in enters this same lane through the two
fixed plain-HTTP browser legs — GET /auth/login/{provider} (state +
nonce + PKCE S256, then a 302 to the IdP) and GET /auth/callback/{provider} (code exchange, id_token validation, shadow
user, then the session established exactly as after Login, handed to
the browser in the opschain_refresh cookie — token.enable_cookies
required). The SPA then proceeds through Refresh/Introspect with no
federated special-casing. Redirect legs cannot be Connect RPCs, which is
why they live beside the mux rather than on this service. The ldap kind
is the one non-redirect variant: POST /auth/login/{code} carries the credentials, verifies them with a directory bind at
login, and answers the same tokens+session shape as Login — with
use_cookie moving the refresh token into the cookie for the SPA; the
CLI posts the same leg. Throttle, audit and quiesce behaviour match
Login verb for verb; a directory outage answers 503, never 401. See
opschain docs identity, "Federated sign-in".
Admin identity reads
The admin identity reads ride this surface, and so do the disable/enable lifecycle writes (below); the local-only user writes (create/update/reset-password) stay v1.
UserService.GetUser— one user row byid, or byusernameoptionally disambiguated byprovider(the openauth_providermatch). A username held by more than one provider without a selector refusesinvalid_argumentnaming the matching providers (the v1 admin show's 422 posture); an unknown lookup answersnot_found. Self-or-admin gated: a caller holding the/adminupdatable determination reads anyone; every other caller reads exactly their own row (the v1/api/users/{id}posture — non-superusers see only themselves), and any other target reads as absent (not_found, never confirming the row exists or enumerating a username's providers). The response'sgroupsis the EFFECTIVE set (provider snapshot ∪ local memberships — what policy assignments match against), and GetUser alone also serves the show facts:superuser(the computed standing, v1'smeta.superuser),default_notification_methods_json,direct_message_ids_jsonandupdated_at.opschain admin users showandopschain users showride it.Usersuperset — theUsermessage carries the v1 admin row's facts beside the picker fields:break_glass,disabled_atandlocal_groups. OnListUsersthey are populated only for callers holding the/adminupdatable determination (the v1 posture: the picker read never served them); every other caller keeps the picker fields alone.opschain admin users listridesListUserswithinclude_dormanton by default, marking dormant rows (--active-onlyhides them).- The
admin groupsCLI verbs rideGroupService(list/create/delete/add-member/remove-member), resolving ergonomic username arguments to user ids throughGetUser(opschain docs cli/admin-groups). AuthServicerevocation routing:opschain tokens revoke <id>classifies the id against the caller's own inventory and routesRevokeAPIKeyorRevokeSession;tokens listgains--sessions/--keys.
User lifecycle writes
Disable/enable moved to v2, valid for any identity — local,
directory (ldap), federated (oidc/github) and service-account rows alike,
a deliberate supersession of the earlier local-only lifecycle rule for these
two verbs (the disabled marker is OpsChain-local shadow state, never
provider data). All three verbs sit behind the /admin updatable
determination (permission_denied on deny) and record the v1 event
vocabulary (api:users:disable / api:users:enable, one event per user
whose state changed), and all three sit on the step-up floor: a session without a live
elevation answers the typed STEP_UP_REQUIRED (or
MFA_ENROLMENT_REQUIRED) refusal before anything resolves or writes;
break-glass and sessionless credentials pass per the documented exemptions.
UserService.DisableUser— by user id (resolve a username throughGetUser). Disabling revokes every live credential immediately — v1 tokens, v2 sessions (revoke_reason=user_disabled) and native api keys, the same cascade the v1 verb ran — and the response reportssessions_revokedplus anowned_automationssummary: the triggers and scheduled activities the user's identity EXECUTES — rows naming themrun_as, plus rows they created with no explicitrun_as(a row someone else runs as survives this disable) — whose firings now refuse identity resolution until reassigned. Exact counts, name lists capped at 20. A warning, never a block. An already-disabled row is an idempotent no-op (no second event, nothing re-revoked).UserService.EnableUser— clears the marker; revoked credentials stay revoked.GetUser.include_owned_automations— the pre-write read of the same survey (what the GUI confirm dialog lists before a disable is confirmed); off by default, the survey costs two queries.UserService.BulkSetUserLifecycle—user_ids[](1–1000) +disabled; never all-or-nothing. One result per requested id in request order (username,changed,refusal,sessions_revoked,owned_automations), one audit event per changed member, idempotent per member (the write posture), so no idempotency key is needed.ListUsers.include_disabled— the listing hides disabled rows by default;include_disabled=truerestores them (disabled_atmarks them for admin callers). By contrast, the GUI users screen rides the hide-by-default feed behind a "show disabled users" toggle, whileopschain admin users listpassesinclude_disabled=trueand keeps showing disabled rows with the DISABLED column marker. Service-account rows list as ordinary identities (providerservice_account, admin facts and the disabled filter included).UserService.CreateServiceAccount— mints a service account:username(unique across EVERY provider — run-as resolves by username, so a collision would make firing-time authority ambiguous; the clash is a named violation), no credential, no login lane (the session and api-key mints refuse service accounts by name). Admin-gated on the lifecycle step-up floor; idempotency key honoured. CLI:opschain admin service-accounts create.UserService.ReassignAutomations— the offboarding verb:from_user_id+to_user_id(an ENABLED service account — a human or disabled target is a named violation). One set-based write repoints every trigger and scheduled activity the from-user's identity executes onto the target's username; authorship stays historical. The response reuses theowned_automationssummary shape for what moved. Idempotent: a second call moves nothing. CLI:opschain admin users reassign-automations.- Scheduled activities carry
run_asandowned_byon theScheduledActivityServicewrite verbs:run_asis authorised against the acting principal's act_as trust grant exactly like the trigger verbs (the merged value re-authorises on update; "" clears) and resolved at firing time with the same named refusals;owned_bymust name a known group.TriggerServicewrites gainowned_bywith the same validation.
Change governance
ChangeGovernanceService is the approval-policy funnel (see opschain docs change-governance for the model and opschain docs governance-matrix for the per-RPC gate table), gated end to end by the
packs:change-governance entitlement: ApprovalPolicy CRUD
(ListApprovalPolicies / GetApprovalPolicy — readable by any
authenticated caller, each response carrying an
authorisations block with the caller's authoring standing;
CreateApprovalPolicy / UpdateApprovalPolicy / DeleteApprovalPolicy
— writes behind the delegable change-governance determination, updatable
on /governance/change<scope>, plus the step-up floor), the reviewer
surfaces (ListPendingApprovals — the inbox, with per-caller
waiting-on-you flags; ListApprovalChecklists — the decision history:
the decided checklists newest first, cursor-paged, filtered by outcome,
decider (mine / decided_by), target subtree, kind and funnel-entry
window, listed to a caller who may read the target, decided the checklist,
or holds the oversight determination — readable on /governance/change
or updatable on /admin — which admits every checklist;
GetApprovalChecklist with the prior-run
evidence panel; both checklist reads carrying the self-explaining
enrichment — per-line waiting_on and policy_scope_path, the caller's
caller_eligibility/caller_may_reject standing, the held subject's
subject_kind/subject_id, and the window coverage for a
window-required checklist; ApproveChecklist / RejectChecklist —
approver-list membership with the self-approval exclusion,
distinct-identity quorum, reject reason mandatory), the release verbs (StartApprovedRun —
the named refusal outside an admitting window names the next scheduled
one; BreakGlassRelease — governance determination on the target,
mandatory reason, loud event, implies the maintenance override), the
governance block (GetGovernanceMetadata / SetGovernanceMetadata —
audited before/after, never fingerprinted), the fingerprint-stamped
masked submission export (ExportSubmission), the pack's dashboard panel
set (GetChangeGovernanceDashboard — the pack's panel figures over a
window: the approval funnel by status, the live pending backlog with its
oldest age, nearest-rank time-to-quorum percentiles, break-glass releases,
approvals voided by fingerprint drift, the change-category mix and the
missing-external-reference count; gated by the pack plus the delegable
change-governance readable determination, read on /governance/change)
and the one-time operator
conversion of the retired settings approver entries
(ConvertSettingsApprovers — root-scope governance determination plus
step-up, a dry_run report mode; the manual page is
cli/approval-policies-convert-settings-approvers).
The subtree owner declarations behind the impacted_asset_owners
approver kind ride the same service:
ListAssetOwners / GetAssetOwner (readable on the delegable
/governance/owners<node path> rule path, 404-on-deny) and
CreateAssetOwner / UpdateAssetOwner / DeleteAssetOwner (updatable
on the same rule path plus the step-up floor, audited). A declaration
covers its node and whole subtree on segment boundaries, the deepest
covering declaration wins, and its subtree is immutable once declared —
move ownership by delete + create. A policy carrying the
impacted_asset_owners kind resolves these declarations per change over
the frozen target set at create: one all-of checklist line per resolved
owner (every one must approve), and a covered create whose impacted
targets include one no declaration covers refuses loudly at create.
Governance foundation
GovernanceService administers the governance foundation primitives every
governance pack consumes (see opschain docs governance for the model
and opschain docs governance-matrix for the per-RPC gate table):
the control catalog (ListControls / GetControl / CreateControl /
UpdateControl — key and domain immutable, retirement a status update),
the evidence ledger (ListEvidenceLedger / VerifyEvidenceLedger /
ExportEvidenceLedger / GetGovernancePostureReport /
AppendAttestation / ReAnchorEvidenceLedger / SetEvidenceLegalHold),
review cadences
(CreateReviewCadence / ListReviewCadences / UpdateReviewCadence),
review campaigns (OpenReviewCampaign / ListReviewCampaigns /
GetReviewCampaign / RecordCampaignDecision / CloseReviewCampaign)
and the waiver register (CreateWaiver / ListWaivers).
The evidence lane adds two auditor reads on the ledger gate (readable on
/governance/evidence + packs:security-governance):
ExportEvidenceLedger serves the hash-chained pull/export lane — one
batch of rows after an after_seq cursor, in chain order, with the
batch's SHA-256 over the canonical opschain-evidence-export/v1 NDJSON
encoding; batches chain by prev_hash, so the accumulated export
re-verifies offline — and GetGovernancePostureReport renders the
opschain-governance-posture/v1 report: findings, per-domain coverage,
the continuous-control-monitoring metric set and the chain-verify
verdict, opening with the honest posture statement (a tool-assisted
attestation of recorded posture, never a certification).
VerifyEvidenceLedger additionally reports archived_through_seq and
archived_segments — the sealed-segment archival boundary the chain
verifies across (opschain docs retention).
The security pack adds the
access-review campaign: OpenAccessReviewCampaign opens an
access_review campaign whose items are sourced server-side from the
live access-review dataset (per-identity grants, system assignments and
both token inventories, each with a provider-badged point-in-time
subject_summary) — admin plus the packs:security-governance
entitlement, the one gate idiom. A revoke decision opens a named work
item instead of auto-revoking: ListGovernanceWorkItems
lists them (governance read, base — a lapsed entitlement never hides open
remediation work) and CompleteGovernanceWorkItem records the by-hand
remediation (governance update + step-up governance.work_item_complete;
a completed item refuses a second completion).
Gating, layered: the reviewer/
auditor-facing verbs live on the user plane behind the delegable
/governance/<surface> rule-path family — the metadata-writer
pattern, granted through ordinary path-RBAC rules and never implied by
admin status. Readable on /governance/controls gates the catalog reads,
/governance/campaigns the campaign reads (and updatable there the
decision recording and closure), /governance/evidence the ledger
read/verify (updatable: attestation appends), /governance/waivers the
exceptions-register read, and /governance/work_items the work-item
list (updatable: completion). A denied caller answers
permission_denied naming the missing determination. The operational
verbs — control catalog curation, cadence configuration, hand-opening
campaigns (OpenReviewCampaign / OpenAccessReviewCampaign),
SetEvidenceLegalHold and CreateWaiver — keep the /admin updatable
determination. The evidence-ledger surfaces — list, verify, attest, legal
hold — additionally require the packs:security-governance entitlement
(the typed PACK_ENTITLEMENT_REQUIRED refusal
names the pack), while campaign closure appends its attestation without
the pack because ledger recording is base posture. Attestation writes,
campaign decisions and closure, legal-hold changes and waiver grants sit
on the step-up floor (governance.attest,
governance.campaign_decision, governance.campaign_close,
governance.legal_hold, governance.waiver_grant) — a session without a
live elevation answers the typed STEP_UP_REQUIRED refusal.
ListReviewCampaigns pages (page_size/page_token, newest first) and
filters by status (open | closed).
Writes are audited per the grammar (api:governance_controls:*,
api:attestations:create, api:evidence_ledger:legal_hold,
api:review_cadences:*, api:review_campaigns:create|close,
api:governance_waivers:create), and campaign decisions record in the
shared approval grammar (audit:review_campaigns:approve|reject|flag) —
the step-gate event family, never a new one.
Run planning, maintenance override and the workflow-run events scope
The v2 twins that unblocked the runs create/--plan CLI cutover:
ChangeService.PlanChange/WorkflowRunService.PlanWorkflowRun— the no-dispatch plan lane, the typed twins of v1'sPOST /api/changes/planand/api/workflow_runs/plan. The request embeds the create message (create);request_metadataand the run-only options (override_maintenance,notify_json,build_without_cache,auto_continue_wait_steps) are ignored, the v1 plan posture. The response is the typedRunPlantree (opschain/api/v2/plans.proto): per-stepverdict(will_run/will_skip/symbolic), evaluatedoperands, resolvedcommand, tier-2unresolved_expressions,estate_reads,fan_outand recursivechildren, with the advisory sentence stamped on every plan. Both areNO_SIDE_EFFECTS(no run row exists for a plan) and authorise exactly as their create (read on the target/project, execute on the action/workflow path, 404-on-deny) — a plan reveals the step tree and resolved values, so it demands what running would.override_maintenanceonCreateChangeRequest,RetryChangeRequest,CreateWorkflowRunRequestandRetryWorkflowRunRequest— the per-request escape hatch v1 accepts on the same four writes, attributed to the authenticated principal and honoured only when they hold update on the window node's…/maintenancerule path.EventFilter.for_workflow_run_id— the for-run twin offor_change_id: the workflow run's own events plus its workflow steps'. v2-only (v1 exposes no workflow-run scope); it feedsruns show --eventsfor server workflow runs.
Runner fleet admin
RunnerService moves the CLI's runner-fleet administration onto v2 — the
FleetQueueService posture applied to the whole family: every verb is
admin-plane gated (the /admin updatable determination, permission_denied
on deny), reads are NO_SIDE_EFFECTS, writes are idempotency-aware.
- Runners:
ListRunners,GetRunner(the full detail: host facts, bindings, observed remote, key fingerprint),SetRunnerDraining(one verb for drain and resume),ForgetRunner(refused while online). - Groups:
ListRunnerGroups,UpsertRunnerGroup,DeleteRunnerGroup,SetRunnerGroupDraining,GetRunnerGroupDemand(the autoscaler signal). - Placement attributes:
ListRunnerAttributes,PutRunnerAttribute,DeleteRunnerAttribute. - Registration tokens:
ListRegistrationTokens,CreateRegistrationToken— the minted token value returns once in the response and is never audited or logged (the audit event carries the fingerprint). - Dispatch plane:
QuiesceFleet,DrainFleet,ResumeFleet.
The REST routes stay mounted for the GUI and the static admin-token
automation lane; drain/forget/fleet verbs
keep recording their established info:runner:* seam events, and the
writes whose REST twins recorded nothing gain api:runner_groups:upsert,
api:runner_attributes:put|delete and api:registration_tokens:create.
CLI cutovers riding them: runs create change|workflow →
CreateChange/CreateWorkflowRun (the -f vocabulary, override_maintenance
included, on the typed contract), runs create … --plan → the plan RPCs
(rendering unchanged), and runs show --events on a server workflow run →
ListEvents with for_workflow_run_id (previously the honest named
refusal).
The surface spans the read lane and
streams above, writes with idempotency keys, the
CLI's v2 lane, and the GUI cutover and integrator reads. Generated
clients: a Go Connect client is generated from the
opschain.api.v2 proto module and ships with the server; the TypeScript
client is generated by the GUI's npm toolchain from the same proto
module.
Platform admin plane
PlatformService moves the operational platform verbs onto v2, gated by
the /admin updatable determination (permission_denied on deny — the
FleetQueueService posture), with the REST routes still mounted for the GUI
and the agent-credential lane:
GetPlatformPosture— each plane's emergency posture, the halt/resume history and the system posture (theGET /api/platform/posturetwin).HaltPlatform/ResumePlatform— the per-plane emergency stop and its resume; both demand step-up (platform.halt/platform.resume, the REST lane's floors) and record the same break-glass platform events through the shared serve cores. Agent-lane trust tiers (decision A14c) stay on the REST route — the only lane agent principals authenticate on.SetAttemptDisposition— dispose one suspended attempt during a runner-plane halt.GetSystemQuiesce/StartSystemQuiesce/AbortSystemQuiesce/ResumeSystemQuiesce— the estate quiesce lifecycle. The write barrier admits these four (and the halt pair) while quiesced — the v2 mirror of the REST allow-list — so a quiesced estate can always be resumed over v2; every other mutating v2 procedure answers the named 503 reason.
System admin plane
SystemService is the ops half of the admin plane on v2 — typed twins of
the Go-native REST admin reads and the database-maintenance verbs, every
procedure behind the /admin updatable determination (permission_denied on
deny — the FleetQueueService posture), served over the same serve internals
as the REST routes (which stay mounted for the GUI and the static-token
automation lane):
- Reads (
NO_SIDE_EFFECTS):GetSystemStatus(the/admin/statustwin),GetDispatchQueue(the/admin/queueoverview — per-entry actions live onFleetQueueService),ListActiveChanges,GetSystemOverview,GetConfigCatalog,ValidateConfig(a pure check of a candidate config document),GetDatabaseStatus,ListMigrations,GetMigrationStatus,GetBackupInfo, andVerifyRestore— a pure read of the restored database against a backup manifest, so it crosses the quiesce write barrier by its read-only census row (the REST twin needed a named POST exemption; the v2 lane does not). - Writes (audited):
AnalyzeDatabase(api:database:analyze),VacuumDatabase(api:database:vacuum),ApplyMigrations(api:database:migrations—allor explicitversions). - The encryption key lifecycle (
opschain admin encryption):GetEncryptionStatus(NO_SIDE_EFFECTS— the configured key ring, the key registry, every encrypted-at-rest column's envelope count per key id,pending,missing_key_ids, the last run) and the audited writeReencryptAtRest(api:encryption:reencrypt— run id, target key id, status and counts, never a value), which refuses withfailed_preconditionwhen an envelope references a key the server lacks, when a run is already in progress, or when no encryption keys are configured. - The historical log_lines backfill control plane (
opschain admin migrate-logs):GetLogMigrationStatus(NO_SIDE_EFFECTS) plus the audited writesStartLogMigration(api:logs:migration_start),PauseLogMigration(api:logs:migration_pause),ResumeLogMigration(api:logs:migration_resume) andStartLogPurge(api:logs:purge_start). The verbs flip durable state for the server-side cluster-singleton worker; migration copies and verifies only, and the purge verb refuses until verification has recorded evidence. Deliberately NOT ported, staying on their REST lanes: the database estate export (GET /api/admin/database/export) and the support bundle (GET /api/admin/support_bundle) — archive/stream blob lanes (the exempt-blob-lane precedent);healthz/readyz/metrics(infrastructure probe and scrape endpoints);/api/admin/v1_usage(the v1-retirement telemetry itself); and the GUI's pods/deployments/clusters/node-background-task/ drain-status tabs, whose routes do not exist on the Go server (the waiver).