Approvals and the enforcement chain
Superseded (2026-10-21): change-set approvals, the Independent reviewer, the two-person rule and the Enforcement chain were retired with the old server. There is no approval flow now, only authorization records: a pack’s owner publishes it and it must pass the publish check, and an administrator assigns and configures; results come from the Hub’s boundary validation, and error codes come only from the contracts’ registry. See B04, T02, T03 and P11 in the generic model. Kept as a record only.
Only one approval concept: the Change set
One submission = one Change set = one Decision. A Change set may contain Assets of several objects: the skill package in one module, the product-line manifest, one rule, one connector instance. The Independent reviewer looks once and decides once, and the Decision binds the combined digest of every Asset; after it passes, each Asset’s lifecycle updates atomically, and if it is declined, all of them are declined.
- The Binding of an Approval request:
inputRevisionId(the task’s input revision),targetIdentity(the ordered digest of each item’s sha256),actionDigest(the digest of the action and the parameters). If any one changes, the request is invalidated, and it is applied for again. - The applicant ≠ the approver. The platform is not an approver: a record writes only “verified by rule X vN”, and does not write approved.
- What a Change set references is not approved again: the connector, the original, and the gateway that a skill package references are already approved Assets. Verification only checks that every reference is present and is approved / running; if one is not ready, the request is held as blocked and the reasons are listed, and it is checked again when a dependency changes.
- A new Change set for the same Asset supersedes the old one: the old request is closed as superseded.
- Only two cases are approved again: the bytes changed (a new version), or the action changed (registering a method ≠ executing on a device).
- An Approval request and its pending input share a validity period: one day by default, adjustable in the deployment configuration (one second to thirty days); an ordinary clarification input has its own period (five minutes by default). Extending the period does not lift the re-checks of the revision, the target identity, the action digest, or the execution exit.
- Every authorized response attempt is recorded as a Decision, including rejected attempts; a repeated Decision on the same request is
DECISION_CONFLICT. Each Decision record keeps the policy and Eligibility context of its moment, and is not rewritten by later responses or policy changes. - A pending draft is visible to the people in the same tenant and authorized scope who hold the approve permission, so they can review the definition and the digests; this does not add the approver to readers, and grants no access to originals, knowledge content, credentials, or runs. Credential references stay private and are no longer visible through the pending request once the approval is done or the asset is disabled.
- When a referenced Asset is not approved / running, the Change set stays open and is marked blocked, with the reasons listed; approving it is
DEPENDENCY_NOT_APPROVEDwith the reasons attached. A dependency’s lifecycle change triggers a re-check through a durable queue, and a restart re-checks too. - A response must carry the Binding back verbatim; a difference in the revision, the target identity, or the action digest invalidates it. Declining an old request that has been superseded answers
approve:falsealong that request’s own Binding: what is checked is its revision, its applicant, its digests, and the asset versions of its time, and the original applicant is not required to still hold permissions. Declining closes that request as declined and ends its registration task normally — that is the completion of the governance flow, not an approval of the content; the new version’s approval and execution eligibility are unaffected. - The single-asset registration endpoints (assets, skills, modules) each produce a one-item Change set automatically; a change that spans several items must be decided once through the Change set’s approval endpoint, never item by item.
- A task’s pending input binds the current input revision: a response or a replacement, one of the two, never an implicit edit through a natural-language message; a response against an old revision is
INPUT_REVISION_STALE, one past the deadline isINPUT_EXPIRED; terminal and unknown tasks accept no input replacement, and new work is expressed as a new submission. Once the input revision advances, the old execution bindings and waiting reasons lapse with it.
At run time there is no approval, only the Enforcement chain
At the moment a Change set is approved, the person has already decided three things: the method (the byte digest), the resources it declares it will touch (writes), and who can use it (the scope policy). On each run after that, what the platform does is not a Decision; it carries out the policy the person has set:
- Permission: this person has the right to execute in this scope, and the resource policy allows them — the policy is data, and it was approved once.
- Binding: the method version is approved, the resource version is running, and the original is approved; the pin is fixed at claim time.
- Verification: deterministic rules — the command is in the bound tree, the function contract, the self-destruct rule, and the build the resource reports = the bound build.
- Lease: the resource lease + fencing. What it resolves is a conflict, not an authorization.
- Audit and Evidence: every step leaves a trace as-is.
A local Circle run and a hub worker run follow the same chain: only the worker touches an external system, and where the model runs makes no difference.
There is one more forced re-check after claiming and before dispatching: the platform fetches the current rule original again and verifies it once more, and the result must equal the approved digest — a stale cache left by an ordinary query does not pass. The re-check record is persisted, bound to the current operation and fence, and only after it is consumed exactly once is the dispatch intent authorized — a dispatch intent proves only that the platform admitted one submission, not that the resource has been dispatched to.
Only three results
| Result | Condition | What the platform does |
|---|---|---|
| pass | all five steps pass | execute, and deliver the output with its sha |
| error | a failure the script can prove 100% true | return the fixed Error code and the facts (which command, which tree, the rule version, the two build values, evidenceId). Where the applicant can fix it by changing the input, the task stops at waiting-input and writes waitingReason; otherwise failed |
| unknown | the script cannot determine it | the status is unknown, everything observed is disclosed, and it is not resent; the applicant chooses to check (reconcile), mark it failed, or give up |
In these cases an approver can make no meaningful decision: they cannot make a command appear in the tree, and they cannot turn the device into a different build. So at run time there is no “exception approval”, and there is no “pre-authorization”.
The error-code catalog
The identifiers of provably true failures are all fixed; a client branches on the code and never parses free text. This table is checked code by code against the registry in packages/contracts/src/index.ts, and the registry is authoritative for the HTTP status and the category; a package may extend the registry through registerErrorCodes — see the two notes after the table.
| Group | Code | HTTP · category | Meaning |
|---|---|---|---|
| admission and request | INVALID_PAYLOAD |
400 · validation | the request shape or a value is illegal; an illegal value is never silently truncated |
| admission and request | BODY_TOO_LARGE |
413 · validation | the request body exceeds the cap |
| admission and request | UNSUPPORTED_VERSION |
400 · validation | the capability package is not installed, or no runner exists for the pinned flow version |
| admission and request | RESULT_TOO_LARGE |
413 · validation | the result exceeds the cap; never truncated into posing as the whole |
| admission and request | SKILLPACK_CHECK_FAILED |
422 · validation | the skill’s declared tools mismatch the trial-run record or the current catalog |
| admission and request | IMPORT_NOT_DECLARED |
422 · validation | the module has an import outside its declarations |
| admission and request | TARGET_NOT_FOUND |
404 · target | the target does not exist |
| admission and request | RATE_LIMITED |
429 · transient | over the rate limit; no credentials or user names echoed |
| identity and permission | UNAUTHENTICATED |
401 · permission | the credential is invalid or expired |
| identity and permission | FORBIDDEN_SCOPE |
403 · permission | outside the authorized scope |
| identity and permission | FORBIDDEN_ACTION |
403 · permission | the action is not held |
| identity and permission | FORBIDDEN_TARGET |
403 · permission | the target is outside the authenticated policy |
| identity and permission | PERMISSION_DENIED |
403 · permission | a permission refusal in the Enforcement chain |
| identity and permission | SELF_APPROVAL |
403 · permission | the applicant cannot approve its own request |
| identity and permission | SHARING_REQUIRED |
403 · permission | sharing or promotion needs explicit consent |
| identity and permission | APPROVAL_REQUIRED |
409 · permission | an approved and active registration is required |
| identity and permission | CONNECTOR_DISABLED |
409 · permission | the connector is disabled |
| identity and permission | IDENTITY_UNMAPPED |
409 · permission | the identity cannot be mapped to an authorized principal (registered, with no throw site yet) |
| identity and permission | OBJECT_NOT_VISIBLE |
404 · permission | the object is not visible to the current identity |
| identity and permission | ASSET_NOT_VISIBLE |
404 · permission | the asset is not visible to the current identity |
| binding and conflict | IDEMPOTENCY_MISMATCH |
409 · conflict-stale | the same idempotency key carried different content |
| binding and conflict | INPUT_REVISION_STALE |
409 · conflict-stale | the response targets an old input revision |
| binding and conflict | INPUT_EXPIRED |
409 · conflict-stale | the response is past its validity |
| binding and conflict | DECISION_CONFLICT |
409 · conflict-stale | a repeated decision on the same request |
| binding and conflict | APPROVAL_STALE |
409 · conflict-stale | the revision or record the approval binds has changed |
| binding and conflict | SOURCE_HASH_MISMATCH |
409 · conflict-stale | the source bytes’ SHA differs from the registration |
| binding and conflict | TOOL_NAME_CONFLICT |
409 · conflict-stale | a tool name conflict |
| binding and conflict | RUNTIME_VERSION_MISMATCH |
409 · conflict-stale | the runner’s executable or revision fails verification |
| binding and conflict | TARGET_IDENTITY_MISMATCH |
409 · target | the target identity differs from the pinned value |
| lease | FENCING_STALE |
409 · lease | the fence no longer is the current lease |
| lease | LEASE_EXPIRED |
409 · lease | the execution lease has expired |
| lease | RESOURCE_BUSY |
409 · lease | the resource is frozen by an unknown; a new submission is refused |
| source | SOURCE_ABSENT |
404 · source-absent | the source does not exist |
| source | SOURCE_FORBIDDEN |
403 · permission | no permission on the source; the collapsed code for an ordinary caller |
| source | SOURCE_LOGIN_EXPIRED |
401 · authentication | the source login has expired |
| source | SOURCE_UNREACHABLE |
503 · source-unreachable | the source is unreachable |
| source | SOURCE_NOT_APPROVED |
409 · dependency | the source is not approved, including a configuration-digest mismatch after a plaintext exception lapses |
| execution | COMMAND_NOT_IN_TREE |
422 · execution | the command is not in the bound tree |
| execution | DESTRUCTIVE_COMMAND |
422 · execution | a destructive rule is hit |
| execution | BUILD_MISMATCH |
409 · execution | the reported build differs from the bound build |
| execution | GATEWAY_UNREACHABLE |
503 · execution | the gateway is unreachable |
| execution | LEASE_UNAVAILABLE |
409 · execution | the lease is unavailable |
| execution | RUNTIME_FAILED |
502 · execution | the runner failed, or a private execution request was rejected |
| execution | SANDBOX_UNAVAILABLE |
503 · execution | no supported isolation adapter |
| execution | HOOK_FAILED |
422 · execution | a Hook handler returned no legal verdict |
| execution | RECEIPT_PROCESSING_FAILED |
500 · execution | processing a receipt failed, without internal details |
| dependency and evidence | DEPENDENCY_NOT_APPROVED |
409 · dependency | a referenced dependency is not approved or not running |
| dependency and evidence | EVIDENCE_INSUFFICIENT |
409 · semantic-insufficient | the evidence is insufficient for the conclusion |
| dependency and evidence | ARTIFACT_VERIFICATION_FAILED |
409 · semantic-insufficient | the artifact or evidence fails verification against the statement to be proven |
| transient and fallback | UPSTREAM_TIMEOUT |
504 · transient | an upstream timeout; a read-only operation may retry with a new key |
| transient and fallback | INTERNAL_ERROR |
500 · internal | the internal fallback; an unregistered code falls back to its status and category |
Codes a package registers through registerErrorCodes: CURSOR_EXPIRED (409 · validation) and NOTIFICATION_STREAM_LIMIT (429 · permission) are registered by the tasks package; INVALID_EXECUTION_WORKBOOK (422 · validation), COMMAND_VALIDATION_FAILED (422 · validation), and GATEWAY_REFUSED (422 · execution) are registered by the execution product pack.
Codes thrown but not registered: CONFIRMATION_REQUIRED, CONFIRMATION_STALE, MODEL_PROXY_UNAVAILABLE, MODEL_UPSTREAM_REJECTED — they fall back to INTERNAL_ERROR’s HTTP status and category, keeping only the code itself.
Errors and waiting-input
A failure the applicant can fix by changing the input stops at waiting-input: source not approved, gateway unavailable, lease insufficient, and build mismatch each write their own waitingReason, the pending list carries the matching request, and the applicant submits a new revision through the original input endpoint; the revision keeps its private ownership and delegation and drops the old execution bindings and waiting reasons. Failures no input change can fix — a missing command, an argument violation, a permission refusal — are failed outright.
The classification of source failures
Source failures collapse for an ordinary caller: absent and forbidden are both reported as SOURCE_FORBIDDEN, and only an identity holding the diagnostic permission (sources.diagnose) sees the full classification. Only a confirmed absence enters the absent cache; an expired login, a permission refusal, and an unreachable source are never treated as absence. The details of a failure locate only the references the caller itself submitted, and do not leak a record’s existence, ownership, or content.
Read timeouts
For an operation declared effect=read, an upstream timeout, a synchronous wait timeout, an unknown report, or a lost lease is failed + UPSTREAM_TIMEOUT: retryable=true, sideEffects=none, the resource is not frozen, and no independent side-effect check is demanded; retrying takes a new key or a changed query. A write operation keeps the original rule: unknown, frozen, and checked independently.
The discipline of the unknown
A lost lease, a worker disconnect, a cancel after dispatch, a timeout: the operation turns unknown, the resource freezes, and a new submission gets RESOURCE_BUSY; nothing is resent and nothing is re-dispatched automatically, and a report from an old fence is rejected. A cancel is only a request: a locally observed process exit is not a remote stop, and remoteStoppingVerified:false is written as-is. A reconcile is a read-only check by another worker under the original identity: only when the terminal state and the input digest both match does the operation become reconciled, delivered, and unfrozen; if the check sees no terminal state, only the check operation itself is failed, and the original task stays unknown with the resource still frozen. A dispatch break without a remote task identity cannot recover automatically — reconcile against the resource first, never blindly re-submit.
Pre-dispatch refusals and the remote lease
An explicit refusal by the resource at its lease, environment, or contract-check stage — a receipt with no task identity and no dispatched marker — is a provably true failed, and the resource is not frozen; the original reason and the full receipt stay in gatewayRefusal, a definite code is used when one exists, otherwise GATEWAY_REFUSED; a refusal at the environment stage is not uniformly read as a build mismatch, and text without stage information is not guessed into a dispatch state. A receipt carrying a task identity or a dispatched marker, any failure after the submission, and any error without a provable stage are unknown, and the possibly still active remote lease is kept. The remote lease is released only after the dispatch is proven not to have happened or a terminal state is confirmed; the release outcome is recorded as observed (remoteLeaseReleased true/false, with the reason kept on failure) and never overwrites the main error.
What if production equipment needs the two-person rule
Do not invent another concept. When a resource is registered it declares executionIsChange: true. An execution submission against it is first registered as an execution-submission draft Asset (freezing the input revision, the method digest, the resource version, the original id, and the workbook sha), as one item of a Change set, and only after approval does it enter the Enforcement chain. A test bed does not declare this by default, and goes straight through the Enforcement chain. One concept, two declarations. Such an approval is consumed exactly once by the operation it belongs to, and a Decision is never reassigned to another operation.
Credentials and resources
- Credentials are written and not read: at registration or rotation they enter the vault, at execution the worker injects them, and everything returned to the client passes through redaction.
- Model keys never enter the harness process either: for each run the worker starts a model proxy bound to an ephemeral loopback port, and the harness’s credential file holds only that endpoint and a single-use nonce; the proxy checks the nonce, strips inbound credential headers, injects the real key, forwards only to the model endpoints of the approved runtime profile, and shuts down when the run ends. The proxy records no request bodies, only metadata in the run’s events; if it cannot start or the upstream rejects it, the run fails with a fixed error code and never falls back to writing the real key into a file. Output masking (run token, nonce, model key) is the last line, not the boundary.
- There are three credential modes: a short-lived token issued by the platform, external OAuth, and a static key. A resource with only one caller (for example, the gateway that hub’s worker calls) uses a static key, compared locally, and does not call back to the platform.
- The endpoint, the CA, and the timeout live in the runtime profile file on the platform host, not in the database and not in Git; runtime-profile, as an Asset, binds the profile name, the version, and the configuration digest, and a dependency requires approved / running.
- Local cancel ≠ remote stop; on timeout, first check the original operation and the scene; if it cannot be confirmed, it stays unknown.
- The static-key mode: the value has a minimum length, registration and rotation are write-only, and the response carries only the reference and the mode; the worker fetches the current value and injects it before each call whose authorization has been checked. A shared resource that is approved and running lets a currently authorized member use its bound key without the member seeing the credential’s metadata or plaintext. Disabling the credential, a resource revision, a role revocation, or a lease or fence change each triggers a fresh check.
- A short-lived token issued by the platform (a run token): it authorizes only the tools, scopes, and tasks declared for this run, bound to the operation and the fence; it is renewed by the worker holding a valid lease, never past that operation’s deadline; it lapses immediately when the task ends, is cancelled, the lease expires, the fence ages out, or the input revision changes. It reaches the child process over private local IPC and lives only in memory and the process environment, never on disk; it cannot go through the session refresh or logout endpoints, and it cannot be used to register assets, submit other tasks, or approve. A single worker holds one run slot at a time, and that slot also serves the run’s child tool calls.
- External OAuth: the issuer and the endpoints live only in the issuance configuration outside the repository, the connector configuration references it, and a request cannot carry an arbitrary endpoint; a remote endpoint must be HTTPS (except a loopback synthetic test), the CA is verified every time, and redirects are not followed. Both device-code and client-credentials grants return only a credential reference and expiry metadata on success — values never enter the API, events, audit, or the repository — and credentials are not shared across scopes or owners. Renewal replaces atomically inside a private single-writer lock: an unrotated refresh token is kept; an unknown or incomplete renewal receipt stops replay, marks the rotation pending, and demands re-registration, so that a replayed old token cannot revoke the whole family; a definite 5xx keeps the existing session; with neither expires_in nor a configured default lifetime, the expiry is unknown and never invented.
- The plaintext exception: a non-loopback source that uses authentication requires HTTPS by default. Where plain intranet HTTP is genuinely needed, an administrator declares
allowPlaintextAuthentication: truein the configuration of the corresponding runtime-profile Asset, and that version takes effect only after an Independent reviewer’s decision — this exception can never be self-approved, even where the scope allows ordinary self-decisions; writing the field into the runtime file directly grants nothing. When the current version is no longer approved/running or its configuration digest no longer matches, a call returnsSOURCE_NOT_APPROVED; unapproved HTTP authentication isSOURCE_FORBIDDEN, without the original address or credentials. Every approved plaintext-authentication operation is permanently markedplaintextAuthentication: truein the dispatch intent, the audit, and the fetched Evidence, with a reference to the asset version that approved it; cached re-reads keep the mark, and a later revocation does not rewrite historical Evidence. - The model proxy’s boundary: it binds one ephemeral loopback port, and the single-use nonce is valid for that run only; the target is pinned by the currently approved runtime profile, and the Host, the query string, and the path cannot switch targets; inbound credential headers are all stripped. Request and response are each capped at 16 MiB, SSE is streamed through, bodies are not recorded, and model traffic is not stored as Evidence; the events carry only upstreamHost, statusCode, requestBytes, responseBytes, durationMs, and requestNumber. Failing to start or a disconnect is
MODEL_PROXY_UNAVAILABLE, and an upstream rejection or overflow isMODEL_UPSTREAM_REJECTED; cancel, timeout, unknown, and local exit all close the proxy, and a late event after the close does not overwrite the local exit already observed. - The isolation boundary: a Skill, a Tool, and a Hook share the same host isolation boundary — an untrusted process and its descendants are restricted in file reads and writes, network egress, and process information, and only the current run directory, the fixed harness’s trusted code and dependencies, the explicit CA, and the loopback endpoints are opened; neither the vault nor the runtime configuration can be read directly or through a symlink. A system without a supported isolation adapter fails with
SANDBOX_UNAVAILABLEand never falls back to running as the same user without isolation. Hooks and Tools run in the same tool-host child process with a ten-second and 16 MiB response limit and a 256 MiB heap limit; Hooks run in batches of at most 200 subjects in order, stopping at the first block; subjects are validated against a strict schema first, and host command lines are parsed literally only. - Output masking: artifacts and events byte-replace the run token, the proxy nonce, and the model key, keep every other byte as-is, record sanitized, and recompute the SHA. Masking is the last line, not the boundary.
- Worker draining and replacement are cooperative: a worker asked to be replaced stops claiming new work and may finish a claim already in flight before it exits, and no device operation is force-killed to meet an upgrade deadline; replacement swaps the process and leaves the data alone. An independently deployed worker is drained and replaced by its own process manager, and the platform’s replacement-requested flag does not mean it has been upgraded.
The enforcement chain’s fixed rules on device execution
The Enforcement chain is generic; its rules for a “table-driven device execution” kind of work are as follows. The permitted device methods, check-point kinds, host-name set, and device naming rule are closed sets declared by the product contract — their content lives in the packs, not in this standard.
Verification
- A task is verified before it is claimed: the platform relocates the build against the retained original bytes, parses the full command paths and overloads, re-reads every device command in the workbook (including the initialization rows), and runs the deterministic command check; a missing command, an argument violation, or an input that cannot be proven is failed.
- The gate accepts only literal lines: the method closed set is declared by the product contract, and anything outside the set — input variables, temporary register substitution, vague invocations, and unverified functions — is
INVALID_EXECUTION_WORKBOOKand must first be expanded into checkable literal lines; a disabled lifecycle method is not passed. One execution caps the number of explicit dispatches, and the device indexes are further limited by the current device count. - The host-method closed set comes from the resource declaration and the product contract, and a host command is interpreted by the literal shell argument contract: multiple lines form one argument, a single line is split on commas outside quotes only, outer quotes are stripped, and the only keyword arguments allowed are the keys the contract declares. The output register is kept for later check points; the input register substitution is forbidden. The closed set can be narrowed by an approved Hook through the allowed-host set of the resource declaration — omitting it or an empty array keeps the product contract — and operations can also narrow per host with a host command policy (an exact command allowlist, byte and timeout caps, whether multiline is allowed): narrowing never widens, the policy is pinned into the target identity with the runtime profile, and changing it invalidates old approvals. A host method whose role has no verified mapping is refused outright, never inferred by name.
- The error location shape is fixed: row (from 1), autoid, object, method, plus the column for a non-literal cell; a whole-workbook format error has null in those fields. Host commands and register contents are never echoed.
Dispatch and the two environment observations
- Before dispatching, the platform is forced to fetch the current command originals again and re-check them, and the result must equal the approved tree and command proof; it then takes the resource lease and runs the environment check (build, device count, readiness), refusing to dispatch when any is not met. It never switches the build, the bed, or initializes devices on its own.
- The environment observation (
execution.environment.observed, whose observationSource is the pre-submission environment self-check) proves only the external check at that moment before submission; the resource may then run its own environment and contract checks, the first pass does not override the second refusal, and the second refusal’s original reason is kept as-is.
Device attribution and log parsing
- A device number in the workbook is determined by the order of the device management addresses in the configuration; a device name is never an ordering basis. The ordered addresses come from three sources by priority: the ordered-address field of the environment self-check’s report, the topology’s bound address set, and the name rule over the topology’s devices; the first two are used only when actually returned. The third source holds only when every device name follows the product contract’s naming rule, the index set equals the device count exactly, and each log line’s address falls uniquely within the corresponding device’s interface address set. Arbitrary names, missing/duplicate/out-of-range indexes, and non-unique address attribution all refuse attribution: the corresponding device step is unknown with unmapped=device_map_unavailable. A device number is never guessed from an arbitrary host name or command text; a shared address belongs to no device and advances no device’s command cursor.
- Parsing advances a command cursor per device: the same command on different devices, or repeated on one device, each gets its own log span; a missing command only marks its own step unknown. A check point matches only an explicit log frame to a workbook row, and a mismatch in count, pattern, method, timeliness, or case identity is unknown/unmapped. A whole-case verdict never widens into per-step verdicts: a case pass does not prove each step passed; a task status=succeeded does not mean the tests all passed; missing evidence is delivered as partial. Host-method logs still match by host name — a route independent of the device index.
This document is one of circle-hub’s design specifications, licensed under CC BY 4.0: quote or build on it with attribution to Circle Hub and qingshanfeihu and a link to the original. See the design specifications index.