Add hydration identity and resumability foundations - #11
Draft
doeixd wants to merge 100 commits into
Draft
Conversation
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)
…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.
…070) - defines the kit-milestone work list
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Component.actionsupportWhy
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
Validation
npm run typecheck:allnpm test— 40 files, 608 tests passednpm run buildgit diff --check