Skip to main content
Version: v5.6.1

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 v1 GET /api/info read, 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 ErrorInfo details carrying a named reason from the frozen ErrorReason catalogue (first entry: ERROR_REASON_SYSTEM_QUIESCED), FieldViolations for validation and RetryInfo for 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 typed ChangeFilter (status codes, node-path subtree, the step-search terms, creator, creation window); GetChange with "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/activities union) 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 the for_change_id scope.
  • 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-path include= 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 from from_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 empty step_id for the whole run, or include_child_steps for 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 with source_id/source_type/group_id (and workflow_step_id/workflow_step_path on 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_REQUIRED elsewhere).
  • 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 PostgreSQL-only read models — activities, events and the audit history — answer unimplemented with a typed ErrorInfo detail naming ERROR_REASON_STORE_POSTGRES_REQUIRED (the v1 501 posture, typed).

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-Remaining and RateLimit-Reset headers.
  • 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.

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 the api_idempotency_keys table on PostgreSQL (in process memory on the embedded store); a repeat racing its first arrival answers aborted with ERROR_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 target node_path, action, git identity, overrides/metadata/notify/typed inputs as JSON text, skip/starting-step and wait-step options, and the override_maintenance escape 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 answers failed_precondition with ERROR_REASON_CANNOT_PROGRESS. The change view's typed pausable field is true exactly while PauseChange is 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's POST /api/steps/{id}/…): execute on the step's action path; continue on an approval-gated wait answers permission_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 answers failed_precondition with ERROR_REASON_VERSION_MISMATCH carrying current/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 /admin updatable determination; a caller without it answers permission_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; TerminateQueueEntry cancels an entry's owner through its own lane (run cancel / task abort — the queue row is never deleted directly; a finalised owner answers failed_precondition); SweepQueueOrphans discards parked entries whose owner is already finalised. Terminations and sweeps audit as api:admin_queue:terminate / api:admin_queue:sweep.
  • AgentService — the agent-fleet admin plane behind the same function-level admin gate as FleetQueueService: 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_release when rendered from the enrolment-bound release's frozen manifest), the key fingerprinted, never raw), SetAgentDraining, ForgetAgent (an online agent answers failed_precondition by name), RevokeAgent, HaltAgent / ResumeAgentHalt (the per-agent emergency scalpel; a halt demands a break-glass reason), HaltAgentGroup / ResumeAgentGroupHalt (the group scalpel, reporting the severed member count), and the decision-support reads GetAgentAssignments, GetAgentActivity, GetAgentTrustReview and GetAgentGovernanceDashboard (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 the packs:agent-governance entitlement plus the funnel read determination). The lifecycle writes record the same customer-visible info:agent:* / warn:agent:* events the REST verbs record — inside the shared serving core, so the two planes cannot drift; the REST /api/agents routes 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 answers failed_precondition naming the approvals inbox), and the assets-under-management assignment verbs CreateTrustAssignment, ListTrustAssignments (NO_SIDE_EFFECTS) and DeleteTrustAssignment. The grant surfaces ride the agent-governance pack gate exactly as the REST routes do; the decisions and assignment writes record the same customer-visible info: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, answering invalid_argument with 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 appends api:changes:create / api:workflow_runs:create for the duplicate itself.
  • WorkflowStepService.ApproveWorkflowStep / ContinueWorkflowStep / RejectWorkflowStep — the workflow-step gates (v1's POST /api/workflow_steps/{id}/…), recorded as api: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 answers permission_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 document id (the v1 GET /api/properties|settings/{id} twin) or by the owner node's node_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), field for field what v1's GET /api/info serves in licence_status, with the same exposure: any authenticated principal, no admin gate.
  • LogService.FollowLogs — additionally resolves a node background task: pass the task id as both run_id and step_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 v1 node_background_task_log_lines read.

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 ranked top_* sections, hot_commits, in_flight) over a required created_after/created_before window plus the ListActivities narrowing vocabulary, with a timeline interval (hour/day/week/month, refused over 1000 buckets), an IANA time_zone for bucket boundaries and a ranked-row limit (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 typed ActivityFilter drilldown that reproduces it through ActivityService.ListActivities — the drill-down contract is the schema. PostgreSQL-backed. The sections are aggregated in SQL over the full window — GROUP BY buckets, 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 required created_after/created_before window, the window totals, the top event types and acting usernames, and an optional payload_facet (counts by data->>'<field>', e.g. applied_tier or feature). The filter is the ListEvents vocabulary verbatim — a panel's drill-down passes the same filter to ListEvents. Interval, time_zone and the 1000-bucket cap follow GetDashboard; ranked lists take limit (default 10, max 50). CLI: opschain events stats.
  • DashboardService.GetSecurityPulse — the security-pulse panel's counters over a from/to window: 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 through ListEvents. Under the packs:security-governance entitlement the response adds a posture block (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 on GovernanceService.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 sensitive credential_extractions counter (api:git_credentials:extract). Each family row carries its type_prefix — the ListEvents drill-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.

  • ActivityService additions — the filter gains ids (exact-id any-of: uuid lookup and batched trails), action and commit_sha (the dashboard drill-down vocabulary); rows gain workflow_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 v1 ui_support/step_actions contract, 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_path selects a subtree and include_archived restores archived nodes. Replaces the GUI's ui_support/node_hierarchy read 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/users write 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 as api:users:create / api:users:update / api:users:reset_password with 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 are NO_SIDE_EFFECTS, the put carries idempotency keys and correlation like every v2 write and audits as api:preferences:create|update (name and supplied version only — document bodies never reach the event lane). Writes are compare-and-set (expected_version 0 creates; a stale version answers the typed ERROR_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. See opschain 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 — CancelChange and CancelWorkflowRun take an optional reason (max 1000 characters; beyond answers invalid_argument with a reason field violation). A supplied reason is recorded on the verb's api:changes:destroy / api:workflow_runs:destroy audit event (request_body.reason) and appended to every cancelled step's audit:steps:cancel trail 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 stays cancelled via API. The step/workflow-step reject verbs already carry the equivalent message attribute.
  • Retry at the moved rev — RetryChangeRequest.refresh_git_rev (bool) re-resolves the original change's git_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 answers invalid_argument with the create lane's named refusal (the asset's template supplies the commit). v1's refresh_sha retry attribute is the upstream-parity alias driving the same engine option (RetryOptions.RefreshGitRev) — it was previously accepted and silently ignored. build_without_cache is 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 — scopes ListAuditEntries to one change's audit trail (the change's own events plus its steps' events — the same for_change predicate 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 — GetInfoResponse gains maintenance_mode, banners_json and instance_info_json, field for field what the v1 GET /api/info read serves (defaults false/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 of FollowLogs (NO_SIDE_EFFECTS, HTTP GET works): up to page_size persisted lines (server default 100, cap 1000) after from_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_cursor is 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 (empty step_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 distinct federated_group_snapshots names, 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); DeleteGroup is 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 /admin updatable determination (permission_denied on deny) and audit as api:groups:create|destroy|member_added| member_removed. Local rows in ListGroups (and the membership verbs' responses) carry member_accounts — each membership's user id, username and provider — the members-editor payload.
  • IdentityProviderService.SweepIdentityProvider — the directory sweep on an ldap-kind connection (the retired GroupService.RefreshDirectory mirror refresh's successor): one service-bind read serving eager re-key of legacy auth_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 /admin updatable determination; an unreachable directory answers unavailable having changed nothing; audited as api:identity_providers:sweep.
  • UserService.ListUsers additions — rows carry email_address (unset when the account holds none) and the request takes include_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_dormant restores them with dormant: true set (opschain docs identity, "Dormant identities"). The request's provider field keeps only one provider instance's rows, matched case-insensitively as an open string over the auth_provider vocabulary (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.GetNodeHierarchy enrichment to v1 payload parity for the GUI shell (all additive): projects carry templates[] (the project template catalog with each version's description/version/sha/rev, served when the caller may read the project's templates path) and workflows[] (the runnable workflows — unarchived with a published version, readable at each workflow's own path); projects report workflow_admin (update on <path>/workflows); every node reports changes_execute (execute on <path>/changes — the summary v1's changes list served as meta.authorisations.execute); and policy_editor: true reproduces the v1 mode — the tree narrows to the caller's policy-admin scope with ancestors kept as context, nodes carry policy_admin instead of workflow_admin, and the response carries policy_update (v1's meta.authorisations.update).
  • WorkflowService.GetWorkflowSchema and SystemConfigurationService.GetSystemConfigurationSchema — the GUI's Monaco JSON Schema documents, byte-identical to what v1 GET /api/ui_support/json_schema serves for workflow and system_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 on PropertiesService/SettingsService beside 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 ListEvents contract — opaque page_token cursors keyed on (created_at, id), stable under concurrent inserts (new rows never shift an already-minted page).
  • G14 — resumable events stream: WatchEventsRequest.from_cursor resumes a dropped WatchEvents stream: 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 the cursor that resumes exactly after it, and a ListEvents next_page_token is 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 answers invalid_argument.
  • G18 — chain pagination: ChainService.ListChainHops reads one correlation chain — the events, changes and workflow runs sharing the id, chronological, the v1 GET /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, with the node_path_start prefix filter pushed into the query. Rows are the v1 artefact serialization typed plus the owning node_path and step_id; purged rows list with their purged_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 / GetConvergedSettings and StepService.GetConvergedProperties — the converged (merged, git-inclusive) document views of v1's GET /api/changes|steps/{id}/converged_properties|converged_settings: the merged data_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 v1 action_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) and exclude_layers (a layer name, optionally suffixed _repository/_database; unknown values answer invalid_argument with 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_settings reads), addressed by node_path; the properties read also takes the v1 converge_date instant. Read on the node path plus read on its /converged_properties (/converged_settings) rule path, 404-on-deny.
  • PropertiesService.ListPropertiesVersions / GetPropertiesVersion and SettingsService.ListSettingsVersions / GetSettingsVersion — the stored version history (v1's GET /api/properties|settings/{id}/versions and …/versions/{version}): cursor-paged listings in version (default) or created_at descending order, the row view omitting each version's data document (ask DOCUMENT_VERSION_VIEW_FULL to 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's GET /api/ui_support/json_schema (model_name=properties, project_settings, node_settings — the settings read selects by kind); 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's step_tree against them, the v1 include=steps read). Read on the owning run's project, 404-on-deny.
  • ScheduledActivityService.ListScheduledActivities — the scheduled-activities index (v1's GET /api/scheduled_activities and 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 as attributes_json (the per-type payload — the WorkflowStep precedent). Rows the caller may not read (read on the owning node's scheduled_activities path) 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's POST|PATCH|DELETE /api/scheduled_activities). The schedule is a typed ScheduleSpec (exactly one of a cron expression or a one-off run_at, plus repeat): supplying it replaces both halves, the v1 explicit-null XOR switch, typed. The version pin is workflow_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 carrying action or settings_overrides_json answers the v1 validation message. A metadata document carrying the reserved top-level opschain key is refused with the v1 message. Authorised like v1: create needs read on the parent node plus update on its scheduled_activities rule path; update/delete need read plus the verb's action on the owning node's scheduled_activities rule 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_assignments read, server-shaped), visible per the policy-read verdict; ListAuthorisationPolicies rows gain created_by/system/timestamps, the response gains can_manage (the v1 meta.authorisations.update flag) and AUTHORISATION_POLICY_VIEW_WITH_ASSIGNMENTS adds 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 the securing-rule management verdict (a superuser, or a rank-1 securing prefix granting update), 404-on-deny. An update replaces the policy's rule set wholesale only when the request carries one (AuthorisationRuleSet present; an empty set clears every rule; absent leaves the links alone), and ReplacePolicyAssignments replaces the full assignment set, the v1 contract (the GUI removes an assignment by resending the remainder). Assignments are per-identity: PolicyAssignmentInput takes a user_id beside username (a username held by accounts of more than one provider is refused as ambiguous), and the served PolicyAssignment carries the stored user_id with username/auth_provider for display — the GUI's identity pickers key on it. System policies refuse modification by name. Audited as api:authorisation_policies:create| update|destroy and api:policy_assignments:update.
  • AuthorisationPolicyService.ListAuthorisationRules / GetAuthorisationRule — the standalone rule catalog (v1's GET /api/authorisation_rules[/{id}]), id-ascending, visible per rule (the v1 AuthorisationRule.authorized scope — an unreadable rule never lists, an unreadable get answers not_found); standalone rule rows carry their linked policy_ids. CreateAuthorisationRule / UpdateAuthorisationRule (wholesale replace, the v1 PATCH/PUT contract) / DeleteAuthorisationRule are the standalone rule writes, gated like every authz-config write on the securing-rule management verdict, 404-on-deny, audited as api:authorisation_rules:create|update| destroy. A rule may carry the step-up bits — require_step_up demands an elevated session on the rule's matched write-shaped actions (update/delete/execute) across the whole v2 surface, step_up_on_read opts its reads in too; setting a bit requires packs:security-governance, and an unmet demand refuses with the floor's typed ERROR_REASON_STEP_UP_REQUIRED naming 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 as api: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), its owning area and an honest enforcement class — ENFORCED (the server denies without the grant) or ADVISORY (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's PUT /api/licence multipart, carried as bytes, 1 MiB cap): an Ed25519 envelope verifies and stores through the entitlement service (which appends its own api:licence:applied event) answering the applied document plus the post-apply licence_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 /licence rule path — the one v1 licence lane that denies 403, carried over as permission_denied with the v1 message.
  • LicenceService.RemoveLicence — removes one stored entitlement document by licence id (v1's DELETE /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-removal licence_status; an unknown id answers not_found. Authorised like the upload (update on /licence, permission_denied with the v1 message); the entitlement seam appends its own api:licence:removed event, exactly as v1.
  • PropertiesService.UploadFileProperties — the file-properties upload (upstream's POST /api/properties/{id}/file_properties, a lane v1-Go never ported): the uploaded bytes serialise per format (raw / base64 / parsed JSON) into the document's opschain.files["<file_path>"] entry, recording a new version through the shared document writer (compare-and-set against the read version). Supplying secret_path/secret_key stores 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 v1 Vault value already exists … title (the GUI probes that prefix to offer the overwrite retry) — and records the secret-vault:// reference. Validation refusals (invalid path/mode/format, binary without base64, duplicate path without replace_file) carry the upstream command's titles verbatim. Audited as api:properties:create with 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:

  • WorkflowService grows the full authoring lane beside its schema read: ListWorkflows (code ascending, optional archived filter, summary/full views; each row carries summary, the resolved one-liner — the stored explicit summary, else the description's first sentence, ellipsis-truncated at 120 characters) / GetWorkflow / CreateWorkflow / UpdateWorkflow (with create_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_properties with property_overrides_json, publish via draft: false) / DeleteWorkflowVersion / DeleteWorkflowDraftVersions. Authorised as v1: reads need project read; creates update on the project's /workflows collection path and the new workflow's own path; updates/deletes update/delete on the workflow's path. Audited as api:workflows:* / api:workflow_versions:*. The permanent-destroy lane rides beside the soft delete: GetWorkflowDestroyManifest previews 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), and DestroyWorkflow performs it — valid only on an archived workflow, taking runs with it only under with_runs, events never touched (the destroy seam records api:workflows:destroyed itself). 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_credentials rule path; the global scope on the /admin updatable 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 supplied client_secret rotates the stored one; a supplied client_secret_ref re-pins the reference) / DeleteIdentityProvider / TestIdentityProvider (discovery + JWKS probe for the oidc kind, the API /meta read for github, a service bind + user search for ldap — reporting detected_entry_id_attribute; outcome in the response). Every procedure gates on the /admin updatable determination. Custody is reference-first: the record carries client_secret_ref, a secret-vault:// reference pinned at write time (an unresolvable reference refuses by name), and an inline client_secret is vault-write sugar (the server stores it under identity-providers/<code>/credential and pins the reference); reads serve client_secret_set plus the reference, never a value, and connections created before the retrofit keep their inline encrypted secret until the next secret write. code is the fixed callback path segment (/auth/login/{code}, /auth/callback/{code} — plain HTTP legs, not RPCs, plus the unauthenticated GET /auth/providers sign-in button read — enabled connections ordered by display_order ascending, code ascending, each row carrying code/kind/display_name/ display_order); display_order is the admin-edited sign-in surface rank (non-negative int, default 0); kind is oidc, github (the GitHub App adapter: host names the instance, empty meaning github.com; a non-github.com host — GHES — is refused without the Enterprise licence; step_up is hard-wired to platform) or ldap (directory_url + require_tls — a plaintext ldap:// URL is refused unless require_tls is explicitly disabled — bind_dn with the bind password on the client_secret seam, user_base/user_attribute/users_filter, group_base/group_attribute/groups_filter, mail_attribute, entry_id_attribute — empty detects entryUUID → objectGUID; step_up hard-wired to platform; no callback leg — sign-in is POST /auth/login/{code} with JSON/form username+password and optional use_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-derived https://login.microsoftonline.com/<tenant id>/v2.0 issuer) buttons ride identity:builtin (Basic defaults, baseline-unioned), any other OIDC issuer rides identity:sso, and the ldap kind is base platform — never identity-gated, like local login; a missing feature refuses permission_denied with ERROR_REASON_FEATURE_ENTITLEMENT_REQUIRED metadata naming the feature and the effective tier. The client secret is write-only end to end: stored encrypted, responses carry only client_secret_set. Audited as api:identity_providers:*. Group→role mapping: ListIdentityProviderGroupMappings (optional provider filter, by id or code) / CreateIdentityProviderGroupMapping / DeleteIdentityProviderGroupMapping — one entry per {provider_group → role | group_name} grant, exactly one target per entry (role accepts admin today), creates gated on identity:sso except 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 as api: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. See opschain 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 supplied credential rotates 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_error on every read). Every procedure gates on the /admin updatable determination. code is the estate identifier (never a key); kind is openbao or hashicorp-vault (one HTTP API today, distinct kinds by design) with a per-connection kv_version (1|2); auth_method is token, userpass or approle, the method's secret half riding the write-only credential seam (encrypted at rest; reads serve credential_set) — or pinned as a provider-qualified secret-vault://<code>@<path>/<key> credential_ref naming 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 are mount_path, ssl_verify/ssl_ca_cert, cipher_stacking and password_length. Multi-vault: the first enabled provider is base platform; enabling a second refuses permission_denied with ERROR_REASON_FEATURE_ENTITLEMENT_REQUIRED naming secrets:multi-vault unless the entitlement is granted. The binding verbs route resolution between enabled providers: ListSecretsProviderBindings (scope ascending, each row joining the bound provider's enabled state and last_test_* health — the posture view) / SetSecretsProviderBinding (upsert by scope_path — /, /projects/<code> or /projects/<code>/environments/<code>; the provider must be enabled; gated on secrets:multi-vault; audited api: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). DeleteSecretsProvider refuses failed_precondition while bindings reference the provider. The composition is live — provider AND binding writes re-resolve resolution with no restart. There is deliberately no display_order (providers never render as an end-user picker; listings order by code). Audited as api:secrets_providers:*, never carrying a credential. The retired vault.* 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 supplied credential rotates the stored API key) / DeleteModelProvider. Every procedure gates on the /admin updatable determination; create and enable additionally require packs:ai-agents (ERROR_REASON_FEATURE_ENTITLEMENT_REQUIRED names it; the polite-lapse posture — stored records keep serving). code is the estate identifier agent manifests reference (model_provider) and the agent-plane model gateway path carries (/api/model/{code}/…); kind is anthropic or openai, picking the default endpoint and the credential injection form. Custody is reference-first: the record carries credential_ref, a secret-vault:// reference pinned at write time (an unresolvable reference refuses by name), and an inline credential is vault-write sugar (the server stores it under model-providers/<code>/credential and pins the reference); reads serve credential_set plus the reference, never a value, and records created before the retrofit keep their inline encrypted key until the next credential write. Audited as api:model_providers:*, never carrying a credential. The gateway itself is agent-authenticated (the session bearer), not part of this admin service — see opschain 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 supplied secret rotates the stored signing secret) / DeleteInboundWebhookConnection. Every procedure gates on the /admin updatable determination. A connection defines the signed endpoint POST /api/hooks/<code> external systems fire triggers through: service_account (the identity every resulting trigger firing executes as), replay_window_seconds, rate_per_minute, enabled and the write-only HMAC secret (at least 16 characters; reads serve secret_set and hook_path, never a value). Audited as api:inbound_webhooks:*, never carrying the secret. The hook endpoint itself is signature-authenticated, not part of this admin service — see opschain 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 supplied secret rotates the stored Splunk HEC token; set_event_filter replaces the prefix filter) / DeleteSiemConnection / TestSiemConnection (one synthetic info:siem:test event through the real sender). Every procedure gates on the /admin updatable determination. A connection pushes the event stream to a customer SIEM — kind splunk_hec (HEC JSON envelopes, write-only token; reads serve secret_set, never a value) or syslog (CEF over udp/tcp/tls) — with batched at-least-once delivery behind a durable cursor and per-connection health (last_delivered_at, lag_seconds, consecutive_failures, last_error). Audited as api: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, the packs:<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; name filters to one pack) / PinPackVersion (marks the version an unversioned packs.yaml declaration resolves to — rollback is a re-pin; empty version clears 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 unversioned installed: declaration, moving only that pack pin — explicit version: declarations and already-sealed versions never move; dry_run reports the set without cutting, queue_asset_upgrades moves the assets through the standard assignment flow under per-asset authorisation). The version inside the signed manifest is the authoritative copy — filenames and tags are decoration. Every procedure gates on the /admin updatable determination; writes are audited api:packs:*. See opschain docs cli/admin-packs.
  • 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 on last_test_*). Every procedure gates on the /admin updatable determination; create and enable additionally require packs:rpc (ERROR_REASON_FEATURE_ENTITLEMENT_REQUIRED names it; the polite-lapse posture — stored records keep serving). kind is ssh-static or ssh-ca; config_json carries connection facts only (for ssh-ca plus the minted certificate's policy: principals / username, validity_seconds) — the ssh-ca credential 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 stores credential_ref, a secret-vault:// reference (provider-qualified secret-vault://<code>@<path>/<key> included) pinned at write time — an unresolvable reference refuses by name — and an inline credential is vault-write sugar (the server stores it under transport-providers/<code>/credential and pins the reference). No response, audit row or error ever carries credential material. Audited as api:transport_providers:*. Use-time resolution and session dispatch belong to the step-dispatch seam, not this service. Bindings ride the same service: ListTransportProviderBindings / SetTransportProviderBinding / DeleteTransportProviderBinding manage the declared scope-path → provider mapping (deepest covering scope wins; / is the estate default), and ResolveTransportProviderBinding answers selection provenance for a node path (the single-enabled provider when nothing binds; several enabled and no binding refuse by name). Set gates on packs:rpc, delete stays ungated, writes audit as api: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 on last_test_*). host is github.com (the default) or a GitHub Enterprise Server host — immutable, and self-hosted hosts gate on the github_self_hosted_instances limit (ERROR_REASON_LICENCE_LIMIT_EXCEEDED; 0 on Basic — the tier boundary). Custody is reference-first: the record stores private_key_ref, a secret-vault:// reference pinned at write time, and an inline private_key is vault-write sugar (stored under github-connections/<code>/credential); no response, audit row or error ever carries key material. Trust policies: ListGitHubTrustPolicies / GetGitHubTrustPolicy / CreateGitHubTrustPolicy (gates on the feature and the github_trust_policies limit; service_account must resolve to a service-account identity) / UpdateGitHubTrustPolicy / DeleteGitHubTrustPolicy (never gated). Every admin procedure sits behind the /admin updatable determination; writes audit as api:github_connections:* / api:github_trust_policies:*. ExchangeGitHubActionsToken is served OPEN — the presented Actions OIDC token is the credential: verified against the connection host's issuer and the connection's oidc_audience, matched to the first enabled trust policy (name ascending) covering the token's repository/ref/environment claims, gated on the feature and the github_token_exchanges_per_day cap (refusals named and audit-evented as warn:github:exchange_refused), and answered with a short-lived github-actions-audience access token acting as the policy's service account (no refresh — the pipeline exchanges again). Every mint lands info:github:token_exchange and one exchange-register row. AttestGitHubRun (authenticated — typically the exchanged token) records a GitHub-side outcome as one info:github:attestation event 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 as api:github_connections:provision_webhook, and a second provision refuses by name. Connections carry installation_id (the JIT GH_TOKEN mint target; 0 = unset, minting refuses by name) and the display-only webhook_connection_code join.
  • JsmIntegrationService — the Jira Service Management integration (integrations:jira-service-management; opschain docs integrations-jsm): JSM connections, the jsm:request / jira:issue typed-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 on last_test_*). base_url is an Atlassian cloud site or Jira Data Center http(s) base URL; auth_email set means cloud basic authentication, empty means a Data Center PAT sent as a bearer; project_keys scope outbound writes (a request outside them refuses by name). Custody is reference-first: the record stores api_token_ref, a secret-vault:// reference pinned at write time, and an inline api_token is vault-write sugar (stored under jsm-connections/<code>/credential); no response, audit row or error ever carries token material. Every procedure except ListJsmRequestLinks sits behind an updatable determination on its per-verb rule path under /admin (/admin/integrations/jsm/connections for the connection surface; /admin/integrations/jsm/requests/comment|transition|fields|resolve for the write door — a plain /admin rule covers them all by prefix, a narrow rule grants exactly one verb); connection writes audit as api:jsm_connections:*. ListJsmRequestLinks lists the jsm/jira typed 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; optional external_id / system filters). 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 + a fields_json object) and ResolveJsmRequest (records the jsm:request reference with relationship resolves on 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 one info: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 as api: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, the refresh_git_rev retry, template-version create/sync); a denial is a named validation error plus a warn:security:git_remote_refused event. ListGitRemotePolicies / GetGitRemotePolicy / PutGitRemotePolicy (create-or-replace; replacing a seeded entry clears its upgrade-grandfathering marker) / DeleteGitRemotePolicy answer to security administrators (project delegates within their projects), auditors (reads) and superusers, 404-on-deny. GetGitRemotePolicyMode / SetGitRemotePolicyMode carry the estate-wide allow-all switch (set: global security administrator or superuser only). ListSecurityRoleGrants / GrantSecurityRole / RevokeSecurityRole manage the local (break-glass) grant lane of security_administrator and auditor — global-security-admin territory; granting auditor additionally requires packs:security-governance. Writes carry the step-up floor, invalidate the authz snapshot and audit as api:git_remote_policies:* / api:security_role_grants:*. Remote-serving reads elsewhere (GitRemoteService) annotate each remote with the caller's per-user usable verdict, and remote create/update answer warnings when no entry covers the configured remote.
  • TransportGrantService — transport grants, the authority layer over the catalog (the trust-grant mould): ListTransportGrants / GetTransportGrant read the registry, CreateTransportGrant proposes 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 lands pending and opens a transport_grant checklist in the one approvals inbox (approvers required); quorum completion activates it, rejection and quorum-TTL expiry revoke it, and ApproveTransportGrant / RejectTransportGrant refuse while that ceremony is open (they serve the base posture without the fabric). RevokeTransportGrant is terminal and never entitlement-gated. Every procedure gates on the /admin updatable determination, grant writes carry the step-up session floor, create/approve require packs:rpc (polite lapse: stored grants keep serving), and writes audit as api: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. ListInfrastructureNodes lists 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); GetInfrastructureNode serves one node with its own properties document. RegisterInfrastructureNode creates 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 *_ref keys and credential material refuse by name (connection custody belongs to transport providers; a host carries rpc.provider_ref, a provider reference, never a secret). References name sibling infrastructure at the same level or above (a hypervisor's host_ref, a device's mgmt_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. BulkUpsertInfrastructureNodes lands 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 are RegisterInfrastructureNode and DeregisterInfrastructureNode — 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). The provisioned/destroyed states stay in the schema as reserved slots for the integration that performs those operations; no RPC here writes them. RecordInfrastructureFacts lands discovered facts under the properties document's facts key and stamps last_facts_at, so every read renders freshness as last-observed — OpsChain records what verbs did and observed; it is not a monitor. 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's infrastructure rule path for creates), 404-on-deny. Audited api:nodes:create/update for CRUD and api: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), and estate('<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.
  • SecretService user-lane additions: the interactive read lane never rides the platform vault credential — GetUserVaultCredential / SetUserVaultCredential / DeleteUserVaultCredential manage the CALLER's own per-provider credential (own-rows: requests carry no user; the principal is the key; token or userpass, encrypted at rest, write-only — reads serve credential_set; keyed user x provider so multi-vault inherits; writes audited as api:user_vault_credentials:*). ResolveSecrets and ResolveGlobalSecret — 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 refuses failed_precondition with ERROR_REASON_USER_VAULT_CREDENTIAL_REQUIRED naming the provider; the vault's own ACL denials surface as the embedded ERROR: 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 — ResolveSecrets by its owner node, ResolveGlobalSecret by a supplied vault_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 explicit provider selection wins over the binding. EncryptSecret is deliberately un-floored and un-audited. The platform lane (step-context resolution, runner env injection, step-scoped principals) is untouched. The vault_owner_id on ResolveGlobalSecret and the store verbs is optional (the generic reader submits none): absent, ResolveGlobalSecret is governed by the global /secrets executable rule alone (nothing ever consumed the node) and the store verbs by the global /secrets updatable rule alone; a SUPPLIED id must still name an existing node, and owner-present stores keep the ancestor secrets-rule-path grants. ListVaultEntries is the governed vault browser's read: one level of the KV tree under an optional prefix (KV v1/v2 per the provider's kv_version; a trailing / marks a subdirectory), and a prefix that is a secret LEAF answers its top-level field names as keys instead (the KV model's keys, e.g. username/token inside demoenterprise/foo — names, never values; both entries and keys empty with denied false 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 answers denied: true, a verdict rather than an error) — names only, so no step-up floor, audited by prefix (api:secrets:vault_list). The optional provider code must name the enabled default until multi-vault support arrives.
  • MetadataService — the node metadata & classification framework (the model reference is opschain docs metadata, the per-RPC gate table opschain 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-owned x-<org>.* namespace; non-x- families are reserved for the base core.* 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→new value_mappings; field types never change in place; the core profile's schema is base-owned and refuses edits). The canonical core.* 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 — all subtree-inherited. GetNodeMetadata (node read authz, 404-on-deny: the node's own values with staleness plus the EFFECTIVE view — per-field inherit: none|subtree resolved 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 explicit unclassified selector, and the date ops before/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 joins owner — the node's effective owner from the /governance/owners fabric, 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 is permission_denied. Writes touching a protective field (core.criticality, core.data_sensitivity, core.external_exposure) 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 answer not_found.
  • GitRemoteService — ListGitRemotes (name ascending, the default_periodic_fetch_interval the 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: 0 clears the override). add_known_host stays 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 answers unavailable with ERROR_REASON_SERVER_NOT_CONFIGURED and the named v1 detail verbatim (the v1 503).
  • 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, plus ListTriggerFirings (typed status/since filters), TestTrigger (the dry run, NO_SIDE_EFFECTS) and DiscardTriggerFiring (read on the trigger plus update on the deferring window's node /maintenance path — the v1 discard lane). A run_as write re-crosses the act_as trust-grant authority on every touch; the accepted artefact spellings normalise to the canonical artifact at the edge. Audited as api:triggers:create| update|destroy|discard.
  • ActionService — ListActions (top-level catalog entries, full_path ascending, children only with include_children) and GetAction (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 as entry_json — the recursive open action-tree shape is served as stored, never lossily retyped. A non-asset node answers failed_precondition with the v1 message.
  • MintModelService — ListMintModelHistory (newest first) / GetMintModel (the stored concretised document, render_logs_json, phase_output_json) / GenerateMintModel — the v1 GET …/mintmodels/generate lane 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_debug opts the one run into the debug render — the render captures the rendered template and resolved properties into the task's render_logs/phase_output at completion, masked; per-run, never sticky. Audited as api:mintmodels:generate (v1's GET recorded nothing — a deliberate v2 tightening).
  • NodeArtifactService — the scoped artifact reads beside the estate-wide ArtifactService index (artifact-spelled amendment 2): ListNodeArtifacts (exactly one of node_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_url points at the authenticated v1 download endpoint; a purged artifact answers failed_precondition with the v1 retention message, the 410 twin). Every row gates on the owning node's /artefacts rule path.
  • NodeService.CompareNodes — the GUI compare dialog's read (v1 POST /api/compare_nodes): both paths resolve under the caller's read scope, and the response carries the v1 node_comparison attributes document as comparison_json (nodes/properties/settings/mintmodel/environments/ assets, each a {node_a, node_b} pair) via the shared apiv1.NodeComparison assembly, converge failures answering invalid_argument with the v1 titles.
  • NodeService lifecycle 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 v1 nodeLifecycleAllowed pair): RestoreNode (the full undo of a soft delete, answering the restored root; the lifecycle writer appends api:nodes:restored itself), 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:destroyed from the writer), and ExportNode (the credential-free export zip in-message, with_history adding the document version trails). Restore and the destroy pair act only on a soft-deleted subtree root — a live node answers invalid_argument with the v1 two-step message verbatim.
  • ArtifactService.UploadArtifact — the v2 twin of v1's multipart POST /api/artifacts, addressed by node path, carrying the content in-message (download bytes stay on the exempt blob lane via GetArtifactDownloadTarget). Authorised like v1 (read on the node plus update on its /artefacts rule path, not_found on deny), validated by the shared artefact writer, audited api: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, typed archived filter), GetProject, CreateProject (update on /projects plus the new path, api:projects:create), UpdateProject (unset fields preserve; archived toggles 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 v1 meta.authorisations pair for the governing rule path; rows carry changes_execute and the owned properties/settings document ids.
  • EnvironmentService — ListEnvironments(project_code) / GetEnvironment(node_path) / CreateEnvironment / UpdateEnvironment / DeleteEnvironment, the same postures over the node-path lane (audited api:nodes:create|update|destroy, the v1 event family).
  • AssetService — ListAssets(parent_path) serves flat rows that carry the pinned template identity (template_code/template_version/ids — the v1 include=template,template_version read, server-shaped) and task_active.5 each row also carries the pinned version's template_version_state/template_version_has_mintmodel and its latest_background_tasks (the v1 asset list's include=latest_node_background_tasks read, 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's template_version_history windows newest-active-first — the v1 node-path version_history index 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 index meta.authorisations at the node's …/version_history rule path. CreateAsset carries template_code/template_version and keeps the v1 parent gates, refused by name: assets need an Enterprise project, so a create under a Standard project answers invalid_argument naming the project, its type and the recovery (the type is immutable — recreate the project with project_type: enterprise); UpdateAsset's template_version repoints the pinned version through the same history-rolling writer v1 uses.
  • TemplateService — ListTemplates(project_code) (name-ascending, per-row read on each template's own rule path; the typed filter carries archived and template_type) and GetTemplate(id). Every template get carries its git remote relation (the equivalence ruling: the v1 include=git_remote contract, secret-bearing fields masked exactly as the v1 git_remotes show — {{hidden}} for a stored value, never the value itself); TEMPLATE_VIEW_FULL adds the version rows (newest first) on lists and gets, the pinned nodes on the get (the v1 include=nodes read), and the template row's pinned_node_ids (the v1 relationships.nodes refs with unreadable nodes dropped — TemplateSerializer#authorized_nodes). CreateTemplate / UpdateTemplate / DeleteTemplate keep the v1 authorisation split (create/delete on the project's …/templates collection path, update on the template's own path) and refusals (in-use archive/disable, the referenced-template delete), audited api:templates:create|update|destroy. ListTemplateVersions (newest first; typed archived/state/ created_after — the asset screen's new-version probe) and GetTemplateVersion(template_id, version) address versions by label; UpsertTemplateVersion creates-or-updates by label with the v1 payload (git_rev, description, archived, locked, float_git_rev) and meta flags (ignore_in_use, fetch_revision), audited api:template_versions:update. DeleteTemplateVersion(template_id, version) (the "port both verbs" ruling — DestroyTemplateVersionCommand semantics): 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 answers archived: true with 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, audited api:template_versions:destroy. CancelTemplateVersionRefresh( template_id, version) (CancelTemplateVersionRefreshCommand semantics): a version not initializing refuses 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 v1 info|error:template_version:refresh_cancelled event; update on the template's rule path, audited api: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 upstream GeneratePropertiesHistoryCommand port; 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's template_versions, so level values 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:

  • Change rows 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 through node_path on the path-addressed estate services). GetChange additionally serves the show-only wait_queue_json/blocking_queue_json meta ("[]" when empty); list rows and write echoes leave them empty.
  • Step rows 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 v1 GET /api/steps read typed: the filter's waiting_for_user is the v1 scope the manage-activity screen sends, each page row's owning change folds into the response (the v1 include=change graph, deduplicated), and rows authorise per the v1 list (read on each step's change target — an unreadable step never appears).
  • WorkflowRun rows gain inputs_json, paused_by_json, cancelled_by_json and the relationship ids (override_properties_id, parent_workflow_step_id, root_step_id); GetWorkflowRun serves the show-only wait_queue_json/blocking_queue_json meta ("[]" when empty — this server's v1 run show serves no computed queues, so the v2 twin carries the same).
  • Activity rows gain the change arm's git identity (git_remote_name, git_rev, commit_sha), both arms' metadata_json and paused_by_json, the workflow arm's workflow_version number, and the background-task arm's task_type/request_number/error_message (the row id is the task id — the ReadLogLines NBT handle). Parent template identity stays on the ACTIVITY_VIEW_WITH_PARENTS trail.
  • EventFilter.payload_equals — typed payload-field equality (the v1 dynamic data ->> 'field' ransacker): the fetch-revision polls' template_version_id/in_progress predicates, compared as the field's JSON text. Event rows gain source_url, the resolved source link the v1 event show's source relationship carries (beside the existing source_type/source_id resource identifier).
  • CurrentUser gains the /api/me notification-preference attributes (email_address, default_notification_methods_json, direct_message_ids_json) resolved from the principal's user record, and directory_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):

  • Step rows 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 published outputs map (sensitive values always serialize redacted). Served wherever a Step row travels: the step show, ListSteps and the CHANGE_VIEW_FULL include graph.
  • Change rows gain the v1 run-option attributes: notify_json, skip_steps_json, starting_step_json (stored JSON documents, "" when absent), auto_continue_wait_steps and paused_at (unset when the change was never paused).
  • WaitingGate rows 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_id and the executed version's workflow_version number, with run_display_name now resolved through the version row (the v1 include chain's source; the workflow read stays as the fallback).
  • ActivityFilter gains 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 (the metadata_text_cont ransacker), the strict updated/started/finished_after/_before root-step time windows (a NULL timestamp matches no bound), workflow_versions (the version_in set; excludes every change row) and scheduled_activity_id (the uuid-cast _eq: case-insensitive, an unparseable value matches nothing). ListActivitiesResponse gains 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 exactly created_at desc and updated_at desc (anything else is a 400), which ActivityOrder already 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 — v1 POST /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 — v1 POST /api/secrets/global: fetch and decrypt one vault value by its secret-vault:// URL. expected_vault_value is 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: true returns 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); provider selects any enabled secrets provider under multi-vault, the default provider when blank. Superuser, or executable on /secrets. NO_SIDE_EFFECTS.
  • SecretService.EncryptSecret — v1 POST /api/secrets/encrypt: cipher-stack a string for the caller (the transportable key when configured, else the universal key); blank values and secret-vault:// references return unchanged. Superuser, or updatable on /secrets. NO_SIDE_EFFECTS (pure computation).
  • SecretService.StoreSecret — v1 POST /api/secrets/store: store a value (or a generated one when vault_value is unset) under a secret-vault:// URL, encrypted per the vault cipher-stacking policy; an existing value refuses with the v1 "Vault value already exists" title unless replace_secret is set. Superuser, or updatable on the owner node's ancestor secrets rule paths (or /secrets).
  • SecretService.StoreSecretFile — v1 POST /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 as StoreSecret.
  • SecretService.GetSecretGrants — the caller's global secret authorizations (global_encrypt, global_decrypt): the exact server-side rules EncryptSecret and ResolveGlobalSecret apply, resolved against the /secrets rule 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 v1 GET /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 with ended (bounded by ended_since); node_path scopes 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 (the TriggerFiring rows), read on the window node's …/maintenance rule path. NO_SIDE_EFFECTS.
    • StartMaintenance / ScheduleMaintenance / ExtendMaintenance / EndMaintenance / CancelMaintenance — the v1 node-scoped verbs: node readable plus update on its …/maintenance rule path; the v1 validation titles arrive as typed field violations (shared apiv1.ValidateMaintenance*Payload validators), and the api:nodes:maintenance_* lifecycle events are emitted by the shared writer, whichever facade the verb came through.
  • TaskService — the typed twins of v1's node-scoped node_background_tasks endpoints (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 through LogService.ReadLogLines with the task id as run and step id. The Go v1 serializer never served render_logs on a task (a failed concretise distils its cause into error_message), so the Task view carries none either — exact exposure parity. NO_SIDE_EFFECTS.
    • CreateTask — the manual actions refresh (node read + update); the api:generate_actions_request:create event rides the shared lane. enable_debug opts the one run into the debug render (masked render_logs/phase_output at 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.
  • ChainService growth:
    • ListChains — the chain explorer's index (v1 GET /api/chains): summaries inside a required, max-window-clamped time window (since, refused beyond the chains_max_window_days setting), narrowed by node-path prefix, origin and has_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_paths narrows to the readable set. NO_SIDE_EFFECTS.
    • GetChainTriage — the server-side triage digest (v1 GET /api/chains/{id}/triage G24/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 (v1 GET /api/system_configuration): its identity, its settings/properties document ids and the caller's authorisation verdicts (the v1 meta.authorisations object, 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 include attribute_predicates: {field, pattern} pairs the engine applies to already-matched candidates — action_path is 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* covers web: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, or webhook bound to an admin connector — see below; deleting a route falls its subscriptions back to the in-app default). A webhook route's config_json must name an enabled connector's connector_id, a url inside that connector's allowlist, and optionally a template (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_key filters) / 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 a thread_key view 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_cursor replays 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 carries can_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_denied for a member without it; a row the caller could not even see answers not_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_app opts 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 + allowlisted url + template); webhook is 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 typed ERROR_REASON_MANDATED_SUBSCRIPTION refusal 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 the failed terminal 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 audits api:notification_preferences:update. The preference governs only the implicit my-activity arm — approvals-waiting notifications keep flowing even at off, 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 (kind webhook today). 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 only signing_secret_set; a keyless server answers the typed ERROR_REASON_SERVER_NOT_CONFIGURED unavailable). 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 with delivered: 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 (their mandate_id clears). Audited as api: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 answers failed_precondition on the Basic tier — its retention is fixed). The write lands a new global-settings version (notifications.feed_retention_days / evidence_retention_days); audited as api:notification_retention:update. See reference/resources/notification for 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, the api:auth:* audit vocabulary), establishing a session row and answering the session plus its tokens. The quiesce split is preserved verb-for-verb: Login refuses while the system is quiesced, Refresh passes.
  • 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 validated aud claim (api; the audience vocabulary is shared with the step tokens and future github-actions exchange) plus amr/acr authn-method and strength claims. TTL: the stored token.access_expiry when 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 emits warn:auth:refresh_reuse. Idle expiry follows token.refresh_expiry (7d default); the session's absolute bound is token.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 with use_cookie — in the HttpOnly, Secure, SameSite=Strict opschain_refresh cookie scoped to the AuthService procedures (honoured while token.enable_cookies allows the channel), guarded by the X-Requested-With CSRF 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 with X-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 as api: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 /admin updatable 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 as api: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 as api: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 and otpauth:// 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 as api: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-sliding mfa.elevation_window (minutes, default 15) and raising the session's acr to totp. Sessions of idp-mode OIDC instances elevate through GET /auth/login/{code}?step_up=<session id> instead (IdP re-auth with prompt=login/max_age=0, auth_time validated). Like Refresh, Elevate passes a quiesced estate. Audited as api:auth:step_up; failed proofs as warn:auth:step_up_failed. 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 refuse failed_precondition with the typed ERROR_REASON_STEP_UP_REQUIRED detail (metadata: verb, reason, mechanism, and provider on the idp mechanism), or ERROR_REASON_MFA_ENROLMENT_REQUIRED when no factor is enrolled. Break-glass principals and sessionless machine credentials are exempt; every refusal is audited (warn:auth:step_up_refused). See opschain docs identity, "MFA factors and step-up".
  • ChangePassword — the self-serve password change (the v1 POST /api/users/me/password twin, 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) as invalid_argument; the substrate records api:users:password_changed itself (never doubled by the handler), and no password value ever reaches an event, a log line or an error message. opschain passwd rides it.

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 by id, or by username optionally disambiguated by provider (the open auth_provider match). A username held by more than one provider without a selector refuses invalid_argument naming the matching providers (the v1 admin show's 422 posture); an unknown lookup answers not_found. Self-or-admin gated: a caller holding the /admin updatable 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's groups is 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's meta.superuser), default_notification_methods_json, direct_message_ids_json and updated_at. opschain admin users show and opschain users show ride it.
  • User superset — the User message carries the v1 admin row's facts beside the picker fields: break_glass, disabled_at and local_groups. On ListUsers they are populated only for callers holding the /admin updatable determination (the v1 posture: the picker read never served them); every other caller keeps the picker fields alone. opschain admin users list rides ListUsers with include_dormant on by default, marking dormant rows (--active-only hides them).
  • The admin groups CLI verbs ride GroupService (list/create/delete/add-member/remove-member), resolving ergonomic username arguments to user ids through GetUser (opschain docs cli/admin-groups).
  • AuthService revocation routing: opschain tokens revoke <id> classifies the id against the caller's own inventory and routes RevokeAPIKey or RevokeSession; tokens list gains --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 through GetUser). 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 reports sessions_revoked plus an owned_automations summary: the triggers and scheduled activities the user's identity EXECUTES — rows naming them run_as, plus rows they created with no explicit run_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=true restores them (disabled_at marks them for admin callers). By contrast, the GUI users screen rides the hide-by-default feed behind a "show disabled users" toggle, while opschain admin users list passes include_disabled=true and keeps showing disabled rows with the DISABLED column marker. Service-account rows list as ordinary identities (provider service_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 the owned_automations summary shape for what moved. Idempotent: a second call moves nothing. CLI: opschain admin users reassign-automations.
  • Scheduled activities carry run_as and owned_by on the ScheduledActivityService write verbs: run_as is 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_by must name a known group. TriggerService writes gain owned_by with 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's POST /api/changes/plan and /api/workflow_runs/plan. The request embeds the create message (create); request_metadata and 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 typed RunPlan tree (opschain/api/v2/plans.proto): per-step verdict (will_run/will_skip/symbolic), evaluated operands, resolved command, tier-2 unresolved_expressions, estate_reads, fan_out and recursive children, with the advisory sentence stamped on every plan. Both are NO_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_maintenance on CreateChangeRequest, RetryChangeRequest, CreateWorkflowRunRequest and RetryWorkflowRunRequest — 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 …/maintenance rule path.
  • EventFilter.for_workflow_run_id — the for-run twin of for_change_id: the workflow run's own events plus its workflow steps'. v2-only (v1 exposes no workflow-run scope); it feeds runs show --events for 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 (the GET /api/platform/posture twin).
  • 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/status twin), GetDispatchQueue (the /admin/queue overview — per-entry actions live on FleetQueueService), ListActiveChanges, GetSystemOverview, GetConfigCatalog, ValidateConfig (a pure check of a candidate config document), GetDatabaseStatus, ListMigrations, GetMigrationStatus, GetBackupInfo, and VerifyRestore — 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 — all or explicit versions).
  • The historical log_lines backfill control plane (opschain admin migrate-logs): GetLogMigrationStatus (NO_SIDE_EFFECTS) plus the audited writes StartLogMigration (api:logs:migration_start), PauseLogMigration (api:logs:migration_pause), ResumeLogMigration (api:logs:migration_resume) and StartLogPurge (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).