v0.9 Sub-issue #4 — Stage 3 Assemble
Part of v0.9 epic.
Implements v0.8 Part B Stage 3 (Assemble): instantiate each resolved
Provider in its declared sandbox class with hard_constraints injected,
emit capability_bound audit events, and produce an "assembled mount" that
Stage 4 (Run) can drive.
Spec ref
docs/LIFE_RUNTIME_STANDARD.md §2.6–§2.8 (asset handling, personality
load, bind runtime obligations)
docs/LIFE_RUNTIME_STANDARD.md Part B §B.4 (sandbox classes)
docs/LIFE_RUNTIME_STANDARD.md Part B §B.4.1 (bundled_in_life refusal)
docs/LIFE_RUNTIME_STANDARD.md Part B §B.7 (capability_bound audit)
docs/LIFE_BINDING_SPEC.md §6 (hard_constraints, fail-close on missing)
docs/LIFE_BINDING_SPEC.md §8 (surface.ui_hints.disclosure_label)
Sandbox classes
| Class |
Implementation |
built_in |
Provider runs in the same Python process as lifectl. Direct method calls on the LifeCapabilityProvider instance. No subprocess. |
user_installed |
Provider runs in a separate OS subprocess. IPC over stdin/stdout JSON-RPC 2.0 (one request, one response, line-delimited). The subprocess is launched as a Python child running a runtime.assemble.subprocess_host shim that imports the user-installed Provider package, hands stdin lines to invoke(), writes responses to stdout. SIGTERM on teardown; SIGKILL after 5s. The shim mode satisfies the §B.4 "separate OS process with IPC" minimum boundary. Stricter sandboxing (firejail / nsjail / seccomp / wasm) is RECOMMENDED future work, NOT required at v0.9. |
bundled_in_life |
REJECTED at Stage 3 even if it slipped past Stage 2. Defence-in-depth per §B.4.1. |
Assemble flow per capability
Given a ProviderRef from Stage 2:
- Read the
binding/runtime_binding.json::capability_binding[capability]
entry: asset_paths[], params{}, hard_constraints{}.
- Resolve
asset_paths[] to absolute paths inside the mounted zip.
For pointer-mode .life, resolve the pointer target with offline-first
semantics (read from local cache; warn but DO NOT auto-fetch).
- Inject
hard_constraints keys into the Provider's initialize() call.
Per binding spec §6, missing-constraint = fail-close.
- If sandbox_class ==
built_in: import + instantiate the Provider in-
process; call initialize(asset_paths, params, hard_constraints).
- If sandbox_class ==
user_installed: spawn the subprocess host, hand
it the Provider import path + the same args; await initialize_ack.
- If sandbox_class ==
bundled_in_life: refuse fail-close.
- After successful
initialize, register the Provider's invoke
handle in the runtime's capability table.
- Inject
surface.ui_hints.disclosure_label (binding spec §8) into
the runtime's user-surface controller (will be consumed by Stage 4 Run).
- Emit
capability_bound{capability, provider_name, provider_version, sandbox_class}.
After all capabilities bound: emit mount_succeeded{package_id, capabilities_bound[]}.
This single event marks the runtime as "live" — Stage 5 watchers may
begin polling immediately after this event.
Module layout
runtime/assemble/
├── __init__.py # exports assemble(verify_result, resolve_result) -> AssembleResult
├── _hard_constraints.py # inject + fail-close-on-missing
├── _disclosure.py # ui_hints injection
├── builtin_host.py # in-process Provider instantiation
├── subprocess_host.py # user_installed subprocess + JSON-RPC shim
└── audit.py # capability_bound + mount_succeeded
AssembleResult dataclass:
@dataclass
class AssembleResult:
capability_table: dict[str, BoundCapability] # capability → live invoke handle
disclosure_label: str
forbidden_uses: dict # passthrough to Stage 4
hosted_api_preference: dict # passthrough to Stage 4
sandbox_subprocesses: list[Popen] # for Stage 5 teardown
Audit events emitted
capability_bound{capability, provider_name, provider_version, sandbox_class} — once per capability.
mount_succeeded{package_id, capabilities_bound[]} — once at end of Stage 3.
assembly_aborted{stage: "assemble", reason} — fail-close on hard_constraint
miss / sandbox spawn error / bundled refusal / initialize exception.
CLI surface
lifectl run <pkg.life> after this PR: runs Stages 1–3; on PASS prints
the bound capability table + disclosure label. Stages 4–5 still TODO.
Stage 1 Verify ✓
Stage 2 Resolve ✓ 3 caps resolved
Stage 3 Assemble ✓ 3 caps bound (1 built_in, 2 user_installed subprocess)
disclosure_label="(AI digital life instance of …)"
Stage 4+ pending sub-issues 5-7
Tests
tools/test_runtime_assemble.py:
- Built-in echo path: registry has only an in-process echo Provider
for text_chat → assemble succeeds, capability_table populated,
capability_bound + mount_succeeded emitted.
- Hard-constraint miss: binding declares
hard_constraints.max_response_length: 500; Provider's
initialize() does not accept it → fail-close.
- Sandbox subprocess happy path: dummy
user_installed Provider
shipped under tests/fixtures/dummy_provider/ exposed via JSON-RPC
shim; assemble spawns subprocess + initialize succeeds.
- Sandbox subprocess crash: Provider raises in initialize → fail-close
assembly_aborted{stage: "assemble"} emitted; subprocess reaped (no zombie).
- bundled_in_life sneak path: registry returns a Provider with
sandbox_class == "bundled_in_life" (bypassing Stage 2 filter via test
harness) → Stage 3 still rejects.
- Disclosure label injection: assemble result includes the binding's
surface.ui_hints.disclosure_label verbatim.
Acceptance
v0.9 Sub-issue #4 — Stage 3 Assemble
Part of v0.9 epic.
Implements v0.8 Part B Stage 3 (Assemble): instantiate each resolved
Provider in its declared sandbox class with hard_constraints injected,
emit
capability_boundaudit events, and produce an "assembled mount" thatStage 4 (Run) can drive.
Spec ref
docs/LIFE_RUNTIME_STANDARD.md§2.6–§2.8 (asset handling, personalityload, bind runtime obligations)
docs/LIFE_RUNTIME_STANDARD.mdPart B §B.4 (sandbox classes)docs/LIFE_RUNTIME_STANDARD.mdPart B §B.4.1 (bundled_in_life refusal)docs/LIFE_RUNTIME_STANDARD.mdPart B §B.7 (capability_boundaudit)docs/LIFE_BINDING_SPEC.md§6 (hard_constraints, fail-close on missing)docs/LIFE_BINDING_SPEC.md§8 (surface.ui_hints.disclosure_label)Sandbox classes
built_inlifectl. Direct method calls on theLifeCapabilityProviderinstance. No subprocess.user_installedruntime.assemble.subprocess_hostshim that imports the user-installed Provider package, hands stdin lines toinvoke(), writes responses to stdout. SIGTERM on teardown; SIGKILL after 5s. The shim mode satisfies the §B.4 "separate OS process with IPC" minimum boundary. Stricter sandboxing (firejail / nsjail / seccomp / wasm) is RECOMMENDED future work, NOT required at v0.9.bundled_in_lifeAssemble flow per capability
Given a
ProviderReffrom Stage 2:binding/runtime_binding.json::capability_binding[capability]entry:
asset_paths[],params{},hard_constraints{}.asset_paths[]to absolute paths inside the mounted zip.For pointer-mode
.life, resolve the pointer target with offline-firstsemantics (read from local cache; warn but DO NOT auto-fetch).
hard_constraintskeys into the Provider'sinitialize()call.Per binding spec §6, missing-constraint = fail-close.
built_in: import + instantiate the Provider in-process; call
initialize(asset_paths, params, hard_constraints).user_installed: spawn the subprocess host, handit the Provider import path + the same args; await
initialize_ack.bundled_in_life: refuse fail-close.initialize, register the Provider'sinvokehandle in the runtime's capability table.
surface.ui_hints.disclosure_label(binding spec §8) intothe runtime's user-surface controller (will be consumed by Stage 4 Run).
capability_bound{capability, provider_name, provider_version, sandbox_class}.After all capabilities bound: emit
mount_succeeded{package_id, capabilities_bound[]}.This single event marks the runtime as "live" — Stage 5 watchers may
begin polling immediately after this event.
Module layout
AssembleResultdataclass:Audit events emitted
capability_bound{capability, provider_name, provider_version, sandbox_class}— once per capability.mount_succeeded{package_id, capabilities_bound[]}— once at end of Stage 3.assembly_aborted{stage: "assemble", reason}— fail-close on hard_constraintmiss / sandbox spawn error / bundled refusal / initialize exception.
CLI surface
lifectl run <pkg.life>after this PR: runs Stages 1–3; on PASS printsthe bound capability table + disclosure label. Stages 4–5 still TODO.
Tests
tools/test_runtime_assemble.py:for
text_chat→ assemble succeeds, capability_table populated,capability_bound+mount_succeededemitted.hard_constraints.max_response_length: 500; Provider'sinitialize()does not accept it → fail-close.user_installedProvidershipped under
tests/fixtures/dummy_provider/exposed via JSON-RPCshim; assemble spawns subprocess + initialize succeeds.
assembly_aborted{stage: "assemble"}emitted; subprocess reaped (no zombie).sandbox_class == "bundled_in_life"(bypassing Stage 2 filter via testharness) → Stage 3 still rejects.
surface.ui_hints.disclosure_labelverbatim.Acceptance
runtime-assemblejob green