Open Questions¶
Gaps, contradictions and unmade decisions found in the original design set. Worked through one at a
time; each resolved item gets a Decision: line and, where it shapes the codebase, an ADR
in docs/adr/.
Status legend: OPEN · DECIDED · DEFERRED (revisit at a named trigger)
Priority legend: P0 blocks commit one · P1 blocks v0.1 core · P2 blocks v0.1 ship
| # | Question | Priority | Status |
|---|---|---|---|
| Q1 | Project name | P0 | DECIDED |
| Q2 | Python version floor | P0 | DECIDED |
| Q3 | License | P0 | DECIDED |
| Q4 | TimeoutError shadows the builtin |
P1 | DECIDED |
| Q5 | IsolationTier needs ordering |
P1 | DECIDED |
| Q6 | stream() loses stderr and exit code |
P1 | DECIDED |
| Q7 | Cancellation semantics | P1 | DECIDED |
| Q8 | Sync facade mechanism | P1 | DECIDED |
| Q9 | Audit / OTel / Meter overlap | P1 | DECIDED |
| Q10 | Deny-by-default vs. the demo | P2 | DECIDED |
| Q11 | STATEFUL_CODE on Docker |
P2 | DECIDED |
| Q12 | v0.1 scope cut | P2 | DECIDED |
| Q13 | Doc bug: is on a dataclass |
P2 | DECIDED |
Q1 — Project name¶
Priority: P0 · Status: DECIDED · Source: original design set
The working name was load-bearing across module paths, DSN handling, the entry-point group, error codes, env vars, the CLI, docs URLs and the third-party package convention — and it is taken on PyPI by a dormant 2020 flashcards package. PEP 541 transfer is slow and uncertain, so it was treated as unavailable.
Decision: the project is sandboxio, with SBX as its short code for error
codes, env vars, the pytest fixture and the CLI alias. Canonical usage is import
sandboxio, unaliased. Full rationale, the candidate sweep and the surface-by-surface table
are in ADR-0014.
Q2 — Python version floor¶
Priority: P0 · Status: DECIDED · Source: original design set
Never stated in the input set. A floor is an adoption gate: the whole target ecosystem
(e2b, modal, langgraph, openai-agents, pydantic-ai, crewai, typer, anyio)
sits at >=3.10, and download share shows a 3.13 floor would exclude roughly three
quarters of it, a 3.14 floor ~94%. crewai additionally caps at <3.14.
Decision: requires-python = ">=3.11", CI matrix 3.11-3.14, development and default CI
on 3.14, from __future__ import annotations repo-wide, no upper cap. Data and
rationale in ADR-0015.
Q3 — License¶
Priority: P0 · Status: DECIDED · Source: not covered in the original design set
Positioning is self-hostable and enterprise-supply-chain friendly, and the market doc cites
Daytona's license changes as a risk, yet no license was chosen. The surrounding ecosystem is
overwhelmingly permissive (anyio, pydantic, langgraph, e2b, openai-agents all MIT).
Decision: MIT for the library, DCO for contributions, and the Phase-2
sandboxio-server license deliberately left open until that package exists. Rationale and
the Apache-2.0 trade-off in ADR-0016. Prose in docs/ is
CC BY 4.0, with fenced code samples staying MIT
(ADR-0026).
Q4 — TimeoutError shadows the builtin¶
Priority: P1 · Status: DECIDED · Source: original design set
The input taxonomy named the class TimeoutError, shadowing the builtin — which, on our
3.11 floor, is what asyncio and anyio raise. The decisive fact: builtin TimeoutError
is an OSError subclass, so inheriting it would let except OSError swallow sandbox
timeouts and would attach dead errno/strerror attributes. Prior art is split (httpx no,
aiohttp yes, redis-py and urllib3 shadow the name).
Decision: no sandboxio exception inherits from a builtin and no name shadows one.
Canonical SandboxTimeout as a catch-all base, with ExecutionTimeout (SBX_E1302,
unchanged meaning) and CreateTimeout (SBX_E1203) carrying the codes, plus a new
SandboxGone (SBX_E1204) for a sandbox whose lifetime ended mid-use — a gap the original
catalog had no error for. No TimeoutError alias. Full rationale in
ADR-0017.
Q5 — IsolationTier needs ordering¶
Priority: P1 · Status: DECIDED · Source: original design set
require_isolation=MICROVM implies a comparison a plain Enum does not provide, and
CONTAINER < GVISOR < MICROVM is not self-evidently a legitimate total order. Kubernetes
RuntimeClass, which we already borrow as a mental model, deliberately does not rank its
handlers. The discriminator was what happens when a stronger tier is added later: a
set-based require_isolation={MICROVM} would reject a future CONFIDENTIAL_VM backend.
Decision: plain Enum with string values plus an explicit rank map spaced by 10, rich
comparisons derived from the rank, no arithmetic, and tier.satisfies(minimum) as the
taught form. The rank orders escape resistance only, not general security. Adds
UNKNOWN as the default for any adapter that does not declare a tier — it satisfies no
requirement, and creating on one warns. Rationale in
ADR-0018.
Q6 — stream() loses stderr and exit code¶
Priority: P1 · Status: DECIDED · Source: original design set
AsyncIterator[bytes] loses stdout/stderr separation and the exit code, and carries a
**kwargs black hole. The decisive constraint was cleanup: breaking out of an async for
does not close the iterator, so an abandoned bare iterator leaks a billing remote process.
Decision: stream() is a plain, non-awaitable factory returning a Process async
context manager that iterates OutputChunk(stream, data) and terminates in
await proc.wait(). Ordering is guaranteed within each stream, not between them.
ExecResult gains streamed: bool, and when set, stdout/stderr are empty by
construction. stream_code is deferred until a second backend supports it. Rationale and
the rejected alternatives in ADR-0019.
Q7 — Cancellation semantics¶
Priority: P1 · Status: DECIDED · Source: not covered in the original design set
Undefined in the input set, and behind the most expensive failure mode in the project.
Under structured concurrency a finally: await sb.kill() is decorative — it is cancelled at
its first checkpoint — so a cancelled agent run leaks a billing cloud sandbox.
Decision: shielded teardown in a bounded anyio.move_on_after(TEARDOWN_GRACE,
shield=True) scope, default 5 s, overridable by SBX_TEARDOWN_GRACE. Cancellation kills
the remote process; detach-on-cancel is deferred. Only the id-known window of create() is
shielded, never the whole call. Cancellation always propagates as cancellation, never
wrapped in a SandboxError. Grace expiry emits OrphanedSandboxWarning; sandboxio reap
is the operator backstop. A background reaper task was rejected as unowned global state.
The mandatory create(timeout=...) is the guaranteed backstop. Rationale in
ADR-0020.
Q8 — Sync facade mechanism¶
Priority: P1 · Status: DECIDED · Source: original design set
"Generated via an anyio blocking portal" was ambiguous between build-time codegen and a
runtime wrapper. The point that collapsed the option space: codegen does not avoid the
portal — unasync-style generation works only when the emitted sync code calls sync
libraries, and our adapters are async-only. Codegen decides where the delegating wrapper
lives, not whether one exists.
Decision: hand-written thin delegation (~30 members) with a CI-enforced parity test
that introspects the async protocols and matches signatures — which removes the drift
objection without a codegen pipeline. The portal is per-sandbox, started by
create_sync() and stopped on exit; N sync sandboxes means N threads, documented. Sync
streaming is offered with its per-chunk thread round-trip cost stated. Supersedes the
runtime-proxy lean in ADR-0002. Rationale in ADR-0022.
Q9 — One event, three sinks (Audit / OTel / Meter)¶
Priority: P1 · Status: DECIDED · Source: original design set
Three surfaces carried overlapping fields and would have drifted, forcing the
secret-redaction rule to be implemented per sink. Two things the original question missed:
the sub-question of whether cost_usd is knowable at all, and that a slow audit sink is a
subtler problem than a failing one.
Decision: one _OperationRecord opened at start and closed at completion, redacted once
at close, rendered three ways; the OTel span starts at open so long executions are visible
while running. No inline cost_usd — both SDKs were inspected and the data does not
exist at execution time (E2B has no cost surface; Modal's billing is post-hoc, account-level
and daily-resolution). Cost ships instead as capability-gated post-hoc reconciliation by
metadata label. Audit sinks are async, must return promptly, are awaited inline under
SBX_AUDIT_TIMEOUT, and failure policy is configurable (warn default, fail available).
Evidence and rationale in ADR-0021.
Q10 — Deny-by-default vs. the demo¶
Priority: P2 · Status: DECIDED · Source: original design set
Half the premise was a misreading: network_mode: none governs the sandbox's egress, while
docker pull runs on the host daemon. The demo was never in conflict with the default. The
real constraint is harder — Docker Engine has no per-host egress filtering (verified
against the CLI: --network accepts only none | bridge | host | container | <custom>), so
Docker cannot honour NetworkPolicy(allow=(...)) at all.
Decision: Docker raises CapabilityNotSupported for a non-empty allow rather than
silently granting bridge access; allowlists are an E2B/Modal capability. Dependencies reach a
deny-egress sandbox by image prep or an offline wheelhouse. A mutable install-then-lock
setup window is rejected — it is unimplementable on Docker and makes the policy in effect
time-varying in the audit record. The demo's under-60 s budget is restated as measured warm.
Rationale in ADR-0023.
Q11 — STATEFUL_CODE on Docker¶
Priority: P2 · Status: DECIDED · Source: original design set
run_code(context_id=...) needs a persistent interpreter, which Docker does not provide.
Baking an exec server or Jupyter kernel into an image fails on a point that outweighs
convenience: users bring arbitrary images, and docker://python:3.12-slim is the documented
zero-config path.
Decision: Docker declares STATEFUL_CODE off in v0.1 and raises
CapabilityNotSupported; stateless run_code is fully supported and results stays None,
never synthesised. A runtime-injected bootstrap — upload a script, docker exec it, frame a
protocol over stdin, needing only Python in the image — is recorded as the intended path,
revisited after the E2B adapter reveals what stateful contexts and rich outputs really need.
Purpose-built-image approaches are rejected outright. Rationale in
ADR-0024.
Q12 — v0.1 scope cut¶
Priority: P2 · Status: DECIDED · Source: original design set
Three real adapters plus the suite, CI matrix, MCP server, two framework adapters, OTel, audit, CLI and full docs is not an eight-week scope. What slips under pressure is documentation and test depth — the two things the differentiation rests on.
Decision: v0.1 ships Docker + E2B + Fake; Modal moves to v0.1.1. Two backends carry the one-line-swap narrative as well as three, and Docker↔E2B is the widest gap in the set, so it stresses the abstraction hardest. One framework adapter (LangGraph) in v0.1; "works with all four" becomes a v0.2 claim. Rationale in ADR-0025.
Q13 — Doc bug: is on a dataclass¶
Priority: P2 · Status: DECIDED · Source: original design set
assert sbx_fake.calls[0].network is NetworkPolicy(egress="deny") is always false. Minor
alone, but the set was fed to coding agents, so the bug propagated into generated tests.
Q13 also called for a sweep of the other snippets for the same class of error.
Decision: value objects are frozen dataclasses compared with ==
(spec/01); the corrected snippet is in
spec/08. The sweep was done and found a worse
one — the routing config in the original set was not valid YAML and was copied verbatim into
the spec before a parser caught it. Fixed in spec/07,
which now also restructures default_class as a top-level mandatory key rather than a
pseudo-route. All findings are listed as
corrections to the original design. Every
config sample in spec/ is now parse-tested in CI.