OpsChain GA Release 5.7.0
OpsChain 5.7.0 is a generally available release, published on 2026-11-12. Every release is listed on the releases index.
Security
-
Go 1.27.4 (govulncheck GO-2026-6859): the toolchain moves to Go 1.27.4 for the
net/httpfix to the HTTP/1 server crash inMaxBytesReader, which the v2 surface now relies on for its request-body bound;go.modand the build image follow. Found by the release train's scanner step on the cp45 tree. -
A PostgreSQL server refuses to start without a token secret: when neither
OPSCHAIN_TOKEN_SECRET_KEY(environment orkeys.env) nor the storedtoken.secret_keysupplies a secret,server starton PostgreSQL now refuses by name instead of composing with no authentication stack and serving every caller as the superuser; a settings read that fails at boot refuses the same way, never opens. The open posture survives only as the explicit test-lane declarationserver.unauthenticated_postgres: true(config catalog, template and dictionary), refused by name undersecurity.posture: hardenedand named loudly in the boot notice. The SQLite store and the loopback posture are unchanged. -
Every v2 request body is bounded: a 10 MiB per-message read bound on every v2 service — measured after decompression, so an anonymous gzip body can no longer inflate in memory before the interceptors run (
resource_exhausted, nothing beyond the bound buffered) — withArtifactService.UploadArtifactreading under a bound derived fromartifacts.max_artifact_mb(its base64 JSON form plus the shared bound) and anhttp.MaxBytesHandlerof that size on the whole v2 mount, soopschain artefacts uploadkeeps working for files above 10 MiB. -
Step collection never follows a symlink out of the run workspace: every post-step read on the runner and in the local lane — the matched artifact files, an image declaration's descriptor and SBOM,
step_result.jsonand the four step files — opens through the workspace root (os.OpenRoot); a path that resolves outside it is refused by name and nothing is stored, so a container step can no longer hand the runner host's credentials to the server. A relative link inside the workspace still collects; the glob lane is unchanged. -
Activating a TOTP factor carries the factor-write guard:
ActivateTOTPFactornow requires a live elevation once the caller holds an active factor, likeEnrolTOTPFactor, so a factor enrolled by a stolen session during the no-factor window cannot be activated later (replacing the recovery codes) without the elevation; the first activation still bootstraps. -
Inbound webhooks budget unknown codes per client address: a delivery naming a code no connection has is limited by the resolved client address (
server.trusted_proxieshonoured) at 10 per minute, checked before the connection look-up for codes the server has not served, so a flood of invented codes neither reads the store nor writes a refusal event beyond that budget (each admitted one still lands itswarn:webhooks:refusedevent); known codes keep their per-connection budget; the limiter table has a hard cap that fails closed. -
Inbound webhook replay is claimed on the verified signature: the exactly-once claim moved from the unsigned delivery id to the digest of the verified signature header, so a captured delivery re-sent under a fresh
X-GitHub-Deliveryanswers409 replayed_delivery("this delivery was already accepted"); the delivery id still names the delivery in the event and the 202 body. -
CEL
content()/list()reads are budgeted per evaluation: one evaluation may make at most 64 repository reads combined; the 65th refuses by name ("read a file once and bind it, or stage the files through the workspace"), in all three postures. -
The runner registration token rides the environment:
opschain runnerreadsOPSCHAIN_RUNNER_TOKENwhen--tokenis not given;scripts/runnerctl.sh(host mode), both example image entrypoints and the Kubernetes runner manifest no longer place the token on the argv, where every process on the host could read it.--tokenstill works; the docs name--max-uses 1as the bound on what a leaked token can enrol. -
Local sign-in refusals cost the same bcrypt compare: an unknown, disabled or passwordless username now compares against a dummy digest minted at the configured
security.bcrypt_cost, so response time no longer tells a username from a wrong password. -
A git remote must name a repository on the network. A project editor could register a filesystem path or a
file://URL as a git remote: the create-time connectivity probe answered whether the path existed, the periodic fetch then mirrored it with no policy door, and mirror directories were named from a guessable hash — so any repository the server's user could read, another project's private mirror included, was onecreateaway. Now the writer admits only transport URLs (http://,https://,ssh://,git://, scp-likeuser@host:path): anything else is refused by name on theurlfield on create and on a URL change —Url must be an http(s)://, ssh://, git:// or user@host:path remote(a 422 on v1,invalid_argumenton v2) — before any probe runs, with one shared classifier behind the writer and the git adapter. Defence in depth: the server's git runs underGIT_ALLOW_PROTOCOL=http:https:ssh:git, so a stored local URL fails its fetch by name (transport 'file' not allowed), recorded on the remote and aserror:git_remote:fetch, and is never cloned; the local lane (packs fetch, a repository's own remotes) keeps reading local paths. The new deploy-authoritativesecurity.allow_local_git_remotes(defaultfalse, never API-settable, anchored false in the hardened posture —truethere is a named boot error) admits local URLs for a lab server whose sample repositories live on its own disk; the boot notice names it when on. Upgrade note: no migration; an existing remote with a local URL keeps its row but stops fetching after the roll unless the flag is set — list them, move each to a transport URL or set the flag before starting the new binary. Reference:opschain docs config(security.allow_local_git_remotes),identity("The security posture"),resources/git_remote,cli/git-remotes-create,cli/git-remotes-update; lesson 3.3. -
Transport grants and approver lines match on the principal id, never the username: a transport grant with a user subject admitted a caller whose id or username equalled the stored subject, dispatch resolved the run's creator from its username, and approval-policy approver lines matched case-folded usernames — so a federated account sharing a local user's username inherited that user's grant and approver seat. Now
subject_idon a user grant, a grant'sapprovers,approvers.userson approval policies,owners.userson subtree owner declarations and the frozen checklist lines store principal ids: a write accepts a principal id, a username exactly one account holds, or<provider>:<username>(the account'sauth_providervalue —local:sam,ldap:corp:sam) and refuses an ambiguous name by name with that remedy.transportgrant.Decide,Approvers.Allows,EligibleLines,MayReject, the approval context, the approver-scoped subject reads, the history views and the finaliser (approval_checklists.finalised_by_id, migration 00144) compare by identity; the self-approval exclusion compares the requester by id when both sides carry one. The run record carries its creator's principal id from creation (CreatedByID, written into the existingcreated_by_idcolumns by every create and retry lane — API v1/v2, templated changes, workflows and their nested spawns, schedules, triggers) and dispatch resolves it by id. Dual-read window: rows written with usernames keep matching, and every such admission recordswarn:identity:username_matchnaming the record (grant id, checklist line, run — never the username) with the remedy;opschain admin identity convert-username-subjects --store … [--dry-run]rewrites every stored username to its id, refusing ambiguous ones by name and reporting unknown ones, recordingaudit:identity:username_subjects_conversion; once it finds nothing to do the username leg is inert. CLI:--subject,--approvers,--approver-userand--owner-usertake the three spellings. GUI (go-opschain-guibuild/b660): the transport-grant subject and approval-policy approver pickers choose accounts and send ids, and stored ids render as usernames with their provider. Reference:opschain docs transport-providers,change-governance,identity,events; thetransport_grant,approval_policy,approvalandasset_ownerdictionary pages; the CLI manual pages; lesson 6.4. -
An identity-provider step-up starts only from the session's own token. The unauthenticated sign-in leg
GET /auth/login/{code}accepted astep_up=<session id>parameter, so a phishing link could nominate which live session a victim's genuine IdP sign-in elevated to full strength. It now refuses the parameter outright (auth_error=step_up_unavailable, auditedwarn:auth:step_up_failed); the newAuthService.BeginIDPStepUp(return_to→authorize_url) seals the session the caller's bearer rode — and only that one — with the round trip's nonce, PKCE verifier and confined return path, sets the login-binding cookie, and answers the provider'sprompt=login/max_age=0authorization URL for the browser to open; the callback elevates that session in place. Sessionless credentials and platform-mode instances refusefailed_precondition; step-up refusal texts (v1, v2, the rule layer) name the RPC. The GUI's step-up dialog calls the RPC and navigates to the answered URL (go-opschain-gui). Reference:opschain docs identity("MFA factors and step-up"),opschain docs resources/session,opschain docs api-v2, lesson 7.4. -
Login and MFA throttle admission is atomic, and no account's success resets the per-address backoff. The back-off was check-then-act, so concurrent attempts all passed the window check before any of them counted, and a successful login cleared both the username and the source-address ladders — a success on any account reset the budget for the address. Admission is now one step (under the lock in memory; one row-locked transaction on the PostgreSQL
login_backofftable): an attempt inside either window is refused before the credential is evaluated, an admitted attempt counts on both ladders and engages the next window immediately, so exactly one of any number of concurrent attempts after a lapsed window proceeds; a success clears the account's own username ladder and refunds the one count its admission took from the source ladder (successful sign-ins never consume the address budget), while a source window already engaged stands until it lapses and the source ladder otherwise decays on its own. Applies to password, LDAP, TOTP and recovery-code proofs. No migration. Reference:opschain docs identity("Login throttling"). -
The federated sign-in
return_tois parsed, never passed through.return_to=/%09/evil.examplesurvived the former prefix checks and reached theLocationheader verbatim; browsers strip the tab and follow//evil.example— an open redirect at the end of a genuine sign-in. The return path is now parsed: only a same-origin absolute path (query and fragment kept) is honoured; a scheme, host, opaque part, userinfo, protocol-relative prefix, backslash or any control character lands on/. The GUI'sfederatedLoginPathmirrors the rule before it builds the link (go-opschain-gui). -
The federated login state is bound to the browser that started it. Nothing browser-side tied the sealed state to the sign-in that minted it, so an attacker could complete their own IdP sign-in, capture the callback URL (valid for ten minutes) and have a victim open it — the victim's browser received a session for the attacker's account (login CSRF). The login leg and the step-up RPC now mint a 32-byte binding secret, set it as the
opschain_login_binding-<id>cookie (Path=/auth/callback/, HttpOnly, Secure on TLS,SameSite=Laxsince the callback is a cross-site top-level GET, lifetime = the state's) and seal its SHA-256 into the state; the callback compares in constant time, refuses an absent or foreign binding withauth_error=invalid_stateandwarn:auth:login_failed, and expires the cookie. HA-safe — nothing process-local; no configuration. Reference:opschain docs identity("Federated sign-in"). -
The Security Administrator is the authorisation-config write gate. Every v1 and v2 write to authorisation policies, rules, rule links and assignments now needs a superuser or a
security_administratorgrant: a global grant writes any policy, a project delegation only the policies whose rule paths all sit under that project (upstream's partial-admin shape expressed in the role); reads follow the role too (global/project security administrators and auditors). Upstream's securing prefixes (rules on<path>/authorisation_policies) grant nothing any more — closing the hole where any prefix holder could self-assign the Superuser policy — and system-policy assignments are superuser-only behind the step-up floor. Refusals answer not found and recordwarn:authorisation_policies:refused(principal id, target, reason). Upgrade conversion: at first boot the server converts every updatable securing-prefix holder into the matching grant (global → global, project → project delegation, deeper prefixes widened to the enclosing project and reported, a group → a group-targeted grant), active on creation, flaggedconverted_from: securing_prefixand queued for the global security administrator to confirm or revoke; the securing rules are retired and the run's report persisted (security_policy_admin_conversions), audited asaudit:security_role_grants:converted/audit:security_role_grants:conversion;opschain admin security convert-policy-admins --store … [--dry-run] [--report]re-runs it. Migration 00141 adds the group target and the review columns tosecurity_role_grantsand the report table. API:SecurityRoleGrantgainstarget_kind,groupname,converted_from,converted_from_path,widened_from_path,review_state,reviewed_by_id,reviewed_at;ListSecurityRoleGrantsnarrows byreview_state/converted; newConfirmSecurityRoleGrant(api:security_role_grants:confirm); policy and rule reads carrywritable. CLI:opschain security roles review list|confirm|revoke,opschain security roles listgains TARGET/PRINCIPAL/ORIGIN/REVIEW columns. The GUI slice (review queue on the Security administration tab,writable-gated controls) lands in go-opschain-gui. -
A federated identity outranks a same-named local user at every username resolver. Directory rows are stored as
ldap:<code>since , but six resolvers still preferred the literalldapprovider, so the tie fell to creation time and an older local twin won run-as and trigger firing-time resolution (refused by name for a break-glass twin, silently substituted for a plain one), notification subscriber lookup, agent owner lookup, write attribution of runs and nodes, ghost-row hiding and the collision defences. One shared predicate (authn.FederatedRowPredicate/FederatedFirstOrder: a legacyldaprow or any<kind>:<code>provider identity) now ranks every federated provider — LDAP, OIDC, GitHub — ahead of the local row at all of them, and thedirectory_shadowedsession fact uses the same rule; the two legacy-only chain-lane sweeps keep their literal match by design. Reference pageidentitycarries the precedence rule. -
The log pipe releases step state at seal and the run's mask set at run end. The server log pipe kept every step's in-memory state and every run's masker — plaintext secret values — until restart, walking them on every read, so memory grew with uptime and secrets outlived their run. A step's state is now forgotten when its log seals (an aged tombstone still answers a straggling chunk as sealed, so the
warn:logs:sealed_refusalmarker keeps landing once per attempt and a re-delivered seal stays idempotent), and the run's masker is dropped when the run finalises — after the last result's emitted events were masked — with a late chunk rebuilding its masker from the mask set sealed onto the job record. The local pipe mirrors the shape. Reference pagerunner-executorsand the HA coherence runbook describe the lifetime.
Fixed
- The
opschain.envbridge always resolves its vault references. Narrow resolution for the Go lane resolved only the references a declarative step's own expressions read, so arun:step that reads asecret-vault://value through theopschain.envenvironment bridge received the literal reference instead of the value, contradicting lesson 3.5 ("the entry lands in the step's environment as the real value"). The bridge is a declared read by construction: every step's environment carries it, so its references now resolve on every layer regardless of what the step's expressions name, and the resolved values join the mask set. The Ruby lane's whole-document resolution and the narrowing of every other property read are unchanged. - Signing out clears the browser tab's cache, and a different principal never inherits it. The GUI kept one query cache for the life of the tab: logging out never cleared it, and nothing compared a fresh sign-in's principal with the one the cache was filled for, so a second user signing in within the role probes' five-minute window saw the first user's sidebar, badges and briefly their cached lists.
logout()now clears the cache (and the stored dashboard tab) before the login route, and ameanswer naming a different principal drops everything cached for the previous one before anything renders. Reference:opschain docs gui-operations("Signing out"). - The Trust tab's Approve and Reject honour a held raise. An administrator on Governance → Agents → Trust could click Approve on a pending grant whose raise an approval policy holds for the Inbox, submit the decision and only then read the server's "held by the approval fabric" refusal. The verbs are now disabled while the raise's checklist is open, the tooltip names the reason from the checklist's own eligibility verdict (waiting on you; not an approver, and whom it waits on; self-approval excluded; already approved), and the row links the Inbox row (
Decide in Inboxfor an eligible approver,View in Inboxotherwise). Revoke on an active grant is unchanged. - Auditors see their lists and dashboards. The auditor role granted reads through the decision floor but never through the list filter, so a global or project auditor with no policy of their own got empty project lists, infrastructure summaries and dashboards while every direct read answered. The read list filter now carries the floor exactly as decisions do: a global auditor lists across every project (narrowed by a scope clamp like any root grant), a project auditor's projects join its policy prefixes, the floor stays read-only, and the Block-all policy still empties the list. Reference page
resources/security_role_grantstates the floor's reach. - Inline integration credentials are held encrypted at rest in the database, never written into the platform vault and launch it."): a PostgreSQL server with no secrets provider registered refused every inline credential ("credential: failed to store the credential in the secret vault") because the inline sugar wrote it into the refusing vault. Now an inline credential supplied to a model provider, identity provider, GitHub connection, JSM connection, image registry, image build provider or transport provider is an arcrypt envelope on the record's own column (
model_providers.credential,identity_provider_connections.client_secret, and migration 00143'stransport_providers.credential,image_build_providers.credential,image_registries.credential,github_connections.private_key,jsm_connections.api_token), the licence installation credential lands in the newlicence_installation_credentialstable (read by the licence manager beforeOPSCHAIN_LICENCE_CREDENTIALand the vault), and every column joins the encryption-key census (opschain admin encryption status, re-encrypt, the rotation runbook). A*_refpins exactly as before and clears the envelope; an inline write clears the reference; rows that pinned the retired<family>/<code>/credentialvault path keep resolving through the vault. Use time answers the envelope first, then the reference, then the named "holds no credential" refusal; a write without encryption keys refuses by name (… need the server's encryption keys configured,ERROR_REASON_SERVER_NOT_CONFIGURED). Transport providers, GitHub connections and JSM connections gaincredential_set/private_key_set/api_token_setso a read says a credential is held;StoreLicenceCredentialanswers no reference on a database store. Reference:opschain docs secrets-providers("The server's own integration credentials need no vault"),opschain docs model-providers,opschain docs transport-providers,opschain docs image-builds, the eight resource dictionary pages, the family CLI manual pages, lesson 3.5.
Added
- One pane for model budgets: GUI only (go-opschain-gui f5f573a6 merged c6f632a7, tidy f8302b99), no server change. Administration → Model providers gains a Budgets tab (
/admin/model_providers?tab=budgets): one row per provider with the tokens per day allocated across its agents' trust grants, the spend today, the headroom, the provider's own day ceiling and an over-allocation marker when the grants together exceed it, each row expandable to one line per agent and grant with a link to the grant — grouped and summed in the browser offAgentPostureService.ListAgentPostureandModelProviderService.ListModelProviders, gated like the provider dashboards. The dashboard gains a Model budgets panel (the per-provider totals and the agents nearest their cap, opening the tab). The provider record's Budgets panel is renamed Agent token caps — it reads the caps of the agents bound to the provider and names where a cap is set (the agent's trust grant, linked); the provider's own ceiling lives on the record. Reference:opschain docs gui-operations, lessons 12.1 and 12.6. - Self-service API keys and password; service accounts and automation reassignment in the GUI: GUI only (go-opschain-gui b2a46a3d merged 9f7f0d55) against existing v2 RPCs. The profile menu's Preferences gains Api keys — list, Create api key… with the value shown exactly once, Revoke (
AuthService.ListAPIKeys|CreateAPIKey|RevokeAPIKey) — and Password (AuthService.ChangePassword; local users only, the directory refusal verbatim). Administration → Users gains Create service account… (UserService.CreateServiceAccount) and, on a disabled row, Reassign automations… (UserService.ReassignAutomations; the target picker offers enabled service accounts only); the disable dialog's offboarding summary now lists the agents the user owns (OwnedAutomationsSummary.agent_count/agent_names) beside their triggers and schedules. Reference:opschain docs gui-operations(Preferences, Users & Groups),cli/tokens-create,cli/tokens-list,cli/tokens-revoke,cli/passwd,cli/admin-service-accounts-create,cli/admin-users-reassign-automations,cli/admin-users-disable,resources/user(OwnedAutomationsSummary), lessons 7.2, 7.3 and 12.5. - Node and workflow lifecycle in the GUI: restore, destroy, export; destroy a workflow with its runs; delete git remotes: GUI only (go-opschain-gui ce5ed815 merged 30d86be8) against existing v2 RPCs. The project, environment and asset lists gain a Show deleted view with Restore and Destroy… (the manifest preview, then a typed-path confirmation) over
NodeService.RestoreNode|GetNodeDestroyManifest|DestroyNode; its rows are derived fromEventService.ListEventsover the node deleted/restored/destroyed events (newest 2000), so a node deleted through an ancestor appears under that ancestor only — aNodeService.ListDeletedNodesread that lists the tombstones exactly is proposed as . A live node's actions menu gains Export subtree… (NodeService.ExportNode); an archived workflow gains Destroy with runs… (WorkflowService.GetWorkflowDestroyManifest|DestroyWorkflow, manifest preview and typed confirmation, the live/active-run/referenced refusals verbatim); the Git remotes tab's Bulk actions gains Delete Git remotes… (GitRemoteService.DeleteGitRemote, the referenced refusal and the kept-row outcome reported). Reference:opschain docs gui-operations,cli/admin-nodes-restore,cli/admin-nodes-destroy,cli/admin-nodes-export,cli/admin-workflows-destroy,cli/git-remotes-delete, lessons 3.3, 4.4, 8.3 and the core-platform guide. - Small GUI form fields the API already accepted: GUI only (go-opschain-gui 0e9041d0 merged 72befe83). The run dialogs gain Run during maintenance (
override_maintenance); the schedule dialog Maximum run count and Allow parallel execution on create and edit (maximum_run_count,allow_parallel_execution— now actually sent); the metadata profile dialog Applies to node kinds (applies_to_kinds: project, environment, asset, infrastructure, entity; none = everywhere); the Runners page a Capacity column (max_parallel_steps) and a run-scoped chip on fleet-queue entries (run_scoped,scope_source); the secrets and transport binding panels a provider disabled chip (provider_enabled); the Packs page a Bundle column (bundle_digest,bundle_size); a templated node's Actions page the version activity windows with their task links (TemplateVersionHistoryEntry.active_from/active_to/task_ids); the notification feed groups one run's rows under its thread (thread_key); the Infrastructure page gains Import nodes… (InfrastructureService.BulkUpsertInfrastructureNodes; lines ofcode,type[,address[,fqdn[,name]]]or a JSON items array); and the Pause verb follows the typedpausableflag — the GUI's pause/resume links were previously never set, a fix. Template pinned nodes were already shown as "In use by" and are left as they are. Reference:opschain docs gui-operations,resources/scheduled_activity,resources/runner,resources/template(TemplateVersionHistoryEntry),resources/change,metadata,infrastructure-nodes,cli/infrastructure-bulk-upsert, lesson 5.5. - A provider-level model budget: the model provider record carries the same six token-cap windows a trust grant does —
tokens_per_minute,tokens_per_day,tokens_per_week,tokens_per_month,tokens_per_quarter,tokens_per_year(migration 00145, nullable; 0 or absent is no ceiling, no estate default) — as a ceiling across every agent and every call through that provider. The model gateway checks every grant window first, then every provider window (both apply, the tighter wins), on one shared counter row per provider and window in the usage store (provider:<code>, the grant counters' substrate with the same in-memory fallback), and refuses the first failure by name:model provider <code>: tokens per <window> ceiling of <n> reached — resets at <local boundary>. The two cap events gainscope:warn:trust:cap_refused/warn:trust:cap_approachingcarryscope: providerwithprovider_code,window,limit,used,timezone,resets_atfor a provider window andscope: grant(the grant's scope path moves togrant_scope) for a grant window. API: the six fields onModelProvider, optional onCreateModelProvider/UpdateModelProvider(0 clears, negatives refuse by name);ListProviderUsage(one model provider) and a model provider'sListProvidersOverviewrow carrytoken_budget—ProviderTokenBudgetWindowper set window (cap, used, remaining, fraction, window start, resets at). CLI:opschain admin model-providers create|update --tokens-per-minute … --tokens-per-year,showprintsToken budget: …,opschain admin providers usage --kind model --code <code>prints the ceiling, spend and remaining per window (token_budgetin-o json). OTel: the usage export shipsopschain.provider.token_budget_remainingas a gauge withprovider.kind=model,provider.codeandwindow. GUI (go-opschain-guibuild/b664): the provider dialog gains a Token budget group (each window Unlimited or Custom) and the provider dashboard a Provider ceiling block of gauges per set window;providerTokenBudget(provider)insrc/api/v2/model-providers.tsis the helper the pane reads. Reference:opschain docs model-providers("The provider-level model budget"),provider-usage,events,resources/model_provider,resources/model_usage(ProviderTokenBudgetWindow), theadmin-model-providers-*andadmin-providers-usagemanual pages,gui-operations, lesson 12.6. - Database, encryption, log-migration and quiesce operations in the GUI: the Administration → Database page now carries the operations its copy used to send to the CLI, against the existing v2 procedures (no server change). Maintenance: Analyze and Vacuum run on demand (optional table list, the configured churn set by default) and render the run's per-table timings, dead-tuple counts and errors. Migrations: every migration with its ledger state, the integrity verdict with findings and remedy, and Apply pending behind a type-to-confirm arm (
ApplyMigrations --all; the out-of-order and mismatch refusals verbatim). System quiesce (a panel on the Server and Database pages): start with the required reason, abort a still-draining quiesce, resume — the drain's remaining attempts listed and polled until the barrier closes. Backup manifest: captured inside the quiesced window (GetBackupInfo, refused by name otherwise), summarised and downloadable; Verify restore replays the manifest's checks from a pasted or uploaded file. Log migration: both phases' state and counters, Start with the batch size, Pause, Resume, and Purge behind its named warning and arm (the evidence-gate refusal verbatim). Encryption at rest: the key census (current and configured keys, the registry with current/previous/retired/MISSING, per-column envelope counts and counts per key, pending, the last run) and Re-encrypt with batch size and row limit, held while a referenced key is missing. Every refusal renders verbatim; verbs are admin-gated as the page already was. Database import and restore execution stay with the CLI (they move files on the server host). Reference:opschain docs gui-operations(Database and Server rows); lessons 8.1, 8.4 and 8.5 name the GUI paths; the encryption-rotation, rolling-upgrade and HA-coherence runbooks point at the panels. - Runner groups, placement attributes, server nodes and sites in the GUI: fleet shaping and HA posture are operable from Administration → Runners against the existing
RunnerServiceRPCs (no server change). The read-only group rows gain a group dialog (UpsertRunnerGroup): New group… in the toolbar, Edit group… on every stored group and Store group… on a group that exists only as a token name — description, the Exclusive switch, placement attributes as key/value rows whose keys are offered from the registered vocabulary and whose enumerated values are offered as the allowed list, node-path bindings one absolute path per line; the dialog states that saving replaces the whole definition, names a duplicated key before anything is sent, and renders the server's refusals verbatim (unregistered key, value outside the allowed list, non-absolute binding, invalid name, the runner-group licence limit on a new name). A Placement attributes panel (ListRunnerAttributes,PutRunnerAttribute,DeleteRunnerAttribute) lists the keys with their allowed values and the reserved tag, registers or re-sets a key (values replaced wholesale, blank = any value) and removes one behind a confirmation, the reserved-key refusal verbatim. A Server nodes panel (ListServerNodes,QuiesceServerNode,ResumeServerNode) shows the held nodes beside the answering node's own name and held state, quiesces a node by its stable server name behind a type-to-confirm that names what drains (readiness, job offers, scheduler shards, leadership and membership, the in-flight run drives peers adopt) and that the posture survives restarts until Resume, and resumes a held row (the not-quiesced refusal verbatim). A Sites panel (ListSitePostures,QuiesceSite,ResumeSite) does the same per site identity, naming the failover (runners to their next configured server, the surviving site claiming the site's location-pinned steps). Verbs are administrator-gated as the page already is. Reference:opschain docs gui-operations(the Runners rows); lessons 6.3, 8.6, 8.7 and the GUI surfaces page name the panels. GUI repo (go-opschain-guibuild/b644). - Secrets provider cloud kinds and rate limits in the GUI: Administration → Secrets providers now registers and edits every kind the API has. The Kind picker gains
aws-secrets-managerandazure-key-vault, each with its own field set per the CLI manual: AWS takes the region (required), an optional endpoint URL override, theaccess-keymethod's access key id and write-only secret access key, and an optional secret-name prefix; Azure takes the vault URL, the Entra ID tenant id, theclient-secretmethod's client id and write-only client secret. The vault kinds keep KV version, mount path andtoken/userpass/approle; the custody toggle (inline or vault reference) and the resolution rules apply to every kind. Both dialogs carry the rate limit fields (rate_limit_reads_per_second,rate_limit_mints_per_minute; empty is the estate defaultsecrets.default_rate_limit, and clearing a lane on edit returns it there), and the list gains a Rate limit column (default, or each lane withdefaultwhere unset). An edit renders the stored kind as stored — the coercion of any non-vault kind toopenbaois gone — and a kind this GUI build does not know opens read-only with a notice naming it. GUI repo (go-opschain-guibuild/b645). Reference:opschain docs gui-operations(the Secrets providers row),opschain docs getting-started/secrets-providers. - The
opschain-github-demorepository: a private demonstration kit LimePoint uses to run GitHub integration demos for prospective customers, in both directions, atgithub.com/LimePoint/opschain-github-demo. The asset template (.opschain/: projectgithub_demo, environmentsdev/prod, assetsweb/reportson theapptemplate;pipeline:dispatch_workflowandpipeline:repo_dispatchunder a per-mint-scopedgithub-app://demo?repos=…&permissions=…reference, a deliberately over-scoped reference onreportsthe allowed scope refuses by name,image:build_and_pushthrough the connection'sghcrregistry withartifacts:resolve_image,config:read_remotewithcontent()/list(),deploy:releaseheld by the production approval policy, theverifyworkflow and the keep-greenhelloaction), the GitHub workflows (ci-local.ymlon the local lane every push withOPSCHAIN_RELEASES_TOKEN;server-change.ymlthrough the reusableopschain-change.ymlwith the OIDC exchange under the clamped trust policy, the held production release, the attestation and the documented out-of-clamp refusal behindworkflow_dispatch; thedispatch-target.yml/dispatch.ymltargets withinitiated_by;attest.yml;on-ref-move.yml), the server seed (demo/seed.sh estate|integration: personas, the delegated security administrator, the connection with its allowed scope, the webhook, thescope_prefixtrust policy, the window-required approval policy, theghcrregistry, the key-rotation control and cadence, the team route, the conclusion and ref-move triggers;reset.sh;totp.sh), and the presenter runbookdemos/(runsheet + modules 00–04: setup under step-up, GitHub → OpsChain, OpsChain → GitHub, the governance wrap, the acceptance arc). The kit referencesLimePoint/opschain-actionat@mainuntilv1is tagged. One dated line onopschain docs integrations-githuband lesson 13.1 points at it. - Governance GUI parity, core: every governance verb the CLI had and the GUI lacked now has a surface, against the existing v2 RPCs (no server change). Governance → Evidence & controls → Controls gains New control, per-row Edit (name, description, owner role, status
active/retired, the instrument refs replaced wholesale) and Retire for administrators, and each key opens the control's detail page at/governance/controls/<key>— fields, instruments, the campaigns citing it; an unknown key and a role refusal render as notices (CreateControl,UpdateControl,GetControl). Reviews & approvals gains a Cadences tab (ListReviewCadences,CreateReviewCadence,UpdateReviewCadence): kind, control, cron schedule, grace, an enabled switch, next due and the latest campaign the cadence opened, with create and edit dialogs; non-admins see the administrator notice. Campaigns gains Open campaign beside Open access review (OpenReviewCampaign): control, kind, due, and hand-supplied items typed or pasted one per line assubject-kind:subject-id[:reviewer], a malformed line named before anything is sent. The Evidence ledger gains Export (ExportEvidenceLedger): the cursor and batch size as the CLI's--after-seq/--limit, the batch re-encoded to the canonical NDJSON in the browser and its SHA-256 checked against the server'sentries_sha256before the.ndjsondownload (a mismatch is flagged and the CLI export named; an empty batch says the cursor is caught up). The approvals inbox gains Break-glass release (BreakGlassRelease) on open rows for superusers and callers with the root change-governance standing: the mandatory reason, a confirmation naming the loudwarn:approvals:break_glassevent, the server's refusal verbatim. Server field violations land on the dialog fields. Reference:opschain docs gui-operations("Governance surfaces"); lessonsgovernance-controls-evidence,governance-campaigns,governance-approvalsname the GUI paths. GUI repo (go-opschain-guibuild/b640). Refinement (owner 2026-11-12): the cadence dialog's Schedule field is the schedule helper the run dialogs use rather than a bare cron box — a preset list, a builder that reads as a sentence and an expression view for a typed 5-field cron expression, each read back in words — and the builder now also says every quarter (m h d */3 *) and every year on a day of a month (m h d M *), with a "First day of the quarter at midnight" preset; a new cadence opens on the quarterly review (02:00 on the first day of every quarter), and an expression the validator rejects cannot be saved. - Approval evidence, licence verification, usage export and agent custody in the GUI: four GUI surfaces on existing v2 RPCs (no server change). A pending approvals-inbox row gains Evidence — prior runs (
ChangeGovernanceService.GetApprovalChecklist): the same-action, same-content run history across targets with a link to each run, or the first-run-anywhere flag — informs, never gates. The Licence page gains an Online verification panel offLicenceService.GetLicenceStatus(online_verificationnow mapped on the GUI's v2 twin: last outcome, last and next check, cadence, failures, last error) with Verify now onLicenceService.TriggerVerification, the round's outcome in a toast and the table, the refusal verbatim. The providers overview and a model provider's dashboard gain Export CSV onModelUsageService.ExportModelUsageover the dashboard's current range and grain (the overview exports the default 30-day/day window), downloaded asmodel-usage-<range>-<grain>.csv. The agent sheet (Governance → Agents, and the approvals inbox) gains Assignments (AgentService.GetAgentAssignments) and Activity (AgentService.GetAgentActivity, the audit timeline newest first, departed agents marked) tabs, hidden on the approver-scoped sheet where the server would refuse the reads. Docs:gui-operationsrows, the approvals, licence, model-providers and what-an-agent-is lessons.ConvertSettingsApprovers/ConvertStepApprovalGatesstay CLI-only (not wanted). - Admin GUI parity, connections and trust: three admin resources whose only GUI presence was a usage-metrics row now have their management surfaces. SIEM connections (Administration → Operate → SIEM connections,
/admin/siem): the list with kind, target, filter, token-stored flag, live delivery health and enabled toggle; a record page per connection (/admin/siem/<code>, also the providers dashboard's "Open record") with its fields, delivery health, the Test verb's outcome inline and the link to its usage dashboard; create/edit dialogs mirroringopschain admin siem create|updateper kind (splunk_hec,syslog,otlp_http) with the receiver token write-only; delete behind a type-to-confirm arm. GitHub (/admin/github): the Connections tab (host, App and installation ids, OIDC audience, the pinned private-key reference, the allowed mint scope editor, webhook ingestion state with a provisioning dialog that shows the generated signing secret exactly once, test, enable, delete) and the Trust policies tab (claim predicates, service account, TTL, enable, delete, and thescope_prefixclamp with its help line). Agent trust (Governance → Agents → Trust tab): grants with subject, scope, tier, actions, caps and approval state — write a grant (also from an agent's sheet, pre-addressed), approve, reject, revoke — the assets-under-management assignments (assign, unassign), the type ceilings and the node-classification ceiling rules (set, replace, remove); reads for any governance role, verbs for an administrator, the step-up floor on ceiling writes honoured through the elevation ceremony. Reference:opschain docs gui-operations,integrations-siem,integrations-github,agent-plane; lesson 12.2 and the GUI surfaces page. - JSM integration admin in the GUI: the Jira Service Management lane, CLI-only until now, gets its GUI surface against the existing
JsmIntegrationServiceRPCs (no server change). JSM (Administration → Operate → JSM,/admin/jsm): the connections list with authentication style (Atlassian cloud or Jira Data Center), base URL, project scope, a stored-credential badge (the pinned vault reference on hover — never the token), webhook ingestion state, health from the last test and the enabled toggle; a record page per connection (/admin/jsm/<code>, also the providers dashboard's "Open record") with its fields, the last test, the Test outcome inline and the usage-dashboard link; create/edit dialogs mirroringopschain admin jsm-connections create|updatewith the API token / PAT write-only (inline and vaulted server-side, or an existingsecret-vault://reference pinned), project keys replacing the stored scope on save; delete behind a type-to-confirm arm; Provision… for Jira webhook ingestion showing the generated signing secret exactly once. Service desk (a tab on every change's page): thejsm:request/jira:issuereferences bound to the change with the ticket link, and the audited write verbs on each row — Comment (always an internal note), Transition, Set fields (labels plus a key/value editor that builds the--fields-jsonobject) and Resolve with evidence links (the typed reference lands on this change's governance block), plus Resolve a request… for a request not yet bound. Entitlement refusals (integrations:jira-service-management) render through the feature notice; every other refusal verbatim. Reference:opschain docs gui-operations,integrations-jsm; lesson 13.2 and the JSM getting-started page. - The
integrations:githubclose-out: the three deferrals the GitHub suite shipped with are closed. Per-mint installation-token scoping: agithub-app://<code>property reference may narrow the token it mints —github-app://corp-github?repos=payments,ledger&permissions=contents:write,pull_requests:read(repository names within the installation's account;name:levelpairs ofread/write/admin) — and the server passes exactly that set to GitHub's installation access-token mint. The connection's new allowed scope (--allowed-repositories/--allowed-permissions;allowed_scopeonGitHubConnection; migration 00142) bounds what any reference may ask: a reference beyond the bound fails its step by name with the newwarn:github:token_scope_refusedevent beside it (the requested and the allowed sets, never a token) and nothing reaches GitHub; a bare reference on a bounded connection mints the allowed set; a connection with no allowed scope is unrestricted, as before. The scope every token was minted under is recorded on the step's setup evidence and oninfo:github:token_mint(repositories,permissions). Per-policy scope clamps: a GitHub trust policy gainsscope_prefix(--scope-prefix /projects/payments): every session it mints is clamped to that node-path subtree — inside it the service account's grants decide as before, outside it every authorisation decision denies, the superuser short-circuit, the auditor and security-administrator floors and list ranges included — so one service account serves several pipelines without any reaching another project. The clamp rides the minted token itself (no policy read on the decide path), is named on the exchange answer (scope_prefix) and oninfo:github:token_exchange, and a denial it causes landswarn:authz:deniedwithscope_prefixandreason: scope_clamp. The published workflows: the publicLimePoint/opschain-actionrepository (the local-lane composite action's home — no second repository) gains the reusableworkflow_callworkflows.github/workflows/opschain-change.ymlandopschain-workflow.ymlfor the server-backed lane (exchange the job's OIDC token under a trust policy, raise the change or workflow run, wait for the terminal state, outputsrun-id/status/url, the job's result from the run's), theseed/starter kit, and README sections "Server-backed workflows" and "GitHub Enterprise Server" (mirror the repository into the instance and pin it);v1moves with the release train. Reference:opschain docs integrations-github("Just-in-time GH_TOKEN", "Trust policies", "The published workflows"),resources/github_connection,resources/github_trust_policy,events,ci,cli/admin-github-connections-create|update|show,cli/admin-github-trust-policies-create|update|show; lessonsintegrations-githuband 13.1. - The CI lane: running OpsChain in CI from the binary with no server is now a documented, packaged lane. Lesson 2.8 "Running OpsChain in CI" (
opschain learn 2.8, module Author work) walks a job end to end: install the pinned release and verify its digest, the checked-out repository is the configured project,runs create change|workflow … --local --approve-all -o json, assert the exit code (0/1/2/3/4), read the run document (run.id,run.commit, the step tree,failed/cancelled/timed_out),opschain logs --localfor a failed step, pin the commit with--ref(and read<sha>+dirty.<digest>when the job wrote into the tree),runs artifacts --downloadinto the CI system's store, unattended secrets throughOPSCHAIN_MASTER_KEYor a0600key file with--no-keychain, pre-pull images in a prior step (the local lane never pulls — theopschain prefetchverb is decided against), and the plain table of what a CI run cannot do with the server-lane pair for each (change:steps,content()/list(), git-sourced packs, evidence). Reference pageopschain docs cicarries the contract (exit codes, the-o jsondocument shape,-qand non-terminal rendering, the host executor's env allowlist, the artifact listing fields) and the usage of the two packagings: theLimePoint/opschain-actioncomposite GitHub Action (inputsversion,action/workflow,env,asset,inputs,ref,approve-all,artifacts,artifact-name,home,working-directory,timeout; outputsrun-id,status,commit,exit-code,run-document,artifacts-dir; job summary +actions/upload-artifact; result from the exit code) and theLimePoint/opschain-gitlab-ciCI/CD componentsrun-changeandrun-workflow(same inputs;opschain-run/run.json+ artifacts kept always;OPSCHAIN_RUN_ID|STATUS|COMMIT|EXIT_CODEdotenv report). Both repositories are authored locally for the owner to create and push. The CI pages describe the CLI as it is today; the environment passthrough andsecret-env://conveniences land with and are pointed at by one dated line. - Local lane gap fills: the single-binary local lane closes the gaps that looked architectural and were not. (1)
node.content(path)andnode.list(glob)read a local run's own snapshot — the working copy as it stands, or the--refcommit — under the server seam's rules (UTF-8 only, the 1 MiB cap, no symbolic links, no submodules,{binary: true}refused, every read recorded as evidence); any node the repository's.opschain/config.yamldeclares reads that one repository, the SHA formgit.content('<remote>', '<sha>', path)reads a commit the repository already holds for a configured remote without contacting it, and the template-version form refuses by name. Reference:opschain docs expressions("content() and list()"). (2)--env-passthrough <names>onruns create change|workflow … --localhands named variables of the invoking process to every step on top of the host executor's default allowlist — the runner's--env-passthroughsemantics,OPSCHAIN_VAULT_*dropped; manual pagescli/runs-create-change,cli/runs-create-workflow,opschain docs runner-executors. (3) Thesecret-env://<NAME>reference scheme: a property value or asecret-typed input naming it resolves from the invoking process's environment in the local lane — the CI job's secret store — at run preparation, masked like any vault value, an unset variable a named failure before anything runs; every server posture refuses the scheme by name (the routing prepass over property documents, a secret input at dispatch, a run's inputs at creation). Reference:opschain docs expressions("Typed run inputs"),secrets-providers("Where references resolve"); lessons 2.6 and 3.5. (4) Git-sourced packs in the local lane through the local pack cache: the newopschain packs fetch [name ...]resolves a repository'sremote:/url:+rev:declarations into<opschain home>/packs/<name>/<sha>through the home's git mirrors (locallyremote:names one of the repository's own git remotes), verifies a pack manifest the tree carries against the product keyring and the local entitlements as a signed bundle is (one that does not verify is refused and never cached), and records the resolution in the pack's cache index; local runs then mount the pack from the cache alone — the mirror serves the step checkout, the cached tree the compile — so a fetched pack runs offline, and an unfetched declaration refuses by name before anything runs. The server lanes are unchanged. Manual:opschain docs cli/packs,cli/packs-fetch; lessons 2.4 and 2.6. (5)opschain runs retryretries a workflow run in the local lane with the server's semantics — successful steps carried with their nested-run link and attempt history, failed or cancelled subtrees re-executed as fresh nested runs, the duplicate re-planned from the original's repository, snapshot and recorded inputs (cli/runs-retry). (6)metadata.*is bound empty in a local run'swhen:—has(metadata.<family>)is false and a guarded condition evaluates instead of failing — and theorchestration.retries/orchestration.fan_out_limitladders read their base layer for local runs from the neworchestration:block of<opschain home>/server/config.yaml(orchestration.retries.attempts,orchestration.retries.backoff,orchestration.fan_out_limit; frozen onto each local run, audited as sourceconfig, out-of-range values ignored with a notice; a server ignores the block). Reference:opschain docs config("orchestration:"),settings-reference,orchestration,expressions. (7)opschain run <action>andopschain run workflow <code>— the aliases ofruns create change|workflow … --localwith the same flags, attached execution and exit codes; the alias never targets a server (--serverrefused by name, the active context ignored). The two pages that already named the verb (architecture,expressions) are now true. Manual:opschain docs cli/run,cli/run-workflow; lesson 0.2. - Runner-resolved secrets providers: a secrets provider now carries
resolution: server | runner. Arunnerprovider stores no credential on the server; references the bindings route to it are syntax-validated, left in place and deferred to the runner through the additivesecret_refsjob-spec element (runner protocol 8), where the runner resolves them at Prepare from thesecrets_providers:section of its own<runner dir>/config.yaml(OpenBao/Vault, AWS Secrets Manager, Azure Key Vault), swaps the values into the step context, the bound inputs' variables and the transport session bundle, and masks them before any output flows — an unresolvable reference fails the step naming the reference and the provider. A converged document can reference a platform vault and a customer vault at once; neither side sees the other's values. Runners declare the provider codes they resolve at registration and on every poll; placement honours it (secrets_providerson the requirement, the runner row and the fleet queue verdictno runner in group … resolves secrets provider …), and an offer to an undeclaring or pre-8 runner is refused by name. The agent SDK gainsResolveSecretover the same resolver. Server-resolved*_reffields (identity providers, GitHub/JSM integrations, image registries/build providers, model providers, a provider's owncredential_ref) refuse a reference that routes to arunnerprovider; transport providers' credentials check out at the runner instead. Interactive reads and the test verb on arunnerprovider answer the callout "reads happen only at the runner; the platform holds no credential". CLI:--resolutiononadmin secrets providers create|update, theRESOLUTIONcolumn andResolution:line. Reference:opschain docs secrets-providers("Where references resolve"),opschain docs runner-executors. - Secrets provider resolution in the GUI: Administration → Secrets providers now shows and edits the resolution locus. The list gains a Resolution chip per provider (
serverorrunner; an absent field renders asserver, the API default) and a runner provider's Credential cell reads "held by runners" instead of "not set". The register and edit dialogs gain a Resolution field whose hint states the rule in plain words — a runner-resolved provider holds no credential on the server (each runner carries it in thesecrets_providerssection of its ownconfig.yaml), a server-resolved provider needs the credential fields — and choosingrunnerremoves the credential input and the custody toggle so no credential can be sent. Editing a provider fromservertorunnerwarns inline, naming the provider, that saving clears the credential stored on the server (and its pinned vault reference); editing fromrunnertoserverrequires the credential (or a vault reference) before Save enables, and the write carriesresolutionwith it. The server's own refusals (resolution,credential,credential_reffield violations) render verbatim in the dialog. The secrets tool's provider header carries the same chip. GUI repo (go-opschain-guibuild/b613-gui). Reference:opschain docs gui-operations(the Secrets providers row),opschain docs getting-started/secrets-providers. - The vault traffic economy: OpsChain now behaves as the best-behaved client a vault has. One read per run per reference: each
secret-vault://reference resolves once per run at the first step that needs it and the run's remaining steps reuse it from a sealed, in-memory run snapshot in the resolving process (the server for aserverprovider, each runner for arunnerprovider), dropped at run end and re-resolved after a restart — a run sees one consistent view of the vault and an out-of-band rotation is seen by the next run. A run step may declaresecrets_written: [<reference>, …]: at its successful completion the server bumps the reference's epoch for the run (run_secret_epochs, shared across replicas), evicts its snapshot entry and ships the epoch on every runner'ssecret_refs, so every later step everywhere re-reads it, past the per-path cache;refresh_secrets: truere-reads everything a step carries. Narrow resolution on the Go lane: a declarative step resolves only the references its command,with:, emit data, bound inputs,set_propertiesand${{ properties… }}reads carry; the Ruby lane keeps whole-document resolution. The run-scoped lease (runner protocol 9 — needed, not cosmetic: a pre-9 runner would read theheldmarker's empty wrapping token as an unwrap failure, so such sessions keep the per-step mint): the response-wrapped child token is minted once per run × runner × provider with a TTL bounded to the run's timeout plus slack, its accessor kept inrun_secret_leasesbeside the run; later steps to the same runner carryvault_lease.held, a different runner gets its own mint, a restarted runner (new session) gets a fresh mint with the old lease revoked by accessor, and run end revokes every lease of the run by accessor — the runner's own 066 child token follows the same per-run rule. The rate budget:rate_limit_reads_per_second/rate_limit_mints_per_minuteon the secrets provider record (--rate-limit-reads-per-second,--rate-limit-mints-per-minute;Rate limit:on show) with the platform defaultsecrets.default_rate_limitunder an unset lane; every real provider call (a read on a cache miss, a mint) waits for its slot in a bounded queue —waiting for vault budget (provider "<code>")in the step log — on the server and on the runner (the budget rides the spec assecret_economy); a 429 (or a cloud kind's throttling answer) is honoured with itsRetry-Afterand retried before the retry budget is spent, and five consecutive 429/5xx answers open a circuit breaker that fails steps fast by name for a minute. Counters:snapshot_hits,token_revokes,queue_waits,rate_limited,breaker_tripsand thebreaker_opengauge now land on the provider usage substrate (the runner reports its counts on the step result assecrets_usage; the server writes them), and each run's evidence carries oneinfo:run:vault_trafficevent:vault traffic: provider "platform" 50 reads (152 from the run snapshot), 1 lease; provider "cust-vault" 7 reads at the runner. Migration00140_secrets_traffic_economy_v1(additive: the two columns and the two run-state tables, also ensured by the registry on both stores). Reference:opschain docs secrets-providers("How steps consume it", "Usage telemetry"),runner-executors,settings-reference("Secrets"),resources/secrets_provider,events,cli/admin-secrets-providers-create|update|show; lesson 3.5. opschain runner migrate-vault-env: theOPSCHAIN_VAULT_*runner-host environment lane retired — a runner started with the variables and nosecrets_providerssection refuses to start by name; the one-off command writes the section from the environment (credential referenced by variable name, never copied) and exits, a no-op on a host without the variables.OPSCHAIN_VAULT_*names are dropped from--env-passthrough. Manual:opschain docs cli/runner-migrate-vault-env; the MintPress runner runbook carries the fleet recipe.
Changed
- Greyed, named, never hidden — role-gated navigation: GUI only, no gate changed. The dashboard's Operations and Insights tabs now render for everyone and are disabled for a non-admin with "Needs the /admin determination" (the stored tab still falls back to Personal); the sidebar's Administration item renders for everyone, greyed with the lock and the same reason while the admin probe refuses or is still resolving; a governance destination whose role probe refuses renders greyed naming the admitting role ("Needs a governance role on /governance/change", security, agents) beside the existing pack greying, in the sidebar and on the Governance overview cards. Reference:
opschain docs gui-operations("Greyed, named, never hidden"), dated line. - The decision history is reachable from the Inbox and filters on target, kind and date: GUI only. Inbox → Approvals carries a Decision history link (and its empty state reads "Nothing waiting on you. See decided approvals →") to Governance → Reviews & approvals → Decided, which links back to the Inbox. The Decided tab gains Target path (prefix), Kind (change, workflow, trust grant, transport grant, agent release, agent rollover) and Requested from / to (local calendar days, inclusive) beside Mine, Decided by and Outcome, sent as the list request's
target_path,run_kind,created_afterandcreated_beforethe API already carried; the filters live in the URL (?tab=decided&target=&kind=&from=&to=) so a filtered history is linkable. Reference:opschain docs gui-operations(Reviews & approvals row). - One Inbox with role-lit perspectives: GUI only (go-opschain-gui 6308bd3e merged 1b6399cb), no server change. The sidebar's Inbox (
/manage_activity?view=) is the one waiting-on-you destination with the perspectives the signed-in user's roles light: Steps (as before, step-gate approvals included), Approvals (the governance approval checklists with agent trust reviews and break-glass — the former Approvals inbox tab, lit when the change- or agent-governance role lights Reviews & approvals and the pack is not gated), Security reviews (the security role grant review queue, lit by the security-administrator gate) and Scheduled; a perspective the roles do not light is absent, a pack-gated one greyed with the pack named. The Inbox count and title now sum exactly what the page shows that user — waiting steps + checklists waiting on them (+ security reviews when lit) — so the badge never counts what the page does not list. Governance → Reviews & approvals keeps the record (Campaigns, Cadences, Decided, the work items); its Approvals inbox tab is removed and/governance/reviews?tab=approvals(and every approvals-tab link the GUI emits) redirects to/manage_activity?view=approvals, so bookmarks keep working; the bell's approval notifications now land on Inbox → Approvals (they previously fell through to the audit page). Reference:opschain docs gui-operations(Inbox, Reviews & approvals, break-glass and evidence rows), the core-gui-surfaces guide, lessons 2.3, 3.2, 4.4, 5.5, 9.1, 10.3, 10.5, 12.2–12.4, 13.2 and the governance-approvals guide. - Rate limits are visible and settable where they are named, with an explicit unlimited, and that should be represented by -1 or other standard and intuitive way."): Administration → Settings → Global config gains a Rate limits block with one row per family. Secrets providers: the estate default
secrets.default_rate_limitis edited there per lane (reads per second, token mints per minute — the stored value or "Unlimited" when unset, a blank lane removed from the document); a provider record'srate_limit_reads_per_second/rate_limit_mints_per_minutenow take three values — a positive integer (its own budget),0(inherit the estate default) and-1(unlimited at this provider even when the estate default is bounded; anything below -1 refused by name) — the metering, the runner's job-spec budget,opschain admin secrets providers create|update --rate-limit-*andshowfollow, and the GUI dialog offers Inherit estate default / Unlimited / Custom per lane with the resolved default shown ("Estate default: 50 reads/s"), the registry column reading "Estate default (…)" where both lanes inherit and otherwise each lane's figure, "unlimited" or "(estate default)". Inbound webhooks: the new global settingwebhooks.default_rate_per_minutereplaces the hard-coded per-connection 60 (unset keeps 60;-1= unlimited; re-read every 30 s) and a connection'srate_per_minutetakes1–6000(own),0(inherit — now the create default for the generic, GitHub-provisioned and JSM-provisioned connections) or-1(unlimited at this connection); the limiter keeps no bucket for an unlimited budget, the Settings row offers Built-in default / Unlimited / Custom, and the generic, GitHub and JSM webhook dialogs gain the same choice with the resolved default shown. API requests: the v2 request limiter (api.rate_limit.*) renders read-only on the row from the config catalog, noted as set in the server configuration. Model providers: the row states that no rate limit applies — token and spend budgets live on the provider dashboards. No migration. Reference:opschain docs settings-reference(the Secrets and Webhooks subsections, the precedence table),opschain docs secrets-providers,opschain docs inbound-webhooks,resources/secrets_provider,resources/inbound_webhook_connection, theadmin secrets providers create|update|show,admin inbound-webhooks create|updateand the GitHub/JSMprovision-webhookmanual pages,opschain docs gui-operations(Settings, Secrets providers, Inbound webhooks, GitHub, JSM rows). GUI repo (go-opschain-guibuild/b662). Migration00146_inbound_webhook_rate_unlimited_v1widens the connection table's rate check to admit-1and0. - The
go-opschainDocker Compose runs the Go server: the evaluation route the documentation cites now starts PostgreSQL 18, the server and its embedded GUI from the pinned release ofLimePoint/opschain-releases—./configure && docker compose up, the GUI athttp://localhost:8443, theadminpassword printed once to the server log, PostgreSQL published on host port 5432 aspostgres://opschain@localhost:5432/opschainwith thePGPASSWORD./configurewrites to.env(the sibling repository's refresh; its Rails-era stack retired). The route's command isdocker compose up(Compose v2) everywhere it is cited — lesson 0.1,opschain docs storage,cli/server-start,cli/server-configure, the SQLite startup notice, theserver configurehint and the "GUI needs PostgreSQL" page — and lesson 0.1 andstoragename the GUI URL and where the first admin password appears. The release runbook gains the per-train refresh step. - Docs: DriftGuard's designs and cards move to their own repository: DriftGuard's designs, decision records, discussion paper and cards now live in
github.com/LimePoint/go-driftguardon the same paths; this repository keeps dated stubs at the old paths and adocs/driftguard/hub page. DriftGuard runs its own release train in parallel to OpsChain's; the only coupling is the taggedgo-coremodules it consumes. Nothing served changes. - The version matrix becomes "Asset versions by environment", a view of the Assets page: the project sub-navigation's
Version matrixentry and itsversion_matrixroute are gone (no redirect); the Assets page at both levels gains a Table | Versions by environment switch beside its toolbar, the versions view at/projects/<code>/assets/versionsand/projects/<code>/environments/<env>/assets/versions. The project's page shows every asset against the right-most environment as the baseline; an environment's page shows only that environment's assets, columns still every environment, with the baseline preselected to that environment. The dashboard panel is renamed Asset versions by environment, its description reworded to the asset framing, and it carries an "Open page" link to the project's view. What the matrix shows (pins, badges, drift tinting, CSV export) is unchanged. - "Versions by environment" is the project Assets page's view alone and lists project-level assets: an environment's Assets page is the table only — no view switch, and its
/assets/versionsroute is gone. The project's view and the dashboard panel now list every asset of the project: the project-level assets come first, under a-column (shown only while such a row is listed) with the cell showing the template version each is on, then the environment-scoped assets as before; the CSV export writes-as a project-level asset's environment. The matrix adds one read per project (the project's own asset list: E + 3). Ruling 2026-11-12 (owner): the "N of M assets differ" summary counts the environment-scoped rows alone — a project-level asset has one cell and cannot drift, so it stays in the table and out of the count. - The server on the SQLite store is a labelled development harness: a server started with no
storebinds to loopback (127.0.0.1:8443) unless--listen/listen:names another address — an explicit non-loopback bind is honoured but called out, since that server has no sign-in; once its socket is open it prints a startup notice naming the posture (development harness on the embedded SQLite store), what it serves (the run engine behind the run/step/log API, the runner and agent fleet, licensing, the local secret store), what it does not (users and sign-in, the estate, templates, the workflow catalog, notifications, governance, providers, the GUI), the bound address and the evaluation route; and the GUI is not served on the SQLite store — every GUI path at the root origin answers one plain HTML page (200, no scripts, no external assets) saying the GUI needs a server on PostgreSQL, while/api, the health surfaces and the admin plane answer as before (gui.enabled: falsestill restores the bare root). The AGE secret store and the static admin token are kept. The v1 "PostgreSQL required" guidance no longer claims the SQLite store serves the GUI or node-path/event reads. The evaluation route for the full server is thego-opschainDocker Compose (PostgreSQL, the server and the GUI in one command, kept current per release): lesson 0.1,opschain docs storage("Evaluating the full server"),conceptsand theserver start/server configurepages cite it, andserver configureprints the route and the DSN its PostgreSQL answers (postgres://opschain@localhost:5432/opschain, password inkeys.env) whenever the persisted store is the SQLite file. A server on PostgreSQL and the local lane are unchanged. - The three postures, named:
opschain docs concepts("Where OpsChain runs: the three postures") and lesson 0.2 ("Three postures, not two") record what each posture is and what it is for — the local lane (one implicit principal, the repository's.opschain/tree, the home's SQLite store and secret store; for developing and evaluating actions and workflows, and CI), a server on the SQLite store (the run engine, the runner and agent fleet, licensing and the local secret store behind the HTTP API; a development harness for tests and GUI work that holds no users, estate, templates, workflow catalog, notifications or governance, and is not an evaluation posture for the estate features), and a server on PostgreSQL (the product). Process: every new or reopened design carries a## Posturessection and every accepted backlog item a Local-lane behaviour line ("refused by name because" is acceptable; silence is not). - Provider usage telemetry on every registered provider family, and model usage folded into it: one unified substrate —
provider_usage_minute/provider_usage_rollup/provider_usage_counters/provider_usage_rollup_state— replaces 's fourmodel_usage_*tables; migration 00139 creates the unified pair and drops the unreleasedmodel_usage_*tables, a stated one-time pre-release exception to the additive-only rule (v5.7.0 has not cut; only the dev snapshot and the verify server carry them). Metrics are rows, never columns: the model lane lands each served call ascalls,tokens(input + output, the metered total whose peaks andvalue_maxare the token peaks),input_tokens,output_tokens,cache_read_tokens,cache_creation_tokenson the model the response reported (detail),refused_callson a cap refusal; the gateway's write path, the six token-cap windows' counter reads, the summaries, the CSV export, cap recommendations, the trust review and the posture page all read the unified rows unchanged (internal/server/modelusageis nowinternal/server/providerusage). The settings are renamed outright, no aliases:model_usage.timezone→usage.timezone,model_usage.fiscal_year_start→usage.fiscal_year_start,model_usage.minute_retention→usage.minute_retention(14 days),model_usage.hour_retention→usage.hour_retention(90 days) — one calendar and one retention pair for the whole substrate, the project override unchanged;model_usage.cap_warning_fractionstays the token budgets' own key. Every family records at its existing seam through one call (providerusage.Record), batched into the minute table: secrets providers, transport providers (checkouts,sessions_minted,session_failuresat dispatch), image build providers (builds,build_failuresat the crossing) and registries (pusheson the image artifact upload with the image as detail,push_failuresat the crossing;build_secondsandbytes_pushedregistered, unfilled — the server sees neither today), identity providers (logins,token_exchanges,refusals,jwks_refreshesin the federated handlers, the LDAP bind and the OIDC client), SIEM and OTLP connections (events_shipped/spans_exported,ship_failures/export_failures, thebacklog_depthgauge counted after each delivery), GitHub connections (api_calls/api_errors/tokens_minted/rate_limit_remainingon the installation-token mint,webhooks_received/signature_failureson the inbound lane) and JSM connections (api_calls/api_errorswith latency on every request, the inbound lane). The reads: v2ModelUsageService.ListProviderUsage(kind, code?, grain, from, to, metric?, group_by?, project_code?, timezone?)answers oneProviderUsageRowper bucket, provider and metric with one total per registered metric, andListProvidersOverview()every registered provider of every family with its health, today's count and errors on the reporting calendar and its gauges — both behind the provider-record administrator's verdict;opschain admin providers usage --kind … [--code …] [--grain …] [--from …] [--to …] [--metric …] [--by …]andopschain admin providers overview(-o json). OTel: every metric is exported per provider on theotlp_httpSIEM connection asopschain.provider.<metric>(provider.kind,provider.code,provider.detail) on/v1/metrics, once a minute on the leader, cumulative sums from the minute's start and gauges. Reference:opschain docs provider-usage(absorbingmodel-usage, the model section kept),resources/model_usage,cli/admin-providers,cli/admin-providers-usage,cli/admin-providers-overview,settings-reference("Usage"),integrations-siem("The usage metrics"), and a usage-telemetry section on each family's page. The per-provider dashboard page and the providers overview page land in the GUI slice. - A server on PostgreSQL never reads the local AGE store: on the PostgreSQL store the zero-config "dev best-effort" fallback that resolved
secret-vault://references through the server home's AGE store is removed — with no provider registered a reference fails by name (no secrets provider is registered …), a step carrying one fails before dispatch, and/api/secretsrefuses; the literal reference is never passed through as if it were the value. The posture rule: the AGE store serves the local lane and a server on the SQLite store (both single-node by construction); a server on PostgreSQL resolves only through registered providers. Migration00137_secrets_providers_resolution(additive,secrets_providers.resolutiondefaultserver). - A cleaner GUI sidebar: the bordered section boxes give way to eyebrow labels, 32px rows with 16px icons and 13px text, an active row marked by a left accent bar and a light primary tint instead of a solid fill, and count pills and the pack lock in the row flow (a pack-gated destination is one row, greyed, with the pack named in its tooltip — still never hidden). The structure changes with it: Dashboard and a new top-level Inbox on top — the Inbox is the Manage activity page, its one count the steps waiting on you plus the approval checklists waiting on you (Manage activity leaves Estate; Reviews keeps no bubble of its own) — then Estate (Projects, Workflows, Activity, Chains, Artifacts, Infrastructure) and Governance (Evidence, Reviews, Policies, Agents, Security, Classification, Audit — the short labels, the full name as the tooltip), with Administration a plain item above Documentation in the footer beside the version (no Admin box; still shown only to admitted sessions). Security administration becomes the Administration tab of the Security page (
/manage_security/admin, the same probe gate) and Notifications governance moves to Administration → Platform (/admin/notifications_governance, admin-gated as before); the old/governance/securityand/governance/notificationsURLs redirect. The Compliance placeholder is hidden from the sidebar and the Governance landing page until the module ships. The collapsed sidebar is icon-only with tooltips. Reference:opschain docs getting-started/core-gui-surfaces("The sidebar"),opschain learn 9.1. - The MintPress runner image takes its MintModel transformer from the 2026-11-04 MintPress release (
examples/mintpress-runner-image/Dockerfile): the donor stage is nowlimepoint/opschain-mintmodel-api:2026-11-04behind theMINTMODEL_API_IMAGEbuild arg — the 2026-11-04 release removed the separateopschain-mintmodel-steps-apiimage (last tag 2026-10-08) and ships the transformer inside the MintModel API image; the runner still serves the transform face on :7001 from/limepoint/transformer. - The MintModel re-renders only when the MintModel changed: the render gate now keys the stored render on the git tree id of the pinned commit's
MintModel/folder plus the properties fingerprint, not on the commit. A new commit that touches nothing underMintModel/— anactions.rbedit, a README, a floating version repinned by the periodic fetch — reuses the stored concrete model and step trees (the row is materialised for the new commit, so the catalog, the templated-change validity gate andmintmodel_validresolve it) and the job runs theactions.rbdiscovery alone: no archive, no render, no runner time on an unchanged model. A commit that changes anything underMintModel/, or a change to the converged properties, re-renders as before; a commit that dropped the folder runs as a version that is not MintModel-backed; a concretise request always re-renders. Rows written before this release carry no tree id and still reuse on their own commit, stamped on the way (migration00132_asset_mintmodel_actions_tree_id, expand). The generation task records which path it took (mintmodel_render:rendered|reused|absent) and its step log announces it (=== MintModel rendered,=== MintModel reused (tree unchanged)). Reference:opschain docs resources/template("MintModel versions" — the reuse rule),resources/mintmodel("What makes a MintModel valid"). - An inherited
secret-vault://reference now resolves through the vault of the node that supplied it: under multi-vault, a bare reference whose converged value came from a database layer — a project, environment or asset document entered in the product, a template layer, a MintModel override lifted from one — resolves through the deepest scope binding covering THAT node, not the node resolving it; so a project's shared secret keeps reading from the project's vault when a change runs under an environment bound to its own vault (the MIG-02 scenario; Rails parity with opschain-api22529aa40). Repository references are unchanged: a reference committed in the template's properties, and a per-run property override, still resolve through the binding covering the resolving node (one committed path, each estate's own vault). This applies in every lane — the step context, the refresh/render task, and the interactive reveal (POST /api/secrets, per value). Two reserved spellings join the provider-qualified form in the@slot:secret-vault://origin@<path>/<key>routes by the supplying node regardless of layer,secret-vault://here@<path>/<key>by the resolving node regardless of layer;secret-vault://<code>@<path>/<key>keeps winning over everything, andorigin/hereare refused as provider codes at create. The step's setup log recordssecrets: resolved via provider "<code>" (origin <node-path>, …)for every value routed away from the run target's provider — never a value;opschain admin secrets bindings resolve <node-path> --origin <node-path>(v2ResolveSecretsProviderBinding.origin_path, responseorigin_secrets_provider/origin_scope_path) shows the forms side by side. Refusals by name: a supplying node no binding covers while no root binding names a default (the property path and the origin node are named), anorigin@reference with no supplying node (a run input), and a reference carrying the reserved?opschain_level=option. Estates with one provider, or whose bindings all point at the same provider, see no change. Reference:opschain docs secrets-providers,cli/admin-secrets-bindings-resolve,resources/secrets_binding; lessons 3.5 andsecrets-providers. - A template version pins its pack versions the way it pins its Git SHA — the explicit per-version pack pin: for a pack its
.opschain/packs.yamldeclares WITHOUTversion:, an unlocked template version may now choose the installed version it seals, independently of the estate-wide admin pin:opschain templates version upsert <p>/<code> --version <v> --pack <name>=<version>(repeatable;--pack <name>=clears the pin back to the estate default), the v2UpsertTemplateVersion.pack_pins(TemplateVersionPackPin {name, version}), the v1 upsert'spack_pinsattribute, and "Select version" on the GUI's Sealed packs panel. Every pin now records its provenance —sealed_packsentries gainmountanddecided_by(declared|admin_pin|highest_installed|explicit) on v1, the v2TemplateVersion(newsealed_packs,SealedPack) and the CLI's-o json— stored in the new nullablepack_pins.decided_bycolumn (migration00129_pack_pins_decided_by, expand; rows written before it read as unknown). Fetch revision honours the pin: a refetch re-resolves every pack as before, but a mount with an explicit pin keeps it, re-validated as installed (an uninstalled one refuses the fetch by name) — the 8 November1.0.0+0.2.5silently moving back to the admin pin's 0.2.4 cannot recur; locked and archived versions are unchanged (no refetch, no pin change). Refusals by name: a locked version, a pack whose declaration carriesversion:(pack <name> is set in .opschain/packs.yaml …, v2failed_precondition+ newERROR_REASON_PACK_DECLARATION_SEALED), a version not installed, a pack the version's commit does not declare, and a version in use unlessignore_in_use(with it, the pinned assets' catalogs refresh the way a fetch refreshes them). Each move is auditedinfo:template_version:pack_repinned.templates re-resolve --versionnow writes the explicit pin on every version it cuts instead of refusing — the refusal and its message retire (ERROR_REASON_PACK_TEMPLATE_UNSEALEDstays in the enum, deprecated, never emitted); the dry run stateswould set the explicit pin X on N template(s), the response and audit event carrydecided_by; omitting--versionkeeps the pin-default cut with its provenance. Reference:opschain docs resources/template(sealed_packs,pack_pins),pack-store,cli/templates-version-upsert,cli/templates-re-resolve,cli/admin-packs-pin,cli/admin-packs,api-v2,events; lesson 2.4. - The Standard/Enterprise project type is removed: every project holds what Enterprise projects held — environments, project-level assets, entities, agents, templates, workflows and changes — and the licence
assetsfeature and limit are the only gate on asset creation (the refusal by name is the licence message). The type gated exactly one write, could not be changed after creation and defaulted to the refusing value, so the default path led to the "assets require an Enterprise project" refusal after the model was built; Rails removed it on 2026-11-02 and this mirrors that position. Compatibility:project_typeis still accepted and ignored on project create and update (v1 JSON:API and v2CreateProject, where the field is marked deprecated), so older clients and YAML files keep working; it leaves project responses (v1 attributes, v2ProjectandRestoredNode, the node export, the compare-nodes child inventories now list every project's assets and its environments' assets, andstep_context.jsonparents), the OpenAPI spec and the data dictionary. The CLI accepts--project-typeand theproject_typefile key with a deprecation note on stderr and no effect;nodes show|listdrop theproject_typeline/field;nodes createhelp, thecli/nodes-createmanual and lesson 1.2 lose the Standard/Enterprise guidance. The GUI's project create dialog and projects table stop sending and showing the type, and the run-workflow picker lists every unarchived project. Thenodes.project_typecolumn stays (additive-only schema); nothing reads it. Upgrade note: authorisation rules on former Standard projects now also govern the project-level paths those projects gain (assets, agents, templates, workflows, changes) — review them after upgrading. Reference:opschain docs resources/project,resources/node,resources/node_comparison,cli/nodes-create,api-v2. - The unregistered server runs on an evaluation footprint; the Basic tier is the registered one: with no licence document stored the embedded defaults (
pkg/licence/defaults.json) now carry 1 project, 1 environment, 1 asset, 1 runner, 1 runner group, no agents, no GitHub integration (everygithub_*cap 0) and no Academy — featuresassets,identity:builtin,packs:common,templates,triggers, grants sourcedevaluation(the literalfree-tiergrant-source identifier is gone). Today's Basic limits — 1 project, 3 environments, 1 asset, 3 runners, 1 runner group, the GitHub integration with its 3 / 1,000 / 1,000 / 0 caps,packs:academy— come only from the registered Basic licence the LimePoint Licence Service mints on self-service registration (tier: basic,enforcement: online, 365 days).packs:academystays baseline-granted on every licensed resolution and is withheld on the footprint. Upgrade note — no grace window (owner "Option 1"): an unregistered server upgrading to this release lands on the footprint at once; the product's lapse semantics carry it — counts are create gates, so every existing project, environment, asset and runner survives (an estate with 3 environments and 3 runners keeps them) and the next create above the footprint is refused by name; the GitHub integration keeps its connections and trust policies, refuses new token exchanges, JIT mints, attestations and webhook provisioning with the existing named refusals and audit events, keeps deletion open, and lights back up on registration. Every refusal on the footprint namesopschain admin licence registerfirst,licence_statusgains a persistentregistrationnotice (state: unregistered+ the sentence; v1/api/infoand the v2LicenceStatus.registrationmessage) andopschain admin licence showprints it as aregistration:line. The hosted registration lane ships switched OFF until the invitation-only period ends; until then evaluators request an evaluation licence from LimePoint. Reference:opschain docs entitlements("The Basic-tier registration lane", "Tiers and resolution"),resources/licence_status,cli/admin-licence-show; lessons 0.0, 1.1, 1.5, 6.2, 13.1; the licensing runbook/a/. Resolvenow unionsgithub_token_mints_per_daylike every other cap (it was the one pointer-shaped limit the per-limit most-permissive union skipped).
Added
- Encryption key lifecycle — rotatable at-rest envelope keys: every value the server encrypts at rest (the property/settings documents and their history, sensitive step outputs, Git remote and credential values, identity-provider client secrets, MFA seeds, secrets/user-vault/model provider credentials, SIEM and inbound-webhook secrets, notification signing secrets, the dispatch mask set, rendered MintModels — the full inventory is in the new encryption key rotation runbook) now carries a key reference (
h.ki: the leading 64 bits of SHA-256 over the derived AES key, 16 hex characters wherever printed), so a key can be rotated: the newOPSCHAIN_PREVIOUS_KEYSkeys.env/environment entry (comma-separated rotated-out primary keys, each optionally<key>:<its own salt>) keeps old keys decryptable while every write seals underOPSCHAIN_PRIMARY_KEY; envelopes written before references existed (or by the Rails application) still decrypt by trial. Newopschain admin encryption status(v2SystemService.GetEncryptionStatus) prints the key ring, the key registry and one set-based census row per encrypted column — envelopes per key id, legacy, pending — exiting 1 while a referenced key is missing;opschain admin encryption reencrypt(ReencryptAtRest, audited asapi:encryption:reencryptwith counts only) rewrites every envelope under the current key in batches of--batch-sizerows per transaction, resumes an unfinished run (--limitspreads a large estate over windows), is idempotent, holds a cluster-wide lock, refuses by name when an envelope references a key the server lacks, and retires the superseded keys once nothing references them. HA key-set gate: the newencryption_keysregistry (migration 00138 / SQLite 00015, with theencryption_reencryptionsprogress rows) records every key that has been a server's current key, and a server booting without a registered, unretired key refuses to start naming the key id — every replica must carry the same key set. Vault-held secrets and the{AES2}cipher-stacked values are explicitly outside this plane (the vault's own rotation;opschain secret migrate). Reference:opschain docs storage("Key references and rotation"),opschain docs cli/admin-encryption, the config and settings catalogs. - The cloud image-build verbs, the image-provenance promotion rule and the approver's image facts:
aws:imagebuilder_run(packs:aws— theimagebuilderSDK client joins the api lane's allowlist, soaws:imagebuilder:*typed actions andaws:api --service imagebuilderarrive with it; a nativeawsu imagebuilder runstarts the pipeline execution by ARN or name — or a one-offCreateImagefrom the pipeline's definition withrecipe_arn/infrastructure_arn/distribution_arnreplaced, never mutating the pipeline — tags the image build version, polls to AVAILABLE with progress, picks the AMI in the provider's region and writes the descriptor withdigest=id:<ami id>) andazure:imagebuilder_run(packs:azure— a nativeazu imagebuilder runover the raw ARM lane POSTs the image template's/run, drains the operation, readslastRunStatusandrunOutputs, picks the gallery image version and writes the descriptor withdigest=id:<gallery version id>). Both takeprovider/registry(image-build://of kindaws_image_builder/azure_image_builder,image-registry://of kindami/gallery),wait(default true),wait_timeout(default 60m) anddescriptor, refuse by name without a provider of their kind, read the contract from the environment and log the credential form value-free;tagsis a JSON object orKEY=VALUElines. S6: the core metadata vocabulary gainscore.image_provenance(required|none; subtree-inherited, protective) — withrequiredeffective on a run's target,artifacts:resolve_imagerefuses by name (besidewarn:image_build:refused,familyimage_artifact,reasonprovenance) an image record that names no build provider or registry, carries no source commit, or whose source commit is not the sealed commit of its producing run; and the approval checklist's context carries the image facts (ApprovalContext.images: roleproduced/consumed, the naming input, reference, identifier, digest, provider, registry, source commit, producing run) for a change that produced or consumes akind: imageartifact, read once per inbox page through the artifact port, printed byopschain approvals show. New BuildKit and Packer pack runbooks. Reference:opschain docs image-builds("The promotion-policy rule", "The approver's checklist"),metadata,resources/approval(ApprovalContextImage),cli/approvals-show,events,artifacts. Thebuildkitandpackerpacks are also raised in the opschain-pack-common source repository (owner-merged PR) so the next re-vendor keeps them. - The agent inventory and posture page: one row per agent enrolled in this estate — never per instance — answering what it is, who owns it, what it may do and where, which release it runs, when it last acted, how much of its budget it spent, whether its transcripts are captured and its model provider approved, and whether it is alive. New v2
AgentPostureService.ListAgentPosture(filter{project_code, tier, agent_type, stale, departed_owner}): eachAgentPostureRowcarries the agent id and governed principal id, project, code, name, type, group, the owner reference, the owner's principal id andowner_departed(the reference is set but the identity catalog no longer holds an enabled user or a known group — an identity-sweep leaver or a removed team; a flag and a count only, tier and caps unchanged, cleared when the access-review work item reassigns ownership), the effective tier from the compiled trust snapshot (the most permissive applying grant clamped by the type ceiling;advisefrom an assignment under the advise floor;none), the assigned scopes, the release with its current stage and artifact digest, instances enrolled and connected, the last governed action (a governed-lane request or a brokered model call: at, kind, outcome, correlation chain), the token-cap windows with the spend against each andover_cap,transcripts_captured, the model provider and its data-handling-approved marker, the disclosures summary, health, last seen andstale; the response adds the summary (total, by tier, stale, over cap, owner departed, without owner), the threshold andcomputed_at. Set-based: one statement over the fleet registry joined to the materialised trust assignments, the instance, channel, release and transcript tables, the identity and provider catalogs (PostgreSQL) and a lateral last-event read off the audit ledger — never a round trip per agent (asserted by a counting driver on both stores) — then a snapshot compile per subject for the tier and one counter read per reporting calendar for the spend (modelusage.UsedMany). New stored settingagents.stale_after(duration, default168h; absent, malformed or non-positive = default): no governed action within it is stale, never-acted included. Newopschain agents posture [--project] [--tier] [--type] [--stale] [--departed-owner] [-o json|csv]— the CSV keys onagent_idandrelease_digestand carries the owner asowner_principal_idonly, never a username or an email. The GUI gains Governance → Agents → Posture (full width, the five filters on the URL, the summary strip, CSV export; a row opens the agent page, its trust review —?agent=<id>now opens the fleet sheet — and the chain of its last action) and the Agents dashboard panel (Add panels, project-scoped,packs:agents: counts by tier, stale, over cap, owner departed, without owner, each opening the Posture tab filtered). Not discovery: the title and the reference page say "enrolled in this estate". Reads need read on the project path, read on the root for the estate; nothing is audited; no migration. Reference:opschain docs agent-posture,resources/agent_posture,cli/agents-posture,cli/agents,settings-reference("Agent posture threshold"),api-v2,agent-plane,ai-agents,gui-operations; the GUI surfaces runbook; lesson 12.6. - EU AI Act and NIST AI RMF mapping lanes in the security audit:
make security-auditand thesecurity_audittrain step now carry two more annexes beside ISO/IEC 42001 Annex A, in the same shape and on the same ratchet. The EU AI Act annex maps the deployer-relevant articles (Art. 9, 10, 12, 13, 14, 15, 26, 72 — 13 rows) as the platform's support for a deployer's obligations and says so in plain words: OpsChain is not itself a high-risk AI system, brokers models it does not train and makes no provider claims. The NIST AI RMF 1.0 annex maps all 72 sub-categories of Govern, Map, Measure and Manage. Each row isattested-mechanicalonly against a named check in the report (seven new shared checks: the evidence ledger's hash chain, the tier ceiling, the approval funnel's events, the model-call event, the review-cadence event, the agent revoke/forget verbs, gosec inmake lint),attested-documentaryonly against a page in the repository, andnot-attestedotherwise with the reason — today 10/2/1 for the EU AI Act (accuracy waits for the evaluation harness) and 16/40/16 for NIST (the Measure function names the evaluation harness, the token telemetry and the agent posture page as what builds its evidence). Each annex has its own committed baseline (tools/security-audit/baselines/eu-ai-act.json,nist-ai-rmf.json): a control attested at the last cut that attests lower now fails the step (exit 3) naming the annex and control;make security-audit-baselineand the train's step 0b advance all three; the step publisheseu_ai_act_ratchetandnist_ai_rmf_ratchetbesideannex_ratchet. The attestation posture — tool-assisted attestation of practice, never certification or legal advice — is stated on both. The questionnaire-shaped registers ship with the product:opschain docs compliance/eu-ai-actandopschain docs compliance/nist-ai-rmf(article or sub-category, what OpsChain provides, evidence pointer, status). Reference:opschain docs security-audit;docs/compliance/README.md; the release-train runbook (steps 0b and 1b). - The ledger as OpenTelemetry traces — the
otlp_httpSIEM connection kind: a third kind on the existing forwarder (same durable cursor, cross-replica lease, event-type filter, at-least-once batches) exports each correlation chain as one trace over OTLP/HTTP (POST <endpoint_url>/v1/traces,protocolprotobuf by default or json per the OTLP/JSON mapping) to Grafana Tempo, Jaeger, Datadog, an OpenTelemetry Collector or a governance tower's collector. The trace id is the chain key's UUID bytes verbatim (coalesce(correlation_id, id)); each batch emits one synthetic root span per chain named by its unit of work (change <id>/run <id>/chain <id>) spanning the earliest to the latest event seen, and one span per event (name = the event type, an instant) whose parent is the recorded causing event where the chain records one, else the root — no causality invented; span ids are SHA-256 over the id's UUID bytes truncated to eight (the root's domain-separated), so re-sends are byte-identical and receivers dedupe. Attributes are identifiers underopschain.*(event.id,event.type,node_path,correlation_id,caused_by_event_id,principal_id,change_id,run_id,agent, model provider/name/tokens on model-call events) and, for an event an evidence-ledger attestation cites,opschain.evidence.hash/.seq; the resource carriesservice.name=opschain,service.instance.id,opschain.estateandopschain.installation_id. Usernames and emails are never attributes. The optional token travels asAuthorization: Beareror in a namedauth_headerwith the HEC token's write-only custody; TLS trust isssl_verify+ssl_ca_cert. A receiver's 4xx is a named failure carrying the status, never the body.opschain admin siem create --kind otlp_http --endpoint-url … [--protocol] [--auth-header] [--ssl-verify] [--ssl-ca-cert]and the matching update flags; migration00133_siem_connections_otlp_httpwidens the kind check. Reference:opschain docs integrations-siem("Traces"),resources/siem_connection,cli/admin-siem-create; runbooksgetting-started/integrations-siemand lesson 13.3. - Token telemetry as a core capability and six-window token budgets: every brokered model call now lands in a UTC minute bucket keyed on the model the provider's response reported (not the request's alias, not the provider record) — calls, input, output, prompt-cache read and creation tokens, refused calls and the largest call — and a leader-only sweep rolls the minutes up hourly, daily, weekly, monthly, quarterly and yearly on the estate's reporting calendar: new stored settings
model_usage.timezone(IANA, defaultUTC) andmodel_usage.fiscal_year_start(MM-DD, default01-01), both with a project override, plusmodel_usage.minute_retention(14 days),model_usage.hour_retention(90 days) andmodel_usage.cap_warning_fraction(0.8). Every rolled row records the calendar it was computed under and the peak minute, hour and day that survive aggregation; changing the calendar re-rolls from the minute rows within retention and marks older rows as rolled under the previous calendar. Trust grants gain five token-cap windows besidetokens_per_day—--tokens-per-minute(sliding, the burst cap),--tokens-per-week,--tokens-per-month,--tokens-per-quarter,--tokens-per-yearonopschain admin trust grant(--tokensis now the alias of--tokens-per-day; v2TrustGrantandPOST /api/trust/grantscarry them) — each a shared, replica-safe counter row the gateway checks per call on both stores; the first failing window is named in the refusal and inwarn:trust:cap_refused(window,limit,used,timezone,window_resets_atas the local boundary), and the newwarn:trust:cap_approachingfires once per window per period at the warning fraction. The trust review shows observed-versus-cap per window (token_caps) with the minute p95 and peak. New v2ModelUsageService(SummarizeModelUsage,ExportModelUsageCSV,RecommendCaps; read on the project path) andopschain agents usage [--project] [--agent] [--since] [--grain] [--by agent|provider|model|project|grant] [--timezone] [-o json|csv]with--recommend-caps --grant <id>(p95 × 1.5 per window) and--proposeraising the replacement grant through the approval funnel. The GUI gains the Model tokens dashboard panel (Add panels, project-scoped), a Usage tab on the project agent page and per-window gauges on the trust review sheets. Tokens and calls only — no price, cost or currency anywhere; model routing by cost is not wanted. Migration00134_model_usage_v1(expand; the registry ensures the same tables on SQLite). Reference:opschain docs provider-usage,resources/model_usage,resources/trust,ai-agents("Budget, rate and token caps"),model-providers,settings-reference("Model usage"),events,cli/agents-usage,cli/admin-trust-grant,cli/admin-agent-trust-review; lesson 12.6. - The
buildkit:buildandpacker:buildverbs in the common pack: the bundledpacks:commonsnapshot gains two families.buildkit:buildbuilds a Dockerfile in the run workspace against the dispatchedimage-build://provider of kindbuildkit(BUILDKIT_HOST, the mTLS client certificate atIMAGE_BUILD_CREDENTIAL_FILE), pushes to the dispatchedimage-registry://through the login atIMAGE_REGISTRY_CONFIG_FILE/DOCKER_CONFIG, and writes the image descriptor thekind: imageartifact records — inputsimage,context,dockerfile,tags(comma-separated),build_args(a JSON object orKEY=VALUElines),platforms,target,push(default true when a registry resolved),sbom(a BuildKit attestation, and the SBOM file beside the record when the call site declares one), every one on the input environment lane, never spliced. Tool precedence:buildctlfirst,dockerwith buildx through a remote builder on the same daemon whenbuildctlis absent;jqis required.packer:buildrunspacker init+packer buildon a committed template against a provider of kindpacker(its credential already in the environment in the builder's form), reads the artefact id from the template'smanifestpost-processor (custom_data.image_id, elseartifact_id) and writes the descriptor withdigest=id:<artefact id>— inputstemplate,var_file,vars(a JSON object orKEY=VALUElines),only,except,manifest,init. Each pack carries anensureaction (PATH → the runner cache → a sha256-verified download from a configured URL; a public URL is never a default) that the build step also runs inline, so a step landing on any runner of the group resolves its own tool. Refusals by name: no build provider resolved, a provider of another kind, push with no registry, a missing dockerfile / template / var file, no manifest written, no tool, nojq. Two engine changes the documented contract needed: anartifacts:declaration is now legal on a composeduses:call site and lands on the composed action's lastrun:step (refused on built-in,actions.rb#andmintpress#steps, and on a name the composed action already declares), and a step whose declaration iskind: imageseesOPSCHAIN_IMAGE_DESCRIPTORandOPSCHAIN_IMAGE_SBOMnaming where to write. Reference:opschain docs packs-common(both families, the worked example),image-builds,artifacts("Declaring artifacts", "The descriptor file contract"),orchestration; lesson 2.4. The hosted platform's own image build moves onto thepackerkind in limepoint-licence-utility. Still to come from the same design: the AWS and Azure run verbs, the promotion-policy rule and the approver's checklist facts. - The image as a typed artifact:
artifacts:on arun:step gainskind: image— the step writes an image descriptor (.opschain/image/<name>.jsonby default:identifier,digestassha256:<hex>orid:<resource id>, optionalprovider,registry,detail) and the collector records the image on the run's node with the producing run, step and sealed commit, the provider and registry codes defaulting to the step's dispatch contract, and an optionalsbom:file as the record's only bytes. The record is addressable asartifact://<node path>/<code>@<digest>; the new built-inuses: artifacts:resolve_image(with: image:) is completed by the server like an emit step — it verifies the reference (exists, is an image, carries the digest, not purged, readable by the run principal) and publishesidentifier,digest,provider,registry,source_commit,run_idand the rest as the step's outputs, recordinginfo:image_build:artifact_resolvedon the consuming run's chain. Every artifact read (v1 and v2) carrieskindandimage;ArtifactService.ListArtifactstakesfilter.kind;opschain artifacts list --kind imagelists the records with their facts; the GUI artifact lists show the kind badge with the identifier and digest. The runner protocol carries the declaration (StepArtifact.kind/sbom,PutArtefactRequest.kind/image); the local lane's store gains the columns (SQLite migration00014_artefact_kind_v1). Reference:opschain docs artifacts("Image artifacts"),resources/artifact,cli/artifacts-list,image-builds. Not yet delivered from the same design: the promotion-policy rule and the approver's checklist facts, thepacks:commonbuildkit:build/packer:buildverbs and the hosted image build onpacker, the AWS and Azure run verbs. - The image build dispatch crossing: a step's
with:value, input or converged property that is, in its entirety,image-build://<code>orimage-registry://<code>is resolved at step dispatch —with:over theopschain.image.build_provider/opschain.image.registryproperty over the deepest binding covering the run's target, every candidate within the bound set, the run principal's read on/platform/image-build-providers/<code>//platform/image-registries/<code>checked, the reserved GCP kind and an unentitled cloud kind refused by name — into the backend's environment contract (IMAGE_BUILD_PROVIDER/_KIND/_ENDPOINT/_REGION/_DEFAULT_<KEY>/_CREDENTIAL_FILE,IMAGE_REGISTRY/_KIND/_HOST/_REPOSITORY/_CONFIG_FILEwithDOCKER_CONFIGandREGISTRY_AUTH_FILE) and a short-lived credential: an STS session forassume_role, an ECR authorisation token, an ACR refresh token from a service-principal token, a Google access token from a service-account key or the host's workload identity, the GitHub connection's installation token forghcr, or the stored client certificate / basic / token written to a file for the step's lifetime — every value in the mask set, nothing persisted. The input that carried the reference reaches the step as the record's code. Each use recordsinfo:image_build:provider_used; each refusal is a named step failure besidewarn:image_build:refusedwith a typed reason. The runner protocol moves to 7:JobSpec.secret_filesmaterialises credential files under the step's data directory with owner-only permissions (an older runner is refused by name for a step that needs one, never offered it without its credential). Thetestverbs now probe for real: the credential parses as its form, STS / the identity platform / the token endpoint answers, a BuildKittcp://daemon connects, a registry's/v2/answers. Reference:opschain docs image-builds("The dispatch crossing"),opschain docs events. - Image build providers and image registries: two new catalog records beside the identity, secrets, model and transport providers. An image build provider (
buildkit,packer,aws_image_builder,azure_image_builder;gcp_cloud_buildreserved in the enum and refused by name — "no installed pack serves this kind") is where a build runs; an image registry (oci,ghcr,ecr,acr,gar,gallery,ami) is where the result is pushed, one record per credential withrepositoryas the prefix. Both carry reference-first custody (credential_ref, inline values vaulted underimage-build-providers/<code>/credential/image-registries/<code>/credential), a kind-checkedcredential_kind(theghcrkind may name agithub_connectionin place of a stored token), an enabled flag, a probe (test) and mandatory bindings: a record is usable only under the estate subtrees it is bound to (/is the explicit estate-wide form), the create flows offer the first binding, and delete is refused while bound. New v2ImageBuildProviderService/ImageRegistryService(list/get/create/update/delete/test/bind/unbind, every verb on the/adminupdatable determination, auditedapi:image_build_providers:*/api:image_registries:*), CLIopschain admin image-build-providers/opschain admin image-registries, the Administration → Image builds GUI screen, rule-path families/platform/image-build-providersand/platform/image-registriesin the catalog, migration00135_image_build_providers_v1. Reference:opschain docs image-builds,resources/image_build_provider,resources/image_registry,cli/admin-image-build-providers,cli/admin-image-registries. The dispatch crossing (image-build:///image-registry://at step dispatch) and thekind: imageartifact follow in the same card. - The Git fetch records ref moves, so a push to any host fires triggers: after every successful fetch of a Git remote the server compares its watched refs — the default branch (unless the remote's new
watch_default_branchis off;--watch-default-branch=falseongit-remotes create|update, thewatch_default_branchfile key and v2 field), the branches floating template versions follow, and every branch or tag an enabled trigger names through its conditions — against the ref snapshot kept from the previous fetch (new tablegit_remote_refs, migration00136_git_remote_refs_v1, expand) and records oneinfo:git_remote:ref_movedper ref that moved, carrying the SHAs the platform observed (ref,kind,change—fast_forward|forced|created|deleted,old_sha,new_sha,commits,fetch), never an author, email or message. A trigger binds to it withevents: [info:git_remote:ref_moved]and conditions such asevent.data.ref eq refs/heads/mainandevent.data.change in fast_forward,created— writing the trigger is what starts the watch, from the remote's next fetch;${{ event.data.new_sha }}feeds the run. A forced move always recordswarn:git_remote:forced_pushbeside the event and fires triggers only when they admitforced; moves of one ref inside the remote's fetch interval coalesce into the open event (the firstold_sha, the latestnew_sha,commitssummed,extended: true); a forced move or a deletion never coalesces. The remote's first fetch after upgrading seeds the snapshot and records nothing. A floating template version's repin (info:template_version:rev_repinned) now rides the chain of theref_movedevent that caused it (caused_by_event_id,ref_moved_event_idin its data), so a push and the repin it produced read as one story in Chains. New reads:GitRemoteService/ListGitRemoteRefsandopschain git-remotes refs <project>/<name>(the watched refs at their last-fetched tips with their last move),last_ref_move_aton the remote;info:git_remote:fetchedgainsfetch(what caused it). Reference:opschain docs triggers("The git ref-move source"),events,resources/git_remote("Ref-move events"),cli/git-remotes-refs,cli/git-remotes-create,cli/git-remotes-update,cli/git-remotes-show; lesson 3.4. - Expressions read repository content anchored to the sealed commit —
node.content(path),node.list(glob), and the two named exceptionstemplates(c).versions(v).content(p)andgit.content(remote, sha, p): a workflow reads a file from the repository it is acting on without a shell step, and the read is anchored:node.content('deploy/manifest.yaml'),node.list('deploy/**/*.yaml'), the same two methods onestate('<asset path>')and on every assetestate(<path>).assetsenumerates (.map(a, a.content('deploy/manifest.yaml'))) resolve through the asset's pinned template version to its sealed commit — the author never names a revision, a floating version reads the commit it is sealed at, never the live tip. Assets only: a project or environment refuses by name (content() needs a sealed commit — … is not pinned to a template version; read its assets), whether reached throughestate()or as the run's target. A read returns UTF-8 text capped at 1 MiB; a binary file, a symbolic link, a submodule, an oversized file, a missing path, a leading/or a..refuse by name, andcontent(path, {binary: true})is reserved (refused by name until binary reads ship). Every value passes the run's mask set. Where it may land: a step input, an environment variable, awith:value or awhen:condition — neverset_properties, a workflow step'sproperty_overrides, an action'semit … with:or an input default, each refused at compile by name. Authorisation: read on the node AND read on the new rule-path family/git/content<node path>/<repository path>(catalogued, node-scoped, enforced), both with the run principal; nothing grants it by default, a glob grants one folder of one subtree (/git/content/projects/payments/**/deploy/**), the path is canonicalised before the lookup,content()on an unpermitted path refuses by name andlist()returns only permitted paths. Evidence: a content-bearingrun:/with:is evaluated at the step's entry and every read lands on the step ascontent_reads{form, node, anchor, commit, path, sha256, bytes}(v2Step.content_reads_json) — never the content. Two named exceptions:templates('foo').versions('1.1.0').content('files/foo.bar')reads a version no asset pins yet (looked up in the run's project; read on the project path plus the content family under it) andgit.content('demo-repo', event.data.new_sha, 'deploy/manifest.yaml')/git.list(…)read the commit aref_movedevent observed (the remote looked up in the run's project; the revision must be a full SHA — a branch or tag name refuses by name; a commit the mirror does not hold refuses by name and hints the fetch; read on the project's/git_remotesplus the content family under the project). Binary reads stay reserved:{binary: true}refuses by name on every form. Local runs have no git seam and refuse by name. Browse lands with it:TemplateService/ListTemplateVersionContentandReadTemplateVersionContentlist the tree at a version's sealed commit one directory at a time and read one text file — authorised like the version read plus the content family with the caller's own authority, so a listing marks a file readable only where a rule grants it and lists a directory only when something readable sits beneath it (opschain templates version browse|cat, the GUI's Browse card on the template version page beside Sealed packs). The GUI also shows each remote's default-branch watch and last ref move on the project's Git remotes table (a click lists the watched refs with their last move, links the recent moves and fetches the remote now), adds the watch toggle to the remote form, and offersinfo:git_remote:ref_movedin the trigger editor's event vocabulary with its condition fields. Reference:opschain docs expressions("content()andlist()"),resources/step,resources/workflow_run,governance-matrix,api-v2(ListRulePathCatalog),triggers. - Webhooks become fetch hints, and a remote can be fetched now: a webhook never carries the trigger — the payload's claimed SHA is discarded and the fetch decides. A verified inbound delivery to a connection whose new
git_remote(<project>/<name>;opschain admin inbound-webhooks create|update --git-remote, the v2 field,GIT REMOTEinlist/Git remoteinshow) names a remote schedules that remote's fetch now, and a delivery carrying GitHub'spushenvelope schedules the fetch of every active remote on the repository the payload names (clone_url/ssh_url, elsefull_nameon github.com) whether or not the connection names one — which is how GitLab, Bitbucket, Azure DevOps and on-premises hosts get push-latency triggers through the generic lane's HMAC with no per-host signature adapter. Hinted fetches are rate-limited per remote (one in flight, hints during it coalesced into one follow-up, at least thirty seconds apart — deferred, never dropped), a hint inside the remote's fetch interval extends the openref_movedevent, and the delivery's ownexternal:webhook:receivedstill lands as the record.GitRemoteService/FetchGitRemoteandopschain git-remotes fetch <project>/<name>fetch a remote now over the same lane, synchronously (update on the project's/git_remotesrule path; an archived or disabled remote is refused as not found; auditedapi:git_remotes:fetch); everyfetched/ref_movedevent names its cause infetch(scheduled|hint|manual). Reference:opschain docs inbound-webhooks("A delivery as a fetch hint"),integrations-github,resources/git_remote,resources/inbound_webhook_connection,cli/git-remotes-fetch,cli/admin-inbound-webhooks-create,cli/admin-inbound-webhooks-update,api-v2,events. - The ISO/IEC 42001 annex ratchet on the release train: the
security_audittrain step now compares the report's ISO/IEC 42001 Annex A annex against the committed baselinetools/security-audit/baselines/iso-42001-annex-a.json(one attestation status per Annex A control plus the pack carve-out scope boundary, theattested_atcut recorded) and fails the step (exit 3) when a control attested at the last cut attests lower now —attested-mechanical→attested-documentary→not-attested, a missing or empty evidence page, a mechanical check failing, the carve-out breaking, or a baselined control leaving the annex — naming the control, its baseline and current status in the report's "Annex ratchet" section, the step log and the newannex_ratchetstep output; new or higher coverage passes. The report files are now collected as thesecurity_audit_reportartifact on the make step itself, so a failed step keeps its report.make security-audit-baselineadvances the baseline when coverage grew (refused while a regression stands) andmake release-trainruns it after the CHANGELOG roll as a review stop; accepting a lower status is a reviewed hand edit of the baseline carrying areason. Findings elsewhere in the report stay report-only until , which reuses this baseline directory and comparison shape. Reference:opschain docs security-audit("The 42001 annex ratchet");docs/compliance/aims/README.md; the release-train runbook (step 0b, step 1b). - The infrastructure inventory summary and the last-used stamp: the new v2
InfrastructureService.SummarizeInfrastructureanswers an inventory's health over a scope in one set-based read — the project ×infrastructure_type×lifecycle_statematrix (InfrastructureSummaryCell, archived rows counted apart), the exception totals (InfrastructureExceptions: stale facts, same-environment duplicate addresses, registered-but-unused, destroyed-but-present, recently provisioned/destroyed from theapi:infrastructure:provisioned/destroyedevents) and the thresholds judged against (InfrastructureSummaryThresholds, withcomputed_at). An emptyscope_pathcovers everything the caller may read (the whole estate under a/read grant, otherwise the readable projects); a project, environment or asset scope rolls its subtree up, 404-on-deny. Infrastructure nodes gainlast_used_at(migration00131_infrastructure_last_used_at, expand; v2 field,opschain infrastructure show"Last used" /-o json), stamped at dispatch when ahost_ref-bound step's transport session is admitted — a refused session is not a use.InfrastructureFiltergainsduplicate_address,last_facts_before,last_used_beforeandchanged_since, each reproducing one exception total exactly so a count is one click from its rows. New stored settingsinfrastructure.stale_facts_after(default168h) andinfrastructure.unused_after(default720h), duration strings read live. The GUI's dashboard panel lands in the GUI half. Reference:opschain docs infrastructure-nodes("The inventory summary", "Settings and configuration"),resources/infrastructure_node,settings-reference,api-v2,cli/infrastructure-show. - Action inputs reach every step as environment variables: each declared input is exported to the step's process as
OPSCHAIN_INPUT_<NAME>(the input name upper-cased) holding exactly the value${{ inputs.<name> }}would splice — defaults applied,with:expressions resolved at dispatch, a run-supplied value containing${{handed over verbatim, asecretinput's reference resolved and routed through the masked secret-injection path — plusOPSCHAIN_INPUT_FILE_<NAME>, a file underinputs/in the step's data-passing directory with the same bytes; a value over 32 KiB travels through its file alone (the value variable is unset — the named environment-string limits are the reason). Reading"$OPSCHAIN_INPUT_BODY"is now the recommended way to consume a JSON body, parameters, tags or a multi-line input: the shell never re-parses it, where the textual splice dropped its quotes before the tool saw them (azure:deployment_applyrefused{galleryName:{value:…}}). The splice stays valid for plain tokens and the packs' quoted-heredoc staging keeps working unchanged. Both executors stage the files identically (the container lane's file variable names the workspace-mount path). Reference:opschain docs orchestration("Consuming inputs inrun:"),runner-executors,expressions; lessons 2.2 and 2.5. opschain admin licence register— self-service registration for the Basic tier: reads the registration schema from the licence service (GET /v1/register; a disabled lane is refused by name), prints the disclosed collection (purposes, observed, declared and platform fields, retention) and requires its acceptance (--accept-notice, or the terminal prompt — piped input never accepts implicitly), takes the declared fields (--organisation,--country,--use-case,--declare name=valuefor schema additions; required fields prompted on a terminal, refused by name otherwise; closed-set and length constraints checked before anything is sent), registers the company email (POST /v1/register), prompts for the emailed token (--tokenfor scripts), completes the registration (POST /v1/register/verify), verifies the minted document against the embedded keyring before anything is applied, uploads it (PUT /api/licence) and stores the installation credential on the server;--save <file>keeps the document (and with no server target, saves it and prints the credential once). New v2LicenceService.StoreLicenceCredentialstores an online installation's phone-home credential in the platform secret vault underlicence/<installation_id>/credential(authorised as update on/licence, never echoed;not_foundwithout a vault), and the server's verification rounds read the credential fromOPSCHAIN_LICENCE_CREDENTIALfirst and that vault path second. Reference:opschain docs cli/admin-licence-register,cli/admin-licence-verify("The installation credential"),api-v2; the licensing runbooka.- Entity — a minimal core estate node: an entity records anything worth keeping in the estate (a schema, a rack, a building) with no template and no automation — no actions, changes, MintModel or background tasks. It lives under a project, environment, asset or another entity (
<parent path>/entities/<code>) and parents entities only; it is core OpsChain, with no entitlement and no count limit. Entities carry properties and settings (anentityconverge layer), properties history, metadata (entityis a valid kind forapplies_to/applies_to_kinds), asset owners, maintenance windows, bookmarks, archive and soft delete / restore / destroy / export, authorised by path, 404-on-deny. The v1 node-path lanes recognise theentitiescollection (index, show, create, update, delete, restore, destroy, export); asset-only sub-resources answer 404 on an entity, a change targeting one and an environment or asset created under one are refused by name. The new v2EntityServiceservesListEntities(keyset, code-ascending, code/search/archived filters),GetEntity,CreateEntity, the set-basedBulkCreateEntities(at most 1000 per batch, per-item refusals) andUpdateEntity/DeleteEntity(PostgreSQL only; the embedded store answersERROR_REASON_STORE_POSTGRES_REQUIRED). Writes record the node event family withkind: entity. Migration00128_entity_nodes_v1(expand) adds the nullable conversion columns for the coming promote/demote verbs and the entity-only partial indexes. Reference:opschain docs resources/entity,resources/node,api-v2,events; the core platform guide's "Entities" section. opschain entity— the Entity CLI:entity list <parent-path>(keyset-paginated,--code/--search/--archivedserver-side filters,--page-size/--page-token/--all, the shared--filter; PATH, NAME, ARCHIVED, CONVERSION, MAINTENANCE),show <entity-path>,create <parent-path> --code --name [--description],bulk-create <parent-path> -f batch.yaml|batch.json(strictitems[].code/name/description; a file over the server's 1000-item cap is split into consecutive batches, per-item refusals printFailed <code>: <reason>, a refused batch stops the run stating what earlier batches submitted),update <entity-path> [--name] [--description] [--archived=bool](only what is supplied) anddelete <entity-path> --confirm(preview without it), all with-o json|yaml. Bare<project>/…paths are accepted; a missing or unreadable path, the embedded store and field refusals each answer a named error. Lesson 1.1 names the entity as the fifth node kind and the core platform guide's "Entities" scenario now runs on the CLI. Reference:opschain docs cli/entity(+-list/-show/-create/-bulk-create/-update/-delete),resources/entity.- Promote an entity to an asset, demote an asset to an entity: the v2
EntityServicegainsPromoteEntity(template code + version required; destination and code default to the entity's own) andDemoteAsset; the CLI gainsopschain entity promoteandopschain asset demote. Each conversion is one transaction that creates a new node with its own id and path, copies the properties (with anopschain_lineageentry — change, at, by, kinds, from/to node, template identity,last_change_idon a demote — appended to the new node) and the settings document (settings values never reach properties, lineage or events), moves child entities and their descendants under the new node set-based, and leaves the old node archived, recordingconversion_stateand itsconverted_tolink (v1 show attributes; v2Entity/Assetfields, the new node carryingconverted_from). The old node is an ordinary archived node — it can be edited, unarchived, deleted and restored like any other; an archived node is not converted (owner, 2026-11-09: nodes are not locked). Promotion crosses the asset create gates (template version exists/unarchived/resolved/not disabled, Enterprise project or environment destination, theassetsentitlement and licence limit) and starts the asset's actions generation after commit; demotion is refused by name (ERROR_REASON_CONVERSION_PRECONDITION_FAILED, metadataprecondition) while the asset has active or queued changes, pending approvals, scheduled activities, or an infrastructure child; like a delete, a workflow or trigger aimed at the asset does not block the demotion, and new work later aimed at the demoted asset answers as on any archived asset. Authorised update + delete on the converted node and create at the destination, 404-on-deny; audited in-transaction asapi:nodes:promoted/api:nodes:demoted(before/after, template identity). Reference:opschain docs resources/entity("Promote and demote"),resources/asset,events,api-v2,cli/entity-promote,cli/asset-demote. - Entities in the node hierarchy and the shared node CLI verbs (FORT-7):
NodeService.GetNodeHierarchygainsinclude_entities(default false — the response is otherwise unchanged): with it,root_pathis required (refusedinvalid_argumentnamingroot_pathwithout one) and may itself be an entity; the entities under the root are read in one indexed path-range query and nested under their project, environment, asset or entity parents at any depth (kind: "entity", identity fields only), pruned per read authorisation andinclude_archivedlike every node; the embedded store answersERROR_REASON_STORE_POSTGRES_REQUIREDfor it. The CLI's node-path grammar accepts…/entities/<code>segments to any depth, soopschain nodes show|create|update,opschain properties|settings show|edit|historyandopschain admin nodes delete|restore|destroy|export|importwork on entities, andopschain vault store|store-file|globaltake an entity as the node that owns the secret;opschain nodes list <path> --include-entitieslists a subtree with its entities in one read (refused by name without a path;--kind entityneeds it); change create,templates assign --assetandartifacts put|list|get --noderefuse an entity path by name;opschain entity deletenow points atopschain admin nodes restore;metadata annotate --kindvalidates against project, environment, asset, infrastructure and entity; the MCPlist_nodestool gainsinclude_entitiesandget_nodereads entities. Reference:opschain docs api-v2,resources/node,resources/entity,cli/nodes,cli/nodes-list,cli/metadata-annotate,integrations-mcp; the core platform guide's "Entities" scenario.
Fixed
- A floating template revision owned by a break-glass account never refreshed: the periodic repin runs as the version's creator by delegated resolution, which break-glass identities refuse, so every floating version the bootstrap administrator created failed to refresh forever with the raw refusal on the version. Now the v1 and v2 template-version writes refuse a floating revision for a break-glass creator at the door — creating or switching a version to floating answers 422 on
float_git_rev:A floating revision is re-resolved in the background as its creator, and break-glass accounts are interactive-only: pin the revision, or create this version as an everyday user— and recordwarn:template_versions:floating_refused(principal id, template, version; never the username); pinned revisions stay allowed for break-glass creators. A floating version a break-glass account already owns now stores that remedy as itsfloat_refresh_error(and theerror:template_version:float_refreshcause) instead of the raw delegated-resolution refusal, so it explains itself; the rule is unchanged. Lesson 3.4,opschain docs identity("Break-glass accounts"),resources/template,events,cli/templates-version-upsert. - Single-node posture documentation drift — each sentence verified against the code: lesson 2.6's sample paths now show the Linux default home (
~/.local/share/opschain/local/opschain.db;$XDG_DATA_HOME, macOS and Windows defaults named beside it) and its secrets section saysopschain secret setreads the value from stdin (prompted or piped) with the argv form available and stdin preferred; lesson 0.1 no longer promises "node-path and event reads" on a SQLite server — it serves the run engine behind the run/step/log API, the runner and agent fleet, licensing and the local secret store — and states that the EULA acceptance event is written on PostgreSQL only;opschain docs api-v2states the real rule (every estate, identity, governance, workflow-catalog, notification and provider family needs the PostgreSQL store) with the family list, what SQLite serves, and the provider families that answer a plainnot_found;opschain docs eventsanddatabase-maintenancesay the licence applied/removed and EULA accepted events are dropped on the SQLite store;opschain docs storageno longer lists the fleet as PostgreSQL-required (the runner and agent registry, the fleet queue and the scheduler work on both stores; only the aged-out runner sweep and the queue's run-owner enrichment need PostgreSQL). The local-mode design carries a dated audit of its approval-gate sentence (the code prompts, or--approve-allauto-answers) and of the verbs it promises that do not exist. - A running step's log chunk is never refused as sealed; a real
sealedrefusal names its path and replica: a server replica that received a chunk of an attempt with no ownership row took "not the first sequence" for proof the attempt had sealed and refused it — on the rolling-upgrade walks eight chunks of five RUNNING steps were refused 0.3–5 s after the job was acknowledged, their lines lost from the completed log while the steps succeeded. A running attempt is rowless on two roads: its owner runs a release without the ownership seam (the OLD side of a mixed estate mid-roll spools locally and claims nothing), or the row was released under a live owner (the boot sweep sealing the owner's orphaned spool, a seal that found the owner's membership gone). Now the receiving replica consults the step's state: an attempt whose step has ended keeps the sealed verdict; a running one is adopted — the replica keeps the row it claimed, spools from that chunk on behind the existing hand-over marker, recordswarn:logs:attempt_adopted(sequence,replica_id,step_status,message) and countsopschain_log_ingest_forwards_total{outcome="adopted"}; later chunks forward to the adopter, the seal reaches it through the row, and the completed log reads whole from every replica. Thesealedverdict itself now carries its evidence — the path (owner_sealed,no_row,forwarded_410) and the refusing replica — into the gap marker (… not delivered — sealed (path no_row, replica ha-b) ***), thewarn:logs:chunks_droppedpayload (path,replica_id) and the runner-facing error; the runner re-asks once after two seconds while its step still runs before giving the chunk up, and prints the server's detail with the discard. Reference:opschain docs events, the HA coherence runbook,opschain learn operations-high-availability. opschain passwdworks under the rotate-on-first-login confinement: a confined principal — the hardened posture's bootstrap admin on first login, or a local password pastsecurity.password_max_age_days— was refused on every v2 call,AuthService.ChangePassword(the procedureopschain passwdrides) included, with the very 403 that namedopschain passwdas the remedy; only the v1POST /api/users/me/passwordroute lifted it, and nothing in the CLI reaches that route. The v2 lane now exempts exactly the twins of the v1 exemptions —ChangePasswordand theIntrospectidentity probe — soopschain passwdrotates the password and the same session works at once; every other call stays refused by name. The remedy text names the CLI command alone on both lanes (change it with 'opschain passwd', then retry— the route is gone from the message, which now matches the admin plane's wording). Reference:opschain docs cli/passwd("Under the rotate-on-first-login confinement"),identity("Password policy"),api-v2(ChangePassword); the identity runbook.- The runner's stop narrative lands in one sink: under
opschain runner --log-file <f>thedraining: finishing the in-flight job, no new work (signal again to stop hard)acknowledgement went to the console whiledrained: no longer acquiring jobsandgoodbye: the server marked this runner stoppedwent to the log file — one graceful stop read as one line on stderr and two in the file, and the HA runbook suite's goodbye checks misread it twice. Thedraining:line is now the runner's own (printed once, on the first drain, through the same writer as the drained and goodbye lines) and the start-upadvertising executors:line follows the same rule, so with--log-fileevery line the command prints lands in the rotated file — written before the exit, nothing left for the console — and without it stderr carries them all exactly as before. Reference:opschain docs cli/runner("Signals"); the runner provisioning runbook. - Approve and reject decisions sit on the subject's correlation chain:
audit:approvals:approve/audit:approvals:rejectrecorded theircorrelation_idonly from the v2request_metadatathe client sent, and the GUI and CLI send none — so the one hop the lineage promises ("… → the created change → the approval decision") rooted its own chain while the funnel'spending/release/lapsed/break_glassevents already sat on the subject's. The decision verbs now default the event's correlation to the held change's or workflow run'scorrelation_id; an explicitrequest_metadata.correlation_idstill wins.opschain docs eventsandopschain docs change-governancedescribe the join. - A re-delivered log seal is already stored, never a 500: during a roll a forwarded seal could time out on the forwarding replica after the owner had stored the segment, and the retry's re-delivery — or a parked owner's own seal after a peer re-claimed its attempt — collided with the
log_segmentsunique index and surfaced aswarn:logs:ingest_forward_failedcarrying a rawSQLSTATE 23505. The segment index append is now idempotent on both stores (INSERT … ON CONFLICT (step_id, attempt, sequence) DO NOTHING, the stored digest compared: an identical row answers success and stores nothing), every roll lands its row at a sequence past everything already indexed for the attempt (so a replica rebuilt after a restart, or a holder whose attempt a peer took over, seals its chunks beside the other segment instead of colliding), and a genuine conflict — different content at one sequence — is refused by name: the owner recordserror:logs:segment_conflict(step_id,attempt,sequence, both digests) and answers 422 on the replica lane, which the forwarder relays as that refusal without recording a forward failure. Reference:opschain docs events, the HA coherence runbook,opschain learn operations-high-availability. - A failed log seal and an unreadable sealed segment are loud: a seal that failed on the replica holding the spool (blob put or index row) was discarded (
_ = logs.Seal), leaving the step marked complete with its log spool-only on that replica and invisible to the others until its next boot; an undecodable sealed segment became anunreadable segmentgap marker with no record, and a relay convergence pass skipped it silently. Now the pipe recordswarn:logs:seal_failed(step_id,attempt,message; once per attempt per minute) beside a notice, leaves the spool and its sidecar on disk so the next seal of the attempt retries and the boot sweep seals what is left — the sweep's boot log now names each orphan it seals — and recordswarn:logs:segment_unreadableonce per segment (segment_id,sequence,blob_digest,message) wherever a sealed segment fails to decode (the tail's marker, the convergence pass, and a paged read, which answers an error naming the segment); the result-submission, lease-expiry and dispatch seal sites log the failure instead of discarding it. The remaining two hardening items of the card (the non-follow read's idle-stop heuristic, CLI SIGPIPE handling) are carried by . Reference:opschain docs events, the HA coherence runbook and,opschain learn operations-high-availability. - The Academy launcher no longer hands the agent an expired login — it proves the session, mints the agent a scoped API token and revokes it on exit:
opschain academy serve|register|status|sync|rotate-key|mcpused to pass the stored login's raw access token — a four-hour credential, as often expired as not — as the agent'sOPSCHAIN_ACADEMY_TOKEN, so an agent started from an older login failed its first product read asthe product did not answer for its licence: 401 Unauthorized for /api/info, and one started fresh died silently mid-session. The launcher now proves the stored session against the server first, renewing a stale access token through the refresh token kept beside it exactly as every other command does (the rotated pair persisted), and refuses an expired login by name —your login for <server> has expired — runopschain login, or set OPSCHAIN_ACADEMY_TOKEN to an API token (opschain tokens create --expiry 720h)(exit 2, nothing minted). With a live session it mints an API token under it through the same procedureopschain tokens createuses — descriptionacademy-agent <label>(--label/ACADEMY_AGENT_LABEL, else the hostname), expiring in 30 days or at the agent's--session-ttlwhen that reaches further — hands that to the agent, announces the key's id and expiry on stderr (never the value) and revokes it when the agent exits, whatever the status; the launcher waits for the agent on every relayed signal, so a Ctrl-C orkillrevokes too, and a launcher killed outright leaves the key to its expiry, named inopschain tokens list.OPSCHAIN_ACADEMY_TOKENset by the caller stays the explicit override: handed over as given, nothing checked, minted or revoked.academy showstill reads the store only. On the agent side (LimePoint Academy SDK PR #17) a 401 from the product is now named as a refused credential with the remedy, never as the licence. Reference:opschain docs cli/academy-serve("The credential"),cli/academy-register,cli/academy-install,cli/academy-status,cli/academy-sync,cli/academy-rotate-key,cli/academy-mcp,cli/academy-show,cli/academy,cli/tokens-create,packs-academy(the refusal table); lesson 0.3. - The git remote edit form keeps the "Override the background fetch interval" toggle on (GUI, owner report 2026-11-10): the form reset was keyed on the identity of an initial-values object the dialog rebuilt on every render, so the toggle flipped back off before the interval could be set; it now resets only when the remote being edited changes.
- The periodic Git-remote fetch runs again, so a floating template version actually floats:
git_remote.periodic_fetch_interval(global, default 900 s), the project setting and the per-remoteperiodic_fetch_intervalwere stored, validated and served but nothing consumed them — the RailsPeriodicGitRemoteFetchJobwas never ported, so afloat_git_revversion repinned only when someone pressed Fetch revision. The cron leader now sweeps every minute for active remotes whose effective interval (remote override → project setting → global setting → 900 s; a non-positive value switches the remote off) has elapsed sincelast_fetched_at, fetches each mirror under the remote's credential, and repins every unlocked, unarchived floating version whose branch tip moved through the manual refetch path (pack pins re-resolved keeping explicit pins, the pinned assets' active windows rolled, their action catalogs refreshed). On the record: each failed fetch recordserror:git_remote:fetchon the remote (withcauseandconsecutive_failures), a fetch that moved refs recordsinfo:git_remote:fetched(never a quiet one), each repin recordsinfo:template_version:rev_repinned(from→to), and a repin the sweep could not perform recordserror:template_version:float_refreshand writes the version'sfloat_refresh_error(the attribute gains its column and its first writer; the next successful repin clears it). Per-remote health on thegit_remoteresource (v1 and v2, additive; CLIgit-remotes listgainsLAST FETCH/OUTCOME/FAILURES,--wideaddsNEXT FETCH/FLOATING,showprints them all):last_fetched_at,last_fetch_outcome(ok|failed|skipped),last_fetch_error,consecutive_failures, the computednext_fetch_at,floating_versions,last_repin_at. For the operations dashboard: new v2GitRemoteService/SummarizeGitRemoteHealth(estate-wide, each row gated on read of its project's/git_remotesrule path;failing_onlytrims the rows, never the totals) returns per-remote rows plus totals (remotes,due,failing,repins_24h,floating_versions); the GUI panel over it is a follow-up in the GUI repository. Migration00130_git_remotes_periodic_fetch(expand). Reference:opschain docs resources/git_remote("The periodic fetch", "Health summary"),resources/template(float_git_rev,float_refresh_error),settings-reference,events,cli/git-remotes-list,cli/git-remotes-show,api-v2; lesson 3.4. templates re-resolveno longer cuts a surprise version that the next fetch would silently undo: first guarded the gap by refusing a--versionother than the pin-else-highest default on a template whose declaration carries noversion:(ERROR_REASON_PACK_TEMPLATE_UNSEALED); replaces the refusal with the explicit per-version pin the cut version keeps across fetches, so the refusal and its message never ship — the enum value stays, deprecated and unused. A sealed template (explicitversion:) still reportspinned. Reference:opschain docs cli/templates-re-resolve,pack-store,api-v2.