Skip to content

Commit 14ebdf1

Browse files
authored
✨ Slice C of #848: reconcile the REPL component tree (#851)
* ✨ Reconcile the REPL component tree Slice C of #848: the keyed composition kernel. A parent describes its complete child set; that set reconciles into one mounted Freedom tree; a normalized event walks its target's own ancestry and comes back as a typed action. No terminal, no bytes, no cell geometry, no command — Slices D and E consume this. `description.ts` is the contract. A description is an immutable value with a sibling key, a component-type identity, the component's own input, the placement its parent chose, and its keyed children. Identity is parent, key and type, so redescription keeps a node and a different key or type replaces it; position is not identity. There is no module-scoped registry: what a mounted component can do lives in its own node's `NodeData` and goes away exactly when the node does. A component is two halves, and the split is what makes a commit mean something exact. `attach` is synchronous and runs *inside* the commit, so the moment a reconciliation is acknowledged every described node already renders what it renders and claims what it claims; it may return a teardown, which the commit registers, so a node removed before any ongoing work had a turn still tears down once. `lifetime` is the node's ongoing work in its own scope. A task starts a turn after it is spawned, so nothing that has to be true at commit time can live there — and for the same reason a redescription is delivered to `onInput` inside the commit rather than published on a stream a subscriber may not have reached yet. `reconcile.ts` validates the whole desired set, over every descendant, before touching anything: a reconciler that mutated as it walked would leave a tree that is neither the old one nor the new one when it met a duplicate key at the end. Then it adds and updates before removing, so a replaced region is never momentarily empty and focus always has a survivor to land on; then it sets canonical order from the described positions. Removal runs the branch's own teardown and awaits Freedom's. Focus is derived from what is mounted rather than remembered across commits. `handoff.ts` is how a description reaches the tree and how its author learns it arrived. `next()` resolves after that exact set committed — not a tick later, not when a queue accepted it. Cancelling abandons an uncommitted offer without touching the tree, and teardown refuses a later offer rather than leaving it waiting for a worker that is gone. Freedom is vendored from `bombshell-dev/playground@8be97e72`, nine sources and the upstream notice, as a pristine copy and a patched copy. `MANIFEST.json` records a SHA-256 for both copies of every file; the drift test refuses an extra, missing or changed file, proves every unpatched production file equals upstream byte for byte, localizes each difference to one named patch, and proves production imports the patched copy from exactly one module and the pristine copy from none. Two patches, each with the pristine behaviour kept as its permanent control. `useRoot()` parents the tree to the caller's scope so it inherits that scope's contexts and dies with it — upstream has no such surface and its root is parented to Effection's global. Focus removal decides by subtree containment rather than by identity, because a drawer closes by removing the branch *above* the focused control, which upstream leaves focused on a node about to be destroyed. The package's manifest asks for `effection@4.1.0-alpha.9`; this repository stays on stable 4.1.0 and the suite re-proves compatibility rather than inheriting the claim. The upstream notice is MIT while the package metadata says ISC; provenance records both and resolves neither. Five review corrections. A description is now issued, never written. `describe()` and `component()` are the only ways to make one, and what each holds is behind a private field — so a plain object of the right shape is not a description, statically or at runtime, and there is nothing on the outside of one to reach into. Input is detached as it is read: copied into the description and frozen as the copy is built, so a caller that mutates its own object afterwards, or while an offer is still waiting, changes nothing, and nothing the caller still owns is frozen out from under it. A component's input is view data, which is what lets the whole pipeline be concretely typed — there is no `as` left in either module. A modal branch is a real focus root. Modal intent is its own thing rather than a member of the opaque placement a parent chose, and it is bound to the mounted node: the push happens once the branch's own children exist, and the pop runs when the branch goes, so the stack never keeps an entry for a node that does not. While it is mounted, focus traversal cannot reach the tree underneath; the same descriptions without the push walk straight out of it, which is the control. There is one way in. The normalized event is a discriminated union, so a key event carrying a frame is not a value anybody can build, and Backtab arrives through `dispatch` exactly as Enter, a pointer activation and Escape do — the host-callable focus shortcut is gone, because a second input path would have its own rules and a modal root would not contain it. The composition surface is parameterized by the caller's own closed action union, and a dispatch answers with a closed outcome: an action a node claimed, a focus move the tree owns, or an event that reached no live node. Cancellation owns an uncommitted offer. The commit runs in the calling operation's own lifetime under a lock that is handed straight from one holder to the next, rather than in a worker this module owns: an offer cancelled while waiting its turn leaves the line and the tree never hears of it, and a holder cancelled mid-commit still releases the lock, so nobody behind it is stranded. `PROVENANCE.md` records that upstream carries an MIT notice and that the private package's metadata says ISC, and states that this repository makes no determination about which controls. The manifest's provenance digest was recomputed and the drift evidence is green. Second round of review corrections. The construction surface is closed. `ReplDescription` and `ReplComponent` are exported as types and not as values: there is no constructor to reach, no static issuer and no reader that takes an arbitrary value and hands its contents back, and `ReplDescribed` is no longer exported at all. `describe()` and `component()` are the only construction, and what a reader can see of a description is read-only. A surface test asserts the module's exact exports, so an alternate issuer or unwrapper reappearing fails rather than being noticed later. The three `as const` assertions are gone; the dispatch outcomes are typed at their declarations instead. A modal branch is inaccessible and its stack is safe. A pointer naming a live node in the current frame is dropped when that node is outside the active focus root, which is what makes the branch underneath unreachable rather than merely covered. The stack is kept centrally and made equal to what is described, in pre-order — an outer drawer is pushed before the inner one it contains and the stack unwinds from the top — and a preserved node whose modal intent changes pushes or pops without losing the lifetime it already has. Two modal roots on branches neither of which contains the other are refused in preflight, because there is no order to push or pop them in. Reconciliation is serial and has an explicit boundary. Preparation is synchronous: the tree reaches its new shape in one turn, so no cancellation can leave it halfway. Retiring the branches it replaced is the tree's own work, owned by the tree and cleared by the retirement itself — a caller halted while waiting for it would otherwise put the slot back while the teardown was still running, and the next commit, seeing nothing outstanding, would overlap it. The one suspension before any mutation is the previous commit's retirement, which is where the new controls cancel: the abandoned branch is never mounted, no concurrent commit is released, nothing is left pending and a later offer completes. Third round of review corrections. An issued handle is opaque, frozen and authentic. A description and a component now carry no public members at all: what each holds is a private field, which property access, enumeration and reflection cannot reach, and both the instance and its prototype are frozen on the way out — so an `input` or an `attach` shadowed onto one after issuance is refused rather than becoming what the tree reads or runs. The composition implementation reads them through one seam of its own, and that seam refuses anything this factory did not issue. `describe()` brand-checks the component before issuing, so a plain object with the right members, arriving through an untyped call, never reaches attachment. Focus and modality are preflighted as one question. They were counted separately, so a page asking for focus beside a modal passed validation and then threw `Cannot focus a node outside the active focus root` — after preparation had already mounted the whole new tree. A single explicit focus claim that the innermost modal path does not contain is now an `Err` before anything is attached, including a claim inside an outer modal but outside the nested one it contains. The positive control is that a claim the innermost modal does contain is still admitted and still wins focus. The last immutability hole. The handles were frozen and the records behind them were not, so the seam the reconciler reads through handed back a writable copy of the component's definition and of the described record: `Reflect.set` could replace the stored `attach`, and the already-issued component would then run it. `readonly` is a statement to a compiler; the freeze is what makes it true at runtime. Both records are frozen before they are stored, and the evidence now reads them through that seam, walks everything they retain, proves each record and list is frozen and each mutation refused, and mounts the already-issued description to show the original input and original behavior still hold. * ⚡ Remeasure test weights at 1019ea5 Measured on run 36293682586 at the accepted Slice C implementation head, and committed unchanged. Its floors are 10 deno, 5 node and 3 bun against installed counts of 15/10/5, so nothing the measurement says requires a shard count to move. The totals sit below the previous measurement's; a floor is what the weights make possible, not what the shard count should be.
1 parent 9c50a21 commit 14ebdf1

30 files changed

Lines changed: 5312 additions & 1075 deletions

‎.agents/architecture-rules.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ the system itself; this file governs how new architecture is designed.
1515
6. Record adjacent debt as a proposed follow-up Story instead of expanding the feature.
1616
7. Each package exports its consumer-facing contextual APIs and their types from /api; consumers depend on that package and import them from /api.
1717
8. Feature-specific runtime APIs stay with their feature package and are not collected into a central runner context.
18+
9. Immutable view data flows down and typed semantic actions flow up; the root alone owns route intent, selection and rebuildable application state.
19+
10. A component lifetime may own a node; a render body may not create or own one.
20+
11. View data and placement cross the direct parent-child boundary and no other.
21+
12. Normalized host input is decided once and dispatched through the target's own ancestry; a host names a target and an event shape, never an action.
22+
13. Freedom alone owns focus; an overlay is a mounted branch, not a second tree.
23+
14. Keyed descriptions reconcile into one mounted tree; validate the complete desired set before mutating any of it.
24+
15. An absent description leaves no node, input, focus, frame contribution or output behind.
1825

1926
The Architect applies these rules without another approval when they settle a
2027
design. A new concept, ambiguous fit, conflict or proposed exception returns to

‎.oxfmtrc.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"scripts/tests/fixtures/**",
1010
"**/npm/**",
1111
"packages/workflow/vendor/cloudflare-computer-dofs/**",
12-
"packages/acp/vendor/acpx/**"
12+
"packages/acp/vendor/acpx/**",
13+
"packages/cli/src/repl/vendor/freedom/**"
1314
]
1415
}

‎architecture.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5307,6 +5307,43 @@ Status is measured against main.
53075307
`<Retry>` composes with validation by nesting: a `<Parse>` failing inside
53085308
fails the attempt — "retry until it parses" is two existing ideas.
53095309

5310+
## The private REPL composition kernel
5311+
5312+
`packages/cli/src/repl/` is a private CLI feature, not a package boundary and
5313+
not part of `@executablemd/core`'s surface. Its layers are one direction:
5314+
5315+
```text
5316+
DurableEvent[] → frozen ReplModel → resolved view → keyed descriptions
5317+
→ one mounted Freedom tree → a frame
5318+
↑
5319+
typed semantic actions, target ancestry → root
5320+
```
5321+
5322+
The Journal is the only durable truth, and history terminates at `ReplModel`:
5323+
projection detaches every value it retains and freezes it, so nothing below
5324+
holds a `DurableEvent` and nothing above can change one. Route resolution is
5325+
pure and answers with the exact objects that model holds.
5326+
5327+
Composition is keyed. A parent describes its complete child set; the reconciler
5328+
judges all of it before mutating any of it, adds and updates before removing,
5329+
then sets canonical order, and acknowledges the offer only after that commit.
5330+
Identity is parent, key and type, so redescription preserves a node and its
5331+
running lifetime while a changed key or type replaces it. Freedom owns the one
5332+
mounted lifetime tree and the one focus store, and a component's capabilities
5333+
live in its own node's `NodeData` — an absent description therefore leaves no
5334+
node, no input, no focus, no frame contribution and no output anywhere.
5335+
5336+
A component is constructed synchronously inside the commit and may own ongoing
5337+
work in its node's scope; a render body owns nothing. Normalized host input is
5338+
decided once, at the host, which names a target and an event shape and never an
5339+
action; the tree walks that target's own ancestry, and the first node to claim
5340+
the event produces the typed action the root acts on. An event nothing claims
5341+
is an explicit failure.
5342+
5343+
Freedom is vendored from a pinned unpublished commit under
5344+
`packages/cli/src/repl/vendor/freedom/`, as a pristine copy and a patched copy
5345+
whose every difference belongs to one named patch. See its `PROVENANCE.md`.
5346+
53105347
## Changing these rules
53115348

53125349
Spec, tests, and mechanics move together, in the same PR. If a workaround

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
"test:node": "tsx scripts/runtime-tests.ts node",
7575
"test:bun": "bun scripts/runtime-tests.ts bun",
7676
"test:deno": "deno task test",
77-
"lint": "oxlint -c .oxlintrc.json --ignore-pattern 'scripts/tests/fixtures/**' --ignore-pattern '**/npm/**' --ignore-pattern 'packages/workflow/vendor/cloudflare-computer-dofs/**' --ignore-pattern 'packages/acp/vendor/acpx/**' packages scripts && oxfmt --check packages scripts",
77+
"lint": "oxlint -c .oxlintrc.json --ignore-pattern 'scripts/tests/fixtures/**' --ignore-pattern '**/npm/**' --ignore-pattern 'packages/workflow/vendor/cloudflare-computer-dofs/**' --ignore-pattern 'packages/acp/vendor/acpx/**' --ignore-pattern 'packages/cli/src/repl/vendor/freedom/**' packages scripts && oxfmt --check packages scripts",
7878
"fmt": "oxfmt --write packages scripts"
7979
},
8080
"workspaces": [

0 commit comments

Comments
 (0)