OCI container runtime v1
This second backend executes one selected READY capability in a fresh Linux
container. It is available through apizr execute and apizr.oci Python APIs.
Governed REST/MCP OCI bundles reuse this backend
when explicitly selected. Direct and local-process generation remain unchanged.
Boundaries and versions
The policy describes requirements; the provider implements launch mechanics.
apizr.oci.provider.ContainerProvider separates probe, creation, attached protocol
command, terminal-state evidence and removal. Docker Engine is the first trusted provider.
There is no Docker SDK dependency and no arbitrary Docker argument/mount option.
| Contract | Version | Meaning |
|---|---|---|
| Existing policy | apizr.execution/v1 |
Stable local-process semantics |
| New policy | apizr.execution/v2 |
Typed resource/control vocabulary, explicit oci-container backend |
| Existing worker plan | apizr.runtime/v1 |
Bound invocation validated by the unchanged worker |
| Container plan | apizr.runtime/v2 |
Outer policy, image/provider and inner worker plan |
| Backend | apizr.oci-container/v1 |
Mandatory Linux container controls |
| Provider | apizr.docker-engine/v1 |
Docker CLI implementation |
| Public result | apizr.execution-result/v2 |
Existing statuses plus provider/resource/cleanup failures |
V2 currently accepts only oci-container; it is not an automatic migration or a
new interpretation of v1. The vocabulary is not Docker CLI syntax. A future
provider/version can implement the container contract. V1 policy, plan, worker,
protocol and governed bundle bytes remain unchanged.
OCI Runtime Specification v1.3
is the conceptual Linux process/namespaces/mounts/cgroups boundary. Apizr does not
emit an OCI config.json, negotiate a runtime-spec version, or claim an OCI
conformance certification. Docker translates its engine configuration into runtime
controls. See the Docker run reference
and resource constraints.
Plan and shared worker
Pure apizr.oci.planner.plan consumes Inspection, original source, optional
notebook executable, selection, policy and explicit RuntimeImage. It does not
probe Docker, import source, acquire images or run a process. It delegates interface
eligibility and effect requirements to existing planners. Unsupported controls
fail closed. Canonical bytes use existing sorted JSON serialization.
The outer plan binds the v2 policy digest, backend identity and runtime image
configuration. Its nested, hashed worker plan binds source, transformed executable,
IR, readiness and interface digests. The worker's v1 policy requests only the
limits, environment and effect controls it actually validates; it does not claim
to enforce containers. The supervisor reconstructs and compares the entire outer
plan before launch. The unchanged apizr.execution.worker then validates the
inner plan and exact source bytes, verifies callable shape, reconstructs arguments,
runs sync/async code and enforces finite JSON results.
No Docker-specific application protocol or duplicate capability worker exists. The existing uint64-length-prefixed JSON request/response travels over attached stdin/stdout, without a network endpoint. Byte/framing limits remain independent of cgroup limits. Ordinary source stdout/stderr is not a response channel.
Plans contain no container names/IDs, paths to the host bundle, daemon endpoint, probe output, timestamps or environment values. Runtime names are transient. Golden plans use a synthetic full image ID, independently of local image builds.
Runtime image and deliberate preparation
V1 accepts a full local sha256:<64 lowercase hex> image ID, verified by
docker image inspect, plus explicit linux/amd64 or linux/arm64 platform.
Mutable tags, abbreviated IDs and registry references are rejected. This is the
verified local image-ID form of immutable identity; registry-digest selection can
be added deliberately later. The exact ID/platform/provider enter the plan.
The image must provide /usr/local/bin/python (supported Python 3.11–3.14), the
installed Apizr worker and apizr.oci.entrypoint, and its Pydantic dependencies.
It must support UID/GID 65532, declare no image volumes, and carry
org.apizr.worker.protocol=apizr.runtime/v1. The label declares compatibility;
it is not authentication or proof of image contents. The selected image is trusted.
Apizr overrides entrypoint, working directory, user and health checks.
scripts/build_worker_image.py deliberately builds a local test image from a
wheel. Its temporary context contains only that wheel, generated Dockerfile and
the hash-locked Pydantic dependency closure from uv.lock. It does not include
user source, push an image or perform registry authentication. Preparation may
acquire the specified base image and dependencies; invocation never does.
Execution uses docker create --pull=never. Absent, mismatched, incompatible or
wrong-platform images yield runtime_image_unavailable. No registry fallback or
implicit update is attempted. The image digest binds selected content, not build
provenance; no signing, SLSA or attestation implementation is included.
Mandatory controls
Every invocation enforces these controls with no escape hatch:
- Unprivileged container, UID/GID 65532, all Linux capabilities dropped and
no-new-privilegesenabled. - Read-only root and one read-only private bind mount at
/bundle, containing exactly the original input and executable source tree. No repository/home/host root, Docker socket, arbitrary device or policy-provided mount is exposed. - Private PID/IPC/cgroup namespaces; no host network namespace. Network mode
nonedenies normal external/host connectivity; loopback still exists. /tmpis bounded tmpfs withnosuid,nodev,noexec, mode 1777./dev/shmis explicitly limited to 64 KiB. Standard container/devand kernel pseudo-files still exist; this is not an empty filesystem. Scratch consumes the cgroup memory budget. Source-relative writes fail; use/tmpfor writable scratch.- Hard memory limit with combined memory+swap equal to memory (no extra swap),
CPU quota and PID limit. CPU is integer thousandths of a CPU:
cpu_millis=500translates to quota 50000 per period 100000. It does not replace wall timeout. - Docker's builtin default seccomp profile must be advertised; missing or unconfined seccomp fails the probe. No custom syscall policy is claimed.
- No restart policy, health check or persistent Docker log capture.
Defaults: 256 MiB memory, one CPU, 64 PIDs, 16 MiB scratch, 5 seconds attached execution, 1 MiB input/output. Bounds are typed, finite and conservative. Scratch cannot exceed half the memory budget. Network inherit, environment inherit and absolute subprocess denial are unsupported v2 requirements and fail closed. Subprocesses are permitted but contained in the PID namespace/cgroup.
Environment and trust
Only explicitly allowed names receive parent values. Docker reads those values
from its CLI environment (--env NAME); values are absent from command arguments,
plans, labels and automatic diagnostics. Before calling the shared worker, the
image entrypoint clears image/Docker-injected environment defaults and preserves
only the requested names. The Docker daemon can inspect container environment:
it is a trusted administrator, not a secret vault. Source can intentionally return
data it receives; successful application output is not secret-filtered.
The POSIX supervisor, selected worker image, Docker CLI/daemon, container runtime
and host kernel are trusted. Docker Desktop additionally trusts its Linux VM.
This is stronger containment than local processes, not a VM boundary for hostile
multi-tenant execution. Kernel/runtime compromise is outside the threat model.
Static planning/generation remain non-executing. execute crosses the explicit
runtime source-execution boundary.
Lifecycle and failures
The provider checks Docker reachability, Linux mode, memory/swap/CPU/PID support,
seccomp and image compatibility separately from planning. Native Windows pipe
supervision is not supported; a Linux/macOS POSIX host can supervise Linux Docker.
Unavailable controls/flags/daemon yield a sanitized backend_unavailable.
A fresh, privately named container receives one request. The existing bounded
pipe supervisor enforces wall time while starting/attaching the worker. Every
post-create path runs explicit rm --force --volumes in finally, including
failed creation, source failure, malformed response, timeout and OOM. Removal
terminates descendants even after setsid(). There is no process pool, host-wide
kill heuristic or automatic removal that hides OOM evidence.
The attached invocation deadline includes Docker start overhead. Probe, create,
and removal have separate bounded CLI timeouts (10 seconds each);
cleanup retries up to three times. A daemon outage may prevent confirmed removal:
return cleanup_failed, never claim successful cleanup, and restore/manage the
trusted daemon before further work. Normal integration paths assert absence of
the exact invocation container after return.
Only reliable terminal Docker State.OOMKilled=true maps a failed worker to
resource_limit. A generic exit code alone remains worker_failed. Wall
expiration is timeout and bypasses terminal observation, even if subsequent
cleanup kills the container.
ContainerProvider.final_state returns internal typed evidence (running,
status, oom_killed, exit_code) or no evidence. Docker validates the four
documented State fields
strictly, requiring their actual JSON types. Additional unused Docker fields are
ignored. Evidence is terminal only when Running=false and Status is exited
or dead. This representation is not added to any public result or manifest.
The provider observes only a generic worker_failed result, before removal.
The default monotonic observation budget is 1 second, independent of the
capability wall deadline. Each inspect subprocess gets at most 250 ms, capped
by the remaining budget. Nonterminal, inconsistent, malformed or unavailable
observations retry after at most 50 ms, without busy waiting. A terminal OOM
returns immediately. An ordinary terminal exit such as 23 returns immediately.
Exit 137 (and exit 0 without a valid worker response) leaves room for delayed OOM
metadata until the budget expires; neither code is OOM evidence. Without positive
terminal OOM evidence the generic failure remains unchanged. An inspect failure
never exposes Docker stderr. Cleanup still runs in finally, and its failure
still takes precedence over classification.
Successful calls and supervisor timeouts perform no extra inspection or sleep. An inconclusive failure adds at most a one-second observation budget, plus normal OS process-creation/reaping and scheduler overhead; Python subprocess timeouts cannot strictly bound those OS operations. Probe/create/removal budgets are unchanged. There is no arbitrary supervisor sleep or GitHub Actions retry.
Issue #57 recorded intermittent direct and MCP HTTP misclassification on Linux CI. Those failed runs did not capture Docker State, so their exact event ordering is unproven. A controlled running/false → exited/false/137 → exited/true/137 sequence reproduces the old one-observation gap. Docker's OOM and exit event handlers update state separately; attach completion alone is not a terminal-evidence contract. Tests cover that sequence, transient inspection failures, strict JSON, bounded waiting, ordinary exit 23, non-OOM exit 137 and timeout precedence. Required OCI CI also checks every sample of 10 real direct OOM invocations and 20 per REST/MCP stdio/MCP HTTP transport, with repeated non-OOM/timeout controls and removal assertions. Passing local samples alone do not establish reproduction of the original Linux CI race.
The strengthened #57 CI investigation captured 17 consecutive terminal
exited/137/OOMKilled=false observations through the entire budget, including
just before removal. Waiting longer is not a general remedy when evidence is
lost: containerd documents a
systemd scope garbage-collection race
that can remove a cgroup before its OOM event is read. Apizr must still return a
generic failure if Docker never reports OOM; it does not inspect host journals or
infer the cause from exit 137.
The required oci-isolation runner therefore explicitly configures Docker's
cgroupfs driver before building its fixture image. This changes cgroup lifecycle
management on the disposable test host, not invocation limits, cgroup namespaces,
network, seccomp, read-only mounts or cleanup. The job logs the before/after engine
and driver versions and retains every OOM assertion. There is no test skip or
workflow retry. Apizr never changes a user's daemon configuration. With other
provider configurations, missing upstream OOM evidence remains an explicit
classification limitation, even when terminal-state polling is correct.
This fix changes three embedded OCI source files and their hashes in generated OCI bundles (including the enclosing manifests). Existing bundles must be regenerated to receive it. Public schemas/versions, policies/plans, direct/local bundles and legacy artifacts remain unchanged. Failure results contain only stable statuses and null values: no provider stderr, source exceptions, host paths, daemon addresses, IDs or environment values.
Evidence and compatibility
Real Docker tests inspect UID, /proc/self/status capabilities/NNP/seccomp,
read-only mount flags, cgroup v2 values and bounded scratch writes. They attempt
host-marker access and connections to a real host TCP listener; allocate beyond
a small memory limit; exhaust a bounded PID allowance; and observe a detached
child heartbeat before timeout then verify removal and loss of access afterward.
Unit tests additionally cover unavailable controls, image mismatch, unsupported
policy, deterministic planning, protocol and cleanup failures. Linux CI always
builds an explicit test image and runs the integration suite; optional local tests
skip only when no image configuration was supplied. Supplied but unusable images
fail, rather than silently skipping security checks.
| Guarantee | Local process v1 | OCI container v1 |
|---|---|---|
| Wall deadline, input/output bounds | Yes | Yes |
| Filtered environment, fresh invocation | Yes | Yes |
| General host filesystem containment | No | Yes |
| External/host network deny | No | Yes |
| Hard memory, CPU quota, PID limit | No | Yes |
| Detached descendant containment | No | Yes |
| Non-root, capability drop, NNP | Not imposed | Mandatory |
| Absolute subprocess prohibition | No | No |
REST/MCP direct and governed output, execution v1 goldens, Capability IR, Readiness, Interface Contract and the legacy generator remain byte-compatible. Custom seccomp/subprocess prohibition, microVM isolation, image publication/acquisition UX and resource scheduling remain deferred.