Skip to content

πŸ§ͺ POC stack: render, route, compose, pause, and reconstruct the XMD REPL (#838–#842) - #844

Closed
taras wants to merge 57 commits into
mainfrom
agent/issue-838-repl-study
Closed

taras wants to merge 57 commits into
mainfrom
agent/issue-838-repl-study

Conversation

@taras

@taras taras commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Current status: the disposable REPL POC stack is complete (#838–#842).

This PR remains a draft, will not merge, and none of its code is a production
starting point. It now carries five accepted experiments under
#827:

starfx@0.16.1 is experimental on this branch. Its presence is not a
production dependency decision. The branch will be discarded, so
verify:clean and runner test-weight regeneration are intentionally not
being performed for it.

The durable conclusions are recorded on the corresponding issues, including
#840 and #842. Production begins afresh from main, following those recorded
contracts and sequencing. Nothing from this PR is merged or cherry-picked.


This is a disposable experiment stack. It will not merge, and none of its
code is a production starting point.
It carries the bounded #838, #839 and
#840 experiments under the REPL quest
#827. What is meant to
last is the written evidence, not the implementation:

The first #840 attempt is reverted in d3aa193d β€” reviewable, not restorable.
The replacement is eleven commits on top of it: three slice deliveries and
eight corrections, all three slices PASS, head 88f03ec3.

Production work begins fresh from main, following the sequencing in
scripts/repl-compose/RESULT.md. Nothing here is cherry-picked.

Run it while it exists:

deno task repl:compose --journey
deno task repl:compose --trace 'xmd://repl/e1/transcript/entry-1/document/+project/+confirm?at=cp-10&inspect'
deno task repl:study --play

#838 β€” the renderer

Why

#838, under the REPL quest
#827, asks whether
@bomb.sh/tty can render the Product Owner's approved XMD REPL Terminal Interface study in a real terminal, and what the design owes a terminal that is
not 2560 Γ— 1440. The animation and focus studies establish the experience; they
do not show that a renderer can carry it legibly or give the terminal back
afterwards.

This is a bounded experiment. Its implementation may be discarded;
scripts/repl-study/RESULT.md is the part that is meant to last.

What changes

Before: the study existed only as a browser canvas, and nothing in this
repository rendered the REPL.

After: one documented command opens it in a terminal.

deno task repl:study                      # in this terminal
deno task repl:study --capture captures/  # every fixture at every profile
deno task repl:study --print nested wide  # one frame, as text

Six fixtures carry the states #838 names β€” empty; a Plan running inside the
document scope with lifecycle rails and three sections collapsed; the Plan's
returned program replacing the expression that produced it; a project Elicit
drawer with three Agent sessions in flight; a paused head with an earlier
checkpoint under inspection; and a settled Entry 1. Resizing moves between the
study's wide composition, its floor at medium, routed full-screen surfaces at
narrow, and an explicit recoverable refusal below 72 Γ— 20 β€” keeping the selected
fixture, the transcript window and the scrubber selection.

How it works

fixtures.ts (semantic) β†’ layout.ts (cells) β†’ render.ts (tty ops) β†’ host.ts (the terminal)
                                                     β†˜ screen.ts (cells back, for evidence)

A fixture holds scopes, phases, sections, sessions, bindings, checkpoints and
drawers β€” never a row, column or byte. layout.ts picks a profile from the
measured size and returns each region's rectangle. render.ts is the only
module that knows the operation vocabulary, and every cell it emits comes from
that rectangle. host.ts is the only module that writes to the terminal.

Review guide

Start with: scripts/repl-study/RESULT.md, then the captures in
scripts/tests/fixtures/repl-study/ β€” nested.wide.txt, drawer.medium.txt,
paused.narrow.history.txt and drawer.too-small.txt show most of the design.

Then review:

  1. scripts/repl-study/layout.ts β€” the profiles, their thresholds, and the
    constrained-terminal policy this experiment had to invent.
  2. scripts/repl-study/render.ts β€” the study's vocabulary in cells, especially
    bandGeometry and notchLayout.
  3. scripts/repl-study/host.ts β€” terminal modes, raw input, signals and
    restoration ordering.
  4. scripts/tests/repl-study.test.ts β€” the evidence and its eight controls.

Look carefully at:

  • The ordering in useTerminalModes, useRawMode, useSignalListener and
    useStdinReader: each registers its cleanup before acquiring, because a run
    halted while acquiring has nothing registered to unwind.
  • Two oxlint-disable-next-line local/no-sync-filesystem comments on
    Deno.stdout.writeSync. The invariant they name: the restoring bytes are
    written from teardown, where an asynchronous write is not guaranteed to
    finish.

What must stay true

  • The Execution History footer is never moved or covered. Enforced by
    layout.ts placing it as the bottom full-width band and the contextual
    surface above it; checked by "stays at the bottom, full width, with a drawer
    open", whose control drawer-covers-footer must fail it.
  • Cells never become application state. Enforced by the module boundary;
    checked by "keeps terminal cells out of the fixtures", which walks every
    fixture for a forbidden key.
  • The terminal is given back. Enforced by cleanup registered before the
    modes are applied; checked at the byte boundary on ordinary exit, on a signal,
    and when a frame throws, with leak-terminal-modes as the control.
  • No production surface is involved. Checked by "depends on the renderer
    from the root manifest alone".

How to verify it

deno task test scripts/tests/repl-study.test.ts \
  scripts/tests/runtime-exclusions.test.ts \
  scripts/tests/test-file-discovery.test.ts
  • Every committed capture is re-rendered and compared exactly, so any rendering
    change shows up as the picture it changed; stale-frame fails it.
  • A resize run across all four profiles is applied to a terminal that really
    changes size, and compared with a fresh full repaint;
    skip-resize-update leaves cells addressed outside the terminal and fails it.
  • Every checkpoint stays selectable at 90 columns, where the track has about
    nine columns for fourteen markers; clip-long-transcript removes the
    coalescing and fails it.
  • The four notch heights β€” selection, head, entry boundary, minor checkpoint β€”
    are measured from the rendered band; flatten-notches fails it.
  • A terminal below 72 Γ— 20 gets the refusal, not a composition;
    ignore-minimum fails it.

Run in a real terminal, it opened at 80 Γ— 24 through a macOS pseudo-terminal,
accepted keystrokes, and restored the modes it changed. RESULT.md lists every
dimension tested.

Scope

Included

  • The harness, its six fixtures, four profiles and committed captures.
  • The conclusion, with five observed limitations and three design states that
    required adaptation.

Intentionally unchanged

  • Focus, the five-region ring and the drawer's focus trap remain
    #839; the harness's own
    keys are not a focus model.
  • No reusable component boundary is proposed β€” that is
    #840, so the region
    functions in render.ts stay private.
  • No XMD executes, no journal is read or written, no Agent session opens.
  • Nothing under packages/ changes, and no export map, CLI command, spec or
    architecture document moves.

New dependencies

  • Package: @bomb.sh/tty 0.9.0, as an exact root devDependencies pin.
  • Used for: terminal layout, input decoding and the ANSI bytes for changed
    cells. It does no I/O of its own.
  • Why existing dependencies are insufficient: nothing in the repository composes
    a terminal interface; marked-terminal formats Markdown and owns no layout,
    cells or input.

Generated or mechanical changes

  • scripts/tests/fixtures/repl-study/*.txt come from
    deno task repl:study --capture. They are worth reading rather than skimming:
    they are the interface.
  • deno.lock and pnpm-lock.yaml carry only the new dev dependency.

Risks and limitations

  • The constrained-terminal policy is this experiment's own design decision, not
    the study's. The thresholds, the narrow routing and the notch tiers are the
    most likely things to be corrected.
  • Depth on the band is a glyph tier rather than a height, and past three tiers
    the band stops distinguishing nesting. The journal list beside it stays exact.
  • Ambiguous-width glyphs (●, β—†) are assumed to be one cell; a terminal
    configured to render them double-width would misalign rails and notches. No
    such terminal was tested.
  • Restoration is proved at the byte boundary and by construction rather than by
    inspecting a pseudo-terminal's mode flags; allocating a PTY is the territory
    #801 withdrew.
  • test-weights.json still has to be remeasured on the runner for this head,
    since the corpus gained a file. That is delivery work, not a claim this PR
    makes.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

#839 β€” one location, and a focus model derived from it

Why

#839 asks for the half #838
did not answer. What #838 left behind is a harness whose location is four loose
fields on a View β€” a fixture name, a scroll anchor, a checkpoint index, a
surface name and a drawer flag β€” mutated by a reducer that reads keys directly.
There is no route, so nothing can be reopened, and there is no focus at all, so
"background work never moves me somewhere else" is not a claim this repository
could make or break.

The Product Owner's approved focus study is the acceptance source: fourteen
numbered frames, each carrying a target list, a focused number, and a meta
record naming that frame's Tab, Shift+Tab, activate, trap and restore behavior.

What changes

Before: --fixture drawer was as specific as a location could get.

After: every one of the study's fourteen frames is a URL, and one documented
command opens it at any of them.

deno task repl:study --route 'xmd://repl/e1/transcript/entry-1/plan/+project'
deno task repl:study --frame 07          # the study's frame, by its number
deno task repl:study --frame 12 --focus-map

Two defects were repaired on the way, and both were only findable by driving
bytes rather than synthetic events:

  • A lone Escape never arrived. @bomb.sh/tty buffers a solitary ESC and
    returns pending: { delay: 25 } with an empty event list. host.ts read
    scanned.events and dropped scanned.pending. Esc was documented in the
    README, handled in the reducer, covered by a test that fed it a synthetic
    { code: "Escape" }, and did nothing at a real keyboard.
  • A real Shift+Tab never arrived either. It is ESC [ Z, reported as key
    code Backtab with no shift flag; the reducer tested Tab with shift.

How it works

The REPL has three kinds of state, and telling them apart is what makes every
acceptance criterion reachable:

journal.ts  execution truth β€” a hand-authored fixture; #842 owns the real one
route.ts    location β€” one URL, and nothing else is location
            everything else is disposable: anchor, selection, overlay, focus
xmd://repl/<execution>/<surface>[/<scope>]*[/+<drawer>]*[?at=<marker>][&inspect][&draft=<text>]

The surface segment says which region owns focus, so a focus move across a
region boundary moves the URL in the same reducer transition. at is the marker
the scrubber has selected β€” canonical location, restored on a cold start with
its scope and bindings β€” and a valueless inspect says the reconstruction at it
is open. Scrubbing replaces; entering inspection pushes.

Freedom's node tree owns focus. A surface is a node, a scope panel a branch
inside it, a drawer a branch pushed as the active focus root, a control a leaf.
Traversal order is tree order, computed on demand from the active subtree. A key
is invoked on the focused node's scope, so every branch between the root and it
runs its middleware. Closing a drawer removes its branch, and its controls and
middleware go with it through structured teardown.

store.ts absorbs view.ts: two places holding where the person is was the
defect this removes. hydrate(url, journal) rebuilds the durable half from
those two inputs alone, and projection() is what two states are compared by.

Review guide

Start with: scripts/repl-study/RESULT-focus.md, then
scripts/tests/fixtures/repl-focus/frame-07.wide.txt (the drawer's trap) and
frame-12.wide.txt (a dimmed Continue numbered in the map and skipped by the
ring).

Then review:

  1. scripts/repl-study/route.ts β€” the schema, and what it refuses.
  2. scripts/repl-study/tree.ts β€” the interface as Freedom nodes, and
    keys.ts / drive.ts β€” the targeting path and the one loop the harness and
    the evidence share.
  3. scripts/repl-study/store.ts β€” the reducer, hydrate() and projection().
  4. scripts/repl-study/host.ts β€” scanKeys, which is the Escape repair.
  5. scripts/tests/repl-focus.test.ts β€” the evidence and its nine new controls.

Look carefully at:

  • tree.ts's reconcile() adds before it removes. The other order destroys
    the focused control before its replacement exists, and focus lands outside the
    region β€” which is how a resumed run first lost its transport slot.
  • Numbering versus traversal order. They are different lists: regions take 1–5
    and controls 6 upward, while traversal is tree order. Frames 05, 11 and 14
    only agree under that reading β€” frame 14 numbers Run 6 and traverses it
    before region 5.
  • scripts/repl-study/vendor/freedom/PROVENANCE.md β€” the two patches against
    upstream, and why the vendor's lint exclusion is not in package.json.
  • markTransport() replaces the space inside [ Continue ] rather than
    widening it: the track's room is computed from that string, and a focused
    control that shortened the track would make focus a layout decision.

What must stay true

  • A background update moves nothing. Checked by reference equality of the
    route, focus, selection and anchor β€” not deep equality, since a reducer that
    rebuilt an equal route would pass that. steal-focus-on-background fails it.
  • The drawer's trap holds, and the footer is inside it. The registry while a
    drawer is open is the top drawer's controls then region:history, which is
    how Quest: Run, inspect, and resume XMD execution in the REPLΒ #827's "keeps the fixed history footer reachable" survives a suspension.
    leak-drawer-trap fails it.
  • The map is a superset of the ring. A visible-but-disabled control is
    numbered and never focusable. focus-hidden-target fails it.
  • A state rebuilds from its URL and a journal alone, selected marker
    included. push-draft-edits, drop-route-on-resize, forget-drawer-invoker
    and drop-selection-on-hydrate each fail one half of it.
  • Focus and the URL move together. keep-route-on-focus fails it, and the
    frame transitions are driven through the reducer forward and in reverse
    rather than proved by building each destination on its own.
  • Every running entry is interrupted, never exited past. A paused or
    reconstructed entry is still running; exit-on-paused-interrupt fails it.
  • Sibling scopes are walkable. inert-sibling-arrows fails it.
  • Escape and Shift+Tab work at a real keyboard. Driven as bytes through
    input.scan(), pending flush included; swallow-pending-escape and
    ignore-backtab reproduce the pre-repair behaviour exactly.
  • SURFACES is unchanged and no Render the REPL interaction study in a real terminalΒ #838 golden moved. Four routing surfaces
    and five focus regions are different things; the input is a focus region
    everywhere and a full-screen surface nowhere.

How to verify it

deno task test scripts/tests/repl-focus.test.ts \
  scripts/tests/repl-study.test.ts \
  scripts/tests/runtime-exclusions.test.ts \
  scripts/tests/test-file-discovery.test.ts

Local results at 2f4098b9: repl-focus 15 passed (71 steps); repl-study 12
passed (49 steps); deno task check, deno task lint, the exclusion and
discovery checks and the documentation gate all exit 0.

The two decoder repairs were shown red first by inverting each in place β€” five
byte-driven cases failed, and restoring both made them pass. The literal "red at
1861db3c" is not runnable, since the suite imports modules that do not exist
there.

Scope

Included

  • route.ts, focus.ts, journal.ts, store.ts, frames.ts, the two decoder
    repairs, focus indication and the F1 overlay, the review and confirm
    drawers, thirteen new controls, three new flags, and 19 goldens.
  • RESULT-focus.md, and a correction in RESULT.md to Render the REPL interaction study in a real terminalΒ #838's Esc claim.

Intentionally unchanged

  • layout.ts's SURFACES, and every committed Render the REPL interaction study in a real terminalΒ #838 golden.
  • Nothing under packages/. No dependency, XMD document, CLI command, export
    map entry, spec or architecture change. The diff touches scripts/ only.
  • No component boundary is proposed (#840),
    no real journal or replay (#842),
    no Effection subtree observation or pause
    (#841). Nothing answers
    an Elicit, executes XMD or opens an Agent session.

Review rounds

2f4098b9 answers an architecture review of 0b8687b0 that found three ways
the URL was not yet the location it claimed to be: focus moved without the
route, the selected marker was classified as disposable and vanished on
hydration, and Ctrl+C exited from a paused entry instead of interrupting it.
The round also implemented Ctrl+←/Ctrl+β†’ against a sibling list derived from
the journal, which gains preview and write beside plan. A new case caught
a missing guard of its own: structural navigation was acting inside an editable
target.

Risks and limitations

  • Escape closes the top drawer without answering it, diverging from study
    frame 09's esc declines. This experiment owns navigation; answering is
    Compose the REPL interface from reusable terminal componentsΒ #840's and Rebuild any REPL view from its journal and URLΒ #842's. RESULT-focus.md records the divergence.
  • "Entered" is read as "focused within". The study says history exposes
    its controls on Enter; the implemented rule is focus-within plus a recorded
    execution. All fourteen frames agree, and none distinguishes the two readings.
  • The overlay is a numbered legend rather than floating callouts, and focus is a
    glyph rather than a colour, so both survive a monochrome terminal and a .txt
    golden.
  • The six Render the REPL interaction study in a real terminalΒ #838 fixtures are the available content: the route and journal choose
    which one a moment shows and override its transport, badge and open drawer,
    but synthesise no content the fixture set lacks.

Generated or mechanical changes

  • scripts/tests/fixtures/repl-focus/*.txt come from
    deno task repl:study --capture-focus. They are the evidence, with the
    numbered overlay on, and are worth reading rather than skimming.

Outstanding

deno task verify:clean on the committed head and a regenerated
test-weights.json are deferred until the complete POC stack reaches delivery,
by the user's direction. Neither was run and neither is committed; the corpus
gained a file, so the weights will need remeasuring on the runner.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

Review round 3 β€” Freedom owns focus

The flat FocusTarget[] registry was withdrawn at architecture review: Freedom's
node tree replaces the DOM in the terminal, so it owns focus, traversal, input
targeting and branch lifetime. scripts/repl-study/focus.ts is deleted and
ReplState has no focus field β€” the store decides what an event means and
names what should happen to focus; the tree carries it out.

What was established before any code moved

@bomb.sh/freedom is private: true and unpublished, so feasibility was probed
first. bombshell-dev/playground is public, so the source at
8be97e7201cd6effddb2f8b240b4b5166641e7f0 is vendored at
scripts/repl-study/vendor/freedom/ with a manifest, provenance and a drift
test.

It runs unmodified on this repository's effection@4.1.0 despite declaring
4.1.0-alpha.9. That was the real risk: two Effection copies mean two scope
trees and two context systems, and Freedom's central claim β€” that its node tree
and the Effection scope tree correspond β€” would have been false against ours.
No lockfile moved and no second Effection was added.

Three defects found

Two are Freedom's, patched in the vendored copy and recorded in MANIFEST.json:

  1. The root was parented to Effection global. focus-stack has no
    useRoot(), so host context does not reach the tree and a failure in node
    work raises into a boundary nobody observes. useRoot() acquires the tree as
    a resource owned by the acquiring scope.
  2. Removal asked about identity, not containment. useFocus() moved focus
    to a successor when the removed node was focused β€” but a drawer closes by
    removing the branch above the focused control, so the common case left
    focus on a destroyed node while a sibling survived.

The third is this harness's own: a reconciler must add before it removes.

The eight discriminating cases

Each is one a flat registry passes and the tree has to earn.

Claim Control that breaks it
input passes through its panel and drawer middleware the path is asserted, not inferred
a nested panel adds its focusables in tree order rebuild-tree-each-sync
closing destroys the branch; nothing reaches its controls keep-closed-branch
nested drawers trap, then restore outward to the invoker leak-drawer-trap, forget-drawer-invoker
removing the focused node selects a survivor both identity and containment cases
background updates do not steal focus steal-focus-on-background
disabled visible controls never enter the chain focus-hidden-target
the overlay is exactly the live tree flat-overlay

Twenty-six controls in total. The previous rounds' corrections are all re-proved
against the new model: the at/inspect schema, selection as location, paused
Ctrl+C, sibling navigation, and a focus move keeping the route in step.

A finding worth naming

The vendor's lint and format exclusions are in .oxlintrc.json and
.oxfmtrc.json, not on the lint task's command line in package.json,
which is where the acpx and Cloudflare DOFS snapshots put theirs. Doing it that
way failed nine tests β€” build-web-client, atomic publication, client assets, and the compiled form-dispatch and compiled-xmd suites β€” with
error: The lockfile is out of date. Editing package.json at all
invalidates deno.lock
for every suite that clones the repository and runs
deno install --frozen. Proven both ways: reverting package.json alone made
build-web-client pass, restoring the one line made it fail again.
oxlint-policy.test.ts parses .oxlintrc.json with strict JSON.parse, so
that file takes no comments.

How to verify it

deno task test scripts/tests/repl-focus.test.ts \
  scripts/tests/repl-study.test.ts \
  scripts/tests/build-web-client.test.ts \
  scripts/tests/oxlint-policy.test.ts

Local results at c2923dae: the two REPL suites 32 passed (117 steps);
build-web-client + build-npm 6 passed (33 steps); oxlint-policy 1 passed
(14 steps); deno task check and deno task lint exit 0. No #838 golden moved.


Review round 4 β€” the tree governs input, and its order is canonical

Two architecture findings against c2923dae, both reproduced before anything
changed.

A branch could not consume a key. drive() recorded the dispatch path and
then reduced the same event globally regardless, so middleware on the focused
node's live ancestor path could intercept Escape and watch the drawer close
anyway β€” the hierarchy annotated the dispatch instead of governing it.

before   path ["drawer:project"]   drawers []            ← consumed, closed anyway
after    handled true              drawers ["project"]   ← consumed, stays open
after    handled false             drawers []            ← unclaimed, fallback closes it

keydown now returns whether it was handled; middleware that handles a key
returns true without calling next, and drive() returns before the reducer.

A live tree and a rebuilt one disagreed about order. A replacement is
appended wherever there is room, so a control changing from enabled to disabled
ended up last:

before   live     return-head β†’ fork β†’ continue
         rebuilt  continue β†’ return-head β†’ fork
after    both     continue β†’ return-head β†’ fork

Every node was present in both, and the reconstruction boundary was still
broken. Reconciling now restores the canonical order by sorting the region's
children. The new case drives frame 11 into inspection through the real path,
discards the store and the tree, rebuilds from the URL and journal alone, and
compares ordered topology; append-replacements is the control that makes them
diverge.

Ancestry no longer comes from identity strings. ownerRegion() is deleted,
Back emits an intent the tree resolves by walking parents, and the route follows
surfaceOwning(). RESULT-focus.md gains a section stating the line: an
identity may address a route, and may not answer where a node is.

Add-before-remove is kept, and focus is asserted to name a surviving node after
replacements and after branch teardown.

How to verify it

deno task test scripts/tests/repl-focus.test.ts \
  scripts/tests/repl-study.test.ts \
  scripts/tests/oxlint-policy.test.ts

Local results at 81f9ec81: repl-focus 22 passed (74 steps); the three
together 35 passed (138 steps), 0 failed; deno task check and deno task lint
exit 0. The focus goldens are unchanged and no #838 golden moved.

One documented command opens the Product Owner's approved `XMD REPL Terminal
Interface` study in a terminal, rendered from semantic fixtures through
`@bomb.sh/tty` 0.9.0:

    deno task repl:study

Six fixtures carry the states #838 names β€” empty, nested execution with
lifecycle rails and collapsed work, generated XMD replacing the expression
that produced it, an Elicit drawer with three sessions in flight, a paused
head with a historical selection, and a settled entry. Four layout profiles
are chosen from the measured terminal size: the study's wide composition, its
floor at medium, routed full-screen surfaces at narrow, and an explicit
recoverable refusal below 72 Γ— 20.

The study composes one screen at 2560 Γ— 1440 and describes no smaller one, so
the constrained-terminal policy is this experiment's own design work, as #827
asks. `scripts/repl-study/RESULT.md` records what the renderer gave us, five
observed limitations, and the three design states that needed adapting.

The committed `.txt` captures under `scripts/tests/fixtures/repl-study/` are
both the captures #838 asks for and the goldens the suite checks, so a
rendering change arrives in review as the picture it changed. Eight named
controls break the harness on purpose β€” stale frames, an uncoalesced scrubber,
a drawer over the footer, a composition below the minimum, an unannounced
resize, a lost transcript window, leaked terminal modes and flattened notches β€”
and the same oracles that admit the honest render reject each one.

Nothing under `packages/` changes: the harness is Deno-only tooling in
`scripts/`, with one exact root dev pin on `@bomb.sh/tty`.
Review found two things missing from f18f785, and both are now proved.

**Animation.** The frame loop discarded `RenderResult.animating`, supplied no
elapsed time, and redrew only after input β€” so a declared transition would have
rendered its first frame and stopped. It now runs a frame clock as a child of
the terminal session: `sleep(16)` in a spawned task, started only while the
renderer reports it is interpolating or the application's own transition has not
finished, and halted the moment both settle. An idle REPL schedules nothing, and
cancelling the session takes the clock with it.

Two kinds of motion run during a playback between two fixtures. The renderer
owns one β€” the contextual band declares a transition, so a drawer opening has
its height and top edge interpolated, and seventeen of forty-one frames in a
measured run were still animating. The harness owns the other: the recorded head
travels along the track and the target's transcript arrives a few rows at a
time, both computed from elapsed milliseconds alone.

The six fixtures stay exactly what they were β€” stable entry points and goldens.
A playback is the path between two of them and holds no state that outlives it:
its phase and elapsed time live in the frame loop, and reconstruction lands on a
fixture rather than halfway through a transition.

Evidence: a deterministic playback rendered with explicit `deltaTime`, with
start, midpoint and settled captures committed; a pseudo-terminal run that keeps
drawing with nothing typed at it; and an interruption mid-transition that leaves
the trace at the frame the signal arrived on and the terminal's modes restored.
Three new controls back them β€” `never-tick`, `restore-mid-animation`, and the
existing `skip-resize-update`.

**Notch height is scope depth.** It encoded selected/head/entry status, which
contradicts the settled decision. Height now carries depth alone β€” depth 0 fills
the band, depth 3 takes the track row, deeper shares the shortest notch β€” and
everything else is said another way: the playhead is its own heavier stem with
its own label, a selection is gold with a caret and a label, and an entry
boundary is `β—†` where an event is `●`. Selecting a checkpoint no longer changes
its notch height, and a test proves it.

That needs one more row than the study's 92-pixel band divides into: four depths
plus a label row the notches do not reach. `RESULT.md` records it as the
adaptation it is.
@taras

taras commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

Both findings are addressed at 3d79cb36.

Animation

You were right that nothing would have moved. draw() discarded animating, no
elapsed time reached render(), and a frame was only drawn after input β€” a
declared transition would have rendered once and waited for a keystroke.

The frame clock is now a child of the terminal session:

function* ticker(events: Signal<HarnessEvent, never>): Operation<void> {
  while (true) {
    yield* sleep(FRAME_MS);
    events.send({ kind: "tick" });
  }
}

It is spawned only while render().animating is true or the application's own
transition has not finished, and halted the moment both settle β€” so an idle REPL
schedules nothing, and cancelling the session halts the clock with it. A tick
carries deltaTime: 16; a keystroke or a resize carries 0, so typing during a
transition does not skip it forward.

Both kinds of motion are there:

  • the renderer's β€” the contextual band declares
    transition: { duration: 260, easing: "easeInOut", properties: ["height", "y"] },
    so a drawer opening has its geometry interpolated by Clay. In a measured
    playback, 17 of 41 frames were still animating.
  • the application's β€” the recorded head travels along the track and the
    target's transcript arrives a few rows at a time, computed from elapsed
    milliseconds in playback.ts, which is pure.

A playback holds no state that outlives it: its phase and elapsed time live in
the frame loop, and the six fixtures remain the stable entry points and goldens,
exactly as you asked. --play <from> <to> adds the path between two of them; the
digits still cut atomically.

One finding worth flagging: some interpolated frames emit zero bytes.
Sub-cell movement changes no cell, so a loop that scheduled on "the frame
produced output" would freeze halfway. animating is the only safe condition.

The five pieces of evidence you asked for

native transition interpolates the drawer itself and reports that it is still moving β€” heights interpolate monotonically, and it passes through intermediate values rather than jumping
application-timed moves the head and reveals the transcript on the application's own clock β€” plus the same instant rendering identically twice
start / midpoint / settled three committed captures from playFrames, driven with explicit deltaTime: play.generated-drawer.{start,midpoint,settled}.txt
PTY, no keyboard input keeps drawing without a keystroke β€” runs under script(1) (both dialects), reads the run's trace, and requires increasing elapsed time, animating frames, and a settled final frame
interruption mid-animation stops the clock and restores the terminal when interrupted mid-animation β€” a real SIGINT at frame 4, the trace ends at frame 4, and stdout ends with the exact revert bytes

The drawer also cannot cover the history footer while it is moving, which the
static evidence could not ask: every frame of the playback is checked against the
footer's bounds.

Three new controls: never-tick (nothing schedules the next frame β€” one frame,
motion unfinished), restore-mid-animation (reconstruction lands halfway through
a transition β€” the goldens reject it), and skip-resize-update.

Notch height

Corrected: height is scope depth and nothing else. Depth 0 fills the band, depth
3 takes the track row alone, anything deeper shares the shortest notch and says
so with Β·. The other three facts moved off height entirely β€” the playhead is
its own heavier stem with its own label, a selection is gold with β–² and its
label, and an entry boundary is β—† where an event is ●. says selection, the head and entry status some way other than height proves that selecting a
checkpoint leaves its notch exactly as tall as it was.

That needed one more row than the study's four: four depths plus a label row
the notches do not reach, or a depth-0 notch and the selection's label fight for
the same cell. The band is five rows now, and RESULT.md records it as the
adaptation it is rather than hiding it.

Verification

deno task test scripts/tests/repl-study.test.ts \
  scripts/tests/runtime-exclusions.test.ts \
  scripts/tests/test-file-discovery.test.ts
β†’ ok | 17 passed (62 steps) | 0 failed
deno task lint β†’ exit 0 Β· deno check β†’ clean Β· git diff --check β†’ clean

RESULT.md now carries an Animation section, and the adaptation list is down
from three to two.

`@bomb.sh/tty` measures both `duration` and `deltaTime` in seconds. This harness
declared `duration: 260` and advanced frames by `deltaTime: 16`, meaning
milliseconds on both sides β€” and because both were scaled by the same thousand,
every test still passed and every capture came out identical. The contract was
recorded wrong while the picture looked right.

The boundary now converts once. `FRAME_MS` stays 16 for `sleep()` and the
playback clock, the renderer is advanced by `FRAME_SECONDS`, the drawer's
transition is `DRAWER_TRANSITION_SECONDS = 0.26`, and the trace records
`deltaSeconds` so the unit is unmistakable where it is read.

Two cases pin it where the harness cannot mark its own homework, following the
library's own arithmetic: a 0.2 transition supplied 0.1 is still animating and
supplied 0.3 in total is not, and one frame of seconds must *not* finish a
transition declared in them. The second is the one that fails under the old
reading.

`RESULT.md` also claimed the renderer never measures time itself. It does: with
no `deltaTime` option it advances by the monotonic time since the previous
render, and passes 0 after a frame that reported `animating: false`. This
harness overrides that deliberately, which is what makes a captured transition
reproducible β€” and it now says so, along with the frame of lag before the
animating flag clears.

No capture changed, which is exactly what made the defect quiet.
@taras

taras commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

Correct, and thank you for the spec link β€” I verified it before changing anything. transitions-spec.md says deltaTime is "the number of seconds elapsed since the previous render" and duration is "seconds", and the upstream tests use 0.2 with 0.1. Fixed at 3662ae02.

The unit

The boundary converts once. FRAME_MS stays 16 for sleep() and the playback clock; the renderer is advanced by FRAME_SECONDS; the drawer declares DRAWER_TRANSITION_SECONDS = 0.26; the trace field is now deltaSeconds, so the unit is unmistakable where it is read rather than inferred.

No capture changed β€” which is the whole point of the finding. Scaling both sides by the same thousand preserved the frame count and the picture, so every test I had compared the harness only with itself and passed.

Two cases now pin it against the library's arithmetic instead:

term.render(box(blue), { deltaTime: 0.1 });   // animating: true
term.render(box(blue), { deltaTime: 0.15 });
term.render(box(blue), { deltaTime: 0.05 });  // animating: false  β€” 0.3 on a 0.2

and the one that actually fails under the old reading:

// One frame's worth of seconds must not finish a transition declared in them.
expect(other.render(box(blue), { deltaTime: FRAME_SECONDS }).animating).toBe(true);

Writing that first case taught me something the spec states and I had not noticed: the animating flag clears one frame after the transition arrives, which is why the library's own test spends 0.3 seconds on a 0.2-second transition. A loop that stopped at the first frame showing the final geometry would leave the last frame undrawn. That is in RESULT.md now.

The clock claim

Also corrected. The renderer does measure time: with no deltaTime option it advances by the monotonic interval since the previous render, and passes 0 after a frame that reported animating: false. This harness overrides it on every frame deliberately β€” ticks get their own elapsed, input and resize get 0 β€” which is what makes a captured transition reproducible rather than a race with the wall clock. RESULT.md says that instead of the previous sentence.

README.md follows the same correction, including what --trace records.

Verification

deno task test scripts/tests/repl-study.test.ts \
  scripts/tests/runtime-exclusions.test.ts \
  scripts/tests/test-file-discovery.test.ts
β†’ ok | 17 passed (65 steps) | 0 failed
deno task lint β†’ 0 Β· deno check β†’ clean Β· git diff --check β†’ clean

A live pty run still reports 41 frames with 17 animating, now with "deltaSeconds":0.016 in the trace.

Noted on the five-row band β€” it stays, and RESULT.md keeps it as an explicit adaptation for the next POC to inherit or overturn.

`deno task repl:study --play` now runs the demonstration end to end: empty β†’
nested β†’ generated β†’ drawer β†’ paused β†’ settled, holding each moment between 1.2
and 2.6 seconds and animating every transition between them. Sixteen seconds,
no keystrokes. When it reaches the settled entry the clock stops and that is
what stays on screen until you leave. `--play <from> <to>` remains as a focused
diagnostic.

Holds are part of the sequencing, not padding. The study's screens are dense,
and a demonstration that cut between them as fast as it could render would show
everything and let a person read nothing.

The clock is now armed one wake-up at a time by the frame loop, which is the
only thing that knows whether the next wait is a frame or the rest of a hold. A
timer that decided that for itself read state the loop had not finished
updating, and skipped whole segments of the story.

The trace names the segment and the moment each frame belongs to, so a run can
be read back as a journey rather than counted. Full-run evidence proves every
fixture boundary is reached in order, that both the renderer's own interpolation
and the application's happen along the way, that the run ends by settling rather
than by exhausting its frame budget, that the final projection is the settled
fixture exactly, that an interruption part way through cancels the journey and
restores the terminal, and that no fixture carries a key belonging to a journey.

Running the whole story at 200 Γ— 50 also found a renderer limit: Clay's
text-measurement cache holds 16 384 words, and one Term rendering this interface
fills it after 86 frames, reporting TEXT_MEASUREMENT_CAPACITY_EXCEEDED and
drawing nothing. The same journey at 80 Γ— 24 completed 207 frames without
reaching it, so it follows how much text a frame measures rather than time. The
harness catches that one error, builds a fresh Term and repaints; a test proves
both that it happens and that the run still finishes. Any session-lived Term
will need the same recovery, and RESULT.md says so.
@taras

taras commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

Done at 1861db3c. The seconds correction you list landed separately in 3662ae02, so this commit is the continuous demonstration.

One command, the whole story

deno task repl:study --play

empty β†’ nested β†’ generated β†’ drawer β†’ paused β†’ settled, about sixteen seconds, nothing pressed. Each moment is held long enough to read β€” 1.2s for the empty REPL, 2.6s for the nested transcript and the paused band, scaled to how much is on screen β€” and every transition between them animates. On arrival at the settled entry the clock stops and that is what stays until you leave with q. --play <from> <to> stays exactly as it was, a focused diagnostic.

Holds are sequencing, not padding: the study's screens are dense, and a demo that cut between them as fast as it could render would show everything and let a person read nothing.

A timing bug worth naming. My first attempt had the clock schedule its own next wake-up, and it read the loop's state before the loop had finished updating it β€” so each wake jumped a whole segment and the run "completed" in 12 frames. The clock is now armed one wake-up at a time by the frame loop, which is the only thing that knows whether the next wait is a frame or the rest of a hold. 207 frames, every segment in order.

The trace names where it is

{"frame":112,"elapsedMs":16,"deltaSeconds":0.016,"animating":true,"motionDone":false,"bytes":1838,"segment":"play:generated→drawer","fixture":"drawer"}

So a run reads back as a journey rather than a frame count. The full-run evidence proves, from that trace and from a deterministic walk of the same journey:

  • every fixture boundary reached, in the study's order (hold:empty … hold:settled, and the fixtures in FIXTURE_NAMES order);
  • both kinds of animation occur along the way β€” frames with animating: true, and frames inside play: segments carrying unfinished application motion;
  • the clock stops after settled: the run ends below its frame budget, so it stopped because the story ended rather than because it ran out;
  • the final projection is the settled fixture exactly, compared against the committed settled.wide.txt golden;
  • an interruption at frame 45 β€” mid-journey β€” ends the trace there, with the last segment still one of the journey's, and stdout ending in the exact revert bytes;
  • no fixture carries a key belonging to a journey (segment, playback, motion, progress, durationMs, reveal), which is what keeps phase disposable and session-local.

What the long run found

Running the whole story at 200 Γ— 50 hit a renderer limit the short tests never could: Clay's text-measurement cache holds 16 384 words, and one Term rendering this interface fills it after 86 frames, reporting TEXT_MEASUREMENT_CAPACITY_EXCEEDED and drawing nothing. The same journey at 80 Γ— 24 completed 207 frames without reaching it, so it tracks how much text each frame measures rather than elapsed time.

The harness now catches that one error, builds a fresh Term and repaints β€” a viewer sees a frame, not a fault β€” and a test asserts both that the rebuild happens and that the run still finishes. Any Term that lives for a session will meet this, so it is in RESULT.md for whoever inherits the harness.

Verification

deno task test scripts/tests/repl-study.test.ts \
  scripts/tests/runtime-exclusions.test.ts \
  scripts/tests/test-file-discovery.test.ts
β†’ ok | 19 passed (72 steps) | 0 failed
deno task lint β†’ 0 Β· deno check β†’ clean Β· git diff --check β†’ clean

The live check: 20 seconds in a pseudo-terminal with a single q at the end reaches "No executions yet", "Plan scope open", "INPUT REQUIRED", "RECONSTRUCTED AT 00:12" and "Entry 1 settled" in order, then restores the terminal.

The harness kept where the person was in four loose fields on a `View`, so
nothing could be reopened and there was no focus at all. Location is now one
URL β€” the execution, the surface, the locus, the drawer stack, the inspected
marker and the draft β€” and focus is a semantic identity resolved every frame
against a registry derived from that route, the journal and the layout.

All fourteen frames of the approved focus study are reachable at any of them,
forward and in reverse, wide and narrow, and a state built by a long
interaction rebuilds from its URL and a journal fixture alone.

Two defects were found by driving bytes rather than synthetic events. A lone
`ESC` never arrived, because the reader dropped `ScanResult.pending`; a real
Shift+Tab never arrived either, because it is `Backtab` with no shift flag.
Both are repaired and both carry a control that reproduces them.

`view.ts` is absorbed into `store.ts`: two places holding where the person is
was the defect this removes. `SURFACES` is unchanged and no #838 golden moved.
@taras taras changed the title πŸ§ͺ Render the REPL interaction study in a real terminal (#838) πŸ§ͺ Render the REPL interaction study in a real terminal, with one location and a derived focus model (#838, #839) Sep 23, 2026
Architecture review of 0b8687b found three places where the URL was not
actually the location it claims to be.

**Focus moved without the route.** Tab changed only `focus`, so the surface
segment went on naming the region somebody had already left: a cold start came
back to the input after tabbing to the footer, and at narrow widths the route
could render one full-screen surface while focus named another. Focus moves now
carry the route in the same reducer transition, and the fourteen frames are
driven *through* the reducer forward and in reverse rather than proved by
constructing each destination independently β€” which is the check that missed it.

**The selected marker was treated as disposable.** It rendered in the band and
vanished on hydration, and `projection()` agreed because it never looked. `at`
is now the selected marker and a valueless `inspect` says the reconstruction is
open; the two were one field and could not be told apart. The projection carries
the selected marker, its scope and its bindings.

**A paused entry is still an entry.** Ctrl+C exited instead of interrupting one,
which hands its lifecycle to whoever closed the terminal. Every running entry is
interrupted now, paused or reconstructed.

`Ctrl+←`/`Ctrl+β†’` are implemented against a sibling list derived from the
journal, which gains `preview` and `write` beside `plan` under the document
scope. Structural navigation now refuses to act inside an editable target β€” its
own guard was missing, and the new case caught it.

Four controls: keep-route-on-focus, drop-selection-on-hydrate,
exit-on-paused-interrupt, inert-sibling-arrows.
The flat `FocusTarget[]` registry is withdrawn. It kept traversal order, an
owner chain and restoration beside the interface, by hand β€” the manual-focus
problem Freedom exists to remove. Every case written against it passed, because
a list compared with itself always agrees; what it could not answer was a
question about where a control actually is.

The interface is now a Freedom node tree. A surface is a node, a scope panel a
branch inside it, a drawer a branch pushed as the active focus root, a control a
leaf. Traversal order is tree order, computed on demand. A key is invoked on the
focused node's scope, so every branch between the root and it runs its
middleware β€” `drawer:project β†’ panel:project.body` is read, not inferred.
Closing a drawer removes its branch, and its controls and middleware go with it.
`ReplState` has no focus field: the store decides what an event means and names
what should happen to focus, and the tree carries it out.

`@bomb.sh/freedom` is private and unpublished, so it is vendored from the public
playground at 8be97e72 with a manifest, provenance and a drift test. It runs
unmodified on this repository's effection 4.1.0 despite declaring alpha.9, so
there is no second scope tree and no lockfile moves.

Two patches are recorded against it. `useRoot()` acquires the tree as a resource
owned by the acquiring scope, because `createRoot()` parents the root to
Effection `global`. And `useFocus()`'s remove middleware now asks whether a
branch *contains* the focused node rather than whether it *is* it β€” a drawer
closes by removing the branch above the focused control, so the common case left
focus on a node that had just been destroyed.

A third fix is this harness's own: a reconciler must add before it removes, or
the focused control vanishes with no survivor in its region and focus lands
outside it. That is how a resumed run first lost its transport slot.

Four controls the tree makes possible: rebuild-tree-each-sync, keep-closed-branch,
flat-overlay, focus-hidden-target. Twenty-six in total.

The vendor is excluded from oxfmt and oxlint through their own configs rather
than the lint task's command line: editing `package.json` invalidates
`deno.lock` and fails every suite that clones the repository and installs.
Two architecture findings against c2923da, both reproduced before anything
changed.

**A branch could not consume a key.** `drive()` recorded the dispatch path and
then reduced the same event globally regardless, so middleware on the focused
node's ancestor path could intercept Escape and watch the drawer close anyway.
The hierarchy was annotating the dispatch instead of governing it. `keydown` now
reports whether it was handled, and a handled key ends there β€” no fallback runs
it. Proved both ways: a drawer that consumes Escape stays open, and the same
drawer with nothing installed closes through the fallback.

**A live tree and a rebuilt one disagreed about order.** A replacement is
appended wherever there is room, so a control that changed from enabled to
disabled ended up last: entering inspection from frame 11 gave
`Return β†’ Fork β†’ Continue` live and `Continue β†’ Return β†’ Fork` from the same URL
and journal. Every node was present in both, and the reconstruction boundary was
still broken. Reconciling now restores the canonical order by sorting the
region's children, and the evidence drives frame 11 into inspection, throws the
store and the tree away, and compares the ordered topology.

Ancestry is no longer reconstructed from identity strings: `ownerRegion()` is
gone, Back emits an intent the tree resolves by walking parents, and the route
follows `surfaceOwning()` rather than a parsed prefix. `RESULT-focus.md` states
the line β€” an identity may address a route, and may not answer where a node is.

The add-before-remove guarantee is kept, and focus is asserted to name a
surviving node after replacements and after branch teardown.

Two controls: append-replacements, and the consumed-key case that fails if the
fallback runs anyway.
Found by running `--frame 12 --focus-map` in a real pseudo-terminal while
writing instructions for it. The interactive harness opened at a frame's
location but never placed its focus, so the footer β€” an explicit region whose
controls exist only once focus is inside it β€” drew none of the transport
controls that frame is about. The overlay numbered five targets where the study
numbers eight.

Nothing caught it because the evidence and the captures entered through
`useFrame` and the harness entered through `openingState`: two ways in, and only
one of them was ever checked. Both now enter through one `enterRoute()`, and a
case walks all fourteen frames through the harness's own opening path β€”
`openingState()` then `enterRoute()`, exactly as `runInteractive` does β€” and
asserts the focused node and the numbered overlay.

`--frame <id>` carries the frame's focus through to the run.
The first slice of #840: the architecture, proven end to end, with the renderer
port that follows it still to come.

`view.ts` projects one immutable `ReplView` with isolated subtrees for sessions,
transcript, bindings, contextual content and history. It is JSON β€” a case
round-trips it to prove so β€” which is how it carries no journal, no store
handle, no Freedom node, no geometry and no callback. `indexOf()` names what a
route may address, so the router in the next slice can refuse what the view does
not contain.

`component.ts` is the whole component interface: a render body attached to a
Freedom node, and a `walk()` that renders depth-first with each parent wrapping
what its children already produced β€” the pinned Bombshell shape. `attach()`
returns a typed updater rather than storing the data behind an `unknown`, so
handing a component new data keeps its node, and with it the node's identity,
focus, middleware and generator-local state.

`components.ts` holds the bodies; `paint.ts` is the downward pass that hands
every mounted node its own slice and then walks the tree. Rendering and the
focus chain now come off one structure: a case renders the REPL through the
mounted tree and asserts the same tree answers both.

Nothing is switched over yet. `render.ts` still draws #838's frames from
rectangles, and the band's notch geometry, the drawer, the transitions and the
overlay are not ported. Completing that is the rest of this slice, and it
reaches further than regenerating captures β€” `bandGeometry`, `notchLayout` and
`columnFor` key off `Fixture["history"]` and #838's suite calls them directly.
`deno task repl:study --catalog` is #840's documented command. It renders every
state the contract names β€” an empty REPL, nested execution, three concurrent
Agent sessions, the project, review and confirmation drawers, bindings at two
scopes, historical inspection, a recorded drawer and a settled entry β€” through
the mounted Freedom tree at the wide and the narrow profile. Twenty-two captures
are committed under `scripts/tests/fixtures/repl-catalog/` and re-rendered
exactly by the suite.

A catalog entry is a *location* β€” a URL and how far the execution had recorded β€”
because that is what the interface is addressed by. Each one hydrates, mounts a
tree, enters through the same `enterRoute()` the interactive harness uses,
projects one immutable `ReplView`, and walks the tree. A catalog capture
therefore cannot show something the running REPL would not.

`drawerBody` renders a recorded drawer as the state it recorded: every control
disabled, `recorded Β· read-only` beneath them, and no affordance that would do
nothing. A case holds that.

Two corrections from review, both mine:

**`BodyContext` no longer carries the Freedom node**, which its own
documentation had already said it did not. A body receives a read-only
`Surface` β€” one unique id and its semantic name β€” so it cannot create children,
remove itself, set props or reach its scope. A case asserts the context has
exactly those four members.

**Components are addressed by the node's unique id, not its name.** Two nodes
legitimately share a name β€” the Execution History region is both a pane and the
way out of a drawer's trap β€” and the renderer refused the duplicate.

`render.ts` still draws #838's frames from rectangles. Removing that second path
is the next slice; it is a migration state, not a limitation.
The catalog's `drawer-historical` state rendered `recorded Β· read-only` while
the tree went on offering its fields for focus and input: the chain held all
four controls, focus landed on the project-name field, and a key was delivered
to it. The picture said one thing and the mounted tree said another.

A drawer reconstructed during historical inspection now keeps its complete
recorded presentation β€” every field and control is still mounted, so the
components render exactly what was recorded β€” and none of them is made
focusable. Nothing enters the ring, nothing receives a key. What stays is the
navigation that remains valid while a recorded moment is open: the Execution
History path inside the drawer's own focus root.

`focusable()` is one-way, so a live drawer cannot quietly become a recorded one.
A drawer whose mode changed is a different drawer: reconciliation unwinds to it
and rebuilds it without actionability, which is the same rule the region
controls already follow.

Three paths are proved rather than one: a live drawer exposes its controls in
tree order and receives input; the recorded state, rebuilt cold from its URL and
journal alone, renders all four controls while `tree.chain()` holds only
`region:history`; and a mounted live drawer transitioned into inspection loses
its actionability through reconciliation, with focus still naming a node that
survived.

All 22 catalog goldens are unchanged β€” the projection already reported the
drawer as historical, so only the tree was wrong.
First step of the renderer migration. `bandGeometry`, `notchLayout` and
`columnFor` took a `Fixture`; they take `HistoryView` now, because the band's
arithmetic is about what is *shown* β€” which transport controls are visible, how
wide their labels are, which markers share a column β€” and none of that is a
question about storage.

`historyViewFrom()` is the one projection of a fixture's recorded history, used
both by `project()` for the component tree and by the rectangle path that still
draws #838's frames. Two projections of the same thing would be two answers
while both paths exist.

A `Notch` now gathers `markers` rather than `checkpoints`, and the band reads
its selection from the marker the view says is selected rather than from an
index into a fixture's array.

#838's geometry assertions are retargeted, not replaced: a `bandOf()` helper
hands each one the view model and every expectation is unchanged. All 39 tests
across the three suites pass and no golden moved.

The old composition path is still there. Removing it is the rest of this slice.
Second step of the renderer migration. The band is a component now: it takes its
own `HistoryView` and the box its parent gives it, and the drawing is
`render.ts`'s unchanged β€” notch height is scope depth, the playhead is its own
stem, a selection is gold with a caret under it.

Components receive a `Placement` carrying the profile as well as the rectangle
and the density, so a component is told the presentation constraints it renders
within rather than looking up the whole layout for itself. `bandGeometry` takes
that placement; #838's call sites are retargeted and their assertions unchanged.

Two nodes legitimately named `region:history` surfaced the same duplicate-id
class of bug once more: the band is addressed by the node's own id, and only the
pane draws it β€” the identically-named node inside a drawer is that trap's way
out, not a second Execution History.

**Twelve of the twenty-two catalog captures changed, for one intentional
reason**: the catalog's band was a simplified list of markers and now renders the
real band β€” the track, the playhead, and notches whose height is scope depth.
That is the migration doing what it is for. The ten states with no recorded
history are byte-identical, and no #838 or #839 golden moved.

The rectangle path still draws #838's frames. Removing it is the rest of this
slice.
Third step of the renderer migration. `contextualRegion` is split into
`drawerRegion` and `inputRegion`, each taking a view and the box its parent
gives it. The component tree and the rectangle path both call them, so the two
cannot disagree about the contextual band while both exist; the rectangle path
keeps a thin shim that goes when it does.

`DrawerView` carries the study's real content β€” the project form's labelled
fields and schema, the review's plan and decisions, the confirmation's preview
and actions β€” rather than a summary of it.

**Eight catalog captures changed, for one intentional reason**: the drawer was
rendering my simplified list of control labels and now renders the study's own
form. `drawer-project.wide` gains the `┃` value gutter and the validation line
it always had in #838's frames.

A recorded drawer says so *before* the form rather than after it. Appended last,
`recorded Β· read-only` fell outside the band's clip and never reached the
screen β€” the notice that tells you nothing here is actionable has to survive the
clip that the rest of the drawer is subject to.

No #838 or #839 golden moved. 39 tests, 140 steps, lint and check clean.
Fourth step of the renderer migration. The surface bar, the header crumb, the
pane separators, the focus marker and the `F1` overlay are mounted nodes now,
so rendering *order* is the tree's rather than a sequence written out inside one
function. They take no focus, so the ring is unchanged: a container draws and is
never a target.

Drawers mount before the trailing chrome, so the marker and the overlay still
land on top of what they describe.

The too-small refusal is a node too. Below the supported minimum the panes are
not dressed at all β€” there is nothing for them to be inside β€” which is the same
refusal #838 established, expressed as a tree rather than as an early return.

**Eighteen catalog captures changed, for one intentional reason**: the catalog
was drawing the panes without the composition around them. It now has the header
crumb and the separators at wide, and the surface bar at narrow β€” the accepted
composition, not a partial one.

No #838 or #839 golden moved. 39 tests, 140 steps, lint and check clean.
The renderer migration is complete: `renderScreen` and the region functions only
it called are **deleted**, and there is one composition path. Every frame β€” the
captures, the catalog, the playbacks, the journey and the interactive harness β€”
is drawn by walking the mounted Freedom tree.

A frame is drawn by a mounted composition, so a caller that wants frames mounts
one first. `playFrames`, the journey and the host mount one per moment and reuse
it, which is what makes a transition a change to a tree rather than a new tree.

**Every one of #838's twenty-six captures is byte-identical.** That was the
measure of whether this was a migration or a rewrite, and four differences found
along the way were each a real defect rather than a reason to move a golden:

- the Run affordance was tied to a non-empty draft, which is a focus-ring rule
  and not a rendering one β€” #838 draws `[ Run ⌘⏎ ]` whenever the fixture offers
  it, and whether Tab may land on it stays the tree's question;
- a routed narrow capture was projected as the transcript rather than as the
  surface it routes to;
- the transcript's arrival animation never reached the component;
- an open drawer owns the contextual band from the *first* frame of the
  transition β€” its height is what interpolates, and guarding the drawer on
  progress made the band jump instead of grow.

Regions are addressed by node id, so evidence that asks about a role β€” "is the
footer ever covered?" β€” gets the id that actually rendered it rather than
guessing a name. `stale-frame` and `drawer-covers-footer` moved onto the tree
with it.

Two catalog captures changed: the empty REPL's Run affordance, for the reason
above.

The whole animated demonstration is re-proved, not only the components: every
moment in order, both kinds of motion, the renderer rebuilt when it exhausts its
measurement cache, a real pseudo-terminal playing start to finish with nobody at
the keyboard, and the journey cancelled part way through.
Two architectural blockers from review, both reproduced first.

**`runInteractive()` mounted two trees.** One for focus and input, another for
rendering, each internally consistent and each looking right on its own β€”
`same object? false`. `composeInto()` now takes the tree it composes into and
brings it to the moment being shown; nothing calls `useReplTree()` a second
time. `useComposition()` remains for a caller that owns the whole composition β€”
a capture, a playback, the replay β€” where mounting one tree is exactly right.
`second-tree` is the control, and it fails the identity assertion.

**`paint.ts` dispatched presentation globally by node name** while holding the
whole `ReplView` and the whole layout. That is the flat-registry shape again:
one place outside the tree decided what every node renders. Presentation is the
tree's now β€” the root is a parent, and it hands each of its own direct children
that child's own view subtree and the placement it allows. `paint` is the walk
and a map from the roles evidence asks about to the ids that drew them.

Evidence added: rendering, focus, input targeting and the overlay all resolve to
the same root *object*; a parent's reconciliation keeps unchanged children; and
a render body receives exactly `self`, `data`, `placement` and `children` β€” its
own data, never the root view, and never a node it could mutate topology with.

No #838 or #839 golden moved, and no catalog capture moved. 40 tests, 144 steps,
lint, check and diff clean.
Reproduced before anything changed. Composing a moment hydrated a **synthetic
state from a fabricated URL**, synced the tree to it and entered its route β€”
on every repaint. Someone who tabbed to the bindings pane had focus dragged back
to the transcript by the next frame:

```text
focus after Tab     : region:bindings
focus after repaint : region:transcript
```

In the interactive harness that runs every frame, so focus was unusable and
nothing noticed: the composition looked right, and so did the tree.

`composeInto()` is projection only, and synchronous. It reads the fixture and
returns the view; it mounts nothing, syncs nothing and focuses nothing. Topology
belongs to the store's own sync and focus belongs to the person β€” drawing may
read both and change neither.

The per-repaint sync was load-bearing for one thing: while the journey projects,
the moment on screen is not the store's, and without a sync the drawer branch
never mounted, so its declared transition never ran and the study stopped
animating. That sync happens once **when the moment changes**, never on a
repaint, and never touches focus.

**The second-tree oracle was dishonest.** It asserted the control's own
construction β€” that two roots differ β€” which the control cannot fail. Node ids
cannot tell two trees apart either, because each counts from one. The oracle now
asks the observable question the honest run also answers: after focus moves,
does what rendered agree with where focus is? One tree says `region:bindings`;
two trees disagree.

No golden moved. 40 tests, 145 steps, lint, check and diff clean.
`FocusView` is gone. It was an identity and a numbered map, worked out
somewhere else and threaded down through `PresentOptions`, every frame
request and the host β€” a second opinion about focus, free to disagree
with the tree. Traversal now derives `"self" | "within" | "outside"` for
each node as it walks, and that word is the whole of what a render body
is told. It never receives a node.

The F1 overlay is the same tree, walked by the root and handed to its
child as data. It takes the placement its parent gave it rather than the
whole `Layout`.

Which control holds focus is now the control's own to say. A field, a
button and a transport action are components with a body each; a parent
reserves the cell and the focused control draws `β–Έ` in it. The drawer's
lines and its gutter cells come out of one pass, so a marker cannot
drift off the word it belongs to, and a recorded drawer reserves nothing
because none of its controls can ever hold focus.

`--capture-focus` drew its frames with a *second* tree: the frame's tree
was asked where focus was, and a freshly mounted one drew the picture.
That is how a capture could mark a node the rendering tree had never
heard of. One tree now answers both.

Evidence: all nineteen committed focus captures reproduce byte for byte.
The study's and the catalog's captures move for one reason β€” a frame
drawn by walking the tree cannot be silent about focus, because the tree
always has some β€” so the focused region wears `β–Œ` and an open drawer,
which traps focus, shows the gutter. A new control hands the renderer
the exact `FocusView` #839 used to hand it and compares bytes: there is
nowhere left for it to land.
…w itself

The root was reaching through its children to attach bodies and boxes to
grandchildren. Each parent now places its own: the input band places
`Run`, the Execution History band places its transport controls, the
drawer places its panel and its way out, and the panel places the form's
fields and buttons. Those closures are installed by the lifecycle that
created the node, which is the one thing allowed to hold it β€” a render
body still receives none. The root presents its direct children and asks
each of them to present theirs.

A child's box now comes from its parent's (`within`), so the density and
the profile it is drawn under are the composition's rather than looked
up again.

Two focus targets could hold the keyboard invisibly:

- `Run` had no cell at all. It gets the space inside its own bracket β€”
  `[β–ΈRun ⌘⏎ ]` β€” the bargain the transport controls already strike, so
  the affordance keeps its twelve columns whether it is focused or not.
- A drawer traps focus and carries its own Execution History target, a
  different node from the band outside it. It now draws its own `β–Œ` over
  the band it is the way back to, so reaching it looks like reaching the
  band β€” which is what it does. That corrects one catalog capture: the
  recorded drawer's only focusable node is that target, so focus has
  been sitting there unshown.

Both are proved with the focus map closed, because the map is the thing
that was covering for them. The stale-focus control no longer casts: the
obsolete field rides on an inferred request variable.

All nineteen focus captures and all twenty-six study captures still
reproduce byte for byte.

Known gap, not corrected here: at the narrow profile the footer is
composed only on the history surface, so a drawer's way-out target is in
the ring with its destination off screen.
…not there

A presentation is a value its parent keeps, not something stored on a
node and recovered later. The node-data registry erased every one to
`Presentation<never>` and cast on the way in and the way out, which meant
nothing could say the data handed to a presenter was the data that
presenter takes. `Presentation<Data>` is now just the type of the
closure; the lifecycle that wrote it holds it β€” the root holds the input
band's and the band's, and a drawer holds its panel's β€” and calls it
directly. No casts remain in the study's production code.

Topology follows the composition. A narrow drawer owns the whole screen,
so the Execution History band it would escape to is not drawn β€” and a
target Tab reaches with nothing on screen to show it is worse than no
target at all. The drawer's own history node is mounted only where the
composition draws that band: no branch, no place in the ring, no
middleware. `useReplTree` takes the terminal it is composed for and
`sync` may report a new one, so a resize remounts what the new profile
composes.

That made two things visible that a single tree had been hiding:
`--capture-focus` rendered one tree at both profiles, and now mounts one
per profile; and the drawer's way out has to exist before the trap is
pushed, or a recorded drawer β€” which has no other focusable child β€”
leaves focus on the container.

Two captures move. `frame-07.narrow` drops `5 Β· Execution History` from
the map, which is the rule doing its work. `drawer-historical.narrow` now
marks the drawer itself, because a recorded narrow drawer has no
focusable child at all and the trap has nowhere else to land.

Evidence: a narrow drawer's chain, map and subtree carry no history
target; a wide one keeps it; resizing wide→narrow with it focused
removes it and leaves focus on a live drawer target; narrow→wide brings
it back after the panel; and walking the whole narrow ring with Tab never
reaches it. Every capture in all three sets is unchanged apart from those
two.
A drawer opened straight from a URL was mounted while the ring was still
on its default first region, so the trap remembered Sessions as what it
had interrupted. Closing it put you there β€” on a surface the URL had
never named.

The surface the route names now takes focus at mount, before the first
trap is pushed, so what the trap restores is what the URL says.

That state is the one place it mattered most: a recorded drawer at the
narrow profile is a read-only modal β€” nothing in it is actionable and the
Execution History band it would escape to is not on screen β€” so the
drawer itself holds focus, Tab does nothing, and Escape is the only way
out. Where Escape lands is the whole of that state's navigation.

The regression walks it: no focusable child, an empty ring, then Escape
closes the drawer alone β€” the reconstruction stays open at cp-04 β€” focus
returns to `region:transcript`, and it is a node the live chain has. A
second Escape leaves the reconstruction, which is a separate step.

No capture moves.
The terminal is read in one place. What travels from there is a
`ReplInput` β€” a key, or a synthetic pointer landing on a cell β€” and
nothing below that boundary parses a decoder's shape again. A resize, a
frame passing and a record arriving from a running execution never
become input: they happened to the interface, not because of a person.

`ReplInputApi.handle(input)` is invoked on the node the input is aimed
at: whatever has focus for a key, whatever is drawn at the cell for a
pointer. A branch that returns true has answered it, and nothing else
runs it β€” not an ancestor, not the root. Middleware receives no node;
the lifecycle that installed it already holds the one it is for.

`ReplActionApi.dispatch(action)` carries what was meant, not how it was
asked for. Enter, Space and a primary pointer are one gesture with three
spellings, so a control tests one thing and its keyboard and its pointer
cannot drift apart. A branch may own an action: the drawer owns Back and
says what it really means there by dispatching `close-drawer`. The root
answers what nothing nearer did, and is the only code that turns an
action into a new state β€” `applyAction` in the store, and the tree's own
focus operations. An action nobody owns reaches the API default and
throws.

`KeyboardApi` is gone; there is no second input path. Tab, Shift+Tab and
Escape are read by the root as actions rather than by the store, and
Enter belongs to whatever was activated, so the store's fallback keeps
only what is not an action: typing, scrolling, scrubbing, the overlay,
quitting.

Evidence: Enter, Space and a pointer on the same control emit
byte-identical actions and leave identical state; a consumed input runs
no fallback β€” proved with `F1`, which the store still owns β€” and emits no
action; the drawer translates Back and a plain Back elsewhere does
something else entirely; an unowned action throws, both outside a
delivery and against the new `disown-actions` root; and closing a branch
takes its path recording and its translation with it. Each of those four
rules was broken in turn and the case that covers it failed.

Mouse reporting stays off. StarFX and the router stay out. Run and Fork
deliberately have no action: both name execution this study's fixture
journal cannot perform, and answering them with nothing would invent it.

No capture moves.
The name-to-action table is gone. A control's action is declared where
the control is: the footer's transport list carries one per entry, the
input band carries `Run`'s, and `surfaces.ts` carries each drawer
target's beside its label and its order. The parent hands it over when it
mounts the child, so nothing infers behavior from `node.name`.

Every enabled control now answers its own activation. The ones that mean
something emit it β€” Run, Fork, Submit, Approve, Request changes, Stop,
Decline, schema disclosure β€” and the ones that do not, a field and a
scroll region, consume it, because an activation that falls past a
control is one the fallback gets to reinterpret. A disabled control and
a recorded drawer's contents are wired to nothing at all, which is the
same act as not making them focusable.

Eight of those actions name operations a real execution owns. The root
owns them and refuses: it names the control, says it needs a real
execution, and leaves the journal and the URL exactly as they were. The
refusal is drawn β€” it takes the row the header spent on air, and the
narrow surface bar's crumb β€” because a refusal nobody can see is
indistinguishable from a button that does nothing, which is the failure
this whole boundary exists to remove. It is disposable: the next thing
you do answers it, and going anywhere clears it.

Evidence walks the tree rather than a list beside it. Seven mounted
states, every focusable control in each, Enter and Space and a pointer at
the cell its own parent reserved: one action byte for byte, one outcome,
and every delivery handled. The roster it reaches is compared with the
one this study declares, so a control that stopped being mounted fails
here instead of going unexercised. Two more cases cover what the
enumeration cannot: a pointer aimed at a control while a *different* one
holds focus speaks for the one it landed on, and a disabled or recorded
control neither handles nor emits.

Each new rule was broken in turn β€” the fall-through, a control's declared
action, the drawn refusal, the hit test β€” and the case that covers it
failed.

No capture moves.
A primary pointer that lands on a visible, enabled, focusable node now
moves Freedom's focus there before its input is delivered. The action is
dispatched from where the person now is, and everything that reads focus
afterwards reads the same answer: which region owns it, so the URL
follows onto the surface that was pointed at, and what Back returns to.

Nothing else moves focus. A cell with nothing drawn in it, a disabled
control, a recorded drawer's read-only contents: none of them can take
focus, so pointing at one changes neither where you are nor what has
happened.

A refusal now survives the navigation that the same input caused. The
URL following focus is part of that one act, not the next one β€” clearing
the notice there would have wiped the answer before it could be read.
What clears it is still the next thing a person does.

`composeInto` hardcoded `inspect: false`, so a composition drew a
recorded drawer as though it were live and actionable β€” the opposite of
what the tree makes of it. The view carries `inspect` now, which is what
lets the evidence ask whether a recorded control offers anything to point
at. No capture moves: no captured moment has a drawer open inside a
reconstruction.

Regressions: pointing at Return to paused head while Continue holds focus
focuses Return and emits `return-to-head`; pointing at Run from the
footer focuses Run, refuses it out loud, and moves the URL's surface to
`input` with the journal untouched; a pointer whose own action removes
the control it landed on leaves focus on a survivor in the live chain;
and a disabled control is hit-testable but neither acts nor takes focus,
while a recorded one offers no cell at all.

The assertion that focus stayed on the old node is replaced β€” it was
describing the defect.
Corrects the two blockers the architecture review of `082fba5e` found.
Base `082fba5e`; nothing amended or rebased, and Slice 2 is not started.

The stale-answer defect was reproduced through the exported
`projectModel()` boundary before anything changed, on serial entries:
`entry-1` opens and answers `document/project`, `entry-2` opens its own
`document/project`, then a stale answer naming `entry-1` is appended. The
head went from `["entry-2/project"]` to `[]` β€” the projection accepted the
answer and consumed the wrong entry's wait.

Its cause was mine, and worth naming: the previous commit's message said
the answer fold compared the entry, and it did not. That edit was applied
to text the formatter had since rewrapped, so it silently matched nothing
while the surrounding claim went into the commit message anyway.

**An answer resolves by its complete owner.** One `owns()` helper now
requires the entry, the exact scope path and the kind to match before a
suspension is consumed, and an answer that matches none is refused by
name rather than by removing whatever looked similar.

**Entries are sequential, and the projection enforces it.** Submitting an
entry while another is unsettled is refused, as is settling an entry that
is still waiting. `entry.settled` returns as a record kind, and `Entry`
carries `settled` again, so a checkpoint says which entry is live.

**The representative `HISTORY` is one entry ending at `cp-10`.** The
overlapping `entry-2` records added in `082fba5e` are gone; concurrent
entry lifecycles are a state the product does not create, and routing is
no longer shown resolving against one. The representative location and
the `cp-10` suspension stack are exactly what Slice 1 first delivered.

**Ownership evidence moves to `SERIAL_HISTORY`**, a separate fixture where
`entry-1` opens a `project` wait in `document`, answers it and settles,
and only then `entry-2` opens the same kind at the same path. Every name
matches; only the owner differs. At `sp-08`,
`entry-1/document/+project` refuses with an empty stack while
`entry-2/document/+project` resolves to the exact model object, and the
identical URL at `sp-03` resolves to entry-1's own wait.

Preserved from the accepted corrections: every decode failure stays
inside `Result`; `Route` stays the closed `SurfaceRoute | EntryRoute`
union minted by checked constructors; `/+` stays malformed; `Suspension`
keeps its owning entry; `resolveRoute()` indexes only the selected
entry's stack; canonical encoding and the 75-route round-trip evidence
are unchanged. The router still imports only `effection` and
`./model.ts`.

Verified at this commit:

- `deno task test scripts/tests/repl-compose-router.test.ts` β†’ 1 passed,
  54 steps, 0 failed
- `npx tsx --test scripts/tests/repl-compose-router.test.ts` β†’ 45 pass,
  0 fail
- `bun test scripts/tests/repl-compose-router.test.ts` β†’ 45 pass, 0 fail
- `deno task test scripts/tests/repl-study.test.ts
  scripts/tests/repl-focus.test.ts` β†’ 35 passed, 125 steps, 0 failed
- `deno task test scripts/tests/test-file-discovery.test.ts
  scripts/tests/runtime-exclusions.test.ts` β†’ 7 passed, 22 steps, 0 failed
- `deno task test scripts/tests/no-module-scoped-registry.test.ts
  scripts/tests/prefer-effection-result.test.ts
  scripts/tests/no-redundant-test-scope.test.ts
  scripts/tests/oxlint-policy.test.ts` β†’ 4 passed, 32 steps, 0 failed
- `pnpm run lint` β†’ exit 0
- `deno task validate:docs` β†’ exit 0
- `git diff --check` β†’ exit 0
- `rg -n 'JournalKind|JournalRecord|JournalFixture|JOURNAL|journalThrough|
  journal fixture' scripts/repl-compose
  scripts/tests/repl-compose-router.test.ts` β†’ exit 1, no matches

Break-it controls, each run against this evidence:

- dropping `suspension.entry === record.entry` from `owns()` fails the
  stale-answer case, and fails it by projecting successfully with
  entry-2's wait consumed β€” the exact reproduction above
- removing the still-running guard fails the overlapping-entries case
- removing the still-waiting guard fails the early-settle case

Every replacement in this commit was applied under an assertion that the
text it was replacing existed, which is what the previous round lacked.
Corrects the remaining serial-lifecycle blocker from the review of
`248d98c0`. Base `248d98c0`; nothing amended or rebased, and Slice 2 is
not started.

Reproduced through `projectModel(EXECUTION, SERIAL_HISTORY)` before
changing anything. The head said:

    head sp-08
    entry-1: settled
      └── document: unsettled
    entry-2: unsettled
      └── document: unsettled

Two live scope trees at one moment, which would carry a false active
scope into everything that reads the model.

**`SERIAL_HISTORY` records the scope exit.** `entry-1` now answers its
`project` wait, leaves `document`, and only then settles β€” before
`entry-2` is submitted. The markers after it shift by one, so the head is
`sp-09`.

**`projectModel` refuses `entry.settled` while any scope that entry
opened has not exited**, searched recursively through the whole tree.
Nothing is marked settled to let an entry finish: `Scope.settled` keeps
meaning that the scope exited, and a history that says otherwise is
refused by name.

At the head, `entry-1` is settled with no live scope, and
`entry-2/document` is the only unsettled entry/scope path.

Preserved: the representative `HISTORY` stays one entry ending at
`cp-10`; the stale-answer ownership regression and every route, decode,
encode and refusal case are unchanged; `Route` stays the closed minted
union; `resolveRoute()` still indexes only the selected entry's
suspension stack; the router still imports only `effection` and
`./model.ts`.

Verified at this commit:

- `deno task test scripts/tests/repl-compose-router.test.ts` β†’ 1 passed,
  55 steps, 0 failed
- `npx tsx --test scripts/tests/repl-compose-router.test.ts` β†’ 46 pass,
  0 fail
- `bun test scripts/tests/repl-compose-router.test.ts` β†’ 46 pass, 0 fail
- `deno task test scripts/tests/repl-study.test.ts
  scripts/tests/repl-focus.test.ts` β†’ 35 passed, 125 steps, 0 failed
- `deno task test scripts/tests/test-file-discovery.test.ts
  scripts/tests/runtime-exclusions.test.ts` β†’ 7 passed, 22 steps, 0 failed
- `deno task test scripts/tests/no-module-scoped-registry.test.ts
  scripts/tests/prefer-effection-result.test.ts
  scripts/tests/no-redundant-test-scope.test.ts
  scripts/tests/oxlint-policy.test.ts` β†’ 4 passed, 32 steps, 0 failed
- `pnpm run lint` β†’ exit 0
- `deno task validate:docs` β†’ exit 0
- `git diff --check` β†’ exit 0
- `rg -n 'JournalKind|JournalRecord|JournalFixture|JOURNAL|journalThrough|
  journal fixture' scripts/repl-compose
  scripts/tests/repl-compose-router.test.ts` β†’ exit 1, no matches

Break-it controls:

- removing the recursive unsettled-scope guard fails the new regression,
  which is an entry with nothing waiting and a scope still open
- removing the fixture's `scope.exit` record fails the whole suite at
  module load, because the projection refuses to describe that moment at
  all rather than producing the two-live-tree head above
Slice 2 of the replacement #840 experiment, on the accepted Slice 1 head
`576e888a`. It adds the declarative boundary between deciding the
interface and mounting it, and nothing above it: no route composition, no
layout, no renderer.

New, in `scripts/repl-compose/`:

- `component.ts` β€” a keyed description over immutable input, and the
  component contract a parent describes children with
- `reconcile.ts` β€” descriptions in, one mounted Freedom tree out, plus the
  walks that read it back
- `frames.ts` β€” the host's clock, and the demand mounted branches place
  on it
- `input.ts` β€” a key down the live ancestry, a typed action back up
- `shell.ts` β€” the small component set the evidence drives

**A parent declares its direct children and their immutable inputs.** It
reaches no registry, asks nothing what is mounted, and hands no child a
way to register itself, so the tree is decided before anything exists. A
component's identity is the component value itself β€” nothing mints an id
and nothing remembers one.

**Reconciliation mounts into Freedom and nowhere else.** Matching is by
key, as Crank matches keyed children: a description whose key *and*
component match the node already there keeps that node, and with it the
node's Effection scope and everything its lifecycle holds. A different
component at the same key is a different child. A key that stops being
described is removed with its whole subtree, awaited rather than started.

**Teardown is structural.** A drawer stack is described as a branch β€” the
second drawer is a child of the first β€” so closing the top one removes
exactly one subtree and closing the bottom removes both. What goes with
it goes because its scope is gone: frame subscription, input middleware,
focus target and presentation alike. Nothing is notified.

**Delivery addresses a position in the tree, never a retained reference.**
Removing a node detaches it but leaves the node object, and a disposed
Effection scope still carries the interceptors installed on it β€” so a
kept reference to a closed drawer's control would otherwise still run
that drawer's middleware and answer with an action. This was found by the
evidence, not reasoned about in advance.

**A component declares every member except `onPress`.** A member is
optional when its absence is the neutral element of a composition, and
required when its absence would substitute a claim. Absent `onPress`
means the component says nothing about a key, so it carries on to the
branch that does understand it. Absent `children` would instead be the
reconciler deciding there is no subtree, and a `children` misspelled or
lost in a merge would mount a tree missing a branch with nothing to
report. `lifecycle` is written `null` rather than omitted, because
whether a branch holds anything disposable decides whether a task is
started for it at all.

Slice 1 is untouched, and so is every #838/#839 file.

Verified at this commit:

- `deno task test scripts/tests/repl-compose-reconcile.test.ts` β†’ 1
  passed, 22 steps, 0 failed
- `npx tsx --test scripts/tests/repl-compose-reconcile.test.ts` β†’ 16
  pass, 0 fail
- `bun test scripts/tests/repl-compose-reconcile.test.ts` β†’ 16 pass,
  0 fail
- `deno task test scripts/tests/repl-compose-router.test.ts
  scripts/tests/repl-compose-reconcile.test.ts
  scripts/tests/repl-study.test.ts scripts/tests/repl-focus.test.ts` β†’
  37 passed, 202 steps, 0 failed
- `deno task test scripts/tests/test-file-discovery.test.ts
  scripts/tests/runtime-exclusions.test.ts` β†’ 7 passed, 22 steps, 0 failed
- `deno task test` over the six repository rule suites β†’ 7 passed,
  62 steps, 0 failed
- `pnpm run lint` β†’ exit 0
- `deno task validate:docs` β†’ exit 0
- `git diff --check` β†’ exit 0

Break-it controls, each run against this evidence:

- matching by position instead of by key fails 2 cases, including a
  reorder that hands one panel's node to the other
- hiding an undescribed branch instead of removing it fails 5 cases
  across teardown, the hidden-drawer control and the registry control
- frame demand that is counted and never released fails 5 cases,
  including the root teardown

Named negative controls in the suite: `positional-only reconciliation`
(index matching moves state to the wrong child), `hidden-but-live drawer`
(hiding leaves the focus target, the input path and the frame demand
behind), and `parallel registry` (a collection beside the tree still
lists a branch the tree no longer holds).
… finish a frame

Corrects the three structural boundaries the architecture review of
`247b8775` found. Base `247b8775`; nothing amended or rebased, and Slice
3 is not started.

**A key names one child.** `compose()` now answers `Result<void>`, and
uniqueness among a parent's direct children is checked over the whole
description tree before a single node is created, removed or handed new
input β€” so a refused composition leaves the mounted tree exactly as it
was, lifecycles included. Two branches under one key is a tree that
cannot be addressed: the reconciler finds children by key, so the second
shadows the first, and the first is then never matched for an update and
never counted as undescribed for removal.

**A retained branch is told what changed rather than rebuilt.** Its new
input travels the same direct parent-child boundary the first one did β€”
no ambient context, no registry, no polling of node description data β€” as
a per-branch handoff the reconciler delivers into only after the identity
match confirms the description was made by the very component that built
the sink. Presentation, children and `onPress` continue to read that same
current input.

That sink crosses the typed boundary with no cast. `InputSink.accept` is
written with method syntax, whose parameter is bivariant in TypeScript,
so a branch keeps a sink typed to its own input while the reconciler
holds it untyped; the identity check is what makes it sound.

**A frame is delivered, not merely sent.** One primitive, `handoff.ts`,
now carries both frames and input: `deliver()` completes once every
receiver that was live when it started has come back for the next value,
which is the moment it finished applying this one. Asking for the next
value *is* the acknowledgement, so there is no `ack()` to forget and a
slow receiver holds the producer rather than being overtaken. The clock's
`tick()` becomes `advance(timestamp): Operation<void>`, and a render walk
immediately after it sees that frame.

`settle()` and its `sleep(0)` are gone. They were a guess at how long a
receiver needed, and a receiver that needed two turns would have been
read before it ran.

Retained unchanged: parent-declared direct children, component-value
identity, one mounted Freedom tree, key-and-component retention, complete
structural teardown, live-ancestry action bubbling, and required
`children` with explicit `null` lifecycle and optional `onPress`.

Verified at this commit:

- `deno task test scripts/tests/repl-compose-reconcile.test.ts` β†’ 1
  passed, 35 steps, 0 failed
- `npx tsx --test scripts/tests/repl-compose-reconcile.test.ts` β†’ 26
  pass, 0 fail
- `bun test scripts/tests/repl-compose-reconcile.test.ts` β†’ 26 pass,
  0 fail
- `deno task test scripts/tests/repl-compose-router.test.ts
  scripts/tests/repl-compose-reconcile.test.ts
  scripts/tests/repl-study.test.ts scripts/tests/repl-focus.test.ts` β†’
  37 passed, 215 steps, 0 failed
- `deno task test scripts/tests/test-file-discovery.test.ts
  scripts/tests/runtime-exclusions.test.ts` β†’ 7 passed, 22 steps, 0 failed
- the six repository rule suites β†’ 7 passed, 62 steps, 0 failed
- `pnpm run lint` β†’ exit 0
- `deno task validate:docs` β†’ exit 0
- `git diff --check` β†’ exit 0

Break-it controls:

- permitting duplicate sibling keys fails 4 cases, including the named
  control
- not delivering new input to a retained branch fails the retained-input
  regression
- delivery that does not wait to be applied fails 8 cases across frames,
  retained input and teardown
- a departing receiver that does not release what a producer is waiting
  on fails the mid-delivery removal case with `Received: "stranded"`

That last control is why this commit adds a case the review's wording
required and the previous evidence did not reach. The first attempt at it
passed against the broken implementation, because every removal in the
suite happened between deliveries rather than during one. The new case
holds a frame in a branch, removes that branch while the producer is
still owed an answer, and bounds the wait so a deadlock fails in half a
second instead of hanging.

Named negative controls now: `duplicate-keys-permitted`,
`positional-only reconciliation`, `hidden-but-live drawer`, and
`parallel registry`.
Corrects the retained-input blocker the architecture review of
`885be7d8` found. Base `885be7d8`; nothing amended or rebased, and Slice
3 is not started.

Reproduced first. `const split: Description = { ...describe(Probe,
"probe", 2), input: 3 }` type-checked, because `Description` exposed
`input: unknown` beside closures over the input `describe()` was actually
called with. Reconciliation accepted it: the identity matched, but
identity only says who made the *original* description. One mounted
component then acted on 3 while it drew 2.

**The erased payload is gone.** A description carries no input at all.
Everything the input decides β€” children, presentation, `onPress`, and
what a retained branch is told next β€” is a closure over one captured
value made in one call. `Description` is now a class with a private
field, so nothing assembled from its parts is one, and there is no member
a spread could overwrite.

**The typed channel needs no cast, no bivariance and no table.** A
component carries its own `NodeDataKey<Handoff<Input>>`, minted when the
component is built with the new `component()` factory β€” metadata an
author declares about a value they own rather than an entry in a
collection somebody keeps. A branch stores its update channel on its own
node under that key, and the description's `update(node)` reads it back
as `node.data.get(component.updates)`, which the compiler already knows
is a `Handoff<Input>`. `InputSink.accept(input: unknown)` is deleted. The
reconciler holds no input of its own and could not substitute one.

Preserved: whole-tree duplicate-key preflight with no mutation on
refusal, key-and-component node retention, lifecycle input
acknowledgement before reconcile returns, acknowledged timestamp
delivery, mid-delivery teardown release, and complete structural branch
removal.

Verified at this commit:

- `deno task test scripts/tests/repl-compose-reconcile.test.ts` β†’ 1
  passed, 39 steps, 0 failed
- `npx tsx --test scripts/tests/repl-compose-reconcile.test.ts` β†’ 29
  pass, 0 fail
- `bun test scripts/tests/repl-compose-reconcile.test.ts` β†’ 29 pass,
  0 fail
- `deno task test scripts/tests/repl-compose-router.test.ts
  scripts/tests/repl-compose-reconcile.test.ts
  scripts/tests/repl-study.test.ts scripts/tests/repl-focus.test.ts` β†’
  37 passed, 219 steps, 0 failed
- discovery, exclusions and the four repository rule suites β†’ 11 passed,
  54 steps, 0 failed
- `pnpm run lint` β†’ exit 0
- `deno task validate:docs` β†’ exit 0
- `git diff --check` β†’ exit 0

New evidence:

- one reconcile moves the lifecycle, children, presentation and key
  handling from input 1 to input 2 together, while the node and its local
  counter survive
- `split-description` proves a description has no `input` member to
  replace, and that nothing assembled from its parts is a description
- `erased-payload` is the named negative control: it rebuilds the old
  shape locally β€” a writable payload beside closures, delivered through a
  bivariant `accept(unknown)` β€” and shows the lifecycle reading 3 while
  presentation still says `present:2`

Break-it controls:

- restoring `readonly input` on the description fails the
  `split-description` regression with `Expected: false, Received: true`
- a branch that does not keep its update channel fails both retained-input
  regressions

One earlier guard did not survive its own break-it round and was
replaced. A `@ts-expect-error` on the split construction still compiled
when the description was made structural again, because the spread was
failing on the absent methods rather than on the absent payload β€” so it
was not testing the claim it was written for. The claim is now checked
directly, and the break-it round above is what it answers to.
A handoff holds the set of live receivers and what each of them still
owes; a clock holds a handoff. Both were plain factories, so that state
belonged to whoever happened to hold the reference and nothing ended it.
They are resources now:

- `createHandoff()` β†’ `useHandoff()`
- `createFrameClock()` β†’ `useFrameClock()`

Teardown releases every producer still waiting on a receiver and clears
the receivers, so the state ends with the scope that asked for it rather
than outliving it and going on being counted.

A branch's update channel is acquired inside that branch's own lifecycle,
which runs in its node's scope β€” so the channel belongs to the branch and
goes when the branch does, instead of being a value the branch happens to
hold.

A component's `NodeDataKey` stays a plain mint in `component()`. It is
immutable metadata an author declares at module evaluation about a value
they own, which State ownership names as the one exception: not state,
and nothing to tear down.

Behaviour is unchanged β€” acknowledged delivery, mid-delivery release,
duplicate-key preflight, retained-input delivery and structural teardown
all read the same.

Verified at this commit:

- `deno task test scripts/tests/repl-compose-reconcile.test.ts
  scripts/tests/repl-compose-router.test.ts` β†’ 2 passed, 97 steps,
  0 failed
- `npx tsx --test scripts/tests/repl-compose-reconcile.test.ts` β†’ 31
  pass, 0 fail
- `bun test scripts/tests/repl-compose-reconcile.test.ts` β†’ 31 pass,
  0 fail
- `deno task test scripts/tests/repl-study.test.ts
  scripts/tests/repl-focus.test.ts
  scripts/tests/no-module-scoped-registry.test.ts
  scripts/tests/no-yield-in-finally.test.ts
  scripts/tests/scope-bound-event-registration.test.ts` β†’ 39 passed,
  157 steps, 0 failed
- `pnpm run lint` β†’ exit 0
- `deno task validate:docs` β†’ exit 0
- `git diff --check` β†’ exit 0

New evidence: a handoff acquired in one scope, with a receiver acquired
in another, reports zero demand once the owning scope is halted; the same
for a clock's demand.

Break-it control: leaving the receivers in place at teardown fails that
regression with `Expected: 0, Received: 1`.
Slice 3 of the replacement #840 experiment, on `a1f75080`. It joins the
two halves the accepted slices built: a resolved location decides the
keyed descriptions, reconciliation mounts them, and layout, rendering,
focus, input and teardown all read that one tree.

New, in `scripts/repl-compose/`:

- `screen.ts` β€” a resolved location described as an interface, and the
  refusal when it is not one
- `render.ts` β€” two renderers, so the seam is a seam rather than an
  implementation with a hopeful name
- `host.ts` β€” the terminal, viewport, frames, raw input and renderer
  choice, and none of their names
- `trace.ts` β€” one location followed through every layer
- `main.ts` β€” the documented command, plus `deno task repl:compose`
- `RESULT.md` β€” retain, revise, discard, and production sequencing

**A refusal is the whole screen.** When the location does not resolve the
description is the refusal and nothing else, so reconciliation removes
what the last location mounted. There is no half-resolved interface
behind it holding focus, input or frames, and that falls out of
describing rather than being arranged.

**Layout presents; it never decides existence.** The viewport reaches
components through their input and decides how a parent arranges what its
children drew. One location composed at two viewports gives the same
topology and the same focus order, with different bytes β€” and every
branch survives the resize with its animation where it was.

**Keyboard and pointer are the same activation.** Both are normalized at
the host into one value before anything is dispatched, so a control
cannot tell a click from a keypress and the equivalence is not a property
anything maintains.

**The renderer is replaceable.** Swapping it changes the bytes and leaves
the resolved location, the Freedom topology, the focus order and the
component-local animation exactly where they were β€” then keeps animating
on the same branches.

**The host names nothing it shows.** A source-level control reads
`host.ts` with its comments stripped and fails on any route segment,
surface, drawer kind or component name, and on importing the history, the
model or the router.

One correction to Slice 2 came out of this work. `handoff.ts` released a
producer on the call that *fetched* a queued value rather than when the
value was applied, because the release lived on the receiver instead of
travelling with the value. Unreachable while every receiver was always
waiting; reachable as soon as one was not. The release is now a property
of the parcel.

Verified at this commit:

- `deno task test` over the four compose suites β†’ 4 passed, 125 steps,
  0 failed
- `npx tsx --test` over the three portable compose suites β†’ 95 pass,
  0 fail
- `bun test` over the same three β†’ 95 pass, 0 fail
- `deno task test scripts/tests/repl-study.test.ts
  scripts/tests/repl-focus.test.ts` β†’ 35 passed, 125 steps, 0 failed
- discovery, exclusions and six rule suites β†’ 14 passed, 84 steps,
  0 failed
- `pnpm run lint` β†’ exit 0
- `deno task validate:docs` β†’ exit 0
- `git diff --check` β†’ exit 0

`scripts/tests/repl-compose-command.test.ts` runs the documented command
as a `deno` child, so it takes a runtime exclusion with its reason and
issue; the composition itself stays portable in
`repl-compose-screen.test.ts`.

Break-it controls:

- layout deciding existence β€” dropping surfaces at narrow width β€” fails
  the two-viewport case
- a refusal that keeps the last good screen behind it fails the refusal
  case
- a pointer normalizing to a different key fails both activation cases
- a host that does not re-derive focus after the tree changes fails the
  focus case
- a queued value acknowledged on fetch fails the new handoff case

The focus control is why this commit adds a case Slice 3 first lacked.
The first attempt at it passed against the broken host, because nothing
asserted where focus landed after a branch was removed.

Test weights were not remeasured; the two new files are charged the
heaviest recorded weight until a runner measures them.
…nt at

Corrects the two Slice 3 boundaries the architecture review of
`3ec939f5` found. Base `3ec939f5`; nothing amended or rebased, and no
production work or further slice is started.

**Every surface a route can name is now a focus-owning branch.** The
workbench describes all five β€” `sessions`, `transcript`, `bindings`,
`input`, `history` β€” and the transcript surface is where the entry lives,
so the surface segment of a URL names something that exists.

**A description says whether it is the branch the location is asking
for.** `claimsFocus(input)` is a property of the input rather than of the
component, because which surface a URL names changes while the component
does not. `compose()` puts focus on the innermost claiming branch, which
is how an opening drawer takes focus from the surface underneath it and
closing it gives focus back β€” with nothing outside `screen.ts` saying the
word "drawer" or "surface". Freedom remains the only focus owner, and the
host now owns no focus policy at all.

Focus moves only when nothing holds it, or when whatever held it is no
longer inside the branch being asked for. An ordinary reconcile does not
take focus away from whoever was using it.

**A pointer names what it was on.** `RawInput` carries an opaque node
identity that survives normalization beside the keypress rather than
inside it, so the tree is still handed a value with no trace of how it
arrived. The host resolves that identity against the live tree: a
focusable target takes focus and is then dispatched to exactly as a
keypress there would have been; a removed, container, never-focusable or
absent one takes no focus and receives no input.

"Disabled" is now a real case rather than an arranged one. Only the top
drawer is interactive, so the controls on a drawer beneath it are a
different component that was never made focusable β€” which is also why
closing the drawer above replaces those nodes rather than handing them a
focusable control's input.

**The trace no longer arranges its own focus.** It reports where the URL
reconstructed focus to, then reaches a control through one explicit
generic interaction β€” a pointer on it, resolved by the host like any
other β€” and shows that the keyboard at that same node answers identically.
The manual `focus()` call is gone.

**RESULT.md is amended.** Input normalization is retained *with* targeted
equivalence, URL-to-Freedom focus reconstruction is recorded as part of
the retained composition contract, and the remaining limit is stated
accurately: resolving a target identity is proven, turning a screen
position into one is not.

Preserved: the router, opaque descriptions, keyed Freedom reconciliation,
structural teardown, acknowledged handoff and frame delivery,
refusal-as-whole-screen, viewport-independent topology, and the
replaceable renderer.

Verified at this commit:

- `deno task test` over the four compose suites β†’ 4 passed, 132 steps,
  0 failed
- `npx tsx --test` over the three portable suites β†’ 101 pass, 0 fail
- `bun test` over the same three β†’ 101 pass, 0 fail
- `deno task test scripts/tests/repl-study.test.ts
  scripts/tests/repl-focus.test.ts` β†’ 35 passed, 125 steps, 0 failed
- discovery, exclusions and six rule suites β†’ 14 passed, 84 steps,
  0 failed
- `pnpm run lint` β†’ exit 0
- `deno task validate:docs` β†’ exit 0
- `git diff --check` β†’ exit 0

New evidence:

- cold reconstruction into a fresh root for all five route surfaces,
  including `transcript` and `input`
- the same URL in a fresh root reconstructing the same focus identity on
  a different node
- focus following the drawer the location opened, and returning when it
  closes
- a pointer activating a control other than the one holding focus, with
  the keyboard at that node then answering identically
- removed, disabled, container and absent targets taking no focus and
  receiving no input

Break-it controls:

- focus that ignores the claim fails the cold-reconstruction and
  drawer-focus cases
- a pointer target that need not be focusable fails the
  removed/disabled/container/absent case
- a pointer that does not move focus fails the targeted-activation case
  and the no-stealing case
- a drawer that asks for nothing fails the drawer-focus cases
Corrects focus ownership after the architecture review of `52198427`.
Base `52198427`; nothing amended or rebased.

**Arbitration is structural.** `claimsFocus` now answers `"none"`,
`"here"` or `"alone"`, and claims are required to lie on one ancestry: a
claim inside a claiming branch is the same place deeper and the deeper
one wins, while two claims in unrelated subtrees name two places at once
and are refused. That refusal happens in the same preflight that checks
key uniqueness β€” over the whole description tree, before a single node is
created, removed, updated or focused β€” so an ambiguous location changes
nothing at all.

The active branch is then found by descending, not by flattening. At each
level at most one subtree can hold a claim, which is what the preflight
guarantees, so no sibling's position is part of the answer. The previous
version flattened every claimant in tree order and took the last, which
made moving an unrelated sibling move focus.

**An open drawer owns interaction, and says so itself.** Its claim is
`"alone"`, so nothing outside it can be reached: the drawer beneath it
and the surfaces behind it stay mounted, keep drawing and keep their
lifecycles, and none of them is a focus or pointer target β€” including the
covered drawer node itself. Input still travels their scopes, so `Escape`
at a top-drawer control still bubbles out through the live ancestry.

A surface claims `"here"` instead, which says where focus starts without
trapping traversal inside one region β€” moving focus across a region
boundary is what moves the URL, so trapping there would be a different
product. Closing the top drawer makes the one beneath it active; closing
the last gives the route-named surface focus and makes the others
reachable again.

**`InertControl` is gone.** Replacing a covered drawer's controls with
non-focusable components was a second mechanism for what the focus
boundary already does, and it left the covered drawer node itself
pointer-focusable. There is one mechanism now.

Preserved: URL reconstruction, live pointer-target validation, targeted
keyboard/pointer equivalence, keyed reconciliation, lifecycle retention,
acknowledged delivery, the renderer seam and the refusal screen.

README and RESULT.md now say ancestry where they said traversal order.

Verified at this commit:

- `deno task test` over the four compose suites β†’ 4 passed, 140 steps,
  0 failed
- `npx tsx --test` over the three portable suites β†’ 107 pass, 0 fail
- `bun test` over the same three β†’ 107 pass, 0 fail
- `deno task test scripts/tests/repl-study.test.ts
  scripts/tests/repl-focus.test.ts` β†’ 35 passed, 125 steps, 0 failed
- discovery, exclusions and six rule suites β†’ 14 passed, 84 steps,
  0 failed
- `pnpm run lint` β†’ exit 0
- `deno task validate:docs` β†’ exit 0
- `git diff --check` β†’ exit 0

New evidence:

- reordering siblings that are not asking leaves reconstructed focus
  unchanged, in three orders
- a claim inside a claim resolves to the deeper one, and only what is
  inside it is reachable
- two claims in unrelated subtrees are refused with `AmbiguousFocus`, and
  the mounted topology and the focused node are both exactly what they
  were
- with `+project/+confirm`, traversal is `confirm`, `confirm.answer`,
  `confirm.back` and nothing else, while the covered drawer and the
  surfaces stay mounted
- a pointer aimed at the covered drawer, a covered control, a background
  surface, a container or nothing moves no focus and emits no action
- `Escape` at a top-drawer control still bubbles through the covered
  ancestry
- closing `confirm` activates `project`; closing `project` returns focus
  to the route-named surface and makes the others reachable
- cold reconstruction for all five surfaces and targeted
  keyboard/pointer equivalence are unchanged

Break-it controls, and one that had to be redone:

- arbitration that takes the shallowest claim fails the nested-claim case
- removing the ambiguity preflight fails the refusal case
- a claim that never excludes anything fails four cases across the
  interaction boundary and the pointer targets

The first attempt at the arbitration control β€” restoring flatten-and-take-last β€”
**passed**, and that is worth stating rather than hiding: once disjoint claims
are refused, flattening and descending agree on every representable input.
Order-independence is guaranteed by the refusal, and the control that bites the
descent is the one that picks the shallowest claim instead of the deepest.
@taras taras changed the title πŸ§ͺ Render the REPL interaction study in a real terminal, with one location and a derived focus model (#838, #839) πŸ§ͺ POC stack: render the REPL study, address it as a URL, and compose it from keyed components (#838, #839, #840) Sep 25, 2026
…annot reach

#841's first slice asks whether middleware around an XMD-owned execution Api
can stop every descendant continuation while the owner and siblings stay
responsive. It can stop every continuation that *is* an invocation of that Api,
and nothing else β€” so pause lands between mediated steps, not at an arbitrary
point in a running continuation.

One `Scope.around()` on the target execution scope gives inheritance by
dispatch rather than by bookkeeping: a grandchild resolves the decorated handle
and the independent sibling running the identical loop resolves the core. The
controller is acquired in the session scope, outside the subtree it holds.
`pausing` is entered synchronously and `paused` is an acknowledged handshake
that settles only when nothing live is unheld, with the live set read from
`api.Scope` creation and destruction. That check fails closed, which is why a
held subtree cannot append history: `paused` is unreachable while any
descendant sits between the two checkpoints.

The boundary is the finding. A child advancing with ordinary Effection β€” what
any component does between two journaled steps β€” is dispatched through the
middleware exactly once, when it is forked, and never returns. Being mediated at
creation is not being pausable, and the controller then reports `pausing`
forever and names the child rather than mistaking it for suspended.

External work invoked through the Api is held on return: the test settles a
promise from outside Effection while the controller is pausing, and the
continuation past it has still not run once `paused` is reported. The external
system is never frozen. The cost is that a pause cannot settle until the call
returns.

`missing-seam.ts` records what closing the gap would take. Every continuation
advances through `reducer.schedule()` on the reducer its scope resolved at
creation, and substituting that for one scope freezes three raw sleep loops
while a sibling advances. It is unreachable: `api.Main` is gone in stable
4.1.0, `api.Scope.create` returns a tuple so middleware over it cannot suspend,
and the package exports map hides the reducer. The only route left β€” rebuilding
`@effection/reducer` by name β€” fails open, so it is reported rather than
proposed: section 4 writes a near-miss name and the subtree advances while the
gate believes it holds everything.

Three deliberate defects were run against the six cases: removing the decoration
fails all six, a vacuous completeness check fails five, and a double release
fails only the exactly-once case. The first run of the first control caught a
real weakness β€” the release case passed by releasing nothing zero times β€” which
now asserts three continuations were held first.

Disposable POC under scripts/repl-pause/ on a branch that never merges. The
later lifecycle matrix and RESULT.md are deferred to after architecture review.
@taras

taras commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Architecture amendment for #841 after Slice 1:

  • Do not add Execution.advance or any other public/core XMD pause API.
  • The REPL installs the middleware before starting its execution and owns the controller for that execution's lifetime.
  • In playing, the middleware delegates immediately but continues accounting for live branches and boundary entry/exit.
  • Pause changes to pausing synchronously. Calls already inside a semantic operation may finish or reach their next existing boundary; newly arriving calls wait.
  • paused is valid only once every live descendant has settled or is retained at a controlled boundary. No journal append or continuation advance may occur after that report.
  • The POC must use existing execution, Component and REPL-owned expansion surfaces. A legitimate path that bypasses them is a finding about the missing Effection seam, not grounds to invent a new XMD protocol.
  • The controller and retained continuations are ephemeral REPL state and never journaled.

The durable contract and acceptance matrix are now updated in #841. Continue the experiment from af0ce6d4; do not rewrite the accepted Slice 1 result.

…e what that misses

Slice 2 replaces Slice 1's invented Api with the surfaces XMD already owns, runs a
real `executeInstalled` document, and adds no core pause API. Slice 1's files and
result are untouched.

Verdict: REVISE. The existing surfaces are safe but leave named legitimate paths
permanently `pausing`.

The behaviour all holds. Pass-through is exact β€” same output and same journal as
the no-middleware control. The document really does come to rest at a boundary,
its journal is fixed across thirty of a sibling's announced advances, and
Continue releases the same continuation exactly once and reaches the expected
result with no record written twice. Work already inside a semantic operation
finishes and the walk stops at its next existing boundary, which is what the
amended contract asks for.

What the REPL cannot do is certify it. Measured on a real execution: twelve live
descendant scopes, one held, and only two that ever cross a surface a REPL can
reach. The other ten are engine-owned and live for the run's whole lifetime, so
the fail-closed controller stays in `pausing` and names them. It is not REJECT β€”
it never reports `paused` while work can advance, because it never reports
`paused` at all. That makes the "nothing advances after paused" clause vacuous
here, so nothing asserts it.

The coverage inventory is measured, not read off the declarations. Eight surfaces
are crossed: importComponent, applyModifiers, applyBoundModifiers, codeBlock,
content, document, the REPL's own expand handler, and a REPL checkpoint per
region chunk. Every one can hold, because each is an operation the REPL wraps.
Three paths cross nothing β€” prose and core structural syntax, a component's own
body, and its spawned descendants β€” and `Execution.document` never exits until
the run is over, so it anchors the subtree and can never be a resting place.
Journal appends land exactly on the crossed boundaries, which is why history
fixity is reachable even though the walk is not fully gated.

External work is never frozen, may finish while pausing, and its continuation is
stopped one boundary later than where it completed β€” at the walk's next
controlled surface, not at the call site.

Surfaces used honestly: Component/Execution middleware at the default `max`,
since `min` is the implementation slot the runtime providers occupy; the REPL's
own captured `expand` handler, decorated as REPL-owned code and never described
as Api middleware; and stable `api.Scope` for accounting only, whose `create` is
synchronous and could not suspend anything. Canonical component identity is
untouched.

Four break-it controls: vacuous accounting fails 5 of 7 and is the REJECT
detector; installing no boundaries fails 6 of 7; a double release fails only the
exactly-once case; a middleware that stops delegating fails pass-through, which
is what makes the comparison against the control real. Two named controls stay in
the suite β€” a descendant that bypasses every surface, and no middleware at all.

13 cases green on Deno, Node and Bun. Disposable POC on a branch that never
merges; the later lifecycle matrix and RESULT.md remain deferred.
The deferred matrix, plus `RESULT.md`. Decision: REVISE β€” retain the design,
change what it is allowed to claim. A REPL can stop a document; it cannot prove
it stopped, and `paused` must not be a reported state until Effection publishes
per-scope scheduling control or a quiescence query.

Ten new rows, run against the real execution. The rest state for a real document
is `pausing`, so each row is proven at the rest point the design reaches, and the
two rows that specifically require `paused` are proven on Slice 1's synthetic
gate with the evidence saying which fixture proved which. Relabelling `pausing`
as `paused` would have made the matrix meaningless.

Closes the reviewer's named gap: an external operation completing while
`pausing`, through the real document. Its continuation runs β€” there is no surface
at the await β€” and is stopped one boundary later, with the next element's body
never entered.

Every terminal path was taken with a continuation actually held, and each
released what the document owned, watched through `Component.retain` because
cleanup has to be observed at a real XMD surface. Interruption and owner shutdown
both settle as `halted` with the gate's release counter at **zero**: a held
continuation is suspended inside `action()`, whose discard runs on unwind, so
teardown unwinds through the hold rather than releasing it and letting ordinary
work resume. The element after the hold never runs and the journal does not move.
A failure during coordination is retained like anything else and reaches the
owner on Continue β€” it tears the execution scope down immediately rather than
being swallowed or deferred.

Three break-it controls for the matrix: holds that never hold fail 5 of 10 (the
survivors are not hold-claims); a resource never released fails all 5 cleanup
rows; a release counted on unwind fails exactly the three "released zero / once"
rows.

Two honest corrections recorded rather than hidden. Adding the retained resource
first broke Slice 1's live-scope count, because a `resource()` body is its own
task; rather than edit Slice 1's assertions to fit, the instrumentation moved so
its numbers stayed true. And the enriched document means Slice 2's measurement is
now 2 of 15 crossing scopes rather than the 2 of 12 its own commit reported, with
the conclusion unchanged β€” the README and RESULT.md both say so.

No assertion was deleted anywhere; the diff is additive, and Slice 1's fixture
only gained a `shutdown()` accessor whose tuple read creates no scope. No core
API, no production package, no canonical component identity, no private Effection
scheduler.

23 cases green on Deno, Node and Bun. Disposable POC on a branch that never
merges.
…a real run

The architectural correction: the completeness boundary was wrong. Asking whether
every descendant Effection scope was suspended is not the question β€” the REPL
pauses XMD *expansion*, and Effection is the runtime that keeps running. With the
obligation set changed to expansion walks, a real `executeInstalled` document
reaches `paused`.

Verdict: RETAIN. The dependency on a future Effection scheduler-decoration or
quiescence API is removed; nothing here needs it.

An active expansion walk is one bracket instance. Four existing operations delimit
one: `Execution.document`, `Component.content`, `Component.tryContent`, and the
REPL's own captured `expand` handler including each region it expands. Everything
else the middleware wraps is a step gate inside a walk. Walk identity travels on a
REPL-owned context for the duration of the bracket, so the root walk β€” which
crosses gates in nineteen different coroutines, measured strictly sequential β€” is
one obligation rather than nineteen.

paused holds when at least one walk is active and every active walk is satisfied,
where satisfied means it holds a continuation or has active children, AND every
active child is satisfied. Both clauses came from a failing case: without
delegation, a parent suspended inside next() while its children are held blocks
forever, which is what two concurrent region walks do to document and expand;
without "every child", a parent held at its own gate reports paused while a
concurrent child is still expanding. Delegation is recorded, not inferred β€” a
bracket reads the enclosing walk id before publishing its own.

Ordinary Effection scopes and tasks are not obligations. api.Scope stays as a
diagnostic that decides nothing.

The mode is EXPANSION PAUSED, never "paused at head". Measured on a real run: 25
live Effection scopes, ordinary component children advancing 80 -> 140, and the
durable Journal head moving 7 -> 8 when background work recorded its outcome β€”
with expansion stopped throughout and the same continuation held in the same
place. Continue released it once and the background record appears exactly once.
Gating DocumentOutput.output is what covers prose, so "the next element or output
does not appear until Continue" is a claim about output too.

Six break-it controls. Restoring descendant-scope completeness fails 9 of 10 rows
and leaves the real execution stuck in pausing β€” the error this correction
removes. Reporting paused early fails 7. Suppressing appends while paused, and
recording an outcome twice, each fail the background row. Letting an expansion
path bypass the gate fails the concurrent-walk and exactly-once rows. Releasing
holds on unwind fails exactly the three release/unwind rows. Plus the required
positive control, permanently in the suite: ordinary Effection children keep
running through the paused interval and do not fail expansion completeness.

Two measurement errors in my own earlier evidence, found and fixed rather than
left standing. Earlier slices timed a paused interval by consuming advances from a
subscription taken much earlier; the signal buffers from subscription time, so
those reads drained a backlog in ~0 ms and the interval measured nothing β€” which
is what first made ordinary component children look as though they had stopped.
Every interval now starts from a fresh subscription. And the no-replay assertion
sampled the journal once; duplicates are now counted at append time.

Slice 1's synthetic gate is byte-identical and stays as focused unit evidence.
missing-seam.ts is kept only as a record of what Effection publishes; the design
does not depend on it. No core XMD API, no production package, no canonical
component identity, no private reducer, deep import, scheduler substitution or
runtime-name reconstruction.

16 cases green on Deno, Node and Bun. Disposable POC on a branch that never
merges.
…ng else

Slice 1 of #842: the pure boundary, before StarFX exists. A closed semantic
vocabulary parsed out of untrusted durable records, one deeply immutable model
projected from one Journal prefix, and #840's URL grammar resolved against
that prefix β€” with a narrowed Result wherever the input cannot describe a run
that happened.

The claims that matter are the negative ones. A historical prefix is folded
from records rather than filtered out of the head, so a later binding, drawer,
outcome or settlement is never applied and has nothing to leak through.
`projectPrefix()` takes records and a marker and has no parameter a cached
snapshot would fit, so discarding the accumulating fold changes no answer. The
event vocabulary is closed and each kind declares its fields, so a record
naming the pause controller, or carrying a continuation beside its own data,
is refused instead of read. Which marker expansion is held at lives only in
`overlay.ts`, which nothing reading durable state imports.

The expansion pause point and the live History head are two positions (#841):
the fixture holds expansion at r-22 and appends a background outcome at r-23,
and selecting each answers 22 and 23 records.

There is one REPL URL grammar. `decodeRoute()` and `encodeRoute()` come from
#840's router unchanged; only resolution is adapted, here rather than there,
because #840 resolves against a table of every checkpoint and that table is
the accelerator #842 must not need. `repl-compose` is untouched.

Nine negative controls are named in the suite, each a weaker implementation
that accepts what this refuses: open-vocabulary, skip-malformed, leaky-prefix,
pause-truncates-head, append-order-siblings, decorated-model,
snapshot-dependent, permissive-ownership and permissive-closure.

This is an experiment and does not merge.
Corrects Slice 1 of #842 against the durable contract the issue now records.
Reviewed at 54a595d; this stacks on it.

`abandoned` is gone. There are four lifecycle statuses and no fifth, and a
scope that was still open when its entry ended is `interrupted` β€” never
`settled`, which would claim an outcome the execution never reached, and never
independently `failed`, which would invent one failure per scope out of the
single ending the Journal recorded. The entry carries what its terminal record
said; each still-running descendant carries the same reason; a scope that
completed earlier stays completed; open waits close; published bindings are
untouched.

`entry.interrupted` joins the vocabulary, which is now ten kinds, and the
marker policy is a value rather than a convention. Every record mints a marker
except the two closing kinds, and each marker carries its weight: a submission
is a major entry boundary, a settlement, failure or interruption is terminal,
a scope or suspension opening is an opening, and a published binding or
recorded outcome is a small checkpoint. `scope.completed` and
`suspension.answered` mint nothing and update what the opening already minted.
The representative journal is 23 records and 20 markers.

An entry's end is a place to stand, so each terminal state is directly
navigable through the #840 grammar. A second minimal fixture carries that
subject β€” three entries, one settled, one failed with a completed scope and a
surviving binding, one interrupted while waiting β€” so the representative
pause/head journal keeps its own shape.

Four controls join the nine: restore-abandoned, omit-terminal-kinds,
closing-marker and interrupt-completed-scope. The future-leakage,
snapshot-independence, ownership and malformed-Journal controls are unchanged
and still discriminating.

This is an experiment and does not merge.
… nothing

Slice 2 of #842, on the accepted Slice 1 at aee0273.

This is `starfx@0.16.1` itself β€” `createSchema`, `createStore`, `slice` β€” added
through the frozen-lock procedure: resolution updated explicitly, lock
committed, `deno task setup` run. It depends on `effection: ^4`, so it sits
beside this repository's Effection rather than beside a second copy, its root
export is React-free, and it runs under Deno, Node and Bun with no production
package touched.

The store holds `execution`, `url`, `records`, `model`, `history`, `location`
and `snapshots`, and takes the caller's Effection scope through `useScope()` so
its lifetime is the session's. Every transition re-derives from the records and
the URL rather than patching, because a patch would be a second way to arrive
at a state and the claim is that there is one.

Snapshots accelerate and never testify. Only a marker prefix is memoized β€” a
prefix that ends at a record cannot change β€” and the live head, the one prefix
that grows, never is. A new store starts with an empty cache and cannot be
handed a populated one, so a snapshot cannot outlive the process that derived
it.

The journey #842 names runs live against a cold rebuild at every step, and the
two are deeply equal at all nine: empty, a first entry, nested scopes, a
binding, the expansion pause marker, a background append while expansion is
held, historical inspection, the live head, and back. While expansion is held
at r-22 and the Journal advances to r-23, the selected model does not move, the
History gains one future marker, and `remote tags fetched` appears in neither.

Continue belongs to the live process. It is offered at the pause marker while
the continuation is held, not at the live head, not once released, and not
after a restart β€” and the reconstruction is identical in every one of those
cases. A viewport is presentation: two terminals give 27 and 34 lines of the
same topology, and the state holds no escape byte, cell or frame.

Four controls join the thirteen: persisted-snapshots, head-memoized,
layout-in-the-store and overlay-in-the-store.

`resolveIn()` is split out of `resolveLocation()` so an accelerated answer and
a cold one finish in the same function. Slice 1's evidence is unchanged and
green.

This is an experiment and does not merge.
Slice 3 of #842, on the accepted Slice 2 at 773fa23.

A live run and a replay are one function. `resume()` walks the document's
steps beside the Journal in append order and each step either consumes the
records it already produced or performs itself for the first time; a consumed
step never reaches the performer, which is the whole no-repeat claim. Where it
stops is the replay frontier, and that is not Continue: Continue resolves a
suspended routine in a process that still exists (#841), and this rebuilds a
position from what was written down.

Two elicitations differ on the way there. An ordinary answer is in the record,
so replay recovers it and asks nobody. A secret answer is not in the record and
never was, so replay knows only that it was asked and asks again; with nobody
to ask, that is a frontier of its own. `suspension.opened` now carries `secret`
and `suspension.answered` carries `answer`, which is what makes those two cases
distinguishable at all, and the projection refuses an answer recorded for a
secret wait β€” a leak cannot be written down and then merely left unread. The
ten kinds and the marker policy are untouched.

An Agent's chunks go to `ephemeral.ts` and are dropped at admission, so a cold
restart shows the admitted result and the semantic scope it created and no
partial text β€” because none was produced, not because it was hidden. The
secret seam lives there too and remembers only which waits were asked.

Typing moves the URL and nothing else: no record is appended, and the ordinary
navigation history is replaced in place rather than grown, so three keystrokes
are one place. That history is process-local, like the snapshot cache, and
lives beside the store rather than in it.

The trace performs two durable effects on the first run and none on the
replay, recovers `channel`, re-prompts for `token`, reaches `complete` with a
person and `unrevealed` without one, and finds the secret in no journal, store,
navigation stack, audit list or run report.

Four controls join the seventeen: replay-reperforms, recoverable-secret,
streamed-into-the-record and draft-as-a-visit. The import evidence now holds
`ephemeral.ts` out of everything that reads a record, beside `overlay.ts`.

This is an experiment and does not merge.
Corrects Slice 3 at 65058ed. Replay matched on SemanticKind alone, so any
record of the right kind stood in for the step at the cursor. Reproduced
against the reviewed commit: a journal whose entry.submitted named
other-entry was accepted and extended as entry-1, and replacing
binding.published notes with binding.published other still suppressed the
publish notes effect and reported it consumed.

Every replayable occurrence now has an identity β€” operation, owning entry,
owning scope, and the durable name of the occurrence there β€” and the identity
is separate from the result: replay matches the request and restores what came
back. `outcome.recorded` carries `request` beside `label`, because an outcome
recognized by its own result could only be recognized by a replay that already
knew the answer.

Alignment runs first and completely. The prior Journal is parsed, projected
and walked against the whole script before any effect runs, so a divergence
performs nothing, appends nothing and leaves the supplied Journal untouched. A
retained record still unclaimed once the document has finished is a divergence
too: it describes work this document does not do. Ordinary answers and durable
results are read from the matched record and from no other position.

Secret re-prompting, drafts, process loss, the frontier and the no-repeat
behavior are unchanged. Agent admission stays at the producer: no record kind
was added to guess whether admitted text looks finished, and the
streamed-into-the-record control now says exactly what identity does and does
not catch β€” a chunk under a foreign request is turned away, one under the
right request is not, which is why admission discards the buffer.

Seven regressions: a wrong submitted entry, a wrong binding, a wrong Agent
occurrence, a wrong suspension, a valid extra record after the document
finished, a compatible prefix performing its first unrecorded effect exactly
once, and an exact replay performing nothing. `kind-only-replay` is the named
control and fails all four identity and terminal-alignment cases.

This is an experiment and does not merge.
Corrects Slice 3 at af6638c. Alignment and divergence were right; restoration
was not. Reproduced against the reviewed commit: take the valid prefix through
outcome.recorded, change only that record's label to RESTORED AGENT RESULT and
resume β€” the Agent is correctly consumed rather than performed, and the
binding it feeds is still published with the script's own literal.

Consuming is not merely declining to perform. The value the live performer
would have produced has to arrive where the performer would have put it, or
the document carries on with whatever its source happened to say and the
replay only looked correct. An entry in `consumed` or `recovered` is an audit
line, not a restoration.

The representative execution is now data-dependent. Every producing step names
what it puts into execution state β€” the Agent step `produces`, an elicitation
`produces` β€” and every consuming step derives from that state: `publish` names
a value with `from` and no longer carries one. The live performer's result and
the matched record's are written to the same map by the same step, so there is
one continuation path and not two. A step whose value nothing produced is an
`ExecutionGap` refusal rather than a silent empty string.

The ordinary answer is data-dependent too: `channel` lands in execution state
and the step after it publishes what the person said, so a recovery that
reached only the report array would show up as a missing binding.

Request identity stays separate from the recorded result, and no effect runs
before full alignment succeeds β€” both unchanged. Agent admission stays
producer-owned; no record kind was added to infer whether recorded text looks
complete.

The new regression alters only the recorded Agent result and proves the Agent
is consumed, `publish notes` is performed exactly once, its appended value is
the altered result, resuming the outcome performs nothing, and a cold
projection shows the altered outcome and the binding derived from it.
`discarded-result` is the named control: it computes what a replay that
recognized the record and then read the script's literal would have published,
and shows the script keeps no such literal to read.

This is an experiment and does not merge.
Slice 4 of #842, on the accepted Slice 3 at b389862. RETAIN.

A fork owns a new Journal whose first record is `entry.inherited`: it submits
an entry, publishes into *this* Journal the environment the parent had at the
source marker, and names the parent and that marker. Nothing points outward
for a value, so removing the parent costs the link and nothing else β€” the fork
still reconstructs, still says where it came from, and merely has nowhere to
send someone who follows it.

Resolving provenance is a separate function over whatever journals the process
can reach, and never part of hydration: a hydration that insisted on a
resolvable link would have made the parent a dependency, which is the thing
being disproved. An unreadable or short parent makes the link unavailable
rather than making the fork a failure.

`entry.inherited` is the one kind Slice 4 added, weighted `boundary` because
it is where a fork's transcript begins. It is the eleventh; the rest of the
policy is unchanged. `source` had to become a per-kind field type β€” an ordinal
on a scope opening, a marker on an inherited entry β€” because one table of
field names could not tell those apart.

The refusal matrix is now complete across every layer that reads untrusted
input: the parser, the projection, the URL codec, the resolver and hydration
itself, which builds no half-session and leaves a live session's state intact
when a move is refused. Two controls carry it: `plausible-partial`, the
readable prefix of a damaged journal describing a binding that was never
published, and `marker-without-a-record`, a prefix ending where no marker was
minted.

RESULT.md is the conclusion: the eleven-kind vocabulary with its marker
policy, the URL schema and the one adaptation made to #840's resolver, the
hydration boundary and what sits outside it, the snapshot policy, the three
rules that make the replay frontier sound β€” each found by a defect β€” the fork
result, twenty-seven named controls, what the evidence does not cover, and the
production sequencing.

This is an experiment and does not merge.
Corrects Slice 4 at ff4cf10. The inherited environment was spread over the
`entry.inherited` record and a run of ordinary `binding.published` records, so
a prefix ending in the middle of the copy hydrated into an environment that
existed in neither execution. Reproduced against the reviewed commit: the
one-record prefix of the fork projected to an empty environment, and the
two-record prefix to half of one.

`entry.inherited` now carries the whole ordered environment as
`bindings: [{ name, value }, …]`, and the projection publishes it from that
record. One record cannot be half-read, so there is no prefix of a fork that
has the entry and only some of what it carried over. `entry.settled` stays
separate: settling is a later thing that happened, not part of the payload.
A prefix holding only the inherited record shows an unsettled synthetic entry
with the complete environment, and needs no parent to settle or continue.

`fork.ts` is the one place a fork reads its parent: `inherit()` projects the
parent at exactly the source marker and writes what it finds into the
payload. The fixture's first record is built by it rather than transcribed,
so "the parent's later bindings are absent" is a fact about the projection
and not about what somebody typed. A marker the parent never minted refuses.

The parser reads the environment as an ordered list of name-and-value
members: a member that is not a binding, has no name, has no text value,
carries anything else, or repeats a name already in the list is refused, and
an empty environment is accepted because a fork of an execution that had
published nothing is a real thing.

Provenance resolution now also projects the parent at the source marker, so a
parent that is missing, short, malformed or inconsistent *there* dims the
link and nothing else β€” while an inconsistency the parent only reaches later
leaves it resolvable. Hydration still never asks.

`multi-record-inheritance` is the named control: the previous representation,
hydrating happily after copying one of its two bindings, beside the atomic
one that has no such prefix.

RESULT.md records the exact fields, that inheritance is self-contained in the
first record, that the parent is needed only while that record is created,
and atomic inheritance in the production sequencing. Slices 1-3 and the
refusal matrix are unchanged.

This is an experiment and does not merge.
@taras taras changed the title πŸ§ͺ POC stack: render the REPL study, address it as a URL, and compose it from keyed components (#838, #839, #840) πŸ§ͺ POC stack: render, route, compose, pause, and reconstruct the XMD REPL (#838–#842) Sep 26, 2026
@taras

taras commented Sep 26, 2026

Copy link
Copy Markdown
Owner Author

The #838–#842 experiment stack is complete. All five experiments concluded RETAIN, their durable conclusions are recorded on the issues and quest #827, and production begins afresh from main. Closing this disposable draft without merging or deleting its branch so the audit trail remains available.

@taras taras closed this Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant