Composition Boundary Learnings
Design Log #195 — Learnings from the composition-boundary work (DL#181 / #187 / #193 / #194)
Written for AI agents. See Log Methodology Note below for details.
Status: RETROSPECTIVE. No implementation. Written while DL#181/#187/#193/#194 are all still in the
tree (branch DL195-196-composition-rethink, HEAD ccf62d14), so every citation below resolves
against live code. Read this before DL#196, which proposes replacing the model these logs built.
Learnings for the Implementer (TL;DR)
- L1 — Everything built in #181/#193/#194 exists to cross one seam: the component runtime boundary. Not four features with four mechanisms; one obstacle with four workarounds. Every entry in the inventory below is a data-crossing or ref-crossing device.
- L2 — Refs and bindings inside one fragment cannot live in different scopes. A ref captures
currentConstructionContext().currDataat construction (references-manager.ts:142-144, into the base ref constructor atnode-reference.ts:367-374); bindings read the samecurrData(element.ts:613,654). One fragment, one scope. DL#194 §5a proved this; it is the hard constraint that forced every design choice, and it will still be true under any replacement. - L3 — Removing the boundary subtracted code; keeping it added code. DL#194 removed the Tier 2
boundary and deleted
__parentContext,parentDataChainre-basing, and ref forwarding for that tier. It kept the Tier 3 boundary and had to invent Fork C. The two halves of the same design log are the controlled experiment. - L4 — Boundary-crossing fails silently. Every defect in the catalogue below rendered wrong output rather than failing to compile. None was caught by a type, a validation, or a unit test; all were caught by an example, a smoke test, or a user clicking.
- L5 — Four render targets multiply every crossing. Element, hydrate, server, main-sandbox each need their own derivation of the same crossing, and drift between them is undetectable except by cross-target fixtures. Issue 3 (dev-server pre-render) was exactly this drift, live for days.
- L6 — Concept count grew faster than capability. The author-facing model is now a table of ref-scope rules keyed by tier × provenance × ref-kind (DL#194 TL;DR). That is a memorisation cost on every page author and every agent.
- L7 — Prevention-first worked wherever it could be applied, and could never be applied to the
boundary itself. Validation catches contract/prop/enum mistakes at build time. It cannot catch
"this ref captured the wrong
currData", because that is a runtime property of a runtime seam. - L8 — DL#181 rejected the materialised-copy alternative against the wrong baseline. Its
trade-off table weighed copy-and-reconcile against reference-based override alone. The real
comparison is against #181+#187+#193+#194 combined — roughly 4,900 lines of
lib/change. The rejection should be re-litigated, not inherited. DL#196 does that.
Non-obvious constraint for any replacement: the currData single-source rule (L2) is not a
current-implementation artifact. Any model that wants refs at one scope and bindings at another
inside one rendered fragment must either introduce a scope switch (which moves the refs too — DL#194
Q7(d) on withData) or reintroduce a boundary plus a bridge. There is no third option.
Background
Four design logs, 2026-09-09 → 2026-09-22, all addressing "how does a usage site customise and reach into a composed headfull component":
| DL | Question it answered | Status |
|---|---|---|
| #181 | How does a usage site override part of a component's markup? | Implemented, merged to main |
| #187 | Can a headfull component exist without a .ts? (three-tier model) |
Implemented on branch |
| #193 | How does a binding or ref in one scope reach another scope? | Phases 1–4 implemented |
| #194 | Which refs are exposed, in which scope? (slot model, Tier 2 inlining) | Phases A–C implemented |
DL#189, #190 and #192 were written in the same period but are not part of this story — see "What is orthogonal" below.
Problem this retrospective addresses
The four logs each concluded soundly and each superseded part of its predecessor:
- #193 §C superseded #181 Q6/VC#5 (override binding scope).
- #193 Phase 3 refinement 1 superseded #193 Phase 3's scope rule.
- #194 superseded #193 refinement 1 and refinement 2, and deleted #162's unwrap.
- #194's Fork C deviated from its own §5b before implementation began.
Four supersessions in thirteen days, each locally correct. That pattern is the symptom. This log names the cause, inventories what it cost, and states what any replacement must preserve — so DL#196 is argued from evidence rather than from fatigue.
Prior Art / Adjacent Mechanisms — the inventory
Everything below is live code on this branch. Grouped by the seam each item crosses.
A. Compile-time template splicing (DL#181, #111)
| Mechanism | Location |
|---|---|
<override> parse / apply (328 lines) |
jay-html-overrides.ts |
| Attribute + per-CSS-property style merge | jay-html-overrides.ts:217 applyOverrides |
| Tier-aware per-tag injector | jay-html-parser.ts:865 injectComposableTemplateIntoTag |
| Compile path / pre-render path callers | jay-html-parser.ts:1092, :972 |
B. Override data crossing the boundary (DL#193 Phase 2a/2c)
| Mechanism | Location |
|---|---|
@jay:parent pragma injection |
jay-html-overrides.ts:94 remapOverrideBindingsToParent, :101 |
| Pragma strip + scope shift | expression-compiler.ts:233 withParentShift |
Reserved __parentContext prop |
component.ts PARENT_CONTEXT_PROP |
| Synthetic parent context handoff | context.ts:152 withSyntheticParentContext, consumed at :367, :405 |
| SSR lexical-ancestor variant | expression-compiler.ts:137 lexicallyInScope, :189 asLexical |
C. The $parent runtime carrier (DL#193 Phase 1, Capability A)
| Mechanism | Location |
|---|---|
| Live parent pointer on the context | context.ts:214 parent |
| Parent chain read by leaf helpers | context.ts:178 parentDataChain |
forScope liveness fix |
context.ts:222 _liveDataSource, set at :308 |
| Update-gate weakening | element.ts:468, :488, :501 dependsOnParent |
| Leaf helpers threading the chain | element.ts:613, :654, :178 |
D. Ref crossing the boundary (DL#193 Phase 3 + refinements)
| Mechanism | Location |
|---|---|
| Forwarded inner-ref lookup | node-reference.ts:290, :306 |
| Collection forwarding trap | node-reference.ts:586 |
Per-ref re-basing selector on childComp |
element.ts:52 refViewState, used at :70-78 |
| Override provenance marker | jay-html-overrides.ts:50 OVERRIDE_INJECTED_MARKER |
| Ref type selection by provenance | jay-html-compiler.ts:582 renderChildCompRef, :636 |
| File-level helper dedup | jay-html-compiler.ts:165 emittedForwardedRefHelpers |
E. Tier 2 inlining — the boundary removed (DL#194 Phase B)
| Mechanism | Location |
|---|---|
Alias overlay on Variables |
expression-compiler.ts:148 aliases, :156 inlinedRoot |
| Alias construction from usage site | jay-html-compiler.ts:523 buildInlineAliases |
| Inline codegen (element/hydrate) | jay-html-compiler.ts:1006 renderInlinedStructuralInstance |
| Inline codegen (server) | jay-html-compiler-server.ts renderServerInlinedStructuralInstance |
| Root-var preservation for guards | expression-compiler.ts:212 withRootVarName |
| Recursion guard | jay-html-parser.ts:1157 headfullRecursionError |
| Passthrough / coercion (Tier 2 pre-inlining) | structural-coercions.ts (76 lines), passthrough-component.ts |
F. Tier 3 slots — the boundary kept (DL#194 Phase C, Fork C)
| Mechanism | Location |
|---|---|
slot contract tag |
contract.ts:13 |
| Foreign-slot anchor marker | jay-html-overrides.ts:60 FOREIGN_SLOT_MARKER |
| DOM passthrough with no-op update | element.ts:100 foreignChild |
slots channel on childComp |
element.ts:53, update loop at :61-68 |
| Slot ref manager (single) | node-reference.ts:186, :190; trap at :535 |
| Slot ref manager (collection) | node-reference.ts:246, :250; trap at :602 |
| Per-item slot fragment sinks | jay-html-compiler.ts:161 slotPreambles, :886 |
| Explicit-ref requirement | jay-html-parser.ts:1367 overrideRequiresExplicitRefError |
Root cause — one seam, traced
A page composes <jay:Card>. The compiler mounts the card as childComp(...) (element.ts:42).
That call creates a new ConstructContext whose parent is deliberately unset — withRootContext
(context.ts:367) makes a parent-less root, attributed to DL#84 data isolation.
From that single decision, in order:
- The card's template binds against the card's
currData, not the page's. → an override authored in the page reads the wrong data. → B (pragma, shift,__parentContext). __parentContextis a prop, and props only re-fire the child reaction; the parent context object must be mutated in place to stay live. → C (parentpointer, in-placeupdate).- In-place update alone is insufficient:
element.ts:468andwrapWithModifiedCheck(context.ts:188) short-circuit on reference equality before the leaf runs. → C (dependsOnParentgate weakening). - A ref constructed inside the card captures the card's
currData(L2). → the page cannot wire it meaningfully. → D (forwarding, then per-ref re-basing selectors, then provenance markers to decide which refs re-base). - Deciding which refs re-base needs a rule. Provenance (#193 refinement 1) proved wrong for plain
elements (#193 refinement 2), so #194 replaced it with declaration (
slot). → F. - Any override fragment that must render in the page scope but mount in the child DOM needs a DOM
carrier with no update coupling. → F (
foreignChild,slots, two more ref traps). - All of 1–6 must be re-derived for element, hydrate, server and main-sandbox. → the multiplier.
And the counterfactual, from the same design log: for Tier 2, DL#194 deleted step 0. Everything from
1 to 7 then became unnecessary for that tier — replaced by one alias overlay in resolveAccessor
(E), which is unit-testable without any DOM.
The seam is the cause. Not the override syntax, not the ref model, not the tiers.
Evidence — the defect catalogue
Every one of these is recorded in DL#193/#194 "Implementation Results". Every one produced wrong rendered output while type-checking and passing the unit suite.
| # | Defect | Found by | Class |
|---|---|---|---|
| 1 | hasForwardedInnerRef used in, bypassing the Proxy trap → ref undefined |
example, manual click | ref crossing |
| 2 | Collection trap exclusion-list caught real impl members → listeners.filter is not a function |
example | ref crossing |
| 3 | Slot ref manager overwritten for repeated Tier 3 instances → .body undefined |
smoke page /foreach-composite |
ref crossing |
| 4 | Slot fragment const hoisted out of forEach → only item 1 wired its handler |
manual click, per item | codegen scope |
| 5 | Dev-server pre-render spliced Tier 3 into Tier 2 shape → hydration coordinate mismatch | hydration error at runtime | target drift |
| 6 | jc prop dropped from instance props → 26 dev-server hydration failures |
full yarn confirm |
target drift |
| 7 | Alias overlay lost in the hydrate conditional guard → "data field not found" | full yarn confirm (8n) |
target drift |
| 8 | Pragma leaked into static attribute values → href="@jay:parent /docs" |
smoke /override |
crossing marker |
| 9 | forScope snapshot made $parent stale under hydration |
example, manual | carrier liveness |
| 10 | Nested Tier 3 prop binding dropped on SSR only (/nested-composition) |
purpose-built smoke page | target drift |
Four are ref-crossing, four are target drift, one is carrier liveness, one is a marker leak. All ten are downstream of the seam. None would exist in a model with no seam to cross.
Note the discovery mechanism: two by full-repo yarn confirm, five by examples/smoke pages written
specifically to stress composition, three by manual clicking. Zero by the type system. Zero by
validation. This is L4 and L7 stated as data.
Cost accounting
Measured on main..HEAD (main = 2c4e55ca, which already contains DL#181):
302 files changed, 18789 insertions(+), 660 deletions(-)
lib/ only: 61 files, +4884 -450
Plus, already on main: jay-html-overrides.ts (328 lines) and its parser wiring.
Grep footprint of the crossing mechanisms across all lib/ (approximate, symbol-name based):
| Mechanism family | hits |
|---|---|
structural (Tier 2 concept) |
~106 |
makeHeadlessInstanceComponent / __headlessInstances |
~77 |
aliases / buildInlineAliases / inlinedRoot |
~73 |
__parentContext |
~36 |
parentDataChain |
~28 |
slotPreambles |
~27 |
withParentShift / PARENT_SCOPE_PRAGMA |
~25 |
dependsOnParent |
~23 |
foreignChild |
~20 |
| slot ref manager / traps | ~20 |
jay-foreign-slot |
~16 |
lexicallyInScope |
~14 |
jay-from-override |
~9 |
The three largest compiler files absorbed most of it: jay-html-compiler.ts (+772),
jay-html-compiler-hydrate.ts (+497), expression-compiler.ts (+396).
Capability delivered: override a component's content and attributes from the usage site; reach
refs inside a composed component; bind page data into an override; $parent inside forEach.
Invariants any replacement must preserve
Stated as constraints on DL#196, derived from the above rather than asserted:
- V1 — One fragment, one scope (L2). Non-negotiable; it is a property of
ConstructContext, not of these designs. - V2 — Cross-target agreement must be mechanically checked. Four targets, and drift is invisible at runtime until hydration fails. Any replacement needs fixtures per target, or a model where the four targets share one derivation.
- V3 — Coordinates must be stable across SSR and hydrate.
assign-coordinates.ts+ DL#126's flat map. Defects 3, 5 and 10 were all coordinate/scope alignment. - V4 — The real headless-instance channel must keep working.
<jay:markdown-content>and every plugin component still resolve props through__headlessInstances, per-phase (DL#189), coerced (DL#190), enum-validated (DL#192). A replacement for headfull composition must not disturb it. - V5 — Failures must be compile-time. The whole catalogue argues this. Prefer a model whose worst failure is a wrong diagnostic over one whose worst failure is a wrong pixel.
- V6 — Tier 3 code is not inlinable. A
.tsowns its own reactive graph (component.ts:148,const reactive = mkReactive()insidemakeJayComponent, declared at:131). Any model claiming to remove the boundary must say explicitly what happens to coded components — DL#194 could not, and that is why Fork C exists.
What is orthogonal — keep regardless of DL#196
Written in the same window, entangled in the same commits, but independent of the seam:
| Work | Why it survives |
|---|---|
| DL#189 phase-aware prop resolution | Operates on real headless instances; the props channel exists in every model (V4) |
| DL#190 instance prop type coercion | Same; fixes a pre-existing bug affecting coded instances too |
| DL#192 enum value validation | Same; and its value increases in a validation-first model |
| class-names utility | runtime/lib/class-names.ts + ssr-runtime + jay-4-react — unrelated to composition |
DL#193 Capability A ($parent in forEach) |
Author-facing parent binding is useful on its own; but it exists only because overrides needed it — keep-or-drop is an open question |
Entanglement note: DL#189 and #187 share commit 7c41a1a4 (51 files); DL#192 and class-names share
f560c97e/153e648e. Any attempt to replay these onto main is a manual split — which is why the
new branch starts from ccf62d14 and subtracts (see DL#196's implementation plan).
Questions
Q1. Is Capability A ($parent in forEach) kept? It is the one piece of the crossing machinery
with standalone author value, and the only one with a runnable example
(examples/jay/parent-binding, regular + secure). Keeping it retains context.ts:214/:222,
parentDataChain, and the dependsOnParent gates (~51 grep hits + the grammar). Dropping it returns
ConstructContext to a construction-time snapshot.
Recommendation: decide in DL#196, not here — it depends on whether the new model has any other need
for a live parent chain.
Q2. Should DL#181 be reverted on main, or deleted forward on the new branch? main is 8
commits ahead of origin/main and includes DL#181. Deleting forward keeps one linear story;
reverting on main makes main a clean base for unrelated work.
Recommendation: delete forward. main is unpushed and will be superseded either way.
Q3. Does the /nested-composition enclosing-instance-scope fix (ccf62d14) survive? It resolves
each discovered instance's props against its enclosing instance's resolved ViewState rather than the
page's. Under a flat model there are no nested headfull instances to scope — but headless instances
can still nest, so the fix may be independently correct (V4).
Open — needs a trace against the DL#196 model before it is discarded.
Q4. Is there a case that genuinely requires a runtime headfull boundary? Recursion is the known
one (DL#194 forbids it for Tier 2, directing authors to add a .ts). Are there others — a component
whose markup structure depends on data only it can fetch?
Open. This is the question that decides whether DL#196 can be complete or must keep an escape
hatch.
Trade-offs (of the model being retired)
| Property | Value delivered | Cost |
|---|---|---|
| Component as single source of truth | Redesign propagates to all usage sites automatically | Redesign changes N pages with no review surface |
| Reference-based override (no copying) | No staleness, no drift reconciliation | Requires the whole crossing apparatus to make the reference reachable |
| Declared slots (#194) | Coded components safe from arbitrary content replacement | New contract surface; two <override> forms; per-tier fill mechanisms |
| Tier 2 inlining (#194) | Removed a boundary; alias overlay is unit-testable in isolation | Two v1 restrictions (no recursion, no root $parent); SSR nested-prop gap |
| Four-target parity | Same output everywhere | Every crossing derived four times; drift invisible until hydration fails |
Verification criteria (for this log)
- Every inventory citation resolves to the named symbol at the named file (checked against
ccf62d14). - Every defect in the catalogue traces to a "Implementation Results" entry in DL#193 or DL#194.
- The orthogonal set is complete — no work in
main..HEADoutside the inventory and the orthogonal table is left unclassified. - The invariants V1–V6 are each grounded in a code citation or a catalogued defect, not in opinion.
Successor: DL#196 — validated inline composition.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.