Quirlv0.5.1
ArchitectureDecisions

Share one bounded execution contract

Canonical Quirl project documentation synced from docs/decisions/2026-08-16_192326063_share-one-bounded-execution-contract.md.

Accepted · 2026-08-16

  • Decision owners: Quirl maintainers
  • Extends: ADR 0016

Context

Quirl's native command, focused data, Lua, script, plugin, and explicit Bash/Zsh front doors originally entered their engines through unrelated argument and result shapes. The engines intentionally retain different semantics, so the composition root needs one common place to preserve source identity, reject undeclared authority, carry cancellation and an absolute deadline, distinguish byte and value output, and state who owns cleanup.

The common boundary must remain passive. Native process execution and job ownership stay in quirl-process, data evaluation stays in quirl-data, Lua policy and VM ownership stay in quirl-lua, and interpreter selection stays in quirl-cli. quirl-contract continues to own versioned agent and package wire documents; making it a runtime owner would add catalog knowledge to every engine and would conflict with ADR 0016.

This decision defines and now enforces that common contract. It does not define a new parser, executor, typed Lua ABI, or streaming implementation.

Failure model and invariants

  • One absolute deadline. Planning converts the validated positive duration into one monotonic absolute deadline. Every startup step, wait, callback, process request, VM budget, stream pull, and final normalization uses the remaining time from that deadline. An engine-local policy may choose an earlier deadline for security, but no adapter may restart, extend, or ignore the plan deadline.
  • Deadline before and after planning. A zero or out-of-range requested duration fails before planning. Once a plan exists, expiry before engine startup, during initialization, during execution or output, and after partial completion returns ResourceLimit. No successful outcome is constructed after the absolute deadline has expired.
  • Deadline/cancellation race. Adapters check explicit cancellation before deadline expiry at every shared safe point. If cancellation is already visible it remains the originating error; otherwise reaching the absolute deadline produces the deadline error. Engines preserve that originating error while cleaning partial work rather than manufacturing a status.
  • Cancellation before dispatch. A set cancellation handle rejects planning before effect checks can be followed by engine initialization or side effects. Cancellation is a ResourceLimit ShellError, not a synthetic exit status.
  • Cancellation during initialization. Adapters check the shared handle immediately before and after fallible engine construction. Lua receives the same atomic handle during construction so its instruction hook and injected process host cannot diverge from the request.
  • Cancellation during execution. Native bounded capture, data evaluation, Lua hooks and process callbacks, and reference runners observe the same handle. An engine remains responsible for terminating and joining work it started before returning the cancellation error.
  • Cancellation after partial output. Inherited or incrementally written output cannot be rolled back. A failure remains Err(ShellError) and never claims a successful status or value; the engine drains or closes owned streams and releases children or VM work before returning.
  • Capture boundaries. A captured stdout or stderr stream containing exactly max_bytes_per_stream bytes succeeds. Observing the next byte causes the engine to continue draining both streams, terminate or reap owned work as appropriate, and then return ResourceLimit with the configured limit and observed/discarded usage. The plan ceiling is the authority; adapters do not substitute a hidden fixed capture window.
  • Inherited output. Inherited stdout and stderr are never copied into the outcome. The engine still observes the same cancellation and absolute deadline, owns terminal handoff and child cleanup, and returns only ExecutionOutput::Inherited after those invariants hold. Output already written to an inherited sink cannot be rolled back.
  • Effect denial. A plan is created only when every declared effect is in the request's allowed set. Denial happens before engine initialization, file access, process creation, working-directory mutation, or output writes.
  • Source identity and spans. One bounded UTF-8 source value carries a bounded logical origin and exact text. Adapters pass its text without joining, escaping, or normalization. Half-open spans are accepted only when ordered, in range, and on UTF-8 boundaries. Engine diagnostics retain or are repaired to that exact source at the composition boundary.
  • Byte/value bridges. Bytes and structured values are distinct closed representations. Construction validates byte totals, value node count, aggregate retained string/key bytes, and nesting depth with an explicit stack. A bridge fails with ResourceLimit before retaining data beyond its declared limit. This contract does not make a collecting bridge implicit.
  • Engine failure after partial output. A returned outcome is successful and therefore always has one status and one representation. Engine errors are returned only through ShellError; diagnostics cannot be used as a second error hierarchy. Bounded non-fatal diagnostics may accompany a successful outcome.
  • Recovery and event consistency. Recovery capture and execution events are emitted only around the one normalized result. A non-zero engine status is a successful outcome and produces result/status observations; cancellation, expiry, capture overflow, or another operating failure produces the same ShellError for recovery and error events. Source identity is repaired once at that boundary before either observer sees it.
  • Partial adapter initialization. The adapter that creates a process tree, Lua VM, reader, writer, or worker immediately owns it through RAII. Partial initialization unwinds in that adapter and preserves the original error even when best-effort cleanup also fails.
  • Cleanup ownership. A plan records whether the selected engine or the output consumer owns retained resources. Current engines own processes, jobs, VMs, and live streams. An outcome records only cleanup that has completed or bounded engine state deliberately retained by the engine; it never transfers a raw child, descriptor, VM, or callback to a caller.
  • Status/error/value consistency. Exited(code) is present only in a successful outcome, including a non-zero process status. Cancelled is an error, not Exited(130), at this cross-engine boundary. A value request cannot return captured bytes and a byte request cannot silently stringify a value.
  • Resource cleanup. quirl-process preserves process-group, terminal, child, pipe, and job guarantees; quirl-lua preserves allocator, instruction, deadline, registration, callback, and process-host bounds; quirl-data preserves pull/collection bounds. The shared layer owns none of those resources.
  • Bounded retention. Source bytes, source-name bytes, argument count and bytes, input bytes, value nodes/depth/text, output bytes, and diagnostics all have explicit maxima. Validation avoids recursion over caller-controlled values and reports configured and observed usage where safe.

Decision

quirl-core owns a new passive execution module containing:

  • bounded source identity and UTF-8 spans;
  • execution mode, request, validated plan, and cancellation handle;
  • fixed declared/allowed effect sets;
  • byte and structured-value input/output representations;
  • status, bounded diagnostics, cleanup ownership/state, and structured outcome;
  • validation helpers and constants defining every retained-data bound.

The structured value moves to this lowest common owner. quirl-data re-exports it under its existing DataValue name and remains the owner of live streams, data envelopes, parsers, adapters, and renderers. This is one value contract, not a parallel data runtime.

The contract is an in-process Rust API, not a frozen serialized protocol. Serializable passive values use deny-unknown representations where applicable, but no protocol version or freeze inventory changes until a protocol front door adopts them as wire data.

quirl-cli owns one execution composition function. It validates a request into a plan, creates one monotonic absolute deadline, selects the existing engine exactly once, injects only declared process capability, propagates the same cancellation identity, maps catalog effects into runtime effects, and returns the common outcome. quirl exec, quirl run, piped and interactive execution, native command and script statements, data, Lua, installed plugin commands, and explicit Bash/Zsh all use that contract. Native inherited execution retains no output but uses the same process request cancellation and deadline. Protocol mode remains typed and fails closed because no protocol executor is installed.

The validated capture range is 1..=1 MiB separately for stdout and stderr. Within that range the plan value is exact and authoritative: native and reference engines accept exactly the ceiling, drain limit+1 without deadlock, and return ResourceLimit rather than a truncated success. Value output keeps the separate structured-value bounds. Data's external two-second policy and Lua/plugin callback policies may end earlier than the plan for security, but they cannot extend the plan deadline.

Existing public engine APIs remain available as narrow adapters. P01 exact source handling, P02 process lifecycle and terminal handoff, and P05 Lua policy and fail-closed capability behavior remain authoritative.

Consequences

  • Front doors share one bounded request/plan/outcome vocabulary without pretending bytes, values, inherited terminal I/O, and non-zero status are the same thing.
  • Effect and cancellation policy has one composition point before dispatch.
  • Core gains passive runtime types but no executor, parser, process, Lua, data, UI, catalog, or CLI dependency; the ADR 0016 graph is unchanged.
  • Data collects only where the front door requests an owned value or bounded renderer; interactive rendering remains pull-based and shares plan timing.
  • Lua runner modules preserve typed value or finite-values outcomes through the ABI defined by ADR 0018 instead of projecting through a script-only result.
  • Installed trusted-Lua plugin commands use the same plan and bounded scheduler; protocol mode remains fail closed.

On this page