Skip to main content

What it is

The Runtime Pipeline is the ordered sequence of checks and steps a BusinessTransaction passes through, from arrival to a signed ExecutionTrustRecord. Its center is RuntimeEngine.execute() (packages/runtime/src/RuntimeEngine.ts), a single method that loads a policy, checks that the policy in force is the approved one, checks the caller’s authority and the facts the policy needs, evaluates the policy, builds a Decision, signs an ExecutionAuthorizationPayload if approved, and then hands the result to the release stage (RuntimePipeline) and the final trust record stage (BusinessTrustPipeline). Everything downstream of a request being accepted runs through this one method.

Why it was built

A system that authorizes real-world actions needs one place where every protection is guaranteed to run, in a guaranteed order, with no way for a caller or a misconfigured deployment to skip a step silently. RuntimeEngine is that place. Its own class doc comment states its responsibilities directly: load the requested policy, evaluate it deterministically, create the Decision artifact, create the initial Execution artifact, execute the Runtime Pipeline, and produce the ExecutionTrustRecord. Most of the protections wired into it (signal-intent binding, capability/policy binding, signal-state verification, policy governance verification) were added incrementally, as optional constructor parameters placed after the required ones, so every pre-existing call site keeps compiling and behaving identically when a new protection is introduced. This is a deliberate, repeated pattern in this codebase, not an accident of growth.

How it works

RuntimeEngine’s constructor takes ten required dependencies: RuntimePipeline, PolicyRouter, PolicyEngine, SignalIntentBinder, DecisionBuilder, ExecutionGate, ExecutionBuilder, BusinessTrustPipeline, RuntimeAuthorizationSigner, and authorizationTtlSeconds. It then takes optional trailing parameters: hooks (defaults to none), refusalRecordBuilder, refusalRecordRepository, signalStateVerifier, capabilityPolicyBinder, policyExecutionVerifier, policyGovernanceAnchorResolver, signingReadiness, executionIntents, and approvalNeededNotifier. Two more, the trustedSignalResolver and the authorityGrantEvaluator, default to implementations that refuse anything that depends on them (see steps 6 and 8 below). At construction, it logs which optional protections are actually configured (runtime_engine_constructed), which is how an operator can confirm, from log output alone, exactly which protections are active for a given deployment. execute(transaction) runs these steps, in this order:
  1. Policy load. policyRouter.load(transaction.policy.name, transaction.policy.version). beforePolicyLoad and afterPolicyLoad hooks fire around this.
  2. Policy content hash. policyContentHash is computed from the actually loaded policy document, never from what the caller declared, using the same canonicalize-then-SHA-256 hasher every other content hash in this codebase uses.
  3. Governance anchor. If policyGovernanceAnchorResolver is configured, it resolves whether this policy traces to a valid, matching approval record. This is evidence only. A resolver error is caught and logged, and it never changes the outcome.
  4. Policy execution verification. If policyExecutionVerifier is configured, it checks the same approval-record question, but this check can refuse. A policy with no approval record, an approval record whose signature does not verify, or live content that no longer matches its approval record is refused before any rule is evaluated. The refusal is recorded as authority status AUTHORITY_UNCLEAR, with matchedRuleId policy-execution-verification-violation.
  5. Capability/policy binding. Runs only if step 4 raised no violation. If capabilityPolicyBinder is configured and the invoked capability has a canonical policy binding, a declared policy that does not match it is refused as NOT_AUTHORIZED, with matchedRuleId capability-policy-binding-violation. This check runs before signal-intent binding for the same reason: checking a narrower guarantee against an already-wrong policy is meaningless. Capabilities with no canonical binding are unaffected.
  6. Per-agent authority grant. Runs only for a policy that declares requireAuthorityGrant, and only if steps 4 and 5 passed. The authenticated caller (metadata.submittedBy, set by the server) must hold a current grant for the action, approved through maker-checker, and the request must be within that grant’s limits. Anything else refuses the request before any business system is asked. The default authorityGrantEvaluator reads no grants, so such a policy is refused as AUTHORITY_UNCLEAR (fail closed).
  7. Signal-intent binding. Runs only if steps 4 through 6 raised no refusal. It checks that every boundSignals entry matches the real Intent’s target and parameters. Any mismatch is refused with matchedRuleId signal-intent-binding-violation.
  8. Authority and business validation. Two separate answers, recorded separately on the Decision. Authority asks whether this kind of action may be decided here at all. Business validation asks whether the facts the policy declares as signalSources are established by their sources for this exact business object. It runs only once authority is established and the proposal matches the Intent, so a request that is not authorized never reaches a business system. A sourced fact that the caller sent differently is refused as INVALID. Any status other than VALID refuses the request; none is turned into VALID. The default trustedSignalResolver has no sources registered, so a policy that declares any is refused as SOURCE_UNAVAILABLE.
  9. Policy evaluation. policyEngine.evaluate(policy, evaluatedSignals) runs the real rules, where evaluatedSignals is the caller’s proposal with every sourced fact replaced by the value its source established. It runs only if none of the earlier steps refused. beforePolicyEvaluation and afterPolicyEvaluation hooks fire around it. A refusal from steps 4 through 8 becomes a synthetic REJECT with evaluatedRules: 0, and no rule is evaluated for a request that already failed an earlier check.
  10. Signal-state verification. Runs only if the provisional decision is APPROVE. A request already refused needs no re-fetch of real state. If signalStateVerifier is configured, it independently re-derives the declared facts from a real external source. A mismatch overrides the decision to REJECT, with matchedRuleId signal-state-verification-violation. No authorization is generated for an approval that verified state contradicts.
  11. Approval verifier guard. If the decision is still APPROVE and no signalStateVerifier is configured, the decision is refused with matchedRuleId approval-verifier-not-configured. PolicyValidator requires every approve rule to depend on a signed approval signal, but only the signal-state verifier checks that the approval behind that signal is real. Without a verifier, a caller could set the signal to true on its own.
  12. Decision. decisionBuilder.build(transaction, policyDecision, assessment). The assessment records the authority result and the business validation result. A non-approved decision also records NOT_EXECUTED with the reason.
  13. Refusal record. For any decision that is not APPROVED, a RefusalRecord is built and persisted. This write is evidence only: a failure is logged and never blocks the outcome. If the refusal could be cured by an approval (a policy rule refusal, or an approval that did not verify), approvalNeededNotifier is notified. A refusal from binding, governance, authority or business validation cannot be cured that way, so no notification is sent for it.
  14. Enforce. executionGate.enforce(decision) is the actual gate. A REJECT throws here, and nothing past this point runs for a rejected transaction.
  15. Authorization. signalsHash is computed, and authorizationSigner.sign(...) produces the SignedExecutionAuthorization. The payload covers the decision, the policy name and version, policyContentHash, signalsHash, and the caller and tenant from the request metadata. It also carries the business conditions the authorization rests on, when business validation is VALID, and the authority grant reference, when one applies. The authorization expires after authorizationTtlSeconds. Chapter 9 covers the envelope’s exact fields.
  16. Execution and Runtime Context. executionBuilder.build(...) builds the Execution, and a RuntimeContext is assembled. The context carries a copy of transaction.policy, extended with contentHash and, if resolved, governanceAnchor. The caller-submitted transaction, already persisted before this method ran, is never mutated.
  17. Release. Before anything reaches a connector:
    • signingReadiness.assertReady() runs, if configured. It throws SigningUnavailableError (503 SIGNING_UNAVAILABLE) when the evidence signing path cannot produce a signature that verifies, so nothing is released.
    • executionIntents.prepare(context) runs, if configured. It signs and stores what is about to be released, and throws ExecutionIntentUnavailableError (503 EXECUTION_INTENT_UNAVAILABLE) if it cannot.
    Then pipeline.execute(context) releases the action to the connector. Chapter 10 covers ExecutionGateway, one implementation of the ExecutionSystem interface this stage calls. If the release raises an error, the intent is marked ERRORED, because the action may still have been performed. A typed error that already states its own outcome passes through unchanged. Any other failure, typically a connector that could not be reached, timed out, or returned an error, is reported as EXECUTION_OUTCOME_UNKNOWN, with the businessTransactionId and authorizationId and a critical log entry. A retry on that response could repeat an action that already happened, so the error is never a bare 500. On success, executionIntents.markReleased(...) saves the execution context, so the trust record can be rebuilt later without calling the connector again.
  18. Trust record. afterExecution and beforeTrustRecord hooks run, then trustPipeline.execute(processedContext) produces the signed ExecutionTrustRecord, then afterTrustRecord runs. Any failure here, after the action was released, is thrown as ExecutionRecordIncompleteError (500 EXECUTION_RECORD_INCOMPLETE), which names the businessTransactionId and authorizationId. An action released with no signed record is the outcome this error exists to make visible. Any error raised anywhere in execute() also fires the onRuntimeError hook.

Signing readiness and failure after release

signingReadiness is CachedSigningReadiness in production. Its probe, VerificationCrypto.probeSigning(), signs a synthetic artifact padded past 4096 bytes through the same signer and key id used for trust records, then verifies it against the public key that signer publishes. A success is trusted for 60 seconds, a failure is never cached, so the next request probes again, and concurrent requests share one probe. createSigningReadiness() enforces this everywhere except when NODE_ENV is exactly test or development. Everything after pipeline.execute() returns happens after the action was released to the connector. A failure there, in the trust record pipeline, the afterExecution and beforeTrustRecord hooks, or persisting the record in Runtime.execute(), is thrown as ExecutionRecordIncompleteError, and a critical execution_released_record_failed or execution_released_record_persist_failed event is logged. A failure before release, such as a policy refusal, keeps its own error. See ADR-0011.

Execution Intents: signed evidence before release, and a repair path

The Execution Trust Record contains the execution result, so it can only be built after release. ADR-0012 adds a separate record that does not: the Execution Intent. ExecutionIntentService.prepare() (packages/runtime/src/ExecutionIntentService.ts) builds it with ExecutionIntentBuilder, signs it, and stores it, immediately before pipeline.execute(). It is fail closed: if the intent cannot be signed and stored, nothing is released and the caller gets 503 EXECUTION_INTENT_UNAVAILABLE. The signed fields are the ids, the policy reference, policyContentHash, signalsHash and businessTransactionHash copied from the signed authorization, action, target and createdAt. The execution result and the raw intent parameters are never in it. An intent proves what was about to be released. It does not prove the action was released, or what its result was. The intent has an unsigned operational state: PREPARED (signed and stored), RELEASED (the release stage returned and the execution context was saved), FINALIZED (a signed Trust Record exists) and ERRORED (the release stage raised an error, so the outcome is unknown). markReleased, markErrored and markFinalized are best effort and never throw, because they run after release. They log at critical severity when they fail. Marking an intent FINALIZED deletes the saved execution context, since the Trust Record then holds it. A fifth state, RESOLVED, is set only by ExecutionIntentService.resolve(): a verified human closes a PREPARED or ERRORED intent after reconciling it at the connector, recording resolution (NOT_EXECUTED or EXECUTED), a required note, and who and when. It is idempotent, never calls a connector, refuses RELEASED and FINALIZED intents and any transaction that already has a Trust Record, and is an unsigned operator statement, not a Trust Record. ExecutionIntentFinalizer (packages/runtime/src/ExecutionIntentFinalizer.ts) rebuilds a missing Trust Record from the saved context. It never calls a connector, it is idempotent, and it refuses with 409 EXECUTION_INTENT_RESULT_NOT_RECORDED when no context was saved. It is exposed as POST /execution-intents/{businessTransactionId}/finalize for a verified human credential. It also verifies the record and generates the receipt. Enforcement is the rule createExecutionIntents() applies: on everywhere except NODE_ENV test or development, where EXECUTION_INTENTS_CHECK=true turns it on. The full operator procedure is in the docs site page Execution Intents. RuntimeFactory.create() (packages/runtime/src/RuntimeFactory.ts) is the composition root that assembles a fully wired RuntimeEngine (via RuntimeBuilder) plus the surrounding ExecutionTrustApplication (transaction, execution, verification and receipt services). It takes the same optional protections as trailing parameters and only wires them into RuntimeBuilder when supplied (if (signalStateVerifier) { builder.withSignalStateVerifier(...) }), the same “absent means unconfigured, not broken” discipline as the constructor itself.

How it enables things, with examples

  • examples/tutorials/03-runtime-execution, the baseline: a transaction through RuntimeEngine.execute() to a trust record, with no optional protections.
  • examples/tutorials/16-runtime-pipeline, the pipeline stages themselves.
  • examples/tutorials/18-runtime-hooks, the RuntimeHook interface (beforePolicyLoad, afterDecision, and others) that lets an integrator observe or extend the pipeline without modifying RuntimeEngine itself.
  • examples/tutorials/19-runtime-composition, composing multiple pipeline stages.
  • examples/tutorials/15-custom-runtime-component, writing a custom pipeline stage.

How to validate this yourself

  • packages/runtime/src/RuntimeEngine.ts, the method itself. Every ordering claim above is a direct comment or statement in this file.
  • packages/runtime/src/RuntimeFactory.ts and RuntimeBuilder.ts, how a RuntimeEngine actually gets constructed for a real deployment.
  • packages/runtime/tests/e2e/runtime.e2e.test.ts, end-to-end proof, including the content-hash-at-decision-time assertion against real on-disk policy content.
  • packages/runtime/tests/unit/optional-protections-logging.test.ts, proves the construction-time log line accurately reflects what is wired.
  • packages/runtime/tests/integration/runtime.integration.test.ts, the fuller integration surface.

Integration requirements

None beyond what Chapter 2 (Configuration and Bootstrapping) already covers. RuntimeEngine itself reads no environment variables. Every dependency it needs is constructed and passed in by RuntimeFactory and application.ts. The optional protections (signalStateVerifier, capabilityPolicyBinder, policyExecutionVerifier, policyGovernanceAnchorResolver, signingReadiness, executionIntents) each have their own configuration surface, covered in the chapters specific to them.