Skip to content

nEmacs Structural Editor — Design

Supersedes inline spec in: ADR-0002 §“nEmacs — Structural Editor”, ADR-0008 (partial; see Open Questions §1 for row-layout reconciliation) Hardware reference: see CLAUDE.md Canonical Hardware Specification — grid, font, colors, keys, row layout.

This document designs the runtime-level nEmacs structural editor for the KN-86 Deckline and specifies the explicit cross-capability contracts with Mission Board, Cipher voice, and the dev REPL. Every buffer nEmacs shows is an AST, not a character stream: the “program is always well-formed” invariant is the whole point of the editor’s existence and it is the reason a 31-key device can edit Lisp at all.


nEmacs is a nOSh-runtime-owned, singleton subsystem. The nOSh runtime instantiates exactly one nEmacs session at a time, occupying a 16 KB editor arena. When nEmacs is active, the rest of the nOSh runtime treats it like any other foreground orchestrator (mission board, REPL): nOSh still owns Row 0 and Row 24, the Cipher voice, and the dispatch back to the home screen.

StructureArenaPersistenceNotes
Edit buffer AST (nemacs_buffer_t)Editor arena (16 KB)Volatile unless SYS-savedPool of tree nodes; each node carries tag, type token id, literal bytes (if any), child head, next sibling, parent backptr.
Cursor (nemacs_cursor_t)Editor arenaVolatilePointer to AST node + editing-mode enum + scroll anchor.
Palette state (nemacs_palette_t)Editor arenaVolatileRanked token array (top N, N ≥ 8), scroll offset, open/closed flag, filter predicate handle.
Recency ringEditor arenaVolatile per session20-token FIFO of tokens inserted this editing session.
Local bindings indexEditor arenaVolatileHash-of-symbol-id → AST node ref, rebuilt on binding insert/delete.
Literal-entry scratchEditor arena (256 B cap)VolatileMulti-tap buffer + cursor within buffer.
Grab-mode clipboardEditor arenaVolatile across a single nEmacs session, discarded on teardownSubtree root + shallow copy semantics.
Snippet library indexDeck state flashPersistentName → blob offset + size; limit 32 snippets at v1 (see §Snippet library).
Snippet library blobsDeck state flash (separate page)PersistentSerialized AST (canonical Lisp source text, UTF-8 CP437) per snippet.
Last-mission resume slotDeck state flash (1 slot)Persistent across suspendsMission id + serialized buffer + cursor path. See §Lifecycle.
  • The VM / evaluator. EVAL on a top-level form is a dispatch to the REPL arena (24 KB); nEmacs does not compile or execute Lisp itself.
  • The status bar and action bar (Rows 0, 24). nOSh owns these. nEmacs only requests slot updates via a published firmware API.
  • The mission acceptance contract. Mission Board owns the contract predicate; nEmacs only knows the contract handle and the error-scope format.
  • The domain vocabulary. Cartridges register vocabulary at load; nEmacs reads it through a published query API. nEmacs never mutates the vocabulary table.
  • Persistent deck state (credits, reputation, cartridge history). The restricted FFI model from ADR-0007 still applies: nEmacs can read-only-display these via nOSh, but scripted missions mutate only mission-local state.

The usable content area is Rows 1–23 (23 rows × 80 cols), per CLAUDE.md Canonical Hardware Specification. Row 0 and Row 24 are firmware territory. nEmacs does not draw them.

Row 1: [nEmacs title bar: "nEmacs: <buffer-name>" ... <MODE> ... <DIRTY?>]
Rows 2–20: [Code buffer viewport — 19 rows of AST rendering]
Row 21: [Local status: path-from-root | depth | position-kind | literal-scratch echo]
Row 22: [Palette row 1: up to 4 tokens, slots [1]..[4], each up to 16 cols wide]
Row 23: [Palette row 2: up to 4 tokens, slots [5]..[8] + scroll/hint indicator]

This is a 5-zone layout inside content (title, code, local status, palette A, palette B). It fits inside Rows 1–23 and leaves Row 0 and Row 24 to nOSh.

  • Row 1 (title bar) is nEmacs’s title line, not the nOSh runtime status bar. It carries buffer-name, current editing mode (NORM / PAL / LIT / GRAB), and a dirty marker *.
  • Rows 2–20 (code buffer viewport, 19 rows) is the AST render. Indentation is derived from tree depth, not literal characters. Parens are rendered as structural box-drawing glyphs from the CP437 layer (, , , , ) — see §Rendering rationale.
  • Row 21 (local status) shows cursor path (e.g. defn > filter > body > if > cond), depth, position kind (FUNCTION_POS / ARGUMENT_POS / BINDING_POS / ROOT), and — when literal-entry mode is active — the growing literal buffer echo (e.g. lit> thresh_).
  • Rows 22–23 (palette, 2 rows) render the top-8 ranked tokens in two rows of four 16-col slots. When the palette scrolls (more than 8 candidates), Row 23 shows a scroll indicator in the rightmost slot.

Reconciliation with firmware rows (Row 0 / Row 24)

Section titled “Reconciliation with firmware rows (Row 0 / Row 24)”

nEmacs publishes a display intent to nOSh on every frame:

nemacs_publish_status_hint(struct {
const char *mode_label; /* "EDIT", "PAL", "LIT", "GRAB" */
const char *buffer_label; /* e.g., "filter-list.lsp" */
bool dirty;
const char *key_hints[6]; /* CAR, CDR, CONS, EVAL, NIL, BACK — one short verb each */
}) -> void;
  • Row 0 (firmware status bar): nOSh retains operator handle + credits + reputation at left; right-half is mode + buffer name + dirty marker from the hint.
  • Row 24 (firmware action bar): nOSh renders the six most-relevant key hints sourced from nEmacs’s hint struct. The label set rotates with mode (see §Input grammar).

This is strictly cartridge-style: nEmacs hints, nOSh renders. nEmacs never writes Rows 0 or 24 directly.

Rendering rationale (why the palette belongs in Rows 22–23, not 23–25)

Section titled “Rendering rationale (why the palette belongs in Rows 22–23, not 23–25)”

ADR-0008’s mockups put the palette in “rows 23–25” and describe a bottom 3-row strip. That is incompatible with the CLAUDE.md row layout (content ends at Row 23; Row 24 is firmware). This design collapses the palette into 2 content rows (22–23) and uses nOSh’s action bar (Row 24) for the key legend that ADR-0008’s third palette row was duplicating. See §Open questions for the explicit reconciliation.

When Mission Board returns an error scope from an acceptance contract evaluation, nEmacs receives a list of AST node ids that are “in-scope” for the failure. It wraps those nodes with CP437 box glyphs (╌ ┌─, ╌ ┘) and inserts a one-line error banner into Row 21 (local status). The palette row then auto-filters to tokens legal-at-cursor AND likely-to-resolve the error (delegating the “likely-to-resolve” check to a contract-supplied optional fix_hint_fn, if provided; otherwise fall back to pure legal filter).


nEmacs has four editing modes: default, palette-open, literal-entry, and grab. All 31 keys (14 function + 16 numpad + 1 TERM) have defined behaviors in each mode; the table below is the full contract. Behaviors not listed for a mode default to “beep (SFX_ERROR) and no-op.”

KeyBehavior
CARDescend into first child of cursor node. If leaf, beep.
CDRMove to next sibling. If last sibling, beep.
CONSOpen palette for current position; enter palette-open mode.
NILDelete current node; push to grab-clipboard (cut-semantics single-step undo). If node was the only child, the parent gets an empty-placeholder node to preserve well-formedness.
BACKAscend to parent. If at root, beep.
QUOTEEnter grab mode rooted at the current node.
LAMBDAEnter literal-entry mode. Scratch buffer starts empty.
EVALDispatch current top-level form (nearest enclosing defn/defmission/top-level expression) to REPL for evaluation. On mission buffers, this triggers acceptance-contract check (see §Scripted-mission flow).
ATOMQuery node type; write one-line type summary to Row 21 (local status). Read-only.
SYSSave and exit. Calls nemacs_persist_or_teardown() (see §Lifecycle).
INFOLonger introspection — opens type/position/size overlay occupying Rows 21 bottom half only (still leaves palette rows visible).
Numpad digits (1–8)Quick-select from palette top 8 WITHOUT opening palette — insert token at that palette index. (This is a power-user shortcut; palette still re-ranks after insertion.)
Numpad 9, 0, .Reserved in NORM mode (no-op).
PAD arrows (if present on hardware)Scroll palette rows 22–23 for candidate beyond top 8.
KeyBehavior
Numpad 1–8Select token at palette slot N; insert at cursor; return to NORM; cursor moves to new node.
EVALConfirm current highlighted palette entry (for when player used PAD to navigate within palette).
BACKDismiss palette without insertion; return to NORM.
PAD up/downScroll palette candidate list (reveals ranks 9–16, 17–24, etc.).
CAR/CDRRe-filter palette on a textual prefix? No. Prefix-filter is a v1.1 nice-to-have. In v1, CAR/CDR in PAL mode are no-ops (beep).
CONSBeep (already in palette).
LAMBDASwitch to literal-entry mode directly, carrying the intent to insert a literal at the current position. Palette closes.
NILBeep.
QUOTEBeep.
ATOMDescribe the currently-highlighted palette candidate (its type, arity) in Row 21. Read-only.
SYS / INFOBeep. (Must exit palette with BACK or EVAL first.)
KeyBehavior
Numpad 0–9Multi-tap character input into scratch buffer (standard phone-style multi-tap map; see §Literal-entry layout).
Numpad .Insert . into scratch (required for float literals; also serves as string delimiter pair in string mode — see below).
Numpad ENTConfirm. Parse scratch as (in order of preference) number → keyword → identifier → string. Insert as AST leaf. Return to NORM.
LAMBDACancel literal-entry. Scratch discarded. Return to NORM.
BACKBackspace in scratch. If empty, cancel and return to NORM.
EVAL / CONS / NIL / QUOTE / CAR / CDRBeep.
ATOMDisplay current literal mode (LIT: num / LIT: id / LIT: str) in Row 21. The mode is inferred from scratch content; player can force a mode via a long-press on Numpad ..
SYSAbort literal-entry and execute SYS (save + teardown). Scratch is discarded.
INFOShow literal-entry multi-tap map in Row 21 half.

If scratch starts with . (single dot), interpret as start-of-string. Subsequent numpad presses insert ASCII CP437 text via multi-tap. Terminate with another . + ENT. The first and last . are dropped; the middle becomes the string content. This avoids needing a dedicated string key.

KeyBehavior
CARExpand grab region downward to include first child.
CDRExpand grab region rightward to include next sibling.
BACKContract grab region to just the root node (or if already at root node, exit GRAB mode).
EVALConfirm grab — copy subtree(s) into grab-clipboard, return to NORM. Cursor stays.
NILConfirm grab AND delete — cut subtree(s) into grab-clipboard, return to NORM. Cursor jumps to parent of deleted region.
CONSPaste grab-clipboard here (insert before current cursor node as new sibling). Empties clipboard? No — clipboard persists until session end, so the player can paste the same subtree multiple times. Return to NORM after paste.
QUOTEBeep (already in grab).
LAMBDA / Numpad / SYS / INFO / ATOMBeep except SYS which still saves and tears down.
CONS
NORM ───────► PAL ────[digit/EVAL]──► NORM (inserted)
│ │
│ └──BACK──► NORM (cancelled)
│ LAMBDA
└──────────► LIT ────[ENT]──► NORM (inserted)
│ │
│ └──LAMBDA/BACK-at-empty──► NORM (cancelled)
│ QUOTE
└──────────► GRAB ───[EVAL|NIL]──► NORM (copied/cut)
└──BACK─at-root──► NORM (cancelled)

nEmacs is launched in exactly three ways:

  1. From home screen / SYS menu — empty buffer, no contract. Used for free-form snippet authoring. Calls nemacs_open_empty().
  2. From Mission Board when a scripted mission is accepted — buffer seeded from mission template, acceptance contract installed. Calls nemacs_load_mission(mission_id, buffer_seed, contract_fn, error_scope_fn) — full signature in §Cross-capability hooks.
  3. From snippet library or SYS menu → “Resume” — buffer loaded from deck state, no contract (unless resuming a suspended mission, in which case the mission hook is re-invoked internally). Calls nemacs_open_snippet(name) or nemacs_resume_suspended().
SourceBuffer state on entryPalette seedContractArena
EmptySingle empty root placeholderTop-level forms filtered for current loaded cart vocabNone16 KB fresh
Missionbuffer_seed AST (may be empty, a template skeleton, or a partial function signature)Scoped to mission’s granted FFI tier (per ADR-0007 Tier 1 + granted Tier 2)contract_fn handle16 KB fresh
SnippetDeserialized AST from deck-state blobPlayer’s recency ring from last session (if same-cart), else freshNone16 KB fresh
ResumeLast-mission resume slotRehydrated recency ring if serialized, else freshOriginal mission contract re-hooked from mission id16 KB fresh

Suspension rules (coexistence with active cartridges, REPL, Mission Board)

Section titled “Suspension rules (coexistence with active cartridges, REPL, Mission Board)”

The 520 KB SRAM budget has to host: static firmware + framebuffer + cipher state + active cartridge arena (16–32 KB) + REPL arena (24 KB when active) + editor arena (16 KB when active) + phase chain (256 B) + misc. Not all of these can coexist — explicit rules:

StateActive-cart arenaREPL arenaEditor arenaRule
Home screenloadedunusedunusedBaseline.
Mission Board browsingloadedunusedunusedCart templates parsed, contracts generated.
Active mission (non-scripted)loaded + workingunusedunusedPhase handler running.
REPL open from homesuspended (code pages kept mapped; cart working data paged out)allocatedunusedCart cannot run handlers.
nEmacs open on empty / snippetsuspended (same as REPL)unusedallocated
nEmacs open on mission bufferkept loaded (mission context needed)unusedallocatedMission template data and cartridge vocab must be queryable.
nEmacs EVAL dispatchkept loadedmomentarily allocatedallocatedPeak concurrent arenas: cart + REPL + editor = up to 72 KB. See §Cross-capability hooks → dev REPL.
Hot Swap request from Mission Board while in nEmacsmust suspend before swapSee §Scripted-mission flow → cartridge-swap interaction.

Suspension semantics: suspension serializes the active cart’s working state (not its code) to deck state, frees its working-data pages, keeps its code/templates pages mapped read-only. On resume, the cart is re-initialized with the serialized state. This is the same mechanism nOSh already uses for REPL today (per ADR-0002 §Consequences).

SYS in any mode triggers nemacs_persist_or_teardown():

  1. If buffer has a contract (mission): do not save as snippet automatically. Prompt: SAVE AS SNIPPET? CONS=yes BACK=no EVAL=no. If CONS, prompt for name via literal-entry mode. If BACK/EVAL, discard the buffer but preserve the mission resume slot so the player can come back.
  2. If buffer has no contract (free snippet): prompt for name (default = current buffer name). Write to deck-state snippet blob. Update snippet index.
  3. Free the 16 KB editor arena. If the mission is still active, resume Mission Board; otherwise return to home screen.

If a cartridge is removed (detect-pin goes low) while nEmacs is open on a mission buffer, the nOSh runtime forces SYS with discard=true. The mission enters the suspended state (phase_chain preserved per ADR-capability-model). Player sees a toast on Row 21: CART REMOVED — MISSION SUSPENDED. Then nEmacs is torn down.


nEmacs’s palette is the ADR-0009 v1 static model, with the specific weight wiring clarified below. The brief’s +5 (cartridge domain vocab), +3 (buffer-local identifier), and +2 (recency) numbers are the ADR-0009 constants (DOMAIN_BOOST = 5; LOCAL_BOOST = 3; RECENCY_BOOST = value 0–10 decaying, floor 2 in the common case of “used recently within last 10 tokens”).

Candidate universe
├── BUILTINS (static)
├── CARTRIDGE_VOCAB ← resolved via Cipher domain-query hook (see §Cross-capability hooks)
├── USER_DEFINED_IN_BUFFER ← AST-walk at every palette refresh
└── SESSION_HISTORY ← last 20-token recency ring
Legal filter (hard constraint, ADR-0009 §Legal-Form Filter)
Score pass:
+DOMAIN_BOOST (5) if token ∈ currently-loaded-cart vocab
+LOCAL_BOOST (3) if token ∈ visible bindings at cursor
+RECENCY (0–10) by age in session ring (decays; floor 0)
+POPULARITY (0–4) per ADR-0009 table
+SEMANTIC_BONUS (1) if context_stack matches context-specific rules
Sort (score desc, then alphabetic)
top 8 → palette
ranks 9–24 → scrollable, accessed with PAD up/down in PAL mode
  • Cartridge domain vocabulary (+5): Queried through a Cipher-published API: cipher_domain_tokens(cart_id) -> const char **. nEmacs consults the currently-loaded cartridge’s id from nosh_api->deck_state()->cartridge_history. When a mission buffer is active, the mission’s :grants list (per ADR-0007) narrows this — nEmacs only boosts tokens from carts whose data the mission grants access to. This means a mission that grants :ice-breaker :black-ledger boosts tokens from BOTH carts’ vocabularies (+5 each), while a mission that grants no cart data only boosts builtins.
  • Buffer-local identifier boost (+3): nEmacs maintains local_bindings_index hash; it is rebuilt lazily on every AST mutation. Scope visibility follows Lisp let/lambda rules — a binding is “local and visible” if the cursor is inside the scope of a (let ((binding ...)) or (lambda (binding ...) ...) form.
  • Recency (+2 typical, 0–10 total): 20-entry FIFO of token ids inserted this session. Recency score is max(0, 10 - age) where age is the position from the most-recent end. The brief’s “+2” corresponds to a token used ~8 positions back; fresh tokens score higher; tokens aged out of the ring score 0.
  • Static popularity prior: The fixed table in ADR-0009 (if=+4, let=+4, lambda=+3, etc.). Not player-alterable.

The palette re-ranks on:

  • Cursor move (CAR, CDR, BACK, descent into drilled node)
  • AST mutation (insertion via palette, literal-entry, grab paste, NIL deletion)
  • Entry into PAL mode (in case cart vocab changed since last visit; rare)
  • cipher_vocab_changed event fired by Cipher voice / cartridge loader (e.g., a mission accepted a defdomain expansion mid-session)

A scripted mission is a Mission Board contract that requires the player to author a Lisp expression satisfying an acceptance contract (ADR-0007). The flow from Mission Board through nEmacs to acceptance is:

1. Mission Board accepts a scripted contract via EVAL.
2. Mission Board → nemacs_load_mission(mission_id, buffer_seed, contract_fn, error_scope_fn, grants[]):
- mission_id: opaque mission handle
- buffer_seed: initial AST (may be empty, skeleton, or type-hint signature)
- contract_fn: predicate lambda (script_output, mission_input) -> (pass | fail(clauses))
- error_scope_fn: optional mapping of failure clause → AST node-id set (for highlighting)
- grants: list of :cartridge-data tokens per ADR-0007
3. nEmacs transitions home → editor mode. Active cart kept loaded (mission context needed).
4. Player edits. Palette reflects mission grants (only :granted cart vocab gets +5).
5. Player presses EVAL on the top-level form (defn or lambda).
6. nEmacs:
a. Walks the AST to the enclosing top-level form.
b. Allocates or wakes REPL arena (24 KB). Peak concurrent: cart (~16–32) + REPL (24) + editor (16) ≤ 72 KB. Safe on 520 KB.
c. Calls repl_eval_form(ast_form, grants) → returns (script_output, err).
d. If repl returned an error (syntax, arity, grants-violation, timeout, OOM):
- nEmacs highlights the last-touched node (best-effort) via error_scope.
- Row 21 shows err message.
- REPL arena freed. Buffer unchanged.
e. If repl returned a value:
- nEmacs calls contract_fn(script_output, mission_input).
- contract_fn returns (pass) or (fail clauses).
- On pass: nEmacs fires nemacs_emit_mission_solved(mission_id). Mission Board awards credits + rep,
plays Cipher debrief (see §Cross-capability hooks → Cipher), tears down nEmacs.
- On fail: nEmacs receives failure clauses + (optionally) error-scope node-id sets from
error_scope_fn(clauses). Highlights those nodes, writes clause list to Row 21,
palette re-filters to legal-AND-potentially-helpful tokens. Player iterates.
f. REPL arena freed in all cases except when debugger-hold flag set (post-v1).
7. Player eventually presses SYS:
- If pass already fired: teardown only.
- If not: prompt for "save as snippet" or "suspend mission" or "discard".

Acceptance-contract evaluation — error scoping

Section titled “Acceptance-contract evaluation — error scoping”

error_scope_fn is an OPTIONAL helper supplied by the mission author. Signature:

error_scope_fn(fail_clauses) -> list of {clause_id, ast_node_id_set}

This lets the author tell nEmacs: “clause :no-extras? relates to nodes in the filter predicate subtree, so highlight those.” Without error_scope_fn, nEmacs highlights the entire top-level form and relies on Row 21’s clause list for guidance. Highlighted nodes render with surrounding box glyphs per ADR-0008 Mock 3.

A multi-phase mission (per capability model spec) can require a cartridge swap between phases. If the player is mid-edit in nEmacs on Phase 1’s scripted sub-task and completes it, nEmacs fires mission_solved(mission_id, phase=1). Mission Board may then prompt for a Hot Swap:

> PHASE 1 SCRIPT ACCEPTED.
> PHASE 2 REQUIRES: BLACK LEDGER.
> INSERT MODULE: BLACK LEDGER.

nEmacs MUST tear down before the swap (the detect-pin lowering on removal forces it anyway; this is the graceful path). On Phase 2 re-entry, Mission Board may re-invoke nemacs_load_mission with a new buffer (Phase 2’s contract). The Phase 1 buffer is NOT carried over — it was a sub-task artifact. This preserves arena budgets and keeps the phase-chain API clean.


Per ADR-0002 v1.0 sign-off, the snippet library ships in v1. This design specifies the storage format and cross-deck portability.

Snippets live in a dedicated flash region within deck state (separate page from operator handle + credits + cartridge_history).

Snippet index entry (32 bytes each, up to 32 entries = 1 KB):
char name[20]; /* name, null-terminated, ASCII CP437 */
uint32_t blob_offset; /* byte offset into snippet blob region */
uint16_t blob_size; /* bytes, including trailing 0 */
uint8_t flags; /* bit 0 = authored-here, 1 = received-via-link, 2 = derived-from-mission */
uint8_t reserved[5];
Snippet blob region:
Canonical Lisp source text (not bytecode, not serialized AST).
UTF-8/CP437 hybrid allowed. Reason: text is portable, parseable by any
compatible VM, and debuggable without the originating deck's arena state.

The choice of canonical Lisp source over serialized AST matters for link-cable portability and future VM revision: two decks running different nOSh runtime versions can still share a snippet as long as the reader’s VM accepts the source.

Snippet names are player-chosen, 20 ASCII characters max, unique per deck. The nOSh runtime rejects duplicates at save time. No namespacing at v1; the Relay module (per capability-model spec) could introduce namespacing if snippet distribution becomes a thing post-v1.

Two decks linked over the TRRS link protocol (platform_link_* API, per capability-model spec) can exchange snippets:

  • Sending deck: LINK > SEND SNIPPET > <name> in SYS menu. Snippet blob + name sent to peer.
  • Receiving deck: sees LINK RECV: SNIPPET "<name>". Prompted ACCEPT? CONS=yes BACK=no. On accept, entry written to receiver’s snippet index with flags.bit 1 = 1.

Cross-deck snippets retain no provenance chain entry of their own (the cartridge provenance chain is per-cart, not per-snippet). Instead, the flags bit distinguishes “authored here” from “received via link” for player awareness.

From home screen: SYS > SNIPPETS > <name> opens nemacs_open_snippet(name). The snippet is deserialized into the editor arena. Edits may be saved back (overwriting) or saved as a new snippet.


This section is the interoperation contract with the other three agents working in the same wave. Each subsection specifies: published API from nEmacs to peer, consumed API from peer to nEmacs, UX flow, and permission boundaries.

← Mission Board (scripted-mission buffer load; acceptance-contract API; save/resume)

Section titled “← Mission Board (scripted-mission buffer load; acceptance-contract API; save/resume)”

Events Mission Board FIRES that nEmacs CONSUMES:

/* Fired when player accepts a scripted contract on the Mission Board. */
void nemacs_load_mission(
mission_id_t mission_id,
const ast_node_t *buffer_seed, /* may be NULL for "empty template" */
contract_fn_t contract_fn, /* predicate (script_out, mission_in) -> pass|fail */
error_scope_fn_t error_scope_fn, /* optional; may be NULL */
const cart_id_t *grants, /* null-terminated list of granted cart ids */
mission_input_t *mission_input /* passed by reference to contract_fn on EVAL */
);
/* Fired if Mission Board needs nEmacs to abort (e.g., cart ejected). */
void nemacs_force_teardown(teardown_reason_t reason);
/* Fired for mid-edit mission updates (rare; e.g., phase clock running out). */
void nemacs_mission_tick(mission_id_t id, tick_kind_t kind);

Events nEmacs FIRES that Mission Board CONSUMES:

/* Fired on EVAL when contract_fn returns (pass). */
void mission_board_on_script_pass(mission_id_t id, const lisp_value_t *output);
/* Fired on EVAL when contract_fn returns (fail clauses) — Mission Board decides
whether to count the attempt (e.g., against retry counter). */
void mission_board_on_script_fail(mission_id_t id, const fail_clauses_t *clauses);
/* Fired on SYS with "suspend" choice. */
void mission_board_on_script_suspended(mission_id_t id, const buffer_snapshot_t *snap);
/* Fired on SYS with "discard" choice after a failed pass. */
void mission_board_on_script_abandoned(mission_id_t id);

Permission boundary: nEmacs never writes to Mission Board internal state. Mission Board never writes to nEmacs’s AST after nemacs_load_mission has returned. The only shared mutable state is the resume slot in deck-state flash, and nEmacs owns writes to it while Mission Board owns reads.

UX flow (happy path): Mission Board → select contract → EVAL → nemacs_load_mission(...) → editor opens with seed → player edits → EVAL → contract passes → mission_board_on_script_pass(...) → Mission Board triggers Cipher debrief + credit/rep update + nEmacs teardown → returns to Mission Board.

UX flow (suspension): Mission Board → nemacs_load_mission(...) → player edits → SYS → “suspend” → mission_board_on_script_suspended(...) → nEmacs persists resume slot → Mission Board shows the mission in “SUSPENDED” state on the board. Later: Mission Board → SUSPENDED mission → EVAL → nemacs_load_mission(...) with the snapshot as buffer_seed.

← Cipher voice (domain vocabulary propagation; ambient commentary)

Section titled “← Cipher voice (domain vocabulary propagation; ambient commentary)”

Events Cipher FIRES that nEmacs CONSUMES:

/* Fired when cartridge load/unload changes domain vocabulary. */
void nemacs_on_vocab_changed(const cart_id_t *loaded_carts);
/* Queryable, not fired: nEmacs asks Cipher for the current vocab for a given cart. */
const char **cipher_domain_tokens(cart_id_t cart_id);

Events nEmacs FIRES that Cipher CONSUMES (opt-in ambient commentary):

/* Low-frequency, debounced. Cipher may or may not say something. */
void cipher_on_editor_milestone(editor_milestone_t kind, const char *context);
/* kinds: FIRST_LAMBDA_DEFINED, FIRST_RECURSION, LONG_EDIT_NO_EVAL, CONTRACT_FAILED_3_TIMES */
/* Fired on EVAL pass — Cipher owns the actual debrief script. */
void cipher_on_mission_script_pass(mission_id_t id, const lisp_value_t *output);

Ambient commentary policy: nEmacs does NOT compose Cipher lines. It only fires milestone events. Cipher decides whether to render a line to Row 21 (editor’s local status) or defer. Cipher has veto over cadence — a player who has been editing for 20 minutes should not get chatty commentary every 30 seconds. The capability-model spec’s Cipher voice policy (terse, observational, does not moralize) applies.

Permission boundary: Cipher never mutates the AST or palette. nEmacs never writes to Cipher’s LFSR state. The vocabulary table is owned by Cipher (built from cartridge defdomain forms at load time) and is read-only from nEmacs’s perspective.

UX flow — domain vocab propagation on cart load: Cipher on cartridge insert → parses defdomain forms → builds vocab table → fires nemacs_on_vocab_changed(loaded_carts) → if nEmacs is open, it flushes cached palette candidates and schedules re-rank on next palette refresh trigger. If nEmacs is not open, the event is a no-op; nEmacs will query on next launch.

UX flow — ambient comment on a long edit: Player in nEmacs for 5 minutes without EVAL → nEmacs fires cipher_on_editor_milestone(LONG_EDIT_NO_EVAL, "filter-list.lsp") → Cipher may render on Row 0’s right-half: > STILL WORKING. NO JUDGMENT. (Editorial tone per capability-model spec Cipher style guide.)

← dev REPL (EVAL dispatch; snippet library sharing)

Section titled “← dev REPL (EVAL dispatch; snippet library sharing)”

Events nEmacs FIRES that REPL CONSUMES:

/* Dispatch a form for evaluation. Blocks until repl returns. Allocates repl arena
on entry if not already allocated. */
repl_result_t repl_eval_form(
const ast_node_t *form,
const cart_id_t *grants, /* NULL = REPL's normal FFI; else mission scope */
uint32_t timeout_ms, /* ADR-0007 default 1000 ms */
uint32_t mem_limit_bytes /* ADR-0007 default 8192 B (mission script) */
);
/* Query REPL history without entering REPL (read-only). */
const lisp_value_t *repl_history_peek(uint8_t index); /* 0 = most recent */

Events REPL FIRES that nEmacs CONSUMES:

/* REPL emits this when the player uses the REPL's "save-as-snippet" action on
a history entry. nEmacs writes to its snippet index (shared storage). */
void nemacs_snippet_save_from_repl(const char *name, const char *source_text);
/* REPL requesting a browsable snippet list — nEmacs supplies names. */
const snippet_index_entry_t *nemacs_snippet_list(uint8_t *count);

Shared storage: snippet library is owned by nEmacs but readable and writable by REPL. REPL’s “save this history expression as a snippet” action and REPL’s “load snippet to history” action both go through nEmacs’s snippet index API. This keeps one source of truth and prevents divergent blob formats.

Arena coexistence during EVAL dispatch: the EVAL dispatch is the one moment in the system where cart arena (16–32 KB) + REPL arena (24 KB) + editor arena (16 KB) are all allocated simultaneously. Peak = ~72 KB. Post-dispatch, REPL arena is freed unless the player has explicitly entered REPL mode (e.g., for interactive debugging of a failed script, post-v1.1).

Permission boundary: REPL never mutates the nEmacs AST. nEmacs never reads REPL’s working memory (only its return value). The grants parameter on repl_eval_form enforces ADR-0007 FFI tiering — the REPL arena runs with restricted FFI for mission-scoped dispatches, full FFI for free-play REPL dispatches.

UX flow — EVAL from editor to REPL: Player in nEmacs presses EVAL → nEmacs finds enclosing top-level form → calls repl_eval_form(form, mission_grants, 1000, 8192) → REPL allocates arena, compiles, runs, returns value or error → nEmacs consults contract_fn (if mission context) or displays result on Row 21 (if free-form) → REPL arena freed → editor continues.

UX flow — REPL-authored snippet shared to editor: Player in REPL composes a useful function → presses “save as snippet” → supplies name → REPL calls nemacs_snippet_save_from_repl(name, source) → nEmacs writes to flash → later, from editor home, player opens the snippet via nemacs_open_snippet(name).


1. CONTRADICTION: palette rows 23–25 (ADR-0008) vs action-bar row 24 (CLAUDE.md)

Section titled “1. CONTRADICTION: palette rows 23–25 (ADR-0008) vs action-bar row 24 (CLAUDE.md)”

This is the row-layout reconciliation the brief flagged. ADR-0008’s mockups describe a “bottom 3 rows (rows 23–25)” palette strip. The CLAUDE.md Canonical Hardware Specification (non-negotiable per Spec Hygiene Rule 5) mandates Row 24 as the nOSh runtime action bar and Rows 1–23 as cartridge/editor content. Rows 24 AND 25 cannot belong to the palette.

Resolution adopted in this design:

  • Palette occupies Rows 22–23 (2 rows, 8 slots: 4 per row × 16 cols each) — still within the content area.
  • The third palette row from ADR-0008 (which duplicated key hints like “CONS: insert | CAR: descend | BACK: parent”) is moved into Row 24 (firmware action bar) via the nemacs_publish_status_hint API. nOSh renders key hints using nEmacs’s current-mode hint struct.
  • Row 21 becomes local editor status (path-from-root, depth, literal-entry echo, error banner) — a role ADR-0008 placed in “row 22” while also saying “row 22 = status bar”. This design promotes that consistently to Row 21.

Action required on ADR-0008: ADR-0008’s “Accepted (v1.0)” status should be amended with a superseding note pointing at this design, OR ADR-0008’s mockups should be updated to the Rows 1–23 regime (preferred, since ADR-0008 is still “Accepted” per its header but predates 2026-04-14 spec hygiene work). Flag for PM.

Status field discrepancy: ADR-0008 claims “Accepted (v1.0)” for nEmacs, but ADR-0002 v1.0 (2026-04-14) says “nEmacs slips post-launch.” If nEmacs is slipping, its ADR should probably be “Accepted pending scheduling” or explicitly marked as post-v1 scope. This is a documentation hygiene issue for PM review.

2. 31-key assumption: ADR-0008 mentions “PAD 2/4 to scroll palette”

Section titled “2. 31-key assumption: ADR-0008 mentions “PAD 2/4 to scroll palette””

CLAUDE.md canonical spec lists 31 physical keys (14 function + 16 numpad + 1 context-sensitive TERM). ADR-0008 references “PAD arrows” and “PAD 2/4” for palette scroll. The numpad is 16 keys, but “PAD 2/4” implies a directional pad. Open question: are PAD-style directional scrolls dedicated keys, or are they alternate functions on numpad 2 / 4 / 6 / 8 when in PAL mode? This design assumes the latter (numpad 2/4/6/8 act as up/left/right/down arrows while in PAL mode, repurposing digit slots 2 and 4 which otherwise select palette slots 2 and 4). This creates an ergonomic conflict: in PAL mode, numpad 2 currently selects palette slot 2 AND scrolls the palette? Resolution proposal:

  • In PAL mode: numpad digits select palette slots (1–8). Scrolling uses QUOTE (unused in PAL) for PAGE-UP and LAMBDA (which currently swaps to LIT mode) for PAGE-DOWN. LAMBDA dual-use is acceptable if it only swaps to LIT when palette is NOT scrolled OR if the PAGE-DOWN affordance is bound to a different key.
  • Alternative: wait for the TERM key to be finalized and assign one direction to it.

Flag for PM to route to Input System architect.

3. Structural-vs-textual rendering: bracket glyphs

Section titled “3. Structural-vs-textual rendering: bracket glyphs”

ADR-0008 says “parens are rendered as box-drawing glyphs” but doesn’t commit to a specific CP437 subset. The font doc (KN-86-Character-Set-and-Font-Architecture.md) is Layer 1 256-glyph code page including CP437 box-drawing. This design proposes a minimal bracket glyph set:

AST shapeLeft glyphRight glyph
Top of form (depth 0–1)┌──┐
Continued form (depth > 1)├──┤
Leaf list bracket└──┘
Quoted form╓──╖
Grab-region highlight▓▓▓▓ (reverse video)
Error scope╌ ┌──┘ ╌

Open question for PM: should this glyph mapping be promoted to the UI Design System doc, or stay as an editor-local convention? I recommend promoting to UI Design System §6 (new “Structural Lisp rendering”) so that hypothetical other consumers (REPL history replay, mission debrief showing “here’s the solution”) use consistent visuals.

The multi-tap scheme is under-specified in ADR-0008 Mock 4. This design assumes phone-keypad classic (2=ABC, 3=DEF, 4=GHI, 5=JKL, 6=MNO, 7=PQRS, 8=TUV, 9=WXYZ, 0=space+symbols, with . switching to string mode and ENT confirming). Open question: does KN-86 have an official multi-tap layout yet? If not, this design’s adoption of the phone-keypad classic is a proposal — flag for gameplay design to ratify or override.

5. EVAL dispatch ownership: does the REPL actually have to be “entered”?

Section titled “5. EVAL dispatch ownership: does the REPL actually have to be “entered”?”

ADR-0002 describes REPL as a top-level firmware utility reachable from home screen. This design treats the REPL’s arena + evaluator as a service that nEmacs can invoke without the user “entering” the REPL UI. This is a departure from a strict reading of ADR-0002, where REPL is a user-facing subsystem. Rationale: the Lisp evaluator is a resource, and the REPL UI is one consumer of it. Having nEmacs also consume the evaluator via repl_eval_form is cleaner than duplicating the evaluator inside the editor. Flag for PM to confirm with dev REPL design agent that this API shape is acceptable. If the dev REPL agent designs a REPL where the evaluator is tightly coupled to the REPL UI state, we need to either refactor this to have a shared eval_core subsystem or duplicate the evaluator (the latter is worse for the arena budget — duplication costs code pages even if working arenas are shared).

The snippet library index is 32 entries × 32 B = 1 KB. Blob region sized arbitrarily. Open question: how much flash is actually allocated to snippets? Deck state is 4 KB per the capability-model spec. Snippet index + blobs need their own flash region separate from the DeckState struct. Flag for embedded-systems review to carve a snippet region (suggest 16 KB → ~100 snippets at average 128 B each, or 8 KB at 32-entry limit).

7. How does a mission SEED a buffer non-trivially?

Section titled “7. How does a mission SEED a buffer non-trivially?”

The brief and ADR-0008 both say missions can seed a buffer, but the AST format for buffer_seed isn’t defined. Proposed format: mission templates carry a (:seed-script <lisp-source>) form at authoring time; Mission Board parses this source into an AST at contract-accept time and passes the AST pointer as buffer_seed. Flag for gameplay design review since it affects the mission template authoring UX.

8. Where does “grab across different parts of the tree” end up? (ADR-0008 Open Q §1)

Section titled “8. Where does “grab across different parts of the tree” end up? (ADR-0008 Open Q §1)”

ADR-0008’s Open Q §1 asks whether grab can span disjoint tree regions. This design answers: v1 grab is contiguous — grab expands via CAR (first child) and CDR (next sibling), both of which stay within the current subtree root. Disjoint grabs are v1.1. Resolution is simple enough to ratify here, but flagged for formal ratification by PM + gameplay design.

9. Cipher vocabulary narrowing by mission grants

Section titled “9. Cipher vocabulary narrowing by mission grants”

When a mission grants :ice-breaker only, should nEmacs boost all loaded carts’ vocab or only the granted cart’s vocab? This design chose: narrow to granted carts. That matches the ADR-0007 intent (scripts shouldn’t accidentally pick up out-of-scope domain terms). But it means a player mid-mission sees fewer cart-specific palette candidates than if they were browsing freely. Flag for gameplay design to validate this tradeoff.


End of design — nEmacs structural editor, post-v0.1 wave. No code. Ready for PM synthesis into the four-capability interaction plan.