Conversation
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.
|
Both findings are addressed at AnimationYou were right that nothing would have moved. 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 Both kinds of motion are there:
A playback holds no state that outlives it: its phase and elapsed time live in One finding worth flagging: some interpolated frames emit zero bytes. The five pieces of evidence you asked for
The drawer also cannot cover the history footer while it is moving, which the Three new controls: Notch heightCorrected: height is scope depth and nothing else. Depth 0 fills the band, depth That needed one more row than the study's four: four depths plus a label row Verification
|
`@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.
|
Correct, and thank you for the spec link β I verified it before changing anything. The unitThe boundary converts once. 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.2and 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 The clock claimAlso corrected. The renderer does measure time: with no
VerificationA live pty run still reports 41 frames with 17 animating, now with Noted on the five-row band β it stays, and |
`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.
|
Done at One command, the whole storydeno task repl:study --playempty β 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 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:
What the long run foundRunning 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 The harness now catches that one error, builds a fresh VerificationThe live check: 20 seconds in a pseudo-terminal with a single |
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.
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.
β¦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.
|
Architecture amendment for #841 after Slice 1:
The durable contract and acceptance matrix are now updated in #841. Continue the experiment from |
β¦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.
|
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. |
#838 β the renderer
Why
#838, under the REPL quest
#827, asks whether
@bomb.sh/ttycan render the Product Owner's approvedXMD REPL Terminal Interfacestudy in a real terminal, and what the design owes a terminal that isnot 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.mdis 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.
Six fixtures carry the states #838 names β empty; a
Planrunning inside thedocument scope with lifecycle rails and three sections collapsed; the Plan's
returned program replacing the expression that produced it; a project
Elicitdrawer 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
A fixture holds scopes, phases, sections, sessions, bindings, checkpoints and
drawers β never a row, column or byte.
layout.tspicks a profile from themeasured size and returns each region's rectangle.
render.tsis the onlymodule that knows the operation vocabulary, and every cell it emits comes from
that rectangle.
host.tsis the only module that writes to the terminal.Review guide
Start with:
scripts/repl-study/RESULT.md, then the captures inscripts/tests/fixtures/repl-study/βnested.wide.txt,drawer.medium.txt,paused.narrow.history.txtanddrawer.too-small.txtshow most of the design.Then review:
scripts/repl-study/layout.tsβ the profiles, their thresholds, and theconstrained-terminal policy this experiment had to invent.
scripts/repl-study/render.tsβ the study's vocabulary in cells, especiallybandGeometryandnotchLayout.scripts/repl-study/host.tsβ terminal modes, raw input, signals andrestoration ordering.
scripts/tests/repl-study.test.tsβ the evidence and its eight controls.Look carefully at:
useTerminalModes,useRawMode,useSignalListeneranduseStdinReader: each registers its cleanup before acquiring, because a runhalted while acquiring has nothing registered to unwind.
oxlint-disable-next-line local/no-sync-filesystemcomments onDeno.stdout.writeSync. The invariant they name: the restoring bytes arewritten from teardown, where an asynchronous write is not guaranteed to
finish.
What must stay true
layout.tsplacing it as the bottom full-width band and the contextualsurface above it; checked by "stays at the bottom, full width, with a drawer
open", whose control
drawer-covers-footermust fail it.checked by "keeps terminal cells out of the fixtures", which walks every
fixture for a forbidden key.
modes are applied; checked at the byte boundary on ordinary exit, on a signal,
and when a frame throws, with
leak-terminal-modesas the control.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.tschange shows up as the picture it changed;
stale-framefails it.changes size, and compared with a fresh full repaint;
skip-resize-updateleaves cells addressed outside the terminal and fails it.nine columns for fourteen markers;
clip-long-transcriptremoves thecoalescing and fails it.
are measured from the rendered band;
flatten-notchesfails it.ignore-minimumfails 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.mdlists everydimension tested.
Scope
Included
required adaptation.
Intentionally unchanged
#839; the harness's own
keys are not a focus model.
#840, so the region
functions in
render.tsstay private.packages/changes, and no export map, CLI command, spec orarchitecture document moves.
New dependencies
@bomb.sh/tty0.9.0, as an exact rootdevDependenciespin.cells. It does no I/O of its own.
a terminal interface;
marked-terminalformats Markdown and owns no layout,cells or input.
Generated or mechanical changes
scripts/tests/fixtures/repl-study/*.txtcome fromdeno task repl:study --capture. They are worth reading rather than skimming:they are the interface.
deno.lockandpnpm-lock.yamlcarry only the new dev dependency.Risks and limitations
the study's. The thresholds, the narrow routing and the notch tiers are the
most likely things to be corrected.
the band stops distinguishing nesting. The journal list beside it stays exact.
β,β) are assumed to be one cell; a terminalconfigured to render them double-width would misalign rails and notches. No
such terminal was tested.
inspecting a pseudo-terminal's mode flags; allocating a PTY is the territory
#801 withdrew.
test-weights.jsonstill 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
#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, asurface 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
metarecord naming that frame's Tab, Shift+Tab, activate, trap and restore behavior.
What changes
Before:
--fixture drawerwas 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.
Two defects were repaired on the way, and both were only findable by driving
bytes rather than synthetic events:
@bomb.sh/ttybuffers a solitaryESCandreturns
pending: { delay: 25 }with an empty event list.host.tsreadscanned.eventsand droppedscanned.pending.Escwas documented in theREADME, handled in the reducer, covered by a test that fed it a synthetic
{ code: "Escape" }, and did nothing at a real keyboard.ESC [ Z, reported as keycode
Backtabwith no shift flag; the reducer testedTabwithshift.How it works
The REPL has three kinds of state, and telling them apart is what makes every
acceptance criterion reachable:
The surface segment says which region owns focus, so a focus move across a
region boundary moves the URL in the same reducer transition.
atis the markerthe scrubber has selected β canonical location, restored on a cold start with
its scope and bindings β and a valueless
inspectsays the reconstruction at itis 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.tsabsorbsview.ts: two places holding where the person is was thedefect this removes.
hydrate(url, journal)rebuilds the durable half fromthose two inputs alone, and
projection()is what two states are compared by.Review guide
Start with:
scripts/repl-study/RESULT-focus.md, thenscripts/tests/fixtures/repl-focus/frame-07.wide.txt(the drawer's trap) andframe-12.wide.txt(a dimmedContinuenumbered in the map and skipped by thering).
Then review:
scripts/repl-study/route.tsβ the schema, and what it refuses.scripts/repl-study/tree.tsβ the interface as Freedom nodes, andkeys.ts/drive.tsβ the targeting path and the one loop the harness andthe evidence share.
scripts/repl-study/store.tsβ the reducer,hydrate()andprojection().scripts/repl-study/host.tsβscanKeys, which is the Escape repair.scripts/tests/repl-focus.test.tsβ the evidence and its nine new controls.Look carefully at:
tree.ts'sreconcile()adds before it removes. The other order destroysthe focused control before its replacement exists, and focus lands outside the
region β which is how a resumed run first lost its transport slot.
and controls 6 upward, while traversal is tree order. Frames 05, 11 and 14
only agree under that reading β frame 14 numbers
Run6 and traverses itbefore region 5.
scripts/repl-study/vendor/freedom/PROVENANCE.mdβ the two patches againstupstream, and why the vendor's lint exclusion is not in
package.json.markTransport()replaces the space inside[ Continue ]rather thanwidening 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
route, focus, selection and anchor β not deep equality, since a reducer that
rebuilt an equal route would pass that.
steal-focus-on-backgroundfails it.drawer is open is the top drawer's controls then
region:history, which ishow Quest: Run, inspect, and resume XMD execution in the REPLΒ #827's "keeps the fixed history footer reachable" survives a suspension.
leak-drawer-trapfails it.numbered and never focusable.
focus-hidden-targetfails it.included.
push-draft-edits,drop-route-on-resize,forget-drawer-invokerand
drop-selection-on-hydrateeach fail one half of it.keep-route-on-focusfails it, and theframe transitions are driven through the reducer forward and in reverse
rather than proved by building each destination on its own.
reconstructed entry is still running;
exit-on-paused-interruptfails it.inert-sibling-arrowsfails it.input.scan(), pending flush included;swallow-pending-escapeandignore-backtabreproduce the pre-repair behaviour exactly.SURFACESis unchanged and no Render the REPL interaction study in a real terminalΒ #838 golden moved. Four routing surfacesand 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.tsLocal results at
2f4098b9:repl-focus15 passed (71 steps);repl-study12passed (49 steps);
deno task check,deno task lint, the exclusion anddiscovery 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 existthere.
Scope
Included
route.ts,focus.ts,journal.ts,store.ts,frames.ts, the two decoderrepairs, focus indication and the
F1overlay, thereviewandconfirmdrawers, thirteen new controls, three new flags, and 19 goldens.
RESULT-focus.md, and a correction inRESULT.mdto Render the REPL interaction study in a real terminalΒ #838'sEscclaim.Intentionally unchanged
layout.ts'sSURFACES, and every committed Render the REPL interaction study in a real terminalΒ #838 golden.packages/. No dependency, XMD document, CLI command, exportmap entry, spec or architecture change. The diff touches
scripts/only.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
2f4098b9answers an architecture review of0b8687b0that found three waysthe 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 fromthe journal, which gains
previewandwritebesideplan. A new case caughta missing guard of its own: structural navigation was acting inside an editable
target.
Risks and limitations
frame 09's
esc declines. This experiment owns navigation; answering isCompose the REPL interface from reusable terminal componentsΒ #840's and Rebuild any REPL view from its journal and URLΒ #842's.
RESULT-focus.mdrecords the divergence.historyexposesits controls on Enter; the implemented rule is focus-within plus a recorded
execution. All fourteen frames agree, and none distinguishes the two readings.
glyph rather than a colour, so both survive a monochrome terminal and a
.txtgolden.
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/*.txtcome fromdeno task repl:study --capture-focus. They are the evidence, with thenumbered overlay on, and are worth reading rather than skimming.
Outstanding
deno task verify:cleanon the committed head and a regeneratedtest-weights.jsonare 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
Review round 3 β Freedom owns focus
The flat
FocusTarget[]registry was withdrawn at architecture review: Freedom'snode tree replaces the DOM in the terminal, so it owns focus, traversal, input
targeting and branch lifetime.
scripts/repl-study/focus.tsis deleted andReplStatehas no focus field β the store decides what an event means andnames what should happen to focus; the tree carries it out.
What was established before any code moved
@bomb.sh/freedomisprivate: trueand unpublished, so feasibility was probedfirst.
bombshell-dev/playgroundis public, so the source at8be97e7201cd6effddb2f8b240b4b5166641e7f0is vendored atscripts/repl-study/vendor/freedom/with a manifest, provenance and a drifttest.
It runs unmodified on this repository's
effection@4.1.0despite declaring4.1.0-alpha.9. That was the real risk: two Effection copies mean two scopetrees 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:global.focus-stackhas nouseRoot(), so host context does not reach the tree and a failure in nodework raises into a boundary nobody observes.
useRoot()acquires the tree asa resource owned by the acquiring scope.
useFocus()moved focusto 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.
rebuild-tree-each-synckeep-closed-branchleak-drawer-trap,forget-drawer-invokersteal-focus-on-backgroundfocus-hidden-targetflat-overlayTwenty-six controls in total. The previous rounds' corrections are all re-proved
against the new model: the
at/inspectschema, selection as location, pausedCtrl+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.jsonand.oxfmtrc.json, not on the lint task's command line inpackage.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 β witherror: The lockfile is out of date. Editingpackage.jsonat allinvalidates
deno.lockfor every suite that clones the repository and runsdeno install --frozen. Proven both ways: revertingpackage.jsonalone madebuild-web-clientpass, restoring the one line made it fail again.oxlint-policy.test.tsparses.oxlintrc.jsonwith strictJSON.parse, sothat 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.tsLocal results at
c2923dae: the two REPL suites 32 passed (117 steps);build-web-client+build-npm6 passed (33 steps);oxlint-policy1 passed(14 steps);
deno task checkanddeno task lintexit 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 anythingchanged.
A branch could not consume a key.
drive()recorded the dispatch path andthen 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.
keydownnow returns whether it was handled; middleware that handles a keyreturns
truewithout callingnext, anddrive()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:
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-replacementsis the control that makes themdiverge.
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.mdgains a section stating the line: anidentity 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.tsLocal results at
81f9ec81:repl-focus22 passed (74 steps); the threetogether 35 passed (138 steps), 0 failed;
deno task checkanddeno task lintexit 0. The focus goldens are unchanged and no #838 golden moved.