Skip to main content
Version: v5.7.0

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/http fix to the HTTP/1 server crash in MaxBytesReader, which the v2 surface now relies on for its request-body bound; go.mod and 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 or keys.env) nor the stored token.secret_key supplies a secret, server start on 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 declaration server.unauthenticated_postgres: true (config catalog, template and dictionary), refused by name under security.posture: hardened and 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) — with ArtifactService.UploadArtifact reading under a bound derived from artifacts.max_artifact_mb (its base64 JSON form plus the shared bound) and an http.MaxBytesHandler of that size on the whole v2 mount, so opschain artefacts upload keeps 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.json and 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: ActivateTOTPFactor now requires a live elevation once the caller holds an active factor, like EnrolTOTPFactor, 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_proxies honoured) 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 its warn:webhooks:refused event); 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-Delivery answers 409 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 runner reads OPSCHAIN_RUNNER_TOKEN when --token is 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. --token still works; the docs name --max-uses 1 as 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 one create away. Now the writer admits only transport URLs (http://, https://, ssh://, git://, scp-like user@host:path): anything else is refused by name on the url field 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_argument on v2) — before any probe runs, with one shared classifier behind the writer and the git adapter. Defence in depth: the server's git runs under GIT_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 as error:git_remote:fetch, and is never cloned; the local lane (packs fetch, a repository's own remotes) keeps reading local paths. The new deploy-authoritative security.allow_local_git_remotes (default false, never API-settable, anchored false in the hardened posture — true there 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_id on a user grant, a grant's approvers, approvers.users on approval policies, owners.users on 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's auth_provider value — 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 existing created_by_id columns 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 records warn:identity:username_match naming 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, recording audit:identity:username_subjects_conversion; once it finds nothing to do the username leg is inert. CLI: --subject, --approvers, --approver-user and --owner-user take the three spellings. GUI (go-opschain-gui build/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; the transport_grant, approval_policy, approval and asset_owner dictionary 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 a step_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, audited warn:auth:step_up_failed); the new AuthService.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's prompt=login/max_age=0 authorization URL for the browser to open; the callback elevates that session in place. Sessionless credentials and platform-mode instances refuse failed_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_backoff table): 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_to is parsed, never passed through. return_to=/%09/evil.example survived the former prefix checks and reached the Location header 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's federatedLoginPath mirrors 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=Lax since 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 with auth_error=invalid_state and warn: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_administrator grant: 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 record warn: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, flagged converted_from: securing_prefix and 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 as audit: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 to security_role_grants and the report table. API: SecurityRoleGrant gains target_kind, groupname, converted_from, converted_from_path, widened_from_path, review_state, reviewed_by_id, reviewed_at; ListSecurityRoleGrants narrows by review_state/converted; new ConfirmSecurityRoleGrant (api:security_role_grants:confirm); policy and rule reads carry writable. CLI: opschain security roles review list|confirm|revoke, opschain security roles list gains 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 literal ldap provider, 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 legacy ldap row or any <kind>:<code> provider identity) now ranks every federated provider — LDAP, OIDC, GitHub — ahead of the local row at all of them, and the directory_shadowed session fact uses the same rule; the two legacy-only chain-lane sweeps keep their literal match by design. Reference page identity carries 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_refusal marker 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 page runner-executors and the HA coherence runbook describe the lifetime.

Fixed​

  • The opschain.env bridge always resolves its vault references. Narrow resolution for the Go lane resolved only the references a declarative step's own expressions read, so a run: step that reads a secret-vault:// value through the opschain.env environment 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 a me answer 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 Inbox for an eligible approver, View in Inbox otherwise). 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_grant states 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's transport_providers.credential, image_build_providers.credential, image_registries.credential, github_connections.private_key, jsm_connections.api_token), the licence installation credential lands in the new licence_installation_credentials table (read by the licence manager before OPSCHAIN_LICENCE_CREDENTIAL and the vault), and every column joins the encryption-key census (opschain admin encryption status, re-encrypt, the rotation runbook). A *_ref pins exactly as before and clears the envelope; an inline write clears the reference; rows that pinned the retired <family>/<code>/credential vault 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 gain credential_set / private_key_set / api_token_set so a read says a credential is held; StoreLicenceCredential answers 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 off AgentPostureService.ListAgentPosture and ModelProviderService.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 from EventService.ListEvents over the node deleted/restored/destroyed events (newest 2000), so a node deleted through an ancestor appears under that ancestor only — a NodeService.ListDeletedNodes read 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 of code,type[,address[,fqdn[,name]]] or a JSON items array); and the Pause verb follows the typed pausable flag — 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 gain scope: warn:trust:cap_refused / warn:trust:cap_approaching carry scope: provider with provider_code, window, limit, used, timezone, resets_at for a provider window and scope: grant (the grant's scope path moves to grant_scope) for a grant window. API: the six fields on ModelProvider, optional on CreateModelProvider/UpdateModelProvider (0 clears, negatives refuse by name); ListProviderUsage (one model provider) and a model provider's ListProvidersOverview row carry token_budget — ProviderTokenBudgetWindow per set window (cap, used, remaining, fraction, window start, resets at). CLI: opschain admin model-providers create|update --tokens-per-minute … --tokens-per-year, show prints Token budget: …, opschain admin providers usage --kind model --code <code> prints the ceiling, spend and remaining per window (token_budget in -o json). OTel: the usage export ships opschain.provider.token_budget_remaining as a gauge with provider.kind=model, provider.code and window. GUI (go-opschain-gui build/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) in src/api/v2/model-providers.ts is 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), the admin-model-providers-* and admin-providers-usage manual 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 RunnerService RPCs (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-gui build/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-manager and azure-key-vault, each with its own field set per the CLI manual: AWS takes the region (required), an optional endpoint URL override, the access-key method'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, the client-secret method's client id and write-only client secret. The vault kinds keep KV version, mount path and token/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 default secrets.default_rate_limit, and clearing a lane on edit returns it there), and the list gains a Rate limit column (default, or each lane with default where unset). An edit renders the stored kind as stored — the coercion of any non-vault kind to openbao is gone — and a kind this GUI build does not know opens read-only with a notice naming it. GUI repo (go-opschain-gui build/b645). Reference: opschain docs gui-operations (the Secrets providers row), opschain docs getting-started/secrets-providers.
  • The opschain-github-demo repository: a private demonstration kit LimePoint uses to run GitHub integration demos for prospective customers, in both directions, at github.com/LimePoint/opschain-github-demo. The asset template (.opschain/: project github_demo, environments dev/prod, assets web/reports on the app template; pipeline:dispatch_workflow and pipeline:repo_dispatch under a per-mint-scoped github-app://demo?repos=…&permissions=… reference, a deliberately over-scoped reference on reports the allowed scope refuses by name, image:build_and_push through the connection's ghcr registry with artifacts:resolve_image, config:read_remote with content()/list(), deploy:release held by the production approval policy, the verify workflow and the keep-green hello action), the GitHub workflows (ci-local.yml on the local lane every push with OPSCHAIN_RELEASES_TOKEN; server-change.yml through the reusable opschain-change.yml with the OIDC exchange under the clamped trust policy, the held production release, the attestation and the documented out-of-clamp refusal behind workflow_dispatch; the dispatch-target.yml/dispatch.yml targets with initiated_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, the scope_prefix trust policy, the window-required approval policy, the ghcr registry, the key-rotation control and cadence, the team route, the conclusion and ref-move triggers; reset.sh; totp.sh), and the presenter runbook demos/ (runsheet + modules 00–04: setup under step-up, GitHub → OpsChain, OpsChain → GitHub, the governance wrap, the acceptance arc). The kit references LimePoint/opschain-action at @main until v1 is tagged. One dated line on opschain docs integrations-github and 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 as subject-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's entries_sha256 before the .ndjson download (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 loud warn:approvals:break_glass event, the server's refusal verbatim. Server field violations land on the dialog fields. Reference: opschain docs gui-operations ("Governance surfaces"); lessons governance-controls-evidence, governance-campaigns, governance-approvals name the GUI paths. GUI repo (go-opschain-gui build/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 off LicenceService.GetLicenceStatus (online_verification now mapped on the GUI's v2 twin: last outcome, last and next check, cadence, failures, last error) with Verify now on LicenceService.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 on ModelUsageService.ExportModelUsage over the dashboard's current range and grain (the overview exports the default 30-day/day window), downloaded as model-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-operations rows, the approvals, licence, model-providers and what-an-agent-is lessons. ConvertSettingsApprovers / ConvertStepApprovalGates stay 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 mirroring opschain admin siem create|update per 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 the scope_prefix clamp 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 JsmIntegrationService RPCs (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 mirroring opschain admin jsm-connections create|update with the API token / PAT write-only (inline and vaulted server-side, or an existing secret-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): the jsm:request / jira:issue references 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-json object) 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:github close-out: the three deferrals the GitHub suite shipped with are closed. Per-mint installation-token scoping: a github-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:level pairs of read/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_scope on GitHubConnection; migration 00142) bounds what any reference may ask: a reference beyond the bound fails its step by name with the new warn:github:token_scope_refused event 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 on info:github:token_mint (repositories, permissions). Per-policy scope clamps: a GitHub trust policy gains scope_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 on info:github:token_exchange, and a denial it causes lands warn:authz:denied with scope_prefix and reason: scope_clamp. The published workflows: the public LimePoint/opschain-action repository (the local-lane composite action's home — no second repository) gains the reusable workflow_call workflows .github/workflows/opschain-change.yml and opschain-workflow.yml for 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, outputs run-id / status / url, the job's result from the run's), the seed/ starter kit, and README sections "Server-backed workflows" and "GitHub Enterprise Server" (mirror the repository into the instance and pin it); v1 moves 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; lessons integrations-github and 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 --local for a failed step, pin the commit with --ref (and read <sha>+dirty.<digest> when the job wrote into the tree), runs artifacts --download into the CI system's store, unattended secrets through OPSCHAIN_MASTER_KEY or a 0600 key file with --no-keychain, pre-pull images in a prior step (the local lane never pulls — the opschain prefetch verb 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 page opschain docs ci carries the contract (exit codes, the -o json document shape, -q and non-terminal rendering, the host executor's env allowlist, the artifact listing fields) and the usage of the two packagings: the LimePoint/opschain-action composite GitHub Action (inputs version, action/workflow, env, asset, inputs, ref, approve-all, artifacts, artifact-name, home, working-directory, timeout; outputs run-id, status, commit, exit-code, run-document, artifacts-dir; job summary + actions/upload-artifact; result from the exit code) and the LimePoint/opschain-gitlab-ci CI/CD components run-change and run-workflow (same inputs; opschain-run/run.json + artifacts kept always; OPSCHAIN_RUN_ID|STATUS|COMMIT|EXIT_CODE dotenv 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 and secret-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) and node.list(glob) read a local run's own snapshot — the working copy as it stands, or the --ref commit — 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.yaml declares reads that one repository, the SHA form git.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> on runs create change|workflow … --local hands named variables of the invoking process to every step on top of the host executor's default allowlist — the runner's --env-passthrough semantics, OPSCHAIN_VAULT_* dropped; manual pages cli/runs-create-change, cli/runs-create-workflow, opschain docs runner-executors. (3) The secret-env://<NAME> reference scheme: a property value or a secret-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 new opschain packs fetch [name ...] resolves a repository's remote:/url: + rev: declarations into <opschain home>/packs/<name>/<sha> through the home's git mirrors (locally remote: 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 retry retries 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's when: — has(metadata.<family>) is false and a guarded condition evaluates instead of failing — and the orchestration.retries / orchestration.fan_out_limit ladders read their base layer for local runs from the new orchestration: block of <opschain home>/server/config.yaml (orchestration.retries.attempts, orchestration.retries.backoff, orchestration.fan_out_limit; frozen onto each local run, audited as source config, 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> and opschain run workflow <code> — the aliases of runs create change|workflow … --local with the same flags, attached execution and exit codes; the alias never targets a server (--server refused 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. A runner provider 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 additive secret_refs job-spec element (runner protocol 8), where the runner resolves them at Prepare from the secrets_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_providers on the requirement, the runner row and the fleet queue verdict no runner in group … resolves secrets provider …), and an offer to an undeclaring or pre-8 runner is refused by name. The agent SDK gains ResolveSecret over the same resolver. Server-resolved *_ref fields (identity providers, GitHub/JSM integrations, image registries/build providers, model providers, a provider's own credential_ref) refuse a reference that routes to a runner provider; transport providers' credentials check out at the runner instead. Interactive reads and the test verb on a runner provider answer the callout "reads happen only at the runner; the platform holds no credential". CLI: --resolution on admin secrets providers create|update, the RESOLUTION column and Resolution: 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 (server or runner; an absent field renders as server, 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 the secrets_providers section of its own config.yaml), a server-resolved provider needs the credential fields — and choosing runner removes the credential input and the custody toggle so no credential can be sent. Editing a provider from server to runner warns inline, naming the provider, that saving clears the credential stored on the server (and its pinned vault reference); editing from runner to server requires the credential (or a vault reference) before Save enables, and the write carries resolution with it. The server's own refusals (resolution, credential, credential_ref field violations) render verbatim in the dialog. The secrets tool's provider header carries the same chip. GUI repo (go-opschain-gui build/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 a server provider, each runner for a runner provider), 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 declare secrets_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's secret_refs, so every later step everywhere re-reads it, past the per-path cache; refresh_secrets: true re-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_properties and ${{ 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 the held marker'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 in run_secret_leases beside the run; later steps to the same runner carry vault_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_minute on the secrets provider record (--rate-limit-reads-per-second, --rate-limit-mints-per-minute; Rate limit: on show) with the platform default secrets.default_rate_limit under 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 as secret_economy); a 429 (or a cloud kind's throttling answer) is honoured with its Retry-After and 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_trips and the breaker_open gauge now land on the provider usage substrate (the runner reports its counts on the step result as secrets_usage; the server writes them), and each run's evidence carries one info:run:vault_traffic event: vault traffic: provider "platform" 50 reads (152 from the run snapshot), 1 lease; provider "cust-vault" 7 reads at the runner. Migration 00140_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: the OPSCHAIN_VAULT_* runner-host environment lane retired — a runner started with the variables and no secrets_providers section 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_after and created_before the 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_limit is 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's rate_limit_reads_per_second / rate_limit_mints_per_minute now 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-* and show follow, 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 setting webhooks.default_rate_per_minute replaces the hard-coded per-connection 60 (unset keeps 60; -1 = unlimited; re-read every 30 s) and a connection's rate_per_minute takes 1–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, the admin secrets providers create|update|show, admin inbound-webhooks create|update and the GitHub/JSM provision-webhook manual pages, opschain docs gui-operations (Settings, Secrets providers, Inbound webhooks, GitHub, JSM rows). GUI repo (go-opschain-gui build/b662). Migration 00146_inbound_webhook_rate_unlimited_v1 widens the connection table's rate check to admit -1 and 0.
  • The go-opschain Docker 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 of LimePoint/opschain-releases — ./configure && docker compose up, the GUI at http://localhost:8443, the admin password printed once to the server log, PostgreSQL published on host port 5432 as postgres://opschain@localhost:5432/opschain with the PGPASSWORD ./configure writes to .env (the sibling repository's refresh; its Rails-era stack retired). The route's command is docker compose up (Compose v2) everywhere it is cited — lesson 0.1, opschain docs storage, cli/server-start, cli/server-configure, the SQLite startup notice, the server configure hint and the "GUI needs PostgreSQL" page — and lesson 0.1 and storage name 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-driftguard on the same paths; this repository keeps dated stubs at the old paths and a docs/driftguard/ hub page. DriftGuard runs its own release train in parallel to OpsChain's; the only coupling is the tagged go-core modules 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 matrix entry and its version_matrix route 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/versions and /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/versions route 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 store binds 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: false still 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 the go-opschain Docker Compose (PostgreSQL, the server and the GUI in one command, kept current per release): lesson 0.1, opschain docs storage ("Evaluating the full server"), concepts and the server start/server configure pages cite it, and server configure prints the route and the DSN its PostgreSQL answers (postgres://opschain@localhost:5432/opschain, password in keys.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 ## Postures section 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 four model_usage_* tables; migration 00139 creates the unified pair and drops the unreleased model_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 as calls, tokens (input + output, the metered total whose peaks and value_max are the token peaks), input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens on the model the response reported (detail), refused_calls on 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/modelusage is now internal/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_fraction stays 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_failures at dispatch), image build providers (builds, build_failures at the crossing) and registries (pushes on the image artifact upload with the image as detail, push_failures at the crossing; build_seconds and bytes_pushed registered, unfilled — the server sees neither today), identity providers (logins, token_exchanges, refusals, jwks_refreshes in the federated handlers, the LDAP bind and the OIDC client), SIEM and OTLP connections (events_shipped / spans_exported, ship_failures / export_failures, the backlog_depth gauge counted after each delivery), GitHub connections (api_calls / api_errors / tokens_minted / rate_limit_remaining on the installation-token mint, webhooks_received / signature_failures on the inbound lane) and JSM connections (api_calls / api_errors with latency on every request, the inbound lane). The reads: v2 ModelUsageService.ListProviderUsage(kind, code?, grain, from, to, metric?, group_by?, project_code?, timezone?) answers one ProviderUsageRow per bucket, provider and metric with one total per registered metric, and ListProvidersOverview() 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 …] and opschain admin providers overview (-o json). OTel: every metric is exported per provider on the otlp_http SIEM connection as opschain.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 (absorbing model-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/secrets refuses; 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. Migration 00137_secrets_providers_resolution (additive, secrets_providers.resolution default server).
  • 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/security and /governance/notifications URLs 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 now limepoint/opschain-mintmodel-api:2026-11-04 behind the MINTMODEL_API_IMAGE build arg — the 2026-11-04 release removed the separate opschain-mintmodel-steps-api image (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 under MintModel/ — an actions.rb edit, 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 and mintmodel_valid resolve it) and the job runs the actions.rb discovery alone: no archive, no render, no runner time on an unchanged model. A commit that changes anything under MintModel/, 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 (migration 00132_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-api 22529aa40). 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, and origin / here are refused as provider codes at create. The step's setup log records secrets: 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> (v2 ResolveSecretsProviderBinding.origin_path, response origin_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), an origin@ 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 and secrets-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.yaml declares WITHOUT version:, 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 v2 UpsertTemplateVersion.pack_pins (TemplateVersionPackPin {name, version}), the v1 upsert's pack_pins attribute, and "Select version" on the GUI's Sealed packs panel. Every pin now records its provenance — sealed_packs entries gain mount and decided_by (declared | admin_pin | highest_installed | explicit) on v1, the v2 TemplateVersion (new sealed_packs, SealedPack) and the CLI's -o json — stored in the new nullable pack_pins.decided_by column (migration 00129_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 November 1.0.0+0.2.5 silently 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 carries version: (pack <name> is set in .opschain/packs.yaml …, v2 failed_precondition + new ERROR_REASON_PACK_DECLARATION_SEALED), a version not installed, a pack the version's commit does not declare, and a version in use unless ignore_in_use (with it, the pinned assets' catalogs refresh the way a fetch refreshes them). Each move is audited info:template_version:pack_repinned. templates re-resolve --version now writes the explicit pin on every version it cuts instead of refusing — the refusal and its message retire (ERROR_REASON_PACK_TEMPLATE_UNSEALED stays in the enum, deprecated, never emitted); the dry run states would set the explicit pin X on N template(s), the response and audit event carry decided_by; omitting --version keeps 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 assets feature 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_type is still accepted and ignored on project create and update (v1 JSON:API and v2 CreateProject, where the field is marked deprecated), so older clients and YAML files keep working; it leaves project responses (v1 attributes, v2 Project and RestoredNode, the node export, the compare-nodes child inventories now list every project's assets and its environments' assets, and step_context.json parents), the OpenAPI spec and the data dictionary. The CLI accepts --project-type and the project_type file key with a deprecation note on stderr and no effect; nodes show|list drop the project_type line/field; nodes create help, the cli/nodes-create manual 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. The nodes.project_type column 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 (every github_* cap 0) and no Academy — features assets, identity:builtin, packs:common, templates, triggers, grants sourced evaluation (the literal free-tier grant-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:academy stays 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 names opschain admin licence register first, licence_status gains a persistent registration notice (state: unregistered + the sentence; v1 /api/info and the v2 LicenceStatus.registration message) and opschain admin licence show prints it as a registration: 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/.
  • Resolve now unions github_token_mints_per_day like 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 new OPSCHAIN_PREVIOUS_KEYS keys.env/environment entry (comma-separated rotated-out primary keys, each optionally <key>:<its own salt>) keeps old keys decryptable while every write seals under OPSCHAIN_PRIMARY_KEY; envelopes written before references existed (or by the Rails application) still decrypt by trial. New opschain admin encryption status (v2 SystemService.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 as api:encryption:reencrypt with counts only) rewrites every envelope under the current key in batches of --batch-size rows per transaction, resumes an unfinished run (--limit spreads 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 new encryption_keys registry (migration 00138 / SQLite 00015, with the encryption_reencryptions progress 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 — the imagebuilder SDK client joins the api lane's allowlist, so aws:imagebuilder:* typed actions and aws:api --service imagebuilder arrive with it; a native awsu imagebuilder run starts the pipeline execution by ARN or name — or a one-off CreateImage from the pipeline's definition with recipe_arn / infrastructure_arn / distribution_arn replaced, 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 with digest = id:<ami id>) and azure:imagebuilder_run (packs:azure — a native azu imagebuilder run over the raw ARM lane POSTs the image template's /run, drains the operation, reads lastRunStatus and runOutputs, picks the gallery image version and writes the descriptor with digest = id:<gallery version id>). Both take provider / registry (image-build:// of kind aws_image_builder / azure_image_builder, image-registry:// of kind ami / gallery), wait (default true), wait_timeout (default 60m) and descriptor, refuse by name without a provider of their kind, read the contract from the environment and log the credential form value-free; tags is a JSON object or KEY=VALUE lines. S6: the core metadata vocabulary gains core.image_provenance (required | none; subtree-inherited, protective) — with required effective on a run's target, artifacts:resolve_image refuses by name (beside warn:image_build:refused, family image_artifact, reason provenance) 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: role produced / consumed, the naming input, reference, identifier, digest, provider, registry, source commit, producing run) for a change that produced or consumes a kind: image artifact, read once per inbox page through the artifact port, printed by opschain 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. The buildkit and packer packs 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}): each AgentPostureRow carries the agent id and governed principal id, project, code, name, type, group, the owner reference, the owner's principal id and owner_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; advise from 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 and over_cap, transcripts_captured, the model provider and its data-handling-approved marker, the disclosures summary, health, last seen and stale; the response adds the summary (total, by tier, stale, over cap, owner departed, without owner), the threshold and computed_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 setting agents.stale_after (duration, default 168h; absent, malformed or non-positive = default): no governed action within it is stale, never-acted included. New opschain agents posture [--project] [--tier] [--type] [--stale] [--departed-owner] [-o json|csv] — the CSV keys on agent_id and release_digest and carries the owner as owner_principal_id only, 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-audit and the security_audit train 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 is attested-mechanical only 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 in make lint), attested-documentary only against a page in the repository, and not-attested otherwise 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-baseline and the train's step 0b advance all three; the step publishes eu_ai_act_ratchet and nist_ai_rmf_ratchet beside annex_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-act and opschain 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_http SIEM 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, protocol protobuf 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 under opschain.* (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 carries service.name=opschain, service.instance.id, opschain.estate and opschain.installation_id. Usernames and emails are never attributes. The optional token travels as Authorization: Bearer or in a named auth_header with the HEC token's write-only custody; TLS trust is ssl_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; migration 00133_siem_connections_otlp_http widens the kind check. Reference: opschain docs integrations-siem ("Traces"), resources/siem_connection, cli/admin-siem-create; runbooks getting-started/integrations-siem and 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, default UTC) and model_usage.fiscal_year_start (MM-DD, default 01-01), both with a project override, plus model_usage.minute_retention (14 days), model_usage.hour_retention (90 days) and model_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 beside tokens_per_day — --tokens-per-minute (sliding, the burst cap), --tokens-per-week, --tokens-per-month, --tokens-per-quarter, --tokens-per-year on opschain admin trust grant (--tokens is now the alias of --tokens-per-day; v2 TrustGrant and POST /api/trust/grants carry 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 in warn:trust:cap_refused (window, limit, used, timezone, window_resets_at as the local boundary), and the new warn:trust:cap_approaching fires 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 v2 ModelUsageService (SummarizeModelUsage, ExportModelUsage CSV, RecommendCaps; read on the project path) and opschain 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 --propose raising 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. Migration 00134_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:build and packer:build verbs in the common pack: the bundled packs:common snapshot gains two families. buildkit:build builds a Dockerfile in the run workspace against the dispatched image-build:// provider of kind buildkit (BUILDKIT_HOST, the mTLS client certificate at IMAGE_BUILD_CREDENTIAL_FILE), pushes to the dispatched image-registry:// through the login at IMAGE_REGISTRY_CONFIG_FILE / DOCKER_CONFIG, and writes the image descriptor the kind: image artifact records — inputs image, context, dockerfile, tags (comma-separated), build_args (a JSON object or KEY=VALUE lines), 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: buildctl first, docker with buildx through a remote builder on the same daemon when buildctl is absent; jq is required. packer:build runs packer init + packer build on a committed template against a provider of kind packer (its credential already in the environment in the builder's form), reads the artefact id from the template's manifest post-processor (custom_data.image_id, else artifact_id) and writes the descriptor with digest = id:<artefact id> — inputs template, var_file, vars (a JSON object or KEY=VALUE lines), only, except, manifest, init. Each pack carries an ensure action (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, no jq. Two engine changes the documented contract needed: an artifacts: declaration is now legal on a composed uses: call site and lands on the composed action's last run: step (refused on built-in, actions.rb# and mintpress# steps, and on a name the composed action already declares), and a step whose declaration is kind: image sees OPSCHAIN_IMAGE_DESCRIPTOR and OPSCHAIN_IMAGE_SBOM naming 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 the packer kind 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 a run: step gains kind: image — the step writes an image descriptor (.opschain/image/<name>.json by default: identifier, digest as sha256:<hex> or id:<resource id>, optional provider, 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 optional sbom: file as the record's only bytes. The record is addressable as artifact://<node path>/<code>@<digest>; the new built-in uses: 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 publishes identifier, digest, provider, registry, source_commit, run_id and the rest as the step's outputs, recording info:image_build:artifact_resolved on the consuming run's chain. Every artifact read (v1 and v2) carries kind and image; ArtifactService.ListArtifacts takes filter.kind; opschain artifacts list --kind image lists 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 migration 00014_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, the packs:common buildkit:build / packer:build verbs and the hosted image build on packer, 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> or image-registry://<code> is resolved at step dispatch — with: over the opschain.image.build_provider / opschain.image.registry property 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_FILE with DOCKER_CONFIG and REGISTRY_AUTH_FILE) and a short-lived credential: an STS session for assume_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 for ghcr, 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 records info:image_build:provider_used; each refusal is a named step failure beside warn:image_build:refused with a typed reason. The runner protocol moves to 7: JobSpec.secret_files materialises 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). The test verbs now probe for real: the credential parses as its form, STS / the identity platform / the token endpoint answers, a BuildKit tcp:// 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_build reserved 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 with repository as the prefix. Both carry reference-first custody (credential_ref, inline values vaulted under image-build-providers/<code>/credential / image-registries/<code>/credential), a kind-checked credential_kind (the ghcr kind may name a github_connection in 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 v2 ImageBuildProviderService / ImageRegistryService (list/get/create/update/delete/test/bind/unbind, every verb on the /admin updatable determination, audited api:image_build_providers:* / api:image_registries:*), CLI opschain admin image-build-providers / opschain admin image-registries, the Administration → Image builds GUI screen, rule-path families /platform/image-build-providers and /platform/image-registries in the catalog, migration 00135_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 the kind: image artifact 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_branch is off; --watch-default-branch=false on git-remotes create|update, the watch_default_branch file 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 table git_remote_refs, migration 00136_git_remote_refs_v1, expand) and records one info:git_remote:ref_moved per 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 with events: [info:git_remote:ref_moved] and conditions such as event.data.ref eq refs/heads/main and event.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 records warn:git_remote:forced_push beside the event and fires triggers only when they admit forced; moves of one ref inside the remote's fetch interval coalesce into the open event (the first old_sha, the latest new_sha, commits summed, 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 the ref_moved event that caused it (caused_by_event_id, ref_moved_event_id in its data), so a push and the repin it produced read as one story in Chains. New reads: GitRemoteService/ListGitRemoteRefs and opschain git-remotes refs <project>/<name> (the watched refs at their last-fetched tips with their last move), last_ref_move_at on the remote; info:git_remote:fetched gains fetch (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 exceptions templates(c).versions(v).content(p) and git.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 on estate('<asset path>') and on every asset estate(<path>).assets enumerates (.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 through estate() 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, and content(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, a with: value or a when: condition — never set_properties, a workflow step's property_overrides, an action's emit … 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 and list() returns only permitted paths. Evidence: a content-bearing run: / with: is evaluated at the step's entry and every read lands on the step as content_reads {form, node, anchor, commit, path, sha256, bytes} (v2 Step.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) and git.content('demo-repo', event.data.new_sha, 'deploy/manifest.yaml') / git.list(…) read the commit a ref_moved event 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_remotes plus 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/ListTemplateVersionContent and ReadTemplateVersionContent list 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 offers info:git_remote:ref_moved in the trigger editor's event vocabulary with its condition fields. Reference: opschain docs expressions ("content() and list()"), 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 REMOTE in list / Git remote in show) names a remote schedules that remote's fetch now, and a delivery carrying GitHub's push envelope schedules the fetch of every active remote on the repository the payload names (clone_url / ssh_url, else full_name on 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 open ref_moved event, and the delivery's own external:webhook:received still lands as the record. GitRemoteService/FetchGitRemote and opschain git-remotes fetch <project>/<name> fetch a remote now over the same lane, synchronously (update on the project's /git_remotes rule path; an archived or disabled remote is refused as not found; audited api:git_remotes:fetch); every fetched / ref_moved event names its cause in fetch (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_audit train step now compares the report's ISO/IEC 42001 Annex A annex against the committed baseline tools/security-audit/baselines/iso-42001-annex-a.json (one attestation status per Annex A control plus the pack carve-out scope boundary, the attested_at cut 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 new annex_ratchet step output; new or higher coverage passes. The report files are now collected as the security_audit_report artifact on the make step itself, so a failed step keeps its report. make security-audit-baseline advances the baseline when coverage grew (refused while a regression stands) and make release-train runs it after the CHANGELOG roll as a review stop; accepting a lower status is a reviewed hand edit of the baseline carrying a reason. 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.SummarizeInfrastructure answers an inventory's health over a scope in one set-based read — the project × infrastructure_type × lifecycle_state matrix (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 the api:infrastructure:provisioned/destroyed events) and the thresholds judged against (InfrastructureSummaryThresholds, with computed_at). An empty scope_path covers 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 gain last_used_at (migration 00131_infrastructure_last_used_at, expand; v2 field, opschain infrastructure show "Last used" / -o json), stamped at dispatch when a host_ref-bound step's transport session is admitted — a refused session is not a use. InfrastructureFilter gains duplicate_address, last_facts_before, last_used_before and changed_since, each reproducing one exception total exactly so a count is one click from its rows. New stored settings infrastructure.stale_facts_after (default 168h) and infrastructure.unused_after (default 720h), 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, a secret input's reference resolved and routed through the masked secret-injection path — plus OPSCHAIN_INPUT_FILE_<NAME>, a file under inputs/ 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_apply refused {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 in run:"), 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=value for 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 (--token for 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 v2 LicenceService.StoreLicenceCredential stores an online installation's phone-home credential in the platform secret vault under licence/<installation_id>/credential (authorised as update on /licence, never echoed; not_found without a vault), and the server's verification rounds read the credential from OPSCHAIN_LICENCE_CREDENTIAL first 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 (an entity converge layer), properties history, metadata (entity is a valid kind for applies_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 the entities collection (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 v2 EntityService serves ListEntities (keyset, code-ascending, code/search/archived filters), GetEntity, CreateEntity, the set-based BulkCreateEntities (at most 1000 per batch, per-item refusals) and UpdateEntity / DeleteEntity (PostgreSQL only; the embedded store answers ERROR_REASON_STORE_POSTGRES_REQUIRED). Writes record the node event family with kind: entity. Migration 00128_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/--archived server-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 (strict items[].code/name/description; a file over the server's 1000-item cap is split into consecutive batches, per-item refusals print Failed <code>: <reason>, a refused batch stops the run stating what earlier batches submitted), update <entity-path> [--name] [--description] [--archived=bool] (only what is supplied) and delete <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 EntityService gains PromoteEntity (template code + version required; destination and code default to the entity's own) and DemoteAsset; the CLI gains opschain entity promote and opschain asset demote. Each conversion is one transaction that creates a new node with its own id and path, copies the properties (with an opschain_lineage entry — change, at, by, kinds, from/to node, template identity, last_change_id on 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, recording conversion_state and its converted_to link (v1 show attributes; v2 Entity/Asset fields, the new node carrying converted_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, the assets entitlement and licence limit) and starts the asset's actions generation after commit; demotion is refused by name (ERROR_REASON_CONVERSION_PRECONDITION_FAILED, metadata precondition) 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 as api: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.GetNodeHierarchy gains include_entities (default false — the response is otherwise unchanged): with it, root_path is required (refused invalid_argument naming root_path without 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 and include_archived like every node; the embedded store answers ERROR_REASON_STORE_POSTGRES_REQUIRED for it. The CLI's node-path grammar accepts …/entities/<code> segments to any depth, so opschain nodes show|create|update, opschain properties|settings show|edit|history and opschain admin nodes delete|restore|destroy|export|import work on entities, and opschain vault store|store-file|global take an entity as the node that owns the secret; opschain nodes list <path> --include-entities lists a subtree with its entities in one read (refused by name without a path; --kind entity needs it); change create, templates assign --asset and artifacts put|list|get --node refuse an entity path by name; opschain entity delete now points at opschain admin nodes restore; metadata annotate --kind validates against project, environment, asset, infrastructure and entity; the MCP list_nodes tool gains include_entities and get_node reads 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 record warn: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 its float_refresh_error (and the error:template_version:float_refresh cause) 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 says opschain secret set reads 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-v2 states 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 plain not_found; opschain docs events and database-maintenance say the licence applied/removed and EULA accepted events are dropped on the SQLite store; opschain docs storage no 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-all auto-answers) and of the verbs it promises that do not exist.
  • A running step's log chunk is never refused as sealed; a real sealed refusal 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, records warn:logs:attempt_adopted (sequence, replica_id, step_status, message) and counts opschain_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. The sealed verdict 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) ***), the warn:logs:chunks_dropped payload (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 passwd works under the rotate-on-first-login confinement: a confined principal — the hardened posture's bootstrap admin on first login, or a local password past security.password_max_age_days — was refused on every v2 call, AuthService.ChangePassword (the procedure opschain passwd rides) included, with the very 403 that named opschain passwd as the remedy; only the v1 POST /api/users/me/password route lifted it, and nothing in the CLI reaches that route. The v2 lane now exempts exactly the twins of the v1 exemptions — ChangePassword and the Introspect identity probe — so opschain passwd rotates 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> the draining: finishing the in-flight job, no new work (signal again to stop hard) acknowledgement went to the console while drained: no longer acquiring jobs and goodbye: the server marked this runner stopped went 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. The draining: 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-up advertising executors: line follows the same rule, so with --log-file every 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:reject recorded their correlation_id only from the v2 request_metadata the 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's pending / release / lapsed / break_glass events already sat on the subject's. The decision verbs now default the event's correlation to the held change's or workflow run's correlation_id; an explicit request_metadata.correlation_id still wins. opschain docs events and opschain docs change-governance describe 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_segments unique index and surfaced as warn:logs:ingest_forward_failed carrying a raw SQLSTATE 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 records error: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 an unreadable segment gap marker with no record, and a relay convergence pass skipped it silently. Now the pipe records warn: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 records warn:logs:segment_unreadable once 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|mcp used to pass the stored login's raw access token — a four-hour credential, as often expired as not — as the agent's OPSCHAIN_ACADEMY_TOKEN, so an agent started from an older login failed its first product read as the 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 — run opschain 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 procedure opschain tokens create uses — description academy-agent <label> (--label / ACADEMY_AGENT_LABEL, else the hostname), expiring in 30 days or at the agent's --session-ttl when 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 or kill revokes too, and a launcher killed outright leaves the key to its expiry, named in opschain tokens list. OPSCHAIN_ACADEMY_TOKEN set by the caller stays the explicit override: handed over as given, nothing checked, minted or revoked. academy show still 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-remote periodic_fetch_interval were stored, validated and served but nothing consumed them — the Rails PeriodicGitRemoteFetchJob was never ported, so a float_git_rev version 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 since last_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 records error:git_remote:fetch on the remote (with cause and consecutive_failures), a fetch that moved refs records info:git_remote:fetched (never a quiet one), each repin records info:template_version:rev_repinned (from → to), and a repin the sweep could not perform records error:template_version:float_refresh and writes the version's float_refresh_error (the attribute gains its column and its first writer; the next successful repin clears it). Per-remote health on the git_remote resource (v1 and v2, additive; CLI git-remotes list gains LAST FETCH / OUTCOME / FAILURES, --wide adds NEXT FETCH / FLOATING, show prints them all): last_fetched_at, last_fetch_outcome (ok | failed | skipped), last_fetch_error, consecutive_failures, the computed next_fetch_at, floating_versions, last_repin_at. For the operations dashboard: new v2 GitRemoteService/SummarizeGitRemoteHealth (estate-wide, each row gated on read of its project's /git_remotes rule path; failing_only trims 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. Migration 00130_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-resolve no longer cuts a surprise version that the next fetch would silently undo: first guarded the gap by refusing a --version other than the pin-else-highest default on a template whose declaration carries no version: (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 (explicit version:) still reports pinned. Reference: opschain docs cli/templates-re-resolve, pack-store, api-v2.