Sprint 3 Design Pack — GWP-248 — Fe VM cartridge runner integration
Notion task: GWP-248
Recovered: 2026-04-26 (lost to worktree contention; restored from Notion block children API)
Objective
Section titled “Objective”Implement ADR-0007’s scripted-mission FFI surface — sandboxed Lisp evaluator for mission-author-defined :input-template and :acceptance-contract lambdas with bounded execution (8KB arena, 1s timeout) and clause-level fail reporting.
Context
Section titled “Context”ADR-0007 (Accepted v1.0) defines the scripted-mission contract — operator writes a Lisp expression; mission template’s acceptance predicate evaluates it; pass/fail returned as clause results. Today no code path runs an isolated Fe context with a memory cap and instruction counter; nemacs.c has separate editor/REPL contexts but neither is sandboxed for script eval.
References: docs/adr/ADR-0007-scripted-mission-ffi.md, kn86-emulator/src/nemacs.c L538-624 (Fe context init pattern).
Acceptance Criteria
Section titled “Acceptance Criteria”- New module src/nosh_scripted_mission.c and .h hosts the sandbox runner
- Sandbox isolates the Fe arena (8 KB) and counts bytecode instructions to enforce 1s timeout (arena reset on completion)
- Tier-1 always-available primitives (pure compute, list ops, string ops) registered; Tier-3 forbidden (no display, no sound, no spawn-cell, no eval, no load-file)
- Tier-2 :grants honored — only granted cartridge-data accessors register for the sandbox session
- (pass) and (fail clause-1 …) primitives return structured records the runtime consumes
- Test test_scripted_mission covers ADR-0007 worked example #1 (filter hostile nodes): both pass and fail paths with clause-scoped feedback
- :timeout and :oom failure paths surface to caller as fail() returns
Constraints
Section titled “Constraints”- Owns: src/nosh_scripted_mission.c and .h (new module).
- Touches additively: tests/test_scripted_mission.c, CMakeLists.txt, nosh_lisp_bridge.c (capability tier flag).
- Must NOT modify: nemacs.c (separate editor/REPL state), main.c.
- TDD; document arena sizing in ADR-0007 amendment if 8 KB proves tight.
Dependencies
Section titled “Dependencies”GWP-233 (Fe arena scaling experiments) may inform sizing.
Sprint 3 Design Pack (PM/BA + Gameplay Designer enrichment, 2026-04-26)
Section titled “Sprint 3 Design Pack (PM/BA + Gameplay Designer enrichment, 2026-04-26)”Disk source of truth: docs/sprints/2026-04-26-sprint3-gwp-248-design.md. Original ACs above are preserved as audit trail; the items below ENRICH scope without superseding them.
1. Story Narrative (PM/BA)
Section titled “1. Story Narrative (PM/BA)”The KN-86 promises operators that the device is a real Lisp deck — that the QUOTE key has a referent, the REPL is genuine, and a ‘scripted mission’ means the player writes Lisp and the deck runs it. ADR-0007 made this contract explicit: a mission template hands the operator an :input-template, the operator authors a lambda, and the runtime evaluates that lambda against an :acceptance-contract predicate that returns clause-level pass/fail. None of that is real today — nemacs.c has Fe contexts for the editor and the REPL, but there is no third context that is bounded (8 KB arena, 1 s wall-clock equivalent expressed as a bytecode-instruction budget), isolated (no leakage into deck state), and capability-tiered. Without this runner, scripted missions cannot ship — which means ICE Breaker’s Lisp identity, Black Ledger’s audit-script puzzles, and the entire :difficulty 4–5 advanced-mission tier are unreachable.
2. Player-Facing Semantics (Gameplay Designer)
Section titled “2. Player-Facing Semantics (Gameplay Designer)”A scripted mission, from the operator’s seat, is a contract on the Mission Board flagged with the λ modifier glyph. When the operator EVALs to accept, the runtime hands the contract off to nEmacs in scripted-mission editing mode. nEmacs receives a ScriptedMissionContext containing input_value (the result of the template’s :input-template), an expected_script_hint, the opaque acceptance_contract_handle, and a grants_mask. The operator types Lisp into the editor, presses the editor’s EVAL key, and that is the moment this sandbox runner fires.
Tier 1 (always allowed): pure arithmetic and comparison; if/let/lambda/quote; car/cdr/cons/list/null?; map/filter/reduce; string ops; (mission-input) and (mission-context) for read-only template data; (print x) and (describe x) to a scratch mission console rendered in nEmacs’s output rail.
Tier 2 (grantable per template): (cartridge-data
Tier 3 (never): all mutators (credit-add, rep-modify, spawn-cell, nosh-text-puts, sfx-*, cart-save, phase-advance, mission-complete); cross-cartridge internals not in :grants; reflection (eval, load-file, intern).
Worked example — capability-denied at the boundary
Section titled “Worked example — capability-denied at the boundary”Suppose a template grants :ice-breaker only, and the operator’s solution attempts a cross-cartridge join via (black-ledger-transaction-list):
(lambda (input) (let ((nodes (mission-input)) (txs (black-ledger-transaction-list))) ; Tier 2, NOT in :grants (filter txs (lambda (tx) (member? (tx-target tx) nodes)))))The sandbox does NOT silently return nil. The Tier 2 dispatch checks :grants before resolving the symbol; the lookup raises :capability-denied carrying (:symbol black-ledger-transaction-list :reason :not-granted). The runner catches the raise, halts evaluation, and returns a synthetic (fail (:capability true “Your script tried to read BLACK LEDGER state, but this contract only grants ICE BREAKER access. Check the mission brief.”)). The diegetic message is ‘the contract is the contract’ — not ‘your code crashed.‘
Worked example — arena overflow
Section titled “Worked example — arena overflow”(define (explode n acc) (if (= n 0) acc (explode (- n 1) (cons n acc))))(lambda (input) (explode 100000 '()))Each cons allocates from the 8 KB arena. After ~600–800 cells the arena bumps past its high-water mark; the next allocation fails; the runner trips its :on-oom handler and returns (fail (:oom true “Script used too much memory.”)). The arena is then discarded wholesale — no mid-run cleanup, no orphan references, no leakage into deck state.
Worked example — instruction-budget timeout
Section titled “Worked example — instruction-budget timeout”(define (spin) (spin))(lambda (input) (spin))The Fe interpreter increments an instruction counter on every bytecode dispatch; the runner sets the counter ceiling to a value calibrated to ~1 s wall-clock on the Pi Zero 2 W (initial estimate ~50,000 bytecode instructions; tunable via header config). When the counter hits the ceiling, the interpreter raises :timeout. The runner returns (fail (:timeout true “Script took too long. Infinite loop?“)).
3. Acceptance Criteria (enrichment — 12 testable items)
Section titled “3. Acceptance Criteria (enrichment — 12 testable items)”- Module placement: kn86-emulator/src/nosh_scripted_mission.{c,h}; public surface is exactly nosh_scripted_mission_init and nosh_scripted_mission_run; no globals; re-entrant per call.
- Arena isolation: each invocation builds a Fe context backed by a dedicated 8 KB arena (configurable, default 8192, max 16384, min 4096). Allocated from a static buffer the host owns; runner never calls malloc. Arena reset/zeroed before EVERY return.
- Instruction-budget enforcement: configurable instr_budget (placeholder 50000 — engineering MUST replace with measured value during bring-up). Overflow raises :timeout; runner returns NOSH_SM_RESULT_TIMEOUT with synthetic clause.
- OOM handling: arena exhaustion returns NOSH_SM_RESULT_OOM with synthetic clause (:oom true “Script used too much memory”). Arena reset still occurs.
- Capability tiering: grants_mask bitfield gates Tier 2 primitive resolution. Tier 1 always bound. Tier 3 NEVER bound — even if grants_mask is malformed. Non-bound symbol raises :capability-denied → fail clause.
- Acceptance-contract evaluation: runner evaluates operator’s lambda first, then evaluates acceptance_contract against (player_result, input_value). Contract MUST return (pass) or (fail :k1 v1 …). Any other shape returns NOSH_SM_RESULT_MALFORMED_CONTRACT.
- Clause extraction: walk (fail …) form, expose clauses as NoshScriptedMissionClause[]; cap at 16, set truncation flag if exceeded.
- Determinism: identical inputs (input_value, lambda, contract, grants_mask, instr_budget, arena_bytes, lfsr_seed) produce identical results every call. No wall-clock reads, no deck-state reads, no PRNG outside Tier 2 (random) which itself takes an explicit seed.
- Test fixture (3 happy paths) in kn86-emulator/tests/test_nosh_scripted_mission.c: ADR-0007 Worked Example 1 end-to-end (filter-by-threat); a map-based transform; a reduce-based fold.
- Test fixture (6 failure paths): capability-denied (Tier 2 not granted); capability-denied (Tier 3 always-blocked); timeout; OOM; malformed-contract; structural-fail.
- No-leakage tripwire: every test snapshots credit_balance, reputation, cartridge_history, cipher_seed before the call; asserts byte-identical after every test case (pass and fail). Tier 3 attempts to call credit-add must NOT mutate credit_balance.
- Build wiring: CMakeLists.txt SOURCES updated; new ctest target; make + ctest pass clean; scan-build reports zero new findings vs. baseline.
4. Edge Cases & Failure Modes
Section titled “4. Edge Cases & Failure Modes”- Acceptance contract itself raises a Tier 3 attempt (buggy mission author writes contract that calls credit-add). Contract is sandboxed too — same tier policy as the operator’s script. Raise becomes NOSH_SM_RESULT_MALFORMED_CONTRACT. Intentional symmetry: mission authors do NOT get elevated privileges relative to operators.
- Operator lambda returns nil. Acceptance contract receives nil and decides; not an error.
- Operator lambda returns a closure (forgot the outer (lambda (input) …) wrapper). Acceptance predicates fail naturally; clean fail with structural clause; no crash.
- Mission template provides malformed input_value. Operator’s script fails at first car/cdr/filter call with :type-error. Runner emits (fail (:input-type true “Mission input is malformed; this is a contract bug.”)).
- Re-entrancy mid-evaluation: nEmacs accidentally calls _run while a previous _run is still on the stack. Re-entry guard returns NOSH_SM_RESULT_REENTRY immediately; never touches arena. Recommend guard lives in runner (defensive).
- Instruction budget set to zero: clamp to minimum 100 instructions and emit debug warning.
- Arena fragmentation across re-eval: second invocation gets a fresh arena — assert no carry-over of cells, bindings, or print buffer. The print buffer lives inside the arena; nEmacs MUST capture before tear-down or the operator loses debug output (hand-off note).
- Acceptance contract that loops: SAME instruction budget applies (counter not reset between operator-lambda and contract evaluations). Operator-lambda timeout vs. contract timeout reported as different fail clauses (:timeout-script vs. :timeout-contract).
5. Cross-References
Section titled “5. Cross-References”ADRs that constrain this work
Section titled “ADRs that constrain this work”- docs/adr/ADR-0007-scripted-mission-ffi.md — primary spec.
- docs/adr/ADR-0001-embedded-lisp-scripting-layer.md — Fe VM, arena-allocated, no GC.
- docs/adr/ADR-0004-vm-selection.md — Fe VM selection, instruction-counter API.
- docs/adr/ADR-0005-ffi-surface.md — Tier 1/2/3 primitive enumeration; runner’s binding table is a strict subset.
- docs/adr/ADR-0002-player-facing-lisp.md — REPL and nEmacs surfaces; this story completes the third leg of the player-Lisp triad.
Module specs that consume this runner
Section titled “Module specs that consume this runner”- docs/software/cartridges/modules/ice-breaker.md — pack-in title; advanced contracts ship as scripted missions per ADR-0007 Worked Example 1.
- docs/software/cartridges/modules/black-ledger.md — forensic accounting; cross-cartridge join in ADR-0007 Worked Example 2 is Black Ledger × ICE Breaker.
- docs/software/cartridges/modules/null.md — Diagnostic cart; consumes runner introspection hooks; sanctioned CIPHER-on-main-grid escape per Spec Hygiene Rule 6.
- docs/plans/post-v0.1/2026-04-21-mission-board.md §’→ nEmacs’ — ScriptedMissionContext shape; upstream contract that nEmacs hands to the runner.
6. Engineering Hand-off Notes
Section titled “6. Engineering Hand-off Notes”Files to touch
Section titled “Files to touch”- kn86-emulator/src/nosh_scripted_mission.c (new, ~400–600 LOC C)
- kn86-emulator/src/nosh_scripted_mission.h (new, public surface)
- kn86-emulator/tests/test_nosh_scripted_mission.c (new, 9 test cases)
- kn86-emulator/CMakeLists.txt (update SOURCES, register test_nosh_scripted_mission target)
- Reference, do not modify yet: kn86-emulator/src/nemacs.c lines 538–624 (Fe context init pattern)
PR size
Section titled “PR size”~600–900 LOC including tests. Medium-large PR. Do NOT split tests from implementation — they are reviewed together.
Test strategy
Section titled “Test strategy”TDD per test-driven-development skill. Write the 9 test cases first, watch them fail, implement to green. The arena-isolation tripwire (AC #11) is a fixture that wraps every other test.
Scope guardrail — NOT in this PR
Section titled “Scope guardrail — NOT in this PR”- nEmacs integration (separate story; ship the runner with a unit-test-only call site)
- cipher-push-event integration on mission_script_failed (Mission Board’s job; GWP-258 territory)
- Tutorial scripted missions for ADR-0002 boot sequence (separate Sprint 4 story)
- mission-simulate-accept REPL primitive (separate story; do NOT block on it)
- Cartridge-side defmission parser (GWP-258’s territory)
Implementation sequencing inside the PR
Section titled “Implementation sequencing inside the PR”-
- Write nosh_scripted_mission.h public surface first.
-
- Write all 9 test fixtures using stub config; they will fail-to-compile because no impl exists.
-
- Stub _init and _run returning canned results; tests now fail-to-pass.
-
- Implement arena setup + Fe context init from nemacs.c pattern. Happy-path 1 (filter-by-threat) goes green.
-
- Implement Tier 1 binding table. Happy paths 2–3 go green.
-
- Implement Tier 2 grants_mask gate. Capability-denied tests go green.
-
- Implement Tier 3 always-block. Tier 3 capability-denied test goes green.
-
- Implement instruction counter + timeout raise. Timeout test goes green.
-
- Implement OOM trap. OOM test goes green.
-
- Implement clause extractor. Structural-fail test goes green.
-
- Implement malformed-contract detection. Malformed test goes green.
-
- Add the deck-state tripwire fixture and re-run all tests.
7. Open Questions for Josh
Section titled “7. Open Questions for Josh”- Instruction-budget initial value (50,000 placeholder) needs measured ground-truth on Pi Zero 2 W. Bring-up question, not a story blocker — engineer ships configurable knob, value tuned during Sprint 4 hardware bring-up. PM action: create a Sprint 4 task ‘Measure scripted-mission instruction budget on Pi Zero 2 W; set production default’ once this lands.
8. Sequencing Dependencies
Section titled “8. Sequencing Dependencies”- Blocks: GWP-258 does NOT strictly block this — they can land in parallel. Interface contract is the acceptance_contract_handle field on NoshScriptedMissionRequest (opaque Fe bytecode pointer).
- Blocked by: Nothing in Sprint 3. ADR-0007 Accepted; ADR-0001/0004/0005 Shipped; Fe context init pattern exists in nemacs.c. Engineering-ready today.
- Unblocks: nEmacs scripted-mission editing mode integration; tutorial scripted mission for ADR-0002 boot sequence; mission-simulate-accept REPL primitive; shipping any ADR-0007 worked-example mission in any cartridge.
- Suggested wave: Sprint 3, Wave 1 — alongside GWP-258. Both are foundational and unblock the post-launch scripted-mission roadmap.