Skip to content

Add hydration identity and resumability foundations - #11

Draft
doeixd wants to merge 100 commits into
mainfrom
agent/resumability-foundation
Draft

Add hydration identity and resumability foundations#11
doeixd wants to merge 100 commits into
mainfrom
agent/resumability-foundation

Conversation

@doeixd

@doeixd doeixd commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Summary

  • add stable family hydration identity and router/runtime hydration propagation
  • modernize the slot-contract golden path and related component/type coverage
  • add component/setup inspection and render-from-committed-bindings foundations
  • align the JSX compiler/runtime event ABI and harden SSR cleanup
  • introduce typed portable code descriptors, schema-validated JSON captures, resolver Layers, and portable Component.action support
  • add the authored resumability implementation plan and update the AF-UI contract/status documentation

Why

AF-UI needs stable hydration identity and a supported public boundary for an external resumability adapter. This change establishes those foundations without claiming full resumability or transferring server closures, Scopes, Layers, fibers, atoms, or DOM handles to the client.

Important fixes

  • routed component wrappers preserve authored slot contracts without replacing prior route registrations
  • compiler-emitted delegated event setup is safe during server imports and follows composed event paths
  • SSR globals, reactive roots, and request context are restored on failure
  • component inspection decodes props once and can render committed bindings without rerunning setup
  • portable descriptors reject missing code, stale builds, identity drift, invalid captures, and non-JSON-safe captures with explicit tagged errors

Validation

  • npm run typecheck:all
  • npm test — 40 files, 608 tests passed
  • npm run build
  • git diff --check

doeixd added 30 commits July 27, 2026 10:13
Source fixes
- Element.on and collection().observeEach registered cleanup on the reactive
  owner rather than the ambient Scope, so listeners and observers survived
  dispose() and Scope.close on the resume/reattach path (which has no owner).
  Both now resolve Scope via Effect.serviceOption and register with
  Scope.addFinalizer, keeping onCleanup only as a fallback. Public types are
  unchanged (R = never), so nothing outside Element.ts moved.
- 8c.4: ExpressionOutput widens to string|number|null|undefined; three
  compiler-facing helpers (exprAttribute/exprClass/exprStyleProperty) each call
  the ordinary helper then delegate to one observeRenderedExpressionTarget
  registrar, so two expressions on one element emit one marker. Client
  attribute/class/style patching replaces the fail-closed rejection.
- installClient now rejects a tampered manifest whose instance carries a
  contradicting element marker (was silently accepted).
- Compiler: expr() is recognised by JSX context and lowered to one grouped
  directive attachment per host element, with source-located compile errors for
  unsupported contexts (previously lowered silently). Ordinal identities are
  content-hashed, so adding an earlier unassigned call no longer shifts a later
  identity.

future/ specification suite
Executable specs for the finished design: spec, QA, and red-green TDD in one
place. Excluded from npm test and typecheck:all by design; a red spec is a work
item, not a regression. Includes a cross-cutting security lane that attacks the
seams between modules, where every security defect found so far has lived.

docs/design-questions/
Inbox and triage for undecided design. 59 scattered unbuilt() markers and ten
"Open Questions" sections became DQ entries; all 38 blocking ones are now
ratified into their owning plans, with rejected alternatives recorded.

Writing the specs and then implementing against them disproved four of those
ratifications, each corrected in place - most notably DQ-003, whose use:
directive form is unemittable, and DQ-002, which assumed a wire field that does
not exist.

Gates: typecheck, typecheck:tests, typecheck:future, build, 914 tests.
8c.7 — Chromium proof and post-widening measurement
- 7/7 browser tests pass. One had to be fixed first: it asserted the literal
  ordinal identity `app/note-button.ts#$0`, which the M7 content-hash change
  replaced. Browser tests are not in `npm test`, which is why the compiler slice
  updated five unit tests and missed this one. It now asserts the shape, not the
  digest, so editing a marker's body no longer breaks a browser test.
- Both heap gates pass: density-24 dormant-vs-eager gap 49,368 B (ceiling
  204,800) and slope 0.6659 (ceiling 1.10). Dormant now grows more slowly per
  expression than eager, 1,138 B/expr vs 1,710 B/expr.
- The widening itself cost 288 raw / 11 gzip bytes at density 24.
- Gate returns GO, so M8d structural targets are unblocked.

Baseline re-pinned from the post-widening run. The previous baseline recorded no
`heapMeasurementMode`; this one records jitless-forced-gc, which strips the V8
JIT code M8c.1 found was being miscounted as expression slope — from both arms.
Cross-run growth comparisons against the old file are therefore invalid and
would overstate the widening as a large improvement. Only within-run gap and
slope figures compare. Documented in the benchmark README.

8c.8 — documentation closure
- RESUMABILITY_GUIDE.md gains a Diagnostics reference covering both families:
  all 9 collect codes and all 12 client codes, with the split stated explicitly.
  Which side emitted a diagnostic is the first thing an operator needs — collect
  means "left out of the manifest, page still works", client means "something
  that should have resumed did not".
- Closed the audit gap that let 8c.4's `unsupported-expression-target` ship
  undocumented: diagnostics.spec.ts checked only client codes, so a server-side
  code could never fail it. It now audits collect codes too and asserts the two
  families are documented as distinct, each with the same can-this-fail control.

Milestone 8c is complete (8c.0–8c.8). future/resumability is 35/41; the six reds
are three M9 SPI specs and three M8d specs, both correctly deferred.

Gates: typecheck, typecheck:tests, typecheck:future, build, 914 tests, 7/7 Chromium.
M0 — characterization and the SSR fallback contract
- src/__tests__/ssr-characterization.test.ts (15 tests) pins the observed SSR
  order as a sequence, not per-step presence: setup steps -> view enter ->
  event attachment -> view exit -> serialize -> root dispose. Serialization
  strictly precedes disposal (try vs finally). Also pins nested renders,
  setup/view failure paths, and request-context restore.
- docs/RESUMABILITY_SSR_CONTRACT.md records the fallback contract for opaque
  setup, opaque handlers, unknown code IDs, capture decode failures, and build
  mismatch, keyed by detection point and diagnostic code.
- Three findings contradict the plan's prose and the tests pin the code: there
  is no ambient Effect Scope during SSR (so "Scope finalization" is not a
  lifecycle step); nested renderToString restores the outer *virtual* document;
  and decodeManifest accepts unknown code IDs, because decode validates shape,
  not resolvability.
- Item 6 (the intentionally-red E2E baseline) is satisfied by obsolescence: its
  acceptance was that it fail because the resume SPI is *absent*, and the SPI now
  exists with 110 resume tests and 7/7 Chromium behind it.
- Corrected a wrong conclusion before it spread: the per-component Scope
  mechanism is not dead code. mountWithManagedRuntime roots it on the client
  (effect-ts.ts:1704/:1742); it is absent only during SSR. The doc records both
  the correct reading and the rejected one.

M1 — item 4 was never actually done, and the gap had already bitten
- copyRouteDecorations hand-copied six __route* fields while
  RouteDecoratedComponent declared eight, so __routeTransition and
  __routeSitemapParams were silently dropped by every wrapper.
- Route.ts now has one declaration site (RouteDecorationRecord +
  RouteDecorationFields) with a compile-time exhaustiveness assertion, so adding
  a field without listing it is a type error. Wrapper-parity tests are
  parameterized over that list, so new fields are covered automatically.

M2 — handle inspection is now uniform
- Added derived, ref and action descriptors; state/query/action/derived/ref all
  answer Resume.inspectHandle and carry one kind symbol. Previously actions
  inspected through a different symbol with a different shape, and derived/ref
  published nothing.
- Opacity is encoded by an absent executable rather than a separate kind, so it
  has one representation.
- Confirmed and documented the conservative default: a bind step with no declared
  resume policy is never snapshotted and falls back to client activation.

Gates: typecheck, typecheck:tests, typecheck:future, build, 950 tests, 7/7 Chromium.
DQ-099 - the validated-manifest TOCTOU
- Memo membership is now granted LAST, only after the graph is deeply frozen,
  so "validated" implies "immutable" and validate-then-mutate-then-reuse is
  unrepresentable - including under an identity-preserving decoder. The
  dependence on Schema.decodeUnknownEffect happening to return a copy is gone.
- The ratified fix was an own-Symbol brand. That turned out to be STRICTLY
  WEAKER than the WeakSet it would replace: Object.getOwnPropertySymbols makes
  such a brand forgeable onto any object, a bypass the WeakSet never had. The
  type is branded; the runtime witness stays an unforgeable private WeakSet, and
  a regression test pins getOwnPropertySymbols empty so the property form cannot
  come back.
- Per-installation scoping deliberately not adopted: three public entry points
  have no installation to scope to, and freeze-before-admit makes the global
  lifetime inert. Deviation from the letter of the decision, recorded as such.
- The manifestIsValidated boolean is deleted - it was the untyped version of the
  brand.

Scope-aware reactions
- api.ts gains createDisposableEffect: parents an Owner to the ambient reactive
  owner and returns a disposer. Element.ts's setAttr(name, fn) and setStyle used
  bare createEffect tied to neither owner nor Scope, so a Style attached through
  a scoped path kept recomputing forever after disposal.
- Exactly-once disposal needed no bookkeeping: Owner.dispose self-guards and
  detaches from its parent, so whichever of the Scope finalizer or parent
  teardown fires first does the work.
- Eight further bare createEffect sites are catalogued in
  DESIGN_IMPROVEMENT_NOTES item 22; effect-ts.ts:1464/:1480 are likeliest.

Allowlist parity and the opaque-setup diagnostic
- The compiler's allowlist is a deliberate third copy; a real import would drag
  runtime deps into a plugin that imports only Babel types. Instead: parity
  tests, plus a guard asserting every plugin import is `import type` - so if that
  property lapses, the test says to switch to a real import.
- New collect diagnostic `opaque-component-setup`. Opaque setup was the only
  fallback path that announced nothing.

Milestone audits (M3, M4, M5) - findings recorded, not yet fixed
See RESUMABILITY_MILESTONE_AUDIT.md and RESUMABILITY_MILESTONE_AUDIT_M3_M5.md.
The theme is guards and conventions that are unenforced or path-limited:
- M3: a portable handler on a NON-DELEGATED event is silently uncollected - no
  marker, no manifest entry, no diagnostic - because only the delegate=true
  branch writes $$name. The backing test forces delegate:true by hand.
- M4: the duplicate-installation check is not atomic; two concurrent
  installClient calls on one root both install listeners, so every interaction
  dispatches twice - defeating M4's own exactly-once guarantee.
- M5: Resume.addressable terminality is convention only. withSlots after it
  rebuilds the object and loses both the activation symbol and the WeakMap
  entry, shipping a dormant boundary that fails permanently on first click.

One test changed deliberately: ssr-characterization's opaque-setup case asserted
the silence as contract. That silence was the defect item 3 closes.

Gates: typecheck, typecheck:tests, typecheck:future, build, 961 tests, 7/7 Chromium.
M3 - non-delegated events are now collected
A portable handler on blur/focus/mouseenter/a custom event was silently
uncollected: no marker, no manifest entry, no diagnostic, because only the
delegate=true branch writes $$name and ServerElement.addEventListener is a no-op.
Fixed by collecting them: a session-level directEventHandlers WeakMap plus
observeDirectEventHandler, called after the delegate branch returns so the
delegated fast path and the compiler ABI are untouched. No new diagnostic code
was needed - the existing opaque-event-handler / event-data-unsupported /
invalid-event-type codes now fire on a path where they previously could not.

The new tests omit the delegate argument entirely. That was the exact blind
spot: the existing helper passed delegate:true by hand, so no test had ever
driven the default.

M4 - the install root claim is now atomic
The duplicate-installation check read the registry, then did four yielding
Effects, then wrote the token. Two concurrent installs on one root therefore
both installed capture listeners and every interaction dispatched twice,
defeating M4's own exactly-once guarantee. The claim is now minted outside the
yielding work with check-and-set adjacent, making the invariant structural
rather than guarded - the same move that closed DQ-099. Effect.onExit releases
the claim on failure or interrupt, idempotently and only if the map still holds
this token.

Worth recording: Effect.all([install, install]) does NOT catch the old bug,
because installClient has no true async boundary with a fake root so the fibers
never interleave. The test that has teeth forces the interleave - a RacingRoot
whose querySelectorAll runs the second install from inside the first one's scan.
Both fixes were verified by reverting them and confirming the new tests fail.

M5 - addressability now survives wrappers
withSlots/withBehavior rebuilt the component via toComponentLike, losing both the
non-enumerable activation symbol and the WeakMap entry, so applying a wrapper
after addressable shipped a dormant boundary that failed permanently on first
click, with no diagnostic. Fixed by preservation rather than diagnosis:
registerComponentMetadataCopier is drained by copyComponentMetadata, the single
choke point every wrapper already funnels through.

The M1 mechanism was not reusable - it copies string-keyed fields, while the
activation is a symbol plus a WeakMap private to resume-session.ts, which
Component.ts cannot value-import without a cycle.

One test changed deliberately: "does not carry addressability through a later
component wrapper" encoded the defect as intended behaviour, asserting the loss
by name. Replaced with the opposite assertion plus a negative control that a
component which never had an activation still does not get one.

Still open, recorded in the audit docs: Element.on handlers remain invisible to
collection (a virtual Handle has no DOM node to mark - a design question, not a
patch), and the type-level half of the M5 fix, since the AddressableComponent
brand is still not carried through a wrapper's return type.

Gates: typecheck, typecheck:tests, typecheck:future, build, 970 tests, 7/7 Chromium.
The regression, shipped in b2f1322
Preserving the activation OBJECT through wrappers copied one that still mounted
the base, because componentActivation captures options.component in its run
closure. So a wrapped addressable SSR'd the wrapper and mounted the unwrapped
base on first click - the view transform, error boundary or provided layer
silently vanishing client-side. That converted a loud
ResumeComponentNotAddressableError into a silent SSR/client divergence, which is
strictly worse than the bug it replaced, and it also bypassed the fail-closed
reconstructibility guard, since restoration inspected the base and saw no
wrapper transforms.

Fixed by re-deriving rather than copying: addressable stores an ActivationSpec
in a module-private WeakMap, and stampComponentActivation builds the activation
for whichever component object it stamps. Fixing the mount target restored the
transform guard for free - they were the same bug.

Proven by mount target rather than reference identity, and verified by
reverting: with the copying copier restored the new test fails with "expected
[Function component] to be [Function component] - no visual difference", which
is the silent-divergence signature in miniature.

The lesson, recorded: preserving a HANDLE is not preserving the PROPERTY. The
activation closed over its component, so copying the symbol moved the marker
without moving the behaviour. When applying "preservation beats diagnosis",
check what the preserved value captures.

Also fixed: interruption is not a query failure
The refresh-fiber observer reported component-query-refresh-failure on any
failure exit including interruption, and the scope finalizer interrupts exactly
those fibers on every normal dispose - so ordinary teardown emitted a false
"refresh failed" diagnostic. Now guarded with Cause.hasInterruptsOnly, matching
the file's own rule and the expression path's suppression. Also interrupts a
fiber that races between refreshFibers.clear() and the callback stopping,
rather than stranding the handle.

Audits recorded (findings only, not yet fixed)
- M5 second pass re-confirmed findings 2-7 and found the regression above.
- M6 is the healthiest milestone audited: genuinely complete, no Scope leak in
  its paths, settledness enforced by whitelist by construction, behavior
  descriptors surviving every wrapper. Three defects: a restored query blanks to
  Failure where a live one keeps Stale; behavior reattachment can only select
  from snapshot bindings, so a behavior selecting a non-resumable value binding
  attaches to nothing; cacheKey is published but read by nothing, so
  single-flight is per-handle only.
- M7: no identity-stability failure survives a deploy - buildId is enforced at
  seven sites, so a stale page falls back rather than addressing a moved
  identity. Defects: nested extract() calls orphan a manifest entry, deferred
  function-nested definitions TDZ-crash when a module-scope statement calls the
  factory (executed, not inferred), and Vite keeps stale entries after file
  deletion. Item 5 (source metadata through the portable ABI) was never done -
  the third such skipped item found today after M1 and M4.

Four separate tests were found to pass under the very bug they are named for.

Gates: typecheck, typecheck:tests, typecheck:future, build, 972 tests, 7/7 Chromium.
Adversarial audit of src/__tests__/ in three disjoint lanes, prompted by the
M3-M7 milestone audits finding acceptance criteria "proven" by tests that pass
regardless of the implementation.

Organizing question: what implementation change would still let this test pass?
Every strengthened test was verified by temporarily breaking the relevant
source and confirming failure; all breaks reverted. src/** outside __tests__ is
unmodified.

972 -> 987 tests. Highlights:

- resume: the portable-behavior reattachment test attached nothing to the
  component and passed with reattach deleted; nested-collection emitted one
  entry, all after the nesting. Added the missing installClient build-mismatch
  coverage flagged by the M4 audit.
- compiler: an evaluateTransformed harness now executes generated modules,
  reproducing the M7 deferred-definition TDZ ReferenceError by execution rather
  than inspection. jsx-runtime-abi asserted toContain("className"), which
  matched the author's own prop name, not any runtime helper.
- core runtime: tautological assertions in style/effect, and a composables
  fixture searching "a" which every row matched, so an implementation that
  never filtered passed.
- Serialization.layer's HTML escaping was never asserted: encode/decode are
  inverses with the escaping deleted.

Source defects found are reported in docs/TEST_SUITE_AUDIT.md, not fixed here;
a fix and its test landing together prove nothing. Resolves the eight
unaudited createEffect sites: none has the unowned-and-unscoped shape.
tracking.ts:61 schedules its flush via queueMicrotask, but reactive.test.ts and
effect.test.ts both patch queueMicrotask to run inline in a module-level
beforeAll. The test named "uses microtask batching with explicit flush" ran
under that patch, so it asserted nothing about scheduling.

Rather than unpatch two whole files, withRealMicrotasks opts a single test back
out to the real queue and restores the fixture in a finally. The test now pins
deferral, early flush(), no double-run, and write coalescing ([0,1,2,5], not
[0,1,2,3,4,5]). A guard test asserts the fixture is restored, since otherwise
every later test in the file silently changes meaning.

Removing the queueMicrotask wrapper in tracking.ts fails the new test and
nothing else in either file, confirming every other test there is
scheduling-insensitive.
Appending function-nested portable definitions after the whole module body was
stronger than the constraint requires and broke a real pattern: a factory
invoked at module scope (export const first = makeSave()) ran before the
definition was initialized and threw a TDZ ReferenceError.

Each definition now goes immediately after its last module-scope dependency --
the earliest legal position. Dependencies are the referenced identifiers whose
binding resolves to the program scope, so anything shadowed inside the
definition does not constrain placement.

The loop walks deferred backwards: two definitions sharing one anchor are each
inserted directly after it, so the last placed would otherwise end up first,
reversing source order and breaking the ~1 identity disambiguation that resolves
collisions in source order. Two previously-passing auto-capture tests caught
this; reasoning did not.

Where a dependency genuinely follows the caller, no placement satisfies both.
The definition still follows its dependency and the author's own evaluation
order throws, rather than capturing an uninitialized binding -- pinned by a test
asserting the ReferenceError.

Closes M7 D2. 988 -> 990 tests.
Third instance of the keep-stale-on-failure family, after Finding-5 and the
router's loaderSuccess gap, so the fix is a shared constructor rather than a
third hand-rolled site.

ResultState.fromExitWithPrevious(exit, previous) settles a typed failure with
prior data to Stale(error, data). Defects and interrupts are deliberately not
preserved as Stale: they are not "the query failed with a value", and the live
queryEffect path publishes Defect for them regardless of prior data.

Resume's restored query now passes its pre-refresh state, so a failed refresh
keeps showing the data the user already has instead of blanking to Failure.
Verified by reverting to fromExit: the new test settles to Failure and fails. A
negative control pins that Stale requires data to keep.

Route.loaderSuccess now reads Stale.data. That one ships WITHOUT a test, and
the plan's severity claim is corrected: nothing on the router path constructs a
Stale (loaders settle through CoreResult.fromExit), so the gap was latent, not
live. A test would have to fabricate a state the system cannot produce --
seeding the loader cache does not work either, since renderRequest re-runs
loaders rather than reading it. Refreshing.previous is typed to exclude Stale,
so Refreshing(Stale) needs no branch.

Closes M6 D1. 990 -> 992 tests.
DQ-010 ratified this as "dom.ts already reconciles keyed children privately;
the decision is simply to expose it". That premise was wrong. Exporting the
function unchanged and running the spec's own reorder case failed immediately,
surfacing two real defects:

1. The reconciler dropped a surviving node. Its forward pass advanced `n` on a
   mismatch but never `o`, so the trailing removal loop removed nodes present
   in newNodes: [a,b,c] -> [c,a] produced [a]. Replaced with a survivor set for
   removals plus a backwards placement pass, so each node is positioned against
   an already-final successor and an unchanged list is a true no-op rather than
   a sequence of self-cancelling moves.

2. ServerNode.insertBefore/appendChild did not detach. Per DOM semantics an
   attached node is *moved*; the server DOM spliced it in while leaving the
   original, so any reorder duplicated the node. Latent until something
   reordered during SSR -- which is what a structural target will do.

Identity is the contract, not an optimisation: subscribers, focus, selection,
scroll, and media playback all live on the node, so a reconciler that rebuilt
rows would produce right-looking HTML and silently destroy all of it. The tests
assert node identity and, for the unchanged case, the *absence* of DOM
mutations.

Closes the M8.6 keyed-reconciliation prerequisite spec. The two structural
target specs (region representation, branch owner disposal) remain red.
992 -> 995 tests.
M8d's face 1 is done; faces 2 and 3 need a region representation that DQ-010
deliberately deferred until 8c.7's gate reported. It returned GO, so the
decision is due.

Three coupled sub-questions, with options and a recommendation:

1. Ownership -- recommend per-instance owners under a region owner, overruling
   the provisional lean in the spec. A region-level owner cannot dispose a
   single removed row, which is the milestone's primary use case; the
   per-instance shape also subsumes branch replacement as the one-instance
   case. Disposal is driven from the same computation that produces the
   reconciler's removals, so DOM removal and owner disposal are derived from
   one list rather than kept in agreement by convention -- the same
   structural-vs-guarded move that closed DQ-099 and the M4 install race.

2. Per-row identity -- recommend data-af-key on a single element root with a
   fail-closed diagnostic, because per-row marker comments push directly on the
   8c slope ceiling of 1.10, the gate that scales with row count. Residual risk
   stated: this makes a single element root a load-bearing authoring
   constraint. Named what would settle it -- run the density-24 fixture with
   markers and read the slope.

3. Manifest shape -- recommend one structural member with a mode field so the
   fence predicate stays single-sited. Unlike DQ-002's ExpressionOutput
   widening, target IS a wire field, so this is a real v4 -> v5 bump; low risk
   because buildId enforcement makes a stale client fail closed.

Nothing assumed: both structural specs remain unbuilt() and no region
representation, target kind, or owner type is in the source.
Re-read against this repo's conventions rather than the problem in the
abstract. One answer changes materially:

The region owner must be a child Scope, not a reactive Owner. Resume.ts is
Scope-first throughout (Scope.addFinalizer at 2197/2204/2218/3634, Scope.close
at 2429/3767/3840), and "cleanup on the reactive owner instead of the Scope" is
the exact signature of the leaks fixed in Element.on, collection().observeEach,
setAttr, and setStyle this session. The M6 audit called out the absence of that
leak in M6 paths as notable. The first draft quietly departed from the
project's existing position; a child Scope per instance also lets region
cleanup share the interruption and exit semantics of the rest of the subsystem.

The single-element-root constraint moves into the Babel plugin's existing
rejector, matching the stated rule that compile-time safety is preferred for
library-authored code with runtime diagnostics reserved for generated/dynamic
paths. The M7 audit found that rejector already issues code frames for a dozen
unsupported JSX shapes. This turns the load-bearing authoring constraint from a
silent production fallback into a build failure pointing at the row. It does
not remove the need for the slope measurement.

The manifest recommendation is unchanged but better grounded: Resume.ts:262-275
already carries a compile-time exhaustiveness device for target names, and one
structural member extends it where two would duplicate it -- the failure mode
that left the attribute allowlist in three copies with only two linked.
Integrates the ratified structural-target design across the planning docs, the
DQ record, and the forward specs, so the decision is stated once
authoritatively and referenced everywhere else.

- RESUMABILITY_IMPLEMENTATION_PLAN.md gains a Milestone 8d section: the
  ratified design, six work items, and an acceptance list. Two acceptance
  criteria are written to be falsifiable rather than plausible -- keyed updates
  assert node identity, not rendered text; row disposal counts finalizer runs
  at the moment of removal, because all three lifecycle leaks found in the
  2026-07-30 audits left correct-looking final state.
- Status header and suggested ordering updated: M8d is in progress, not "not
  started", and is re-sized from small to medium. It was called small when it
  was believed to be an export plus two targets; face 1 alone turned up two
  defects, and faces 2 and 3 touch collect, the compiler rejector, client
  install, and the manifest version.
- DQ-030 marked ratified, pointing at the plan as authoritative and retaining
  itself as the reasoning record.
- M8C_PLAN's DQ-010 note records that its provisional lean is overruled.
- fences.spec.ts: both unbuilt() specs now describe the ratified design and
  name Milestone 8d as owner instead of citing the deferral. The overruled
  lean is called out by name with "do not reinstate it from an older doc",
  since it survives in older text.

Both structural specs stay red; nothing was implemented here.
DQ-030 ratified data-af-key with an explicit escape clause: price per-row
markers against the slope ceiling, and switch if they come in under 1.10. Ran
it. They do, comfortably -- so the escape clause fires and the decision flips.

Method: an env-gated lane (AF_BENCH_ROW_MARKERS=1 on the *build*, since the
fixtures are server-rendered at build time) wraps every resumable row in a
marker comment pair. Two arms, 3 runs each, same session, compared as a paired
delta -- these runs do not reproduce the checked-in 5-run baseline's absolute
figures, so only the difference between arms is claimed. data-af-key needed no
lane: the rows already carry data-expression-index.

Slope 0.6648 -> 0.6840 against a 1.10 ceiling. Per row: 37.2 B raw, 6.2 B
gzipped, 35 B retained heap -- about 4.7% of available headroom. Gzip is where
the intuition was most wrong: near-identical comment strings compress to ~6 B.

The switch REMOVES work. data-af-key required every row to have exactly one
element root, enforced by a new Babel rejection plus a collect diagnostic and a
non-resumable fallback. Markers delimit text, fragment, and multi-node rows
equally, so the single-element-root authoring constraint is gone -- with it
M8d's compiler work item and the flagged risk that I did not know how often
real lists would violate it. Recommendations (1) per-instance child Scopes and
(3) one structural manifest member at v5 are unchanged.

Recorded honestly: the 35 B/row figure prices markers only, because the per-row
Scope cancels out of a paired delta, so the slope must be re-read once faces 2
and 3 are real. 24 rows is a small list; the linear per-row cost extrapolates,
the ratio does not.

Also found: the 1.10 slope gate is NOT automated -- neither run.mjs nor
verify.mjs computes it, it is read by hand from the result JSON. It was relied
on for this decision, so it is now an open item to move into verify.mjs. A gate
nobody runs is a gate that has already stopped working.

The measurement lane stays in the fixture, off by default, so this comparison
is repeatable rather than a number in a document.
CORRECTION FIRST. I wrote in three docs and a commit message that the 1.10
slope gate was not automated. That was wrong. verify.mjs has always enforced
resumedGrowth <= eagerGrowth * 1.1, and run.mjs calls it on every run, so a
violation has always failed the benchmark. The claim came from grepping for
"slope", "1.10", and "204800" and finding nothing -- the code used the literals
1.1 and 200_000 and the phrase "110% of eager growth". A search that misses is
evidence about the search, not about the code. Corrected in
docs/design-questions/resumability.md, RESUMABILITY_IMPLEMENTATION_PLAN.md, and
benchmarks/resumability/README.md.

What was genuinely wrong, and is now fixed:

- Thresholds are named and exported (SLOPE_CEILING, FIXED_GAP_CEILING_BYTES)
  instead of inline literals, so they are greppable.
- The gate reports its computed value on success rather than only throwing on
  failure. DQ-030 was decided by recomputing the ratio by hand out of the
  result JSON because the number was never printed; the new output reproduces
  it exactly (0.6840 / 1.10, headroom 0.4160).
- The values are persisted to result.gates and declared in result.schema.json,
  so the artifact carries the numbers a decision depends on.
- Two silent skips now print SKIPPED with a reason: absent CDP heap
  measurements, and the fixed-gap budget off its calibrated environment. A run
  with no heap data used to pass as cleanly as one that met every budget.
- Failure messages now carry the measured ratio and both growths.

Verified all four paths against doctored results: calibrated (both enforced),
uncalibrated (slope enforced, fixed gap report-only with reason), no heap data
(both SKIPPED), and a blown slope (exit 1, "ratio 5.7622, dormant 226940B,
eager 39384B").

Also removed a stale M8d acceptance criterion that still required a row to have
a single element root -- markers deleted that constraint, and the replacement
criterion asserts the opposite, since a suite that only renders single-element
rows would not notice the constraint being reintroduced.
…ion)

Writing up durable learnings surfaced a real error: DQ-030 was already taken.
The resumability lane owns DQ-001-029 and is FULL; DQ-030-049 belongs to the
router lane, where DQ-030 is a ratified decision referenced from
ROUTER_CONSOLIDATION_PLAN.md and future/router/authoring-tiers.spec.ts. I had
assigned "the next free ID" without re-reading the lane table, and widened the
resumability range in the README to cover it -- which would have left two
different ratified decisions sharing one name across five documents.

Renumbered the structural-target question to DQ-100 across the plan, the M8C
plan, the DQ entry, fences.spec.ts, the benchmark README, verify.mjs, and
result.schema.json. Router's DQ-030 is untouched.

README now records the rule that caused the mistake: lane ranges are
load-bearing, and when a lane fills, the next free block above DQ-099 is taken
and recorded rather than spilling into the neighbouring lane.

The collision was caught by a memory recall while writing up the session, not
by any check in the repo -- which is itself the argument for the README note.
Faces 2 and 3 per DQ-100: keyed list and branch regions resume with
per-row marker comment pairs (af:row:<id>:<encodedKey>:s/e), one
{ kind: "structural", mode } manifest member at v5 (v4 still emitted
when no structural entry exists), and a per-instance child Scope per
row closed from the reconciler's own removal list -- disposal and DOM
removal derive from one key diff.

- authoring: structuralExpressionCode / bindStructuralExpression;
  rows are { key, text } in this slice, keys comment-encoded
- collect: af:row pairs inside the af:expr region; invalid structural
  output fails closed (unsupported-expression-output); structural
  expressions on attribute/class/style targets are refused with a
  suppressed write
- client: recover rows from markers on first invalidation, reconcile
  by key via dom.reconcileArrays, close dropped rows' Scopes at
  removal (counted by tests, not inferred from final state), close
  survivors with the installation
- wire: ManifestV5Schema + v5 fixture, v4-on-v5-client case, and a
  structural-smuggled-into-v4 negative control in manifest-compat
- future/resumability/fences.spec.ts went fully green and is promoted
  into src/__tests__ (resume-structural.test.ts and friends)

1009 unit tests; typecheck, typecheck:tests, build green; benchmark
gates enforced and passing (slope 0.6648/1.10, gap 51224/200000 B,
read from result.gates).
M8d work item 6, closing the milestone. New report-only lane:
structural-{1,24}.html render one authored structuralExpressionCode list
(1 vs 24 keyed rows); client/structural.ts installs it; structural.mjs
measures ready (dormant) and after-one-patch (every row Scope live) heap
in the same jitless-forced-gc configuration as the gated lane, 3-run
medians, paired in-session against the scalar-expression-per-row
resume-{1,24} baseline.

Per row, density 1->24 (/23):
  live heap    379.3 B  (scalar-expression shape: 1404.2 B)
  dormant heap  47.8 B  (1137.9 B)
  doc raw/gzip  55.1 / 9.2 B  (435.1 / 29.4 B)
  manifest         0 B, one entry total  (300.5 B)

The gated density lane keeps its scalar shape so the pinned baseline
stays comparable; payloadFor now also counts af:row markers (no-op for
the default lane). Recorded in the plan (M8d now complete), DQ-100, and
the benchmark README; artifact at
bench-results/resumability/structural-latest.json.
R3 (DQ-030 -- no inert authorization API ships):
- RouterRuntime runs Route.runMatchedRouteGuards before any loader; a
  failing guard rolls the location back, runs no loader, commits no
  loader data. Guards live on RouteEntry (unified + legacy projections).
- loaderErrorCases render their tagged fallback in renderRequest.
- Route.transition is deleted (surface, decoration record, internals).
- Component.route is self-stamped unified-route sugar (stampSelfRoute);
  its type carries the route facet so Route.loader composes uncast.

R5 (DQ-033/036/038):
- Single-flight envelope schema-validated at the trust boundary via the
  Serialization seam; loader results cross through ResultWire with a
  sparse rich-value tree (Dates survive; golden fixtures byte-identical;
  malformed responses are typed SingleFlightDecodeError and hydrate
  nothing).
- Tagged error classes: SingleFlightInvokeError/DecodeError/
  TransportError, RouteLoaderTimeoutError({routeId, timeoutMs}).
- One transport ladder (context -> endpoint -> local) in both
  Atom.action forms; single-flight-runtime.ts process-global DELETED
  (the cross-request bleed); free runEffect returns the effect so
  caller context reaches the action.
- One segment engine (route-pattern.ts): splats for Route, optional
  segments for ServerRoute, link substitution via the shared model.

API inference fixes so tests (and users) need no casts: transport
service de-genericized to an unknown envelope; MergeParams preserves
optional modifiers; guard enhancer exposes its component signature;
runCachedLoader's type carries the timeout error.

Promoted future/router/{authoring-tiers,wire-and-errors}.spec.ts (all
17 green) into src/__tests__/ as fully typed tests -- no any, no casts.
Gates: typecheck, typecheck:tests, 1026 tests, build.
API fixes (each was forcing casts at call sites):
- Atom.value: DeepWiden now passes functions and built-in instances
  (URL, Date, RegExp, Error, Promise, Map, Set, WeakMap, WeakSet)
  through unchanged instead of mangling them with a mapped type;
  Atom.value(new URL(...)) is a WritableAtom<URL> without casts.
- route-loader tests: transport doubles and wire payloads now type as
  written (post-R5 unknown envelope + exported wire schema type);
  value reads narrow through typed helpers instead of (x as any).value.
- Type pins in src/type-tests/inference-no-cast.ts: Atom.value with
  class instances, optional link params, guard's component signature,
  castless transport doubles, runCachedLoader's timeout error type,
  the constructible wire payload type, and Component.route sugar's
  route facet under direct application.

Known gap, recorded not papered over: building sugar through
.pipe(Component.route, Route.loader) drops the route facet in
contextual inference (TS resolves intersection call signatures
differently in pipes than in direct calls). The two remaining casts in
route-loader.test.ts are annotated with this; the fix is the deferred
ADR-006 dispatcher collapse, not another signature reorder.

Gates: typecheck, typecheck:tests, 1026 tests, build.
DQ-031(a): RouterRuntime.toLayer provides Route.RouterTag, the narrow
url/navigate/back/forward facade implemented by the runtime (URL is a
reactive atom fed by the history adapter). One script over
RouterService behaves identically against the runtime and the Memory
layer; the interface provably stays narrow enough for loader-less
layers. Link active state reads the service's URL; the window.location
read and the pushState+PopStateEvent fallback are deleted -- a Link
outside a runtime or document is inert, touching no browser global.

DQ-031(b): queryAtom.set updates the atom immediately, FORKS the
navigation (no Effect.runSync inside a signal write), rolls back on
failure, and surfaces the error on the service's optional
onNavigationError channel -- never swallowed. Route.reload routes its
failure there too. RouterService.navigate error channel widened to
unknown accordingly.

DQ-032 (SWR supervision): LoaderCacheStore gains dispose() --
interrupts in-flight refreshes, unsubscribes reactivity, refuses late
writes (closes the server write-after-response leak). At most one
in-flight refresh per cache key: concurrent stale reads join the
running refresh. The runtime provides SwrRefreshSupervisorTag so a
superseding navigation interrupts refreshes a previous one left in
flight. isFresh is strict: staleTime 0 = immediately stale.

Promoted future/router/{navigation-stack,swr-supersession}.spec.ts
(all 14 green) into src/__tests__/ fully typed -- no any, no casts.
Gates: typecheck, typecheck:tests, 1040 tests, build.
Keep-stale on the loader path: a failed refresh settles the cache to
Stale(error, previousData) via ResultState.fromExitWithPrevious -- the
same rule the resume/query path applies -- so the router's Stale
surfacing is now LIVE, not latent. Runtime snapshots project Stale into
BOTH loaderData (the in-hand data) and errors (the typed error).

DQ-035: a Stale parent feeds its dependsOnParent child, and the child's
own Success is degraded to Stale carrying the parent's error --
transitively along the loader tree (Result.all's composition rule).

R6 / DQ-034 -- one handoff: manifest v5 gains an optional loaders
record ((routeId, params) identity, results through the canonical
ResultWire projection; Resume.ManifestLoaderEntrySchema). Streamed
deferred loader entries are inert JSON scripts on the manifest channel
(<script type="application/json" data-af-loader>): no executable
inline JS and no second window global beside the resume manifest.
readLoaderHandoff's document path collects them; the envelope + notify
hook stays as the client-side incremental mechanism for M11.5.

future/router/ is EMPTY: regression-invariants and
loader-handoff-manifest promoted fully typed (no any, no casts);
R1-R6 all implemented.
Gates: typecheck, typecheck:tests, 1053 tests, build.
Two interleaved server renders now share NOTHING: session, diagnostics,
and document are all per render.

- src/render-state.ts: ServerRenderState (session + the render's own
  server document) as an Effect service, read SYNCHRONOUSLY off the
  running fiber (Fiber.getCurrent().context) -- the FiberRef-equivalent
  this Effect v4 beta lacks. Provided by the new Resume.collectAsync
  for the whole render effect, so it survives suspension and is
  inherited by forked child fibers.
- renderToString: installs the fiber's session+document for exactly its
  own synchronous slice (serialization included -- markers are observed
  during prop serialization) and restores the ambient globals after.
  A render suspended mid-flight resumes with ITS document, not whatever
  another request installed last; the global document is restored
  exactly (identity-asserted).
- Resume.collectAsync: async-capable collection sharing collect's
  assembly (collectInternal).
- DQ-009: every event marker is "<installationId>:<eventId>". The page
  installation gets an explicit scope id like any fragment (auto pN or
  the validated installationId option; ':' rejected at collection
  time). Manifests of every version carry optional installationId; the
  client unscopes markers against it, treats foreign scopes as
  not-ours, and accepts only unqualified markers for legacy manifests.

future/streaming/resume-session-isolation.spec.ts: 5/5 green (one
stale fiber-API usage in the spec corrected to Fiber.await). Marker
pins across resume/ssr-characterization/diagnostics repinned to the
qualified form with a deterministic page0 scope id.
Gates: typecheck, typecheck:tests, 1053 tests, build.
…aming

Per ratified DQ-006: async boundaries are AUTHORED. Any Effect value in
the render tree (an unresolved Component.renderEffect) becomes an
af:region comment-pair; a synchronous region that merely takes
wall-clock time renders inline with no region -- page structure is
never a function of timing. One function, mode as a validated option:

- ordered: regions flush in document order, zero scripts; the start
  marker travels with the preceding shell chunk, content + end marker
  flush on settle. Later regions wait behind earlier ones, but every
  boundary starts computing immediately (ordering constrains flushing,
  never parallelism).
- out-of-order: the whole shell (placeholder pairs included) flushes
  first; each region swaps in as it settles, fastest first, via a
  nonce-carrying CSP-compatible inline script (template + comment
  walker, no inline handlers, no javascript: URLs).

Chunks are emitted whole -- no chunk ever splits a resume marker. The
stream renders under ONE session + document for its whole life (ambient
per-render state inside Resume.collectAsync, else stream-local), and
M11.1's fiber-state mechanism is generalized: resume observation hooks
now resolve session AND document from the running fiber, so an
addressable component rendered inside an async region emits balanced
boundary markers.

Also: Chromium browser tests re-run -- 7/7 green, validating the DQ-009
scoped-marker wire change end-to-end in a real browser. Two spec
corrections in render-to-stream.spec.ts (lazy marker regex swallowing
the start marker; stale fiber.await API).
Gates: typecheck, typecheck:tests, 1053 tests, build.
Both files went fully green and are promoted typed (no any, no
assertion casts; the one deliberate cast models an untyped JS caller
hitting renderToStream's runtime mode validation, and says so):

- streaming-session-isolation.test.ts: interleaved manifests disjoint,
  no diagnostic bleed, session carried into forked fibers, disjoint
  documents with exact global restoration, ':' rejected in scope ids.
- render-to-stream.test.ts: shell-first flush, ordered holds document
  order with zero scripts, out-of-order placeholder+nonce-swap,
  authored-boundaries-only (DQ-006), balanced component boundaries
  across chunks.

future/streaming keeps its remaining red specs (M11.4+); support.ts
stays for them.
Gates: typecheck, typecheck:tests, 1063 tests, build.
- Resume.installClientStreaming: live ingest/endOfStream handle; region-scoped
  markers (DQ-009 with region id as scope), per-region event tables, silent
  queue + exactly-once replay for pre-record interactions, fail-closed
  truncation (stream-truncated diagnostic, listeners torn down)
- installClientStreamed rewritten as a door over the same ingest primitive;
  Resume.mountFragment added as the third door (M11b item 1)
- promote streaming-manifest + live-stream-install specs into src/__tests__
  (typed, shared streaming-fake-dom double); sharpen render-to-stream's
  no-swap-script assertion to allow inert JSON manifest records
- plan: M11 items 5-6 marked done
…pages (M11b 1-3)

- second mountFragment overload: (installation, region, {html, manifest})
  injects a server Resume.collect result between the region's markers and
  installs its events into the page's own dispatch table (zero new root
  listeners)
- DQ-015 as recommended: verbatim manifest keys, client-assigned scope,
  DOM markers re-qualified at mount so independent fragments never collide
- build mismatch fails closed after injection: HTML visible but inert,
  fragment-build-mismatch diagnostic + ResumeClientBuildMismatchError
- DQ-013 handle {dispose, disposed(), inspect()}; remount disposes the
  previous fragment exactly once; parent installation untouched
- typed unit coverage in src/__tests__/mount-fragment.test.ts; the future
  spec file stays at 4/5 (fragmentAction deferred behind DQ-014/DQ-011)
doeixd added 30 commits August 12, 2026 12:18
…ts restore LIVE machines

- resume-handle: ViaSnapshotPolicy — a schema-backed PROJECTION policy for
  handle-shaped bindings (read extracts the snapshot at collect; restore
  rebuilds a live handle in the restoration Scope). Wire kind stays
  'state': no manifest version change.
- Collection: a via policy snapshots through read() with no handle
  inspection; read failures are snapshot-read-failure diagnostics.
- Restoration: via bindings decode then restore() runs in the restoration
  scope — setup is never replayed; failures fail closed as typed
  ResumeStateSnapshotDecodeError; via bindings claim no hydration key.
- Component.BindingSource + bindingSource(): factory + resume policy in
  one statically visible value; .bind(name, source) records the policy on
  the PLAN (restoration inspects plans, never runs factories — the reason
  a thunk cannot carry the policy).
- Machine.resumable(definition): sugar over snapshotVia — identical wire
  entries and identical live restores to the hand-written projection (one
  mechanism, pinned).
- Spec premises corrected in place (documented): .value carries no resume
  option and one SetupInput arg; the interim collects but fail-closed
  restoration rejects the policy-less machine bind (exactly why
  snapshotVia exists); resumable passes the source directly; manifest
  binding assertions target .value. machine-resume.spec 5/6 (last red is
  open DQ-056). New src/__tests__/machine-resumable.test.ts (3 tests,
  sabotage-verified). Suite 1243 green. Kit plan's oldest debt closed.
- View.Slots.instantiate: fresh handles per component instance for
  define-time DEFAULT slots; an explicit Slot.bind(slot, handle) is an
  author decision and is never re-minted (the defaulted marker).
- Component.withSlots materializes the render instance per setup run and
  tags the published record; invokeCommittedView runs the view under
  View.runWithSlotInstance so fromSlots binds the view to the SAME handle
  set as bindings.slots — two instances of one widget never share handles,
  and bindings.slots is genuinely a projection of the rendered view.
- component:slot-target-drift (new ComponentDiagnosticCode): setup
  publishing one handle set while the view renders another is reported
  once per component, naming the drifted slots — the backstop DQ-051 says
  lands FIRST.
- Behavior.forSlots RETAINS its slot contract as metadata.requires;
  validateAttachmentBySlots checks required-vs-actual capability via the
  extendsCapability lattice (stronger slots legal, weaker fail closed as
  component:slot-capability-mismatch on the dynamic path).
- Spec premise corrected (documented): the assembled-widget negative
  control now uses the DQ-050 shape — its legacy manual form is exactly
  the drift the backstop spec requires reported.
- Three legacy src tests updated to the ratified semantics (per-instance
  identity assertions; drift beside capability mismatch; listener via
  Behavior.attachToSlots instead of define-time shared handles).
  slots spec 8/9 (last red is open DQ-070). 1243 unit + 12/12 Chromium.
- Behavior.binding(name, { state }) declares the state a provides witness
  OWNS; Component.withBehavior materializes it once in the COMPONENT's
  scope before the behavior runs, landing it on bindings so the DQ-052
  deps channel hands it in by name.
- Replacing the behavior that authored the state KEEPS it: a matching
  replacement reuses the component-owned atom (both attachments write one
  atom — the no-fork REPLACE move no longer silently discards state). A
  component-authored binding of the same name is adopted, never overridden
  by the behavior's factory.
- An incompatible replacement dies loudly with ProvidedStateMismatchError
  (+ behavior:provides-state-mismatch via the opt-in reporter) — a
  diagnostic, never a silent reset, per the ratified decision.
- Registry rides a non-enumerable symbol carried across wrapper spreads
  beside the DQ-058 attachment registry.
- no-fork-customization.spec.ts: both DQ-053 specs green (+ the
  slot-contract-survival test flipped green) — remaining reds there are
  the K1 recipe phase and K4. Three new unit tests, sabotage-verified.
  Suite 1246 green.
…s, cssLayerOrder, DQ-054 fold, attachTo as/identity

Everything here was ratified in COMPONENT_KIT_PLAN.md (2026-07-30) or in
this session's DQ passes; this lands it:

- Style.mergeRecipes(base, patch): pure data merge — variants deep-merge
  (patch wins last), defaults override, base/compound patches compose in,
  the base recipe untouched. Patch slots are type-constrained to the
  base's names; a dynamic unknown slot is skipped and reported as
  style:unknown-recipe-slot (never silent, never a throw).
- Style.extendRecipeSlots(base, names): the ratified name-carrying
  widening (DQ-062 → Decided; the boolean flag cannot re-type the result).
- RecipeDef.compound with the ratified { when, style } spelling (variants'
  plural compounds renamed to match); resolution order base -> variants ->
  compound pinned; null explicitly unsets a defaulted axis with
  recipe.without(...) sugar.
- Style.cssLayerOrder: closed branded tuple ending in the consumer's
  'app' layer; inLayer takes CssLayer.
- DQ-054 fold: Style.forSlots DELETED; Style.make(contract, styles,
  { exhaustive? }) is the one contract-aware builder — keys constrained,
  binding inference KEPT (fixed BindingNamesOfValue: a piece without
  _bindings inferred unknown, and unknown & string = string, which made
  the check demand an unnameable binding; also ComposedStyle now carries
  the diagnostics field so conditional-type inference stays exact).
  Opt-in exhaustive coverage reports style:missing-slot-style.
- Behavior.attachTo: optional remap (identity attachment — the slots
  record IS the element map), and the ratified 'as' namespacing replacing
  the untyped merge callback (reslot without binding collisions).
- Real bug found+fixed: lookupToken could never resolve dotted LITERAL
  token keys — no fontSize token ('body.sm') ever resolved.
- Spec premise corrections (documented): ratified when-form compound,
  null-unset, cssLayerOrder name; widening unbuilt replaced with the real
  green spec. recipe-merge 8/12 (remaining: DQ-061 theme x2, DQ-063/064
  parked); no-fork 7/8 (K4 only). Suite 1246 green.
- Theme.compose(...definitions): definition-time composition producing ONE
  complete Layer — categories merge by key, later definitions win per
  token, sources untouched. The rejected alternative (merge-aware
  Layer.merge for one Context service) stays rejected.
- resolveToken: bounded, cycle-guarded semantic indirection — a token
  whose value is itself a token path ('brand' -> 'color.blue500')
  resolves through the palette level; layer() resolve uses it.
- Spec premises corrected to the ratified form (Layer.merge -> compose).
  recipe-merge.spec.ts 10/12 — only the parked DQ-063/064 placeholders
  remain. New theme-compose.test.ts (sabotage-verified).
- Status checkpoint: kit plan + CURRENT_STATUS — the components lane has
  zero buildable reds; everything left is parked DQs or later phases.
  Suite 1248 green.
…1 reds buildable now

Graded board for future/agent (57 reds) + future/security (14 reds),
built from AGENT_NATIVE_NOTES.md §10 (DQ-080-090 ratified, 'AN-1 is
unblocked'), the ratified DQ-096 naming set, and a per-test fromSrc
inventory. Grade A (build now, zero design decisions): AN-1 src/Agent.ts
(~29 reds), AN-2 reactivity-push, AN-4 result rendering, three small
independents. Grade B: AN-3 MCP in @affe/agent (spec premise correction
owed — specs load src/agent-mcp against the ratified packaging). Grade C:
DQ-094/095/097/098 each carry recorded recommendations and block only 4
placeholders. Grade D: two authorization reds are a router-lane defect
(Route.guard never read by router-runtime). Build order: AN-1 first.
…ce, audit, drift (DQ-080..088)

The agent surface, per the ratified decisions and the DQ-096 naming set:

- catalog/expose/exposeMutation: entries are projections of Portable.code
  (tool id = code id, no second identity family); DQ-087 render targets
  must be addressable — checked at construction; DQ-084 mutation is
  constructor-declared (isMutation inspects it).
- dispatch: ONE pipeline, authorize -> lookup -> drift -> args decode ->
  approve -> audit write-ahead -> run -> encode. Two-arm envelope exactly
  (DQ-080; url synthesized as /_affe/actions/<tool> per section-10
  correction 4); drift is the literal PortableBuildMismatchError shaped as
  wire data carrying the fresh manifest (DQ-081); authorization OUTERMOST
  so an unauthorized caller learns nothing (DQ-086); args decode through
  the declared Schema.Tuple before run observes them; undeclared errors
  become AgentErrorEncodeError and are never forwarded; declared tagged
  errors cross the wire as discriminated values. singleFlightHandler is
  the same function.
- Governance services (CallerContext/Approval/Authorizer/AuditLog) +
  uiLayer/agentLayer; makeDispatcher checks approval requirements at
  CONSTRUCTION with typed GovernanceUnsatisfiedError (DQ-082); audited()
  write-ahead with refuse-by-default and a durable audit-refused record
  (DQ-083); Agent.secret structural redaction + catalogDiagnostics
  name-heuristic warning (DQ-085); structArgs/argNames struct projection
  and JSON Schema manifest via Schema.toJsonSchemaDocument (DQ-088);
  dispatchCacheKey reuses Portable.cacheKey (identity unification).
- Spec premises corrected (documented): the 41->42 decode arithmetic; the
  DQ-083 attempt count (the ratified durable-refusal record REQUIRES the
  second sink attempt, contradicting the original single-attempt line).
- catalog-dispatch + build-drift went fully green and are PROMOTED to
  src/__tests__/agent-dispatch.test.ts (13 tests); governance is 10/11
  (DQ-095 placeholder), identity-unification 2/4 (AN-2/AN-4 halves).
- effect-atom-jsx/Agent subpath added (+package pin). Suite 1263 green.
makeReactivityBroadcast (connect/publish/flush/connectionCount +
serverLayer), ReactivityBroadcast service, applyPushedInvalidation.
Agent.dispatch publishes invalidated keys on the ok arm only, and
reactivityKeys normalize at expose-time (witnesses accepted, one
vocabulary across payload/audit/push). live-sync specs promoted to
src/__tests__/reactivity-push.test.ts with expanded coverage.
Agent.renderResult validates the success value against the activation
props descriptor before anything mounts (AgentRenderPropsError on
refusal); Agent.renderResultFragment yields dormant {html, manifest}
via a real Resume.collect. Resume.installFragment installs a fragment
standalone: zero code loads at install, resolver captured at install
time (process-local addressable registry as fail-closed fallback),
exact-once lazy activation and exact-once disposal.
Component.make(setup, view) two-arg shorthand added; a bare setup()
composes with view-inferred props. identity-unification specs promoted
to src/__tests__/agent-identity.test.ts.
…t tests

Agent: ExposeOptions<Args, A> typed against the Portable.Code axes
(args tuple, success codec, RenderTarget<A> upgrades DQ-087 to compile
time); Catalog<Entries> with keyof-typed renderResult/renderResultFragment
tool names; ToolsOf. reactivity-push: connectScoped (Scope-owned
subscription). Component: bindable accepts the atom-or-value union
precisely; Setup.bind three-arg overload keeps params A-free (policy
infers into P, validated via PolicyBindingOf constraint) so the
differential-pair call site infers instead of collapsing to unknown.
Behavior: Deps axis threads through withMetadata/provides/events/emits;
attachTo derives the provided contract from the behavior type so
DQ-053-provided deps are not demanded from the component.
typecheck:tests debt cleared (behavior-catalog, behavior, bindable,
machine-resumable) with no casts; new type-tests agent-catalog and
bindable-inference pin the guarantees. Remaining typecheck:all red is
the pre-existing ADR-006 examples gap only.
Three real defects, one family (true on one path only):
1. Route.guard piped onto a route NODE stamped __routeGuards onto the
   node object where nothing read it — inert authorization. guard now
   has an explicit node branch and routeGuardsOfTarget reads both node
   and component guards.
2. renderRequest/renderRequestStream/runStreamingNavigation ran matched
   loaders without consulting guards. matchedGuardDenial now runs
   parents-first fail-fast BEFORE any loader; denial yields 403, an
   empty loader payload, no deferred scripts, and marks the request
   store so the render path treats the route as blocked (no guard
   re-run, no render-time loader).
3. Loader cache identity diverged across tiers (entry model vs
   materialized meta) and the strict staleTime-0 SWR rule re-ran every
   loader on the render read; per-request stores are now requestScoped
   (request snapshot = fresh) and entries pass their normalized id.
authorization.spec fully green (stale DQ-096 unbuilt markers rewritten
as real specs) and promoted to src/__tests__/authorization.test.ts.
New tests probe the recurring defect family (gated on one tier/door,
open on another): guard authoring tiers (node-piped, component-stamped,
pipe-order), parent-layout guards gating child loaders, the streaming
render door, and the single-flight mutation payload.

They caught two live bugs, both fixed:
1. Component.route (which every node materialization routes through)
   copied component metadata but not route decorations, silently
   dropping __routeGuards (and any stamped __route* field) from the
   wrapper — "the component is protected" was an auth bypass on the
   materialized tree. Now copyRouteDecorations.
2. actionSingleFlight revalidated matched loaders with no guard
   consultation, shipping protected loader payloads back in mutation
   responses. Revalidation is now gated by matchedGuardDenial; the
   author-seeded setLoaders output stays.
routeGuardsOfTarget also reads stamped guards on unified self-stamped
routes, so both guard channels gate on every door.
packages/agent: mcpTools (one MCP tool per agent-exposed entry, object
inputSchema per DQ-088), mcpServer.callTool = the ONE Agent.dispatch
pipeline (drift, errors, envelope inherited, never re-implemented),
McpAuth pluggable authenticator (runs before tool-name validation;
authenticated identity feeds CallerContext and the audit trail),
exposure enforced not just hidden (distinct not-exposed vs unknown
codes). Transport-neutral: no MCP SDK dependency.

Per DQ-096 the adapter is a workspace package, not src/agent-mcp —
mcp-projection.spec re-pointed via new harness fromPackage helper;
vitest + tsconfig.tests alias @affe/agent and effect-atom-jsx/Agent to
source so suite and adapter share one module identity. Gating coverage
+ public-subpath import lint in src/__tests__/agent-mcp.test.ts.
Remaining spec red is the parked DQ-098 A2A marker only.
All four ratified as recommended (user-delegated), recorded in
platform.md + AGENT_NATIVE_NOTES + the TRIAGE-2026-08-17 brief.

DQ-094: AN-5 dependency restated (repo-native ViewSpec slice pinned by
generative-view-spec.spec.ts; json-render demoted to reference input);
deferred naming closed: src/ViewSpec.ts + src/view-spec-json-render.ts.
DQ-095 built: Agent.makeApprovalStore — pluggable store behind the
Approval service; restart/close resolves pending approvals as typed
denials (never drops, never hangs); pending() is plain renderable
data; unknown-id resolution fails typed.
DQ-097 built: Agent.suggested — kit-shipped partial entries with no
access representable (type + runtime + catalog all refuse smuggled
exposure); app completes via expose/exposeMutation(suggestion,
{access}).
DQ-098: userland; a bridge, if ever, lives in @affe/agent — boundary
pinned by test.

governance.spec (11) promoted to agent-governance.test.ts,
result-rendering.spec (7) to agent-render.test.ts, mcp-projection
merged into agent-mcp.test.ts. The typed ports surfaced two spec
fixtures declaring args their code never accepted (fixed) and a
makeDispatcher Layer.empty inference gap (fixed). future/agent/ now
holds only the AN-5 spec.
Reviewed vercel-labs/json-render #321 (v0.20.0) and its feature PRs
(#299/#300/#302/#307/#319/#320). New upstream review doc maps each to
our IR: named slots (#320) are a 1:1 match for our slot-shaped IR so
the lowering maps slots verbatim instead of flattening; leaves carry
children:[] (#299); action bindings lower whole with params and any
future renderer bridge implements executeAction(ActionBinding) (#307);
autoFixSpec (#300) is host-side repair AFTER our fail-closed
validation, never a substitute; nested $item repeats (#319) recorded
for the future repeat slice. README states the DQ-094 status (notes =
reference input; the spec file is the plan) and that v0.20 semantics
win over the copied gen2 docs. The lowering contract is pinned as a
new executable spec in generative-view-spec.spec.ts (red until AN-5
builds view-spec-json-render).
…ViewSpec

src/ViewSpec.ts: the typed view-tree IR with NO markup-bearing node kind
(DQ-090 holds by unrepresentability — NodeKinds is the complete set);
constructors, stateModel with typed refs, componentCatalog; decodeSpec
is the trust boundary (hand-rolled structural decoder: unknown kinds
and undeclared fields — a smuggled html field included — are REJECTED
not stripped, and refusals name the kind/path but never echo field
contents); validate with seven near-neighbour-distinct diagnostic
codes, verdicts always from the options, never from claims a ref
carries.

src/view-spec-json-render.ts: lowering to json-render v0.20 semantics —
named slots verbatim (#320, no flattening), children:[] on every leaf
(#299), whole {action, params} bindings (#307), RFC 6901 pointer
escaping, $state/$bindState only here.

Agent.emitViewSpec: generative UI as an ordinary catalog entry with the
library-owned viewSpecBuildId; refusals are typed ViewSpecInvalidError
carrying diagnostic CODES, never spec content.

All 10 AN-5 specs green first run; file promoted to
src/__tests__/view-spec.test.ts (15 tests, expanded: nested-slot depth
refusal paths, wrapper excess keys, forged-writable refs, pointer
escaping, code-only refusals). injection.spec DQ-090 marker rewritten
as a real spec (same-shape refusal for markup kinds vs typos) and
green. Export subpaths wired. future/agent/ is EMPTY — the agent lane
is complete.
…kers

Two real trust-boundary defects fixed:
1. readLoaderHandoff spread `envelope.entries` from the untrusted window
   global BEFORE the schema saw it — a malformed global (string,
   missing/non-array entries) threw a TypeError DEFECT instead of
   failing typed. The global is now handed to the schema verbatim when
   malformed; script entries merge only into a well-formed envelope.
2. decodeSingleFlightResponse accepted a both-arms envelope (ok:true
   plus error) because v4 Schema strips unknown keys — DQ-080 decided
   exactly two arms, so internal inconsistency now fails closed with
   SingleFlightDecodeError.

Stale markers discharged as real specs: M7 secret-prone-capture
diagnostic (at its real seam, the compiler, warning severity =
suggest-not-block + benign-name negative control); Agent.secret
redaction across success AND denial audit records + manifest no-echo;
AN-1 dispatch arg validation (five malformed shapes, handler never
runs). Two stale error-name expectations corrected to R5's shipped
SingleFlightDecodeError. DQ-091 partial outcome recorded (validation
half shipped via R5; envelope version field remains the open half and
the one remaining trust-boundary red). secrets.spec promoted to
src/__tests__/security-secrets.test.ts (5 tests).
versioned, formControl + lightDark built

Future reds 14 -> 7; every remaining red is the unbuilt kit-widget
milestone (K3/K4 src/kit, DQ-063/064/070, K3 Clock) — no fixable issue
remains.

- SafeHtml is now a real markup channel: View.html fails closed on
  unbranded values (the brand is the authorization, not the shape) and
  the insertion path renders branded values as markup while unbranded
  twins keep escaping — the differential pair that makes the guarantee
  non-vacuous. safe-html.spec (5) and injection.spec (7) fully green.
- Style.whenBinding piece selection is REACTIVE (closes the recorded
  attach-time-snapshot defect): binding-conditional pieces re-resolve
  per property inside the reactive accessor; branch-off unsets (K1
  null-unset); non-conditional pieces keep the resolve-once path. The
  known-defect pin in style.test.ts now asserts the fixed behavior.
- DQ-091 closed: singleFlightWireVersion=1 on BOTH envelope arms,
  missing/mismatched version fails closed; fixtures updated.
- behaviors/form-control.ts (K0b): hidden native input projection —
  dormant widgets submit real forms pre-JS; value follows the atom
  reactively; invalid is an atom mirroring aria-invalid.
- Theme.lightDark: zero-JS light/dark tokens via CSS light-dark().

Promoted (all green): safe-html, injection (security-injection),
trust-boundary (security-trust-boundary + shared security-support
fixture), machine-resume (kit-machine-resume).
docs/AGENT_SURFACE_GUIDE.md: the operator guide for the agent lane —
catalog authoring (typed expose, suggestions, structural secrets), the
one dispatch pipeline and its ratified order, the full Agent error
family, governance services + ApprovalStore contract, live sync, result
rendering, the @affe/agent MCP projection, and the ViewSpec IR with its
seven diagnostic codes and the json-render v0.20 lowering. Security
posture summarized in one section with decision ids.

agent-guide-docs.test.ts extends the RESUMABILITY_GUIDE anti-drift
discipline: ViewSpec diagnostic codes, NodeKinds, Agent error tags, and
MCP error tags are DERIVED from the source unions/classes and must all
be findable in the guide; structural anchors (dispatch order, envelope
arms, approval contract, exposure enforcement) pinned too.

CURRENT_STATUS_IN_REDESIGN_PLAN.md: 2026-08-17 status section — agent
lane complete, security lane empty, gates at 1361/102, remaining 7 reds
= the kit-widget milestone.
…seam

TRIAGE-2026-08-17-components ratified in full (user-delegated); the
components lane now has ZERO open design questions and the kit-milestone
work list is defined:

- DQ-056 closed with the whenBinding-fix outcome (per-property reactive
  accessors; dormancy residual stays with the resume lane).
- DQ-063: absorb CSS-Tags as @affe/css (author-owned upstream; tokens,
  layer order, and Theme refs version as one surface).
- DQ-064: static extraction = slot-unit fail-open, K4-owned;
  binding-conditional pieces are never extracted.
- DQ-070: slot-as-projection UNBLOCKED (DQ-050 + M11b landed) — kit
  milestone finale, ABI watch retained.
- DQ-066 BUILT: press gains a now?: () => number seam +
  clickSuppressionMs Schema knob; deterministic test owns time instead
  of sleeping through it. Effect Clock/Locale service ships with K3
  first time-holding widget.
- DQ-067: measurement-gated (keep the version counter).
- DQ-068: typed attribute tokens + absence rule, lands with dialog.
- DQ-069: closed unions + null-deselect, lands with the widgets.
- DQ-059/060: make(setup, view) shorthand is the golden path (its
  existence answers why specs preferred positional); two blessed entry
  points + a scoped test helper at the export audit.

Spec markers re-pointed from open DQs to their decided owners.
Clock/Locale + RelativeTime, @affe/css foundation

src/kit/dialog.ts: the six-layer widget (tokens/anatomy+pattern/machine/
behavior/recipe/assembled Dialog) with the closed-union platformFloor —
native <dialog> + invoker commands, first open needs zero JS. Views now
render as their node in SSR (serverValueToHTML unwraps View). Validation
-only renders (no document) degrade to a contract-only View.
src/kit/index.ts: the widget registry — the a11y gate iterates it, and
exampleProps is typed against each component's own props (DQ-069).
src/kit/time.ts: Clock/Locale services + RelativeTime, the first
time-holding widget (DQ-066(b)) — deterministic under injected layers,
locale injected too, live layers as production defaults.
packages/css (@affe/css, DQ-063): the absorbed rung-zero foundation —
one token namespace (--af-*), the ratified @layer order stated first,
color-scheme light dark; consumes public core subpaths only.

Specs green + markers discharged: a11y-and-native-floor 7/7,
no-fork-customization 8/8 (six-layer test), services-and-determinism
4/4, recipe-merge 11/12 (only the K4 extraction marker remains).
src/attributes.ts states the ratified rule once: HTML booleans (false
removes, true sets ""), enumerated ARIA booleans ("true"/"false" strings
reading back as booleans), numerics (stringify out, Number back),
strings + the data-* escape verbatim, absence = undefined reads and
null/undefined-removes writes. BOTH handle implementations now route
through it (Element test handle parses reads; dom.setAttribute
serializes writes), and attribute-contract.test.ts is the table-driven
conformance suite that requires them to agree — including the
false-removes round trip on both sides. The three data-* assertions in
element.test.ts migrated to the contract (typed reads of data-* are
strings, per the escape-hatch rule).
…tion (DQ-070); future/ is empty

- Style.extractStatic: per-module extraction, SLOT as the fail-open unit;
  binding-conditionals never extracted (reactive per DQ-056); token paths
  lower to the @affe/css --af-* var namespace; non-destructive to compose.
- View.Slot.render/mountTarget: a projected slot emits its own
  af:slot:<name> comment-pair region (client insert + SSR), children
  evaluate lazily at placement, and the region is a typed named mount
  target for fragments.
- Promoted the last five future/ spec files to src/__tests__; the forward
  specification suite is fully discharged.
- Examples: fold Route.title into the loader pipe (single-op pipe hits the
  documented R3/ADR-006 contextual-inference gap and types the page as a
  non-callable route node).
- Style.extractStatic now extracts pseudo selectors, machine states (as
  [data-state] attribute selectors - the DQ-056 dormant-widget payoff),
  nested selectors (& splices), media blocks, and inLayer overrides;
  fail-open per slot unchanged. Spec written red in future/, driven green,
  promoted to src/__tests__/static-extraction-fidelity.test.ts.
- Core fix: bare token names resolve ONLY in the property's own category
  (tokenCategoryOfProperty), so CSS keywords are never hijacked by token
  leaf collisions (display: "none" used to resolve through radius.none).
  Dotted paths stay explicit token requests; literal dotted keys
  ("body.sm") still resolve. Shared tokenPathForProperty drives both
  runtime resolution and var(--af-*) lowering.
Route enhancers no longer type differently depending on .pipe() chain
shape. Two independent causes:

- `Route` extended `Pipeable<Route<...>>`, so `self` in a pipe was the
  ROUTE facet alone; a `Component.route` sugar value lost its component
  facet and typed non-callable. `Route` (and `LayoutRoute`) now declare a
  `this`-polymorphic pipe.
- Each enhancer's `RouteNodePipeOp` brand was INTERSECTED with its generic
  call signature, which stops TypeScript instantiating that signature in
  the context of `pipe` (the result degrades to `unknown` or to the
  conditional distributed over its constraint). The brand is now a member
  of the same object type.

Drops the two `as Effect.Effect<unknown, never, never>` casts and their
KNOWN INFERENCE GAP comments in route-loader.test.ts, and the
`Route.seedLoader(UserPage as any)` cast in both single-flight examples.
router-typed-links now declares its query schema — the tightened typing
correctly rejected `page: 2` against an unschema'd string query.

Pins: src/type-tests/route-pipe-collapse.ts (compile-time) and
src/__tests__/route-pipe-inference.test.ts (runtime, promoted from
future/router/). future/router/ is now empty.
The tracker still claimed 7 reds in future/, named the kit-widget phase as
the next milestone, and listed the ADR-006 examples gap as the only
typecheck:all red — all three are now false. Records the kit milestone
(DQ-063/064/070 + property-aware token resolution) and the ADR-006 collapse
with their commits, refreshes the gate numbers (0 errors across every
typecheck leg, 1415 tests / 110 files), and states plainly that there is no
forward worklist left.

Also withdraws a stale follow-up: 'physically remove the deprecated
attach/attachByView' was already reversed on 2026-07-07 (line 896) — both
are documented in source as intentionally retained escape hatches and carry
no @deprecated tag.

future/README.md gains a current-state note: the suite is empty, so
'npm run test:future' exits non-zero with 'No test files found', which is an
empty suite rather than broken tooling.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant