# ARCHITECTURE.md — hardened design

The build is four layers with one dependency direction, eight invariants and fail-closed exits.
Every invariant names the automated check in scripts/verify.mjs that enforces it.

## 1. Layers

Layers, outer to inner.

    +--------------------------------------------------------------+
    |  adapters    src/platform/dsh.ts, src/platform/shim.ts        |
    |              sandbox/runner.py, scripts/verify.mjs            |
    +--------------------------------------------------------------+
    |  ports       src/platform/ports.ts  (SandboxPort, ReceiptStore)|
    +--------------------------------------------------------------+
    |  domain      src/caps.ts, src/receipts.ts, src/policy.ts      |
    +--------------------------------------------------------------+
    |  surface     src/index.ts, src/tools/*.ts                     |
    +--------------------------------------------------------------+

The domain is pure. It computes caps, hashes and policy decisions from values passed in.
The surface calls ports. The adapters are the only place a dsh or msb name appears.

## 2. Dependency rule

Dependency rule, five lines, enforced by review and by the checks named in section 3.

- The domain imports no adapter, no dsh, no node:child_process and no network module.
- The domain sees ports as TypeScript interfaces only, never an implementation.
- An unverified API may appear in an adapter and in no other file.
- Every adapter seam is registered in docs/CANON.md section 6 with its one-line fix.
- A breaking change must move the adapter, never the core: if the fix edits src/caps.ts, the design is wrong.

## 3. Invariants

Invariants. One line each, with the check id that fails the build when it breaks.

I-1 The domain imports no adapter: no_forbidden_imports
I-2 Untrusted commands reach msb only through SandboxPort: no_host_exec
I-3 Commands are argv arrays, never a shell string: py_tests and ts_import
I-4 The receipt preimage is prev_hash, a newline, then canonicalJson(body): chain_ok
I-5 An unknown state stops the run and never continues: names_ok
I-6 Caps are frozen constants and are never raised at runtime: caps_ok
I-7 No secret appears in argv, environment or receipts: files_ok
I-8 Teardown runs in finally and the sandbox is gone afterwards: py_tests with integration skipped
I-9 Parallel work is bounded by CAPS.concurrentSandboxes and appends to one receipt chain per path: ts_tests (index.pool.test.ts)

## 4. Failure taxonomy

Failure taxonomy. Every row is fail-closed: the run stops, a receipt is written, the microVM is destroyed.

| Condition | State | Action | Receipt field |
|---|---|---|---|
| Input is not a public https URL or the goal is empty | FAILED | reject the request, create no run | error.code E_INPUT |
| /dev/kvm is missing | FAILED | stop before any sandbox starts | error.code E_KVM_ABSENT |
| OCI image pull fails | FAILED | stop the run, keep receipts | error.code E_IMAGE_PULL |
| msb create fails | FAILED | stop the run | error.code E_SANDBOX_START |
| A command passes 600 s | ROLLBACK | kill the process group, destroy the sandbox | error.code E_TIMEOUT |
| Memory passes 4096 MiB | ROLLBACK | out-of-memory kill, destroy | error.code E_MEM_LIMIT |
| Process count passes 256 | ROLLBACK | kill the group, destroy | error.code E_PROC_LIMIT |
| Disk passes 8 GiB | ROLLBACK | kill the run, roll back | error.code E_DISK_LIMIT |
| Runs per job pass 6 or time passes 90 min | FAILED | reject the run, keep receipts | error.code E_BUDGET |
| A key pattern appears in sandbox output | ROLLBACK | redact, stop, raise critical | error.code E_SECRET_SEEN |
| The regression gate fails | ROLLBACK | reject the patch | error.code E_GATE_FAIL |
| No approval record exists | RUNNING | do not apply the patch | error.code E_NO_APPROVAL |
| A prev_hash does not match | FAILED | mark the run failed | error.code E_CHAIN_BROKEN |
| Egress to a host outside the allowlist | RUNNING | drop the packet, log it | error.code E_EGRESS |

## 5. Lane ownership

Lane ownership. One writer per path, so two lanes can never edit the same file.

| Lane | Owns | Must not touch |
|---|---|---|
| contract | docs/CANON.md, docs/ARCHITECTURE.md | src/, sandbox/, tests/ |
| scaffold | package.json, tsconfig*.json, LICENSE, THIRD_PARTY_NOTICES.md, cordis.patch.yml, src/platform/, scripts/ | src/tools/, src/caps.ts, src/receipts.ts, src/policy.ts, src/index.ts |
| core | src/caps.ts, src/receipts.ts, src/policy.ts, src/index.ts, src/tools/, src/*.test.ts | src/platform/, sandbox/, tests/, scripts/ |
| sandbox | sandbox/ | src/, tests/, docs/ |
| tests | tests/ | src/, sandbox/, scripts/ |
| deploy | deploy/, control-plane/, docs/kvm-check.md, docs/provider-smoke.md, docs/demo-script.md, docs/ops.md | src/, tests/, sandbox/, README.md |
| docs | README.md, docs/submission/, docs/BLOCKED-EXTERNAL.md, docs/LIMITS.md, skills/ | src/, sandbox/, tests/, deploy/, scripts/ |

## 6. Receipt preimage

Receipt preimage, executable pseudocode.

    GENESIS = "0" * 64
    body = { seq, run_id, job_id, stage, tool, argv, exit_code,
             stdout_sha256, stderr_sha256, artifact_hashes, ts, actor }
    canonical = canonicalJson(body)          # keys sorted, no whitespace, non-finite rejected
    hash      = sha256(prev_hash + "\n" + canonical)
    receipt   = body + { prev_hash, hash }

Worked example for the first receipt.

    prev_hash = GENESIS
    canonical = {"argv":["true"],"exit_code":0,"seq":1,...}
    hash      = sha256("0000...0000\n" + canonical)
    receipt   = { ..., "prev_hash": GENESIS, "hash": "<64 hex>" }

A verify pass recomputes every hash from GENESIS forward and compares the chain head.
One mismatch fails the run with E_CHAIN_BROKEN and keeps every receipt on disk.
