@zakkster/lite-signal-decorators 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,102 @@ All notable changes to `@zakkster/lite-signal-decorators` are documented here.
4
4
  The format follows Keep a Changelog; this project adheres to Semantic
5
5
  Versioning.
6
6
 
7
+ ## [1.2.0] - 2026-08-30
8
+
9
+ The flagship of the decisions/0013 strategic-admission track: a story-grade,
10
+ release-after-release ladder (S8 `@localTo` -> S9 `snapshotOf` -> S10
11
+ `costOfInstance` -> S11 fleet helpers), and this is its first rung. `@localTo`
12
+ clears strategic criterion (a) -- impossible to compose correctly on the shipped
13
+ surface: PD-51 measured the effect-based recipe clobbering a user write one tick
14
+ late, so glitch-free reset needs in-package compare-on-read machinery. The one
15
+ release nobody else can copy from a recipe.
16
+
17
+ ### Added
18
+
19
+ - **`@localTo(source, { equals? })`** -- upstream-keyed resettable local state
20
+ (19th export; the 18-member surface grows by exactly one). A field FOLLOWS
21
+ `source(self)` until someone writes it, a write OVERRIDES, and a changed
22
+ upstream RESETS it -- decided by compare-on-read, so it is glitch-free,
23
+ synchronous, and PURE (no box write on the read path; legal inside any
24
+ `@derived`). `source` is a REQUIRED tracked `(self) => value` fn read inline
25
+ (no extra node). `equals` (default `Object.is`) governs the upstream compare
26
+ ONLY -- the write path never compares.
27
+ - **The `locals` buildless section** -- `defineReactive(Class, { ..., locals })`
28
+ with `locals: { key: { source, equals?, initial? } }`, the buildless twin of
29
+ `@localTo` (fail closed on a missing/non-fn source), full parity with the
30
+ decorator path on both emit lanes.
31
+ - **The initial-value unification rule** (decisions/0014): a declared
32
+ initializer means the member STARTS there and resets on the first upstream
33
+ move (the `@trackedReset` flavor); an OMITTED initializer means the initial is
34
+ the source evaluated once at wiring and the member follows upstream from the
35
+ first read (the `@localCopy` flavor). One decorator, both field semantics,
36
+ selected by the natural syntax.
37
+ - **The ABA contract, stated plainly** (decisions/0014, shipped + asserted): no
38
+ public revision counter exists (NodeDescriptor is `{id, kind, value}`), so the
39
+ upstream compare is VALUE-based. Upstream A -> local write X -> upstream B ->
40
+ upstream back to an equals-A value leaves the read showing the STALE LOCAL X --
41
+ the reset requires upstream to change relative to the last adoption, not to
42
+ have moved transitively. tracked-toolbox's `@localCopy` has the same property;
43
+ it is documented in README/llms.txt, pinned in torture (S8-A6), never softened.
44
+ - **The `localto-torture` lane** (`test/torture/localto-torture.mjs`, scenario
45
+ 13 of 17) with its own `TORTURE_BREAK` sabotage control: the zero-alloc
46
+ read/write storm, the ABA-stale write/reset interleave asserted AS the
47
+ contract, pooled park/reinit box+seen reset, and tracking-edge + pure-compute
48
+ pins.
49
+ - **`test/17-localto.test.mjs`** (34 cases) -- the full lattice on both emit
50
+ lanes plus buildless `locals`: read/write/reset, both initial flavors, the ABA
51
+ contract, `equals` override survival, park/reinit reset, `costOf` accounting,
52
+ source-throw fail-closed, and the option/source rejection matrix.
53
+ - Emit fixtures extended: `fixture.src.ts` gains a `@localTo` member; the two
54
+ compiled outs + the source hash regenerate (no new fixture row or file).
55
+
56
+ ### Changed
57
+
58
+ - **The per-instance cost formula is now `P + L + D + E + 1`** (was `P + D + E +
59
+ 1`): one signal box per local plus the anchor; the plain per-instance seen-slot
60
+ is never a node (+0). `costOf` returns the `L` term; `capacityFor` sizes it.
61
+ Reflected everywhere the formula appears in README/llms.txt.
62
+ - The shipped `SignalDecorators.d.ts` header de-staged to emitter-named
63
+ phrasing (PD-49) -- a 1.1.1 zero-grep escapee (the sweep listed the `.js`
64
+ but not the `.d.ts`), caught by the S8 review.
65
+ - **The export surface: 18 -> 19** -- an additive MINOR under the 1.0.0 semver
66
+ promise (new exports are minors). The 1.0.0 hot canon
67
+ (`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: `@localTo` ships
68
+ its own accessor bodies and pays its own measured cost.
69
+ - Three-place version sync to 1.2.0 (`package.json`, the `VERSION` const,
70
+ `llms.txt`); the `test/15` surface-freeze recount 18 -> 19.
71
+
72
+ ### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
73
+
74
+ - A `@localTo` read measures **1.69x** a plain decorated read (two tracked reads
75
+ + a compare, versus one box read) -- documented as-is, not softened.
76
+ - Read AND write storms at N and 8N: **0.000 B/op** (control-relative +2 B), gc
77
+ major **0**, `maxPauseMs <= 0.08`; a derived over one local holds EXACTLY 2
78
+ source edges (upstream + box), 0 extra nodes over 1e5 reads.
79
+ - 4096 park/reinit cycles: `tracker.size()` 0, findings 0, warnings 0,
80
+ `activeNodes` to exact baseline, zero pool growths; release frees exactly
81
+ `P + L + D + E + 1` nodes.
82
+
83
+ Records: decisions/0013 (strategic-admission track), decisions/0014 (the localTo
84
+ contract + the ratified spike numbers), `spikes/localto-contract.mjs` (Q1..Q6,
85
+ EXIT A green against peer 1.5.0).
86
+
87
+ ### Gate output (section-10 chain, archived verbatim)
88
+
89
+ ```
90
+ fixtures OK exit 0 -- emit fixtures regenerated
91
+ test OK exit 0 -- 291 pass / 0 fail
92
+ test:gc OK exit 0 -- 291 pass / 0 fail
93
+ torture OK exit 0 -- 15 passed, 2 skipped, 0 warned, 0 failed in 33.5s
94
+ torture:controls OK exit 0 -- 17 passed, 0 skipped, 0 warned, 0 failed in 2.8s
95
+ torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 17/0/0/0; canary 1.9.0-canary.1 SUITE-GREEN 17/0/0/0]
96
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
97
+ cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.1s; controls 8/8 controls fail correctly in 5.1s
98
+ pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
99
+ ----------------------------------------------------------------------
100
+ GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
101
+ ```
102
+
7
103
  ## [1.1.1] - 2026-08-30
8
104
 
9
105
  A docs-accuracy patch. No runtime, fixture, or emit-matrix byte changed; the
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  > view-model with a measured per-property cost, one deterministic teardown, and
16
16
  > poison-on-dispose safety -- built on @zakkster/lite-signal.
17
17
 
18
- **`@reactive accessor` fields, `@derived` getters, `@reactiveEffect` methods, `@batched` actions, one `@reactiveHost` wiring site -- and `disposeReactive()` tears the whole instance down in one call, every time, with nothing left dangling. A decorated read costs ~1.0x a hand-written instance-field signal read. An instance costs exactly P + D + E + 1 pool nodes and gives all of them back on dispose. A buildless twin, `defineReactive()`, delivers the identical feature set with zero transpiler.**
18
+ **`@reactive accessor` fields, `@derived` getters, `@reactiveEffect` methods, `@batched` actions, one `@reactiveHost` wiring site -- and `disposeReactive()` tears the whole instance down in one call, every time, with nothing left dangling. A decorated read costs ~1.0x a hand-written instance-field signal read. An instance costs exactly P + L + D + E + 1 pool nodes and gives all of them back on dispose. A buildless twin, `defineReactive()`, delivers the identical feature set with zero transpiler.**
19
19
 
20
20
  ```js
21
21
  import { reactive, derived, reactiveHost, disposeReactive } from "@zakkster/lite-signal-decorators";
@@ -100,7 +100,7 @@ class Player {
100
100
  }
101
101
  }
102
102
 
103
- const p = new Player(); // exactly 2 + 2 + 1 + 1 = 6 pool nodes (P + D + E + 1)
103
+ const p = new Player(); // exactly 2 + 0 + 2 + 1 + 1 = 6 pool nodes (P + L + D + E + 1)
104
104
  p.hit(80); // shield 0, hp 70 -> status still "healthy": effect does NOT re-run
105
105
  p.hit(20); // hp 50 -> "critical": effect runs once
106
106
  disposeReactive(p); // effect stopped, deriveds + boxes disposed, slots poisoned
@@ -138,6 +138,7 @@ const ReactivePlayer = defineReactive(Player, {
138
138
 
139
139
  - **`@reactive accessor x = v`** -- a per-instance signal box in a unique symbol slot. The read body is one slot load + one monomorphic box call: zero branches, zero allocation.
140
140
  - **`@derived get y()`** -- a lazy computed owned by the instance's anchor, with optional custom `equals` for change-cutoff.
141
+ - **`@localTo(source) accessor x = v`** -- upstream-keyed resettable local state: reads follow `source(self)` until you write, a write overrides, and a changed upstream resets it -- glitch-free by compare-on-read, no effect, no extra tick. With an initializer the field starts there and resets on the first upstream move; without one it follows upstream from wiring. One signal box + one plain seen-slot per member; the read is pure, so it is legal inside any `@derived`.
141
142
  - **`@reactiveEffect m()`** -- a method that auto-runs as an effect after wiring. Manual calls are leak-guarded (a call inside a foreign tracking scope is untracked, so it records zero stray dependencies) and identity-guarded (a foreign receiver throws by name instead of running against garbage).
142
143
  - **`@batched m()`** -- the method body inside one engine batch: N writes, one flush. Action-grade by design, with a measured per-call cost -- not a per-frame path.
143
144
  - **`@reactiveHost`** -- the single wiring site. Its most-derived constructor builds the anchor, every derived, and every effect exactly once, after all fields of all classes in the chain initialize. `@reactiveHost({ registry })` binds the whole chain to an isolated lite-signal registry.
@@ -205,7 +206,7 @@ The failure mode this package is built against is not "reactivity doesn't work"
205
206
 
206
207
  ### Member decorators
207
208
 
208
- All four take a bare form and a factory form (`@reactive` and `@reactive({...})` both work).
209
+ The first four take a bare form and a factory form (`@reactive` and `@reactive({...})` both work); `@localTo` is the exception -- it always takes a required `source` argument (detailed below the table).
209
210
 
210
211
  | Decorator | Placement | Options | Behavior |
211
212
  |---|---|---|---|
@@ -213,6 +214,24 @@ All four take a bare form and a factory form (`@reactive` and `@reactive({...})`
213
214
  | `@derived` | `get y()` | `equals(a, b)` | Lazy computed owned by the anchor. Recomputes on dependency change; `equals` cuts propagation when the result is unchanged. |
214
215
  | `@reactiveEffect` | `m()` | `scheduler(run)` | Auto-runs as an effect at wiring, re-runs on tracked changes. `scheduler` defers re-runs (frame coalescing etc.). Manual calls: leak-guarded + identity-guarded. |
215
216
  | `@batched` | `m()` | -- | Runs the body inside one engine batch: all writes flush once, at close. Nesting flushes at the outermost close. Action-grade -- see [the numbers](#the-numbers). |
217
+ | `@localTo` | `accessor x = v` | `equals(a, b)` | Upstream-keyed resettable local state. Read follows `source(self)` until written, a write overrides, a changed upstream resets. Compare-on-read (pure); `equals` governs the upstream compare only. Takes a REQUIRED `source` argument -- see below. |
218
+
219
+ ### `localTo(source, { equals? }?)`
220
+
221
+ `@localTo(source)` declares a field that **follows an upstream value until someone writes it, then resets when upstream changes** -- the "local copy you can edit, that re-syncs on a real update" pattern, done glitch-free with no effect and no extra tick. `source` is a REQUIRED tracked `(self) => value` function, read inline on every get (no extra node). The get is **pure** -- it compares `source(self)` to a per-instance last-seen slot and returns the local box when upstream is unchanged, else the upstream value; it never writes a box, so a `@localTo` read is legal inside any `@derived`. A write always overrides (the write path never compares). `{ equals }` (default `Object.is`) governs the **upstream** compare only.
222
+
223
+ Two field flavors, selected by the natural syntax (the [initial-value unification rule](decisions/0014-localto-contract.md)):
224
+
225
+ ```js
226
+ @reactiveHost
227
+ class Field {
228
+ @reactive accessor upstream = "server";
229
+ @localTo((self) => self.upstream) accessor draft; // no initial: FOLLOWS upstream from wiring
230
+ @localTo((self) => self.upstream) accessor pinned = ""; // initial: STARTS "", resets on first upstream move
231
+ }
232
+ ```
233
+
234
+ **The ABA contract (honest, shipped, never softened).** The upstream compare is VALUE-based -- lite-signal exposes no public revision counter, and reaching for a private one would be impure. So the reset triggers when upstream *changes relative to the last adoption*, not when it has moved transitively: upstream `A` -> local write `X` -> upstream `B` -> upstream back to an equals-`A` value leaves the read showing the **stale local `X`**. tracked-toolbox's `@localCopy` has the same property. A coarse custom `equals` widens override survival on purpose. See the [compare-on-read design bullet](#design-decisions-worth-knowing).
216
235
 
217
236
  ### Class decorator
218
237
 
@@ -229,6 +248,7 @@ The buildless twin. Installs the members on `Class.prototype`, wraps the class t
229
248
  |---|---|---|
230
249
  | `signals` | `["a", "b"]` or `{ key: value \| { initial \| init \| equals } }` | A plain non-function value is the initial. `initial` is taken verbatim; `init(self)` computes per instance; a bare function is a named throw (ambiguous -- wrap it). |
231
250
  | `deriveds` | `{ key: (self) => value \| { get, equals } }` | |
251
+ | `locals` | `{ key: { source, equals?, initial? } }` | Map only. The buildless twin of `@localTo`. `source` REQUIRED and a `(self) => value` fn (missing/non-fn is a named throw); `equals` governs the upstream compare; `initial` (verbatim) selects the reset-from flavor, its absence the follow-from-wiring flavor. |
232
252
  | `effects` | `{ key: (self) => void \| { run, scheduler } }` | Map only. |
233
253
  | `host` | `{ registry }` or omitted | Same validation as `@reactiveHost`. |
234
254
 
@@ -248,7 +268,7 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
248
268
 
249
269
  | Export | Signature | Behavior |
250
270
  |---|---|---|
251
- | `costOf` | `(Factory) => { nodes, links, signals, deriveds, effects }` | The measured, settled per-instance cost, probed on the class's bound registry (frozen result, cached per class). Double-probed: an inconclusive or polluted probe THROWS -- never a guess. `nodes` is exactly P + D + E + 1; `links` is the first-full-read link count. |
271
+ | `costOf` | `(Factory) => { nodes, links, signals, deriveds, effects }` | The measured, settled per-instance cost, probed on the class's bound registry (frozen result, cached per class). Double-probed: an inconclusive or polluted probe THROWS -- never a guess. `nodes` is exactly P + L + D + E + 1; `links` is the first-full-read link count. |
252
272
  | `capacityFor` | `(inventory, { headroom }?) => RegistryConfig` | Sizes a `createRegistry` config from `[Factory, count]` pairs: nodes exact, links x `headroom` (floored at the engine minimum of 1), `prealloc: "eager"`, `onCapacityExceeded: "throw"`. Fail-closed inventory and options validation. Link policy + caveats: [decisions/0007](decisions/0007-capacity-policy.md). |
253
273
  | `enableLabels` / `labelOf` | `(on)` / `(idOrHandle, registry?) => string \| undefined` | Opt-in devtools identity (default OFF): while on, wiring registers per-registry `nodeId -> "Class.prop"` / `"Class#method"` / `"Class@anchor"`; dispose unregisters. `labelOf` misses return `undefined`, never throw. |
254
274
  | `auditReactive` | `(on)` | Opt-in leak auditor (default OFF): a lazily-created `FinalizationRegistry` reports any instance collected WITHOUT `disposeReactive`, naming class and shape. Holds no instance references itself; zero cost and zero registrations while off. |
@@ -260,7 +280,7 @@ With labels and audit off, the zero-GC budgets are byte-identical to 0.3.0 -- th
260
280
  | Export | Value |
261
281
  |---|---|
262
282
  | `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
263
- | `VERSION` | `"1.1.1"` |
283
+ | `VERSION` | `"1.2.0"` |
264
284
 
265
285
  ### The rejection matrix
266
286
 
@@ -349,6 +369,8 @@ The fair baseline for a decorated property is a hand-written instance field (`th
349
369
 
350
370
  A decorated reactive property costs **~1.0x a hand-written instance-field signal read**; both are ~2x a module-level signal because that is the cost of per-instance storage, paid either way -- an engine indirection, not a decorator tax. Writes are ~2.5-3x a module-const read across all instance layouts (box `.set` propagation dominates; inherent to any reactive write). The rejected dictionary layout is the one that *degrades at fleet scale* (cross-instance IC megamorphism) -- the hazard only a class-shaped benchmark exposes, and the reason this package doesn't use one.
351
371
 
372
+ A `@localTo` read measures **1.69x** a plain decorated read -- two tracked reads (upstream `source` + the local box) plus a value compare, versus the one box read of `@reactive` -- and it pays that cost with **0.000 B/op** under the read/write storms (`gc.major 0`, observed `maxPauseMs` 0.07-0.08 against the 4.0 gate); the compare-on-read is honest arithmetic, not free.
373
+
352
374
  ### `@batched` per call (`spikes/batched-cost.mjs`)
353
375
 
354
376
  | Path | ns/op |
@@ -361,7 +383,7 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
361
383
 
362
384
  ### Per instance
363
385
 
364
- `P + D + E + 1` pool nodes -- one per signal, per derived, per effect, plus the anchor. All of them return to the pool on dispose: conservation is node-exact (`activeNodes` to baseline, zero pool growths, allocations minus disposals reconciled) over 4096-cycle churn and a wall-clock soak.
386
+ `P + L + D + E + 1` pool nodes -- one per signal, per local, per derived, per effect, plus the anchor. All of them return to the pool on dispose: conservation is node-exact (`activeNodes` to baseline, zero pool growths, allocations minus disposals reconciled) over 4096-cycle churn and a wall-clock soak.
365
387
 
366
388
  <details>
367
389
  <summary><strong>Zero-GC design notes: the allocation table + the gates</strong></summary>
@@ -373,15 +395,15 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
373
395
  | `@derived get` read | none | lazy computed read |
374
396
  | Effect re-run | none retained | gated: zero major GC across the read/write torture lanes |
375
397
  | `@batched m()` call | 1 thunk + 1 rest array | the documented, measured exception (+7 ns vs raw batch); action-grade only |
376
- | `new Host()` | P + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
398
+ | `new Host()` | P + L + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
377
399
  | `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
378
400
  | `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
379
401
 
380
- The gates that hold it (run on every change, all green at 1.1.1):
402
+ The gates that hold it (run on every change, all green at 1.2.0):
381
403
 
382
- - `npm test` / `npm run test:gc` -- **257/257** on both lanes.
404
+ - `npm test` / `npm run test:gc` -- **291/291** on both lanes.
383
405
  - Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
384
- - Torture: **16 scenarios** (zero-GC read/write lanes at `maxMajor 0, maxPauseMs 4`; 4096-cycle leak gate at 0 live / 0 findings / 0 warnings; capacity atomicity at every overflow point; a 300-seed x 20k-op oracle with zero divergences; the `reinit-torture` acquire/release gate) -- **14 run + 2 that skip correctly below their peer floors** (`scope-adoption` needs 1.6.0, `using-dispose` needs 1.9.0; the installed peer is 1.5.0). A skip *below* a floor is the forward-compat design working; a skip *at or above* it is a FAIL (run.mjs enforces floor-escalation). Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- **16/16 controls** prove each gate can actually fail.
406
+ - Torture: **17 scenarios** (zero-GC read/write lanes at `maxMajor 0, maxPauseMs 4`; 4096-cycle leak gate at 0 live / 0 findings / 0 warnings; capacity atomicity at every overflow point; a 300-seed x 20k-op oracle with zero divergences; the `reinit-torture` acquire/release gate; the `localto-torture` zero-alloc read/write storm + ABA-stale interleave lattice + pooled park/reinit) -- **15 run + 2 that skip correctly below their peer floors** (`scope-adoption` needs 1.6.0, `using-dispose` needs 1.9.0; the installed peer is 1.5.0). A skip *below* a floor is the forward-compat design working; a skip *at or above* it is a FAIL (run.mjs enforces floor-escalation). Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- **17/17 controls** prove each gate can actually fail.
385
407
  - `churn-soak` + `fleet-soak`: sustained construct/use/dispose and a 10s 2k-VM fleet tick; pools at floor and retained heap flat at every sample.
386
408
 
387
409
  The cross-framework matrix lives in `bench/` (private, never shipped): six engines -- both our tiers, the hand-written `lite-raw-boxes` baseline, MobX 7, signal-utils/signal-polyfill, and a hand-rolled alien-signals class -- across eight class-shaped scenarios (including the `churn-reuse` acquire/release lane, where the lite tiers pool with zero retained growth and MobX/signal-utils/alien-class are structurally `unsupported` -- no disposable instance lifecycle to pool), checksum-verified for identical work, stamped into `bench/results.txt`. The formal verdicts are in [`decisions/0006-kill-criteria.md`](decisions/0006-kill-criteria.md): the decorated path measured **0.94x** the hand-written baseline on vm-write and **1.10x** on a 10k-instance fleet read (the 2.0x kill line cleared with margin), and **0 major + 0 minor GC over 4096 construct/use/dispose cycles** with pools at floor -- while emitting ~12.6x less transient garbage per churn run than the hand-rolled class it replaces.
@@ -406,18 +428,19 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
406
428
  - **Symbol-slot storage.** Chosen over the emitter's private backing (emitter-dependent codegen) and a dict (fleet megamorphism, measured) -- and it is the same mechanism poison uses, so storage, dispose, and poison are one design.
407
429
  - **Statics and `#` privates are rejected, not half-supported.** A module-level signal belongs to raw lite-signal; a private member can't be reached by the wiring protocol -- both are named decoration-time throws.
408
430
  - **Pooled reinit is an identity-stable arena tool, not a speed shortcut (1.1.0).** `releaseReactive(vm)` parks a live instance to the engine pool and `reinitReactive(vm, initials?)` revives it -- a three-state lattice (live / parked / disposed) over the same instance, so consumers keep the object reference across turnover. It holds its gate: over 4096 acquire/release cycles at the churn shape (P=4, D=2, E=1) it measures **0 major GC**, retained delta-heap **at or below the in-process zero-alloc control**, and exact pool conservation (`activeNodes` back to baseline, zero pool growths, a parked instance holding 0 engine nodes). The honest throughput number, same shape, 2026-08-30 stamp (module 1.1.0): plain construct/dispose CHURN is *faster* -- **1323K ops/s** vs reuse's **1159K** -- because construction is already allocation-light and pool-conserving, so reinit is not a per-op win. Reach for it when you need identity-stable pooled instances under sustained turnover with zero retained growth (an arena/fleet primitive), not when you want raw op speed. MobX has no equivalent lifecycle at all: its instances are never disposable, so there is no release/reinit cycle to pool ([decisions/0010](decisions/0010-reinit-contract.md), [0011](decisions/0011-reinit-api.md)).
431
+ - **`@localTo` resets by compare-on-read, and its ABA limit is stated, not hidden (1.2.0).** The reset is decided *on the read* -- `source(self)` compared to a per-instance last-seen slot -- so it is glitch-free, synchronous, and pure (no box write on read, legal inside a `@derived`). The rejected alternative was an effect that watches upstream and clears the local: PD-51 measured that recipe clobbering a user's write one tick late, which is the whole reason the feature lives in-package instead of a cookbook recipe. Because the compare is value-based (lite-signal exposes no public revision counter and a private one would be impure), the honest limit is ABA: upstream `A` -> local write -> upstream `B` -> upstream back to an equals-`A` value shows the stale local -- the same property tracked-toolbox's `@localCopy` ships, documented rather than papered over ([decisions/0013](decisions/0013-strategic-admission-track.md), [0014](decisions/0014-localto-contract.md)).
409
432
 
410
433
  ---
411
434
 
412
435
  ## Testing (for clients & QA)
413
436
 
414
437
  ```bash
415
- npm test # node --test, 257 tests
416
- npm run test:gc # the same 257 with --expose-gc (enables the allocation assertions)
438
+ npm test # node --test, 291 tests
439
+ npm run test:gc # the same 291 with --expose-gc (enables the allocation assertions)
417
440
  npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> cookbook -> pack
418
441
  ```
419
442
 
420
- **257 tests** across sixteen files, all green at 1.1.1. The decorator protocol is tested three times over: against a mock standard-decorators emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
443
+ **291 tests** across seventeen files, all green at 1.2.0. The decorator protocol is tested three times over: against a mock standard-decorators emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
421
444
 
422
445
  | File | Tests | Covers |
423
446
  |---|---:|---|
@@ -435,8 +458,9 @@ npm run gate # the full pre-publish chain (section 10): fixtures -> test
435
458
  | `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
436
459
  | `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
437
460
  | `14-qa-s4-boundary` | 21 | S4 adversarial edges: stats-less facade closure, signals-only capacity floor, label/audit boundary matrix |
438
- | `15-cookbook` | 14 | [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) drift/parity: each fenced block byte-compared against its tagged companion `#region` (both directions + both-way coverage), surface freeze (exactly 18 exports), citation allowlist, link law, static-cost probe |
461
+ | `15-cookbook` | 14 | [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) drift/parity: each fenced block byte-compared against its tagged companion `#region` (both directions + both-way coverage), surface freeze (exactly 19 exports), citation allowlist, link law, static-cost probe |
439
462
  | `16-reinit` | 29 | Pooled-reinit lattice on both emit lanes: park/reinit/dispose transitions, the five `reinitReactive` fail-closed states, parked-touch throws by name, `initials` boundary matrix (0..N+1 keys, null/undefined, NaN/-0 verbatim), `Symbol.dispose` on a parked instance, accessor descriptors byte-identical across reinit, self-release re-entrancy, ledger conservation |
463
+ | `17-localto` | 34 | `@localTo` on both emit lanes + buildless `locals`: the read/write/upstream-reset lattice, both initial flavors (follow-from-wiring vs reset-from-initial), the ABA stale-local contract, `equals` override survival, park/reinit box+seen reset, `costOf` = P+L+D+E+1, source-throw fail-closed, and the fail-closed option/source matrix |
440
464
 
441
465
  ### Emit-support matrix
442
466
 
@@ -447,14 +471,14 @@ Generated by `node test/fixtures/emit-matrix.mjs` from `test/fixtures/hashes.jso
447
471
 
448
472
  | Source | Emitter | Emit lane | Compiled output | sha256 | At decoration time |
449
473
  |---|---|---|---|---|---|
450
- | `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `1a3fc0f943bf` | accepted -- full decorator surface wired + pinned green |
451
- | `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `eb9dfb5939b1` | accepted -- full decorator surface wired + pinned green |
474
+ | `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `5d6837710b34` | accepted -- full decorator surface wired + pinned green |
475
+ | `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `f7d8b3e5ed19` | accepted -- full decorator surface wired + pinned green |
452
476
  | `static.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/static.src.js` | `d2a03e3d5f70` | rejected -- static member is a named throw at decoration time |
453
477
  | `static.src.ts` | Babel | standard 2023-11 | `babel-out/static.src.js` | `dc936c5aa235` | rejected -- static member is a named throw at decoration time |
454
478
  | `legacy.src.ts` | TypeScript 5 | legacy (experimental) | `ts-legacy-out/legacy.src.js` | `c1059b1d37b1` | rejected -- legacy emit -> named rejection at decoration time |
455
479
  | `legacy.src.ts` | Babel | legacy (experimental) | `babel-legacy-out/legacy.src.js` | `1d35a02c57ce` | rejected -- legacy emit -> named rejection at decoration time |
456
480
 
457
- Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`, `legacy.src.ts` `30ac3dabaf7c`.
481
+ Source hashes: `fixture.src.ts` `339c40148a70`, `static.src.ts` `81fb649965e6`, `legacy.src.ts` `30ac3dabaf7c`.
458
482
  <!-- EMIT-MATRIX:END -->
459
483
 
460
484
  ### The torture suite (dev-side, never shipped)
@@ -462,13 +486,13 @@ Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`,
462
486
  Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
463
487
 
464
488
  ```bash
465
- npm run torture # all 16 scenarios (14 run + 2 floor-gated skips)
489
+ npm run torture # all 17 scenarios (15 run + 2 floor-gated skips)
466
490
  npm run torture:semantic # the correctness lane (CI)
467
491
  npm run torture:soak # the wall-clock churn + fleet soaks
468
492
  npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
469
493
  ```
470
494
 
471
- Sixteen scenarios: emit-matrix, ordering, lifecycle, pool-conservation, zero-GC lanes, capacity atomicity (every overflow point x both construction paths), the full disposed-poison surface + resurrection storms, a 4096-cycle lite-leak gate, a **300-seed x 20k-op oracle fuzzer** (decorated vs hand-wired raw twin in lockstep: every derived value, every effect fire count, every graph opcode tally), raw/decorated interop + cross-registry + `registry.destroy()` contracts, batch/untrack semantics, the `reinit-torture` acquire/release gate (4096 pooled cycles: `maxMajor 0`, retained delta-heap at/below the in-process zero-alloc control, exact pool conservation), the wall-clock churn soak, and a 10s 2k-VM fleet soak -- plus two forward-compat scenarios (`scope-adoption`, `using-dispose`) that **skip correctly** while the installed peer sits below their per-feature floors (1.6.0 `createScope`, 1.9.0 `Symbol.dispose`). A skip below a floor is the design working; a skip at or above it is a FAIL. On the installed 1.5.0 peer: 14 pass, 2 skip. Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- a gate that cannot fail is not a gate. Seeded lanes replay exactly via `TORTURE_SEED`.
495
+ Seventeen scenarios: emit-matrix, ordering, lifecycle, pool-conservation, zero-GC lanes, capacity atomicity (every overflow point x both construction paths), the full disposed-poison surface + resurrection storms, a 4096-cycle lite-leak gate, a **300-seed x 20k-op oracle fuzzer** (decorated vs hand-wired raw twin in lockstep: every derived value, every effect fire count, every graph opcode tally), raw/decorated interop + cross-registry + `registry.destroy()` contracts, batch/untrack semantics, the `reinit-torture` acquire/release gate (4096 pooled cycles: `maxMajor 0`, retained delta-heap at/below the in-process zero-alloc control, exact pool conservation), the `localto-torture` gate (zero-alloc `@localTo` read/write storm at `maxMajor 0`, the ABA-stale write/reset interleave asserted AS the shipped contract, pooled park/reinit box+seen reset, tracking-edge and pure-compute-read pins), the wall-clock churn soak, and a 10s 2k-VM fleet soak -- plus two forward-compat scenarios (`scope-adoption`, `using-dispose`) that **skip correctly** while the installed peer sits below their per-feature floors (1.6.0 `createScope`, 1.9.0 `Symbol.dispose`). A skip below a floor is the design working; a skip at or above it is a FAIL. On the installed 1.5.0 peer: 15 pass, 2 skip. Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- a gate that cannot fail is not a gate. Seeded lanes replay exactly via `TORTURE_SEED`.
472
496
 
473
497
  ### The cookbook lane (dev-side, never shipped)
474
498
 
@@ -546,7 +570,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
546
570
  - **Not a deep/proxy observation layer.** No `observable.deep`, no wrapped Arrays/Maps/Sets, no proxy magic -- the reactive unit is a declared member, not a traversed object graph. Collections are `@zakkster/lite-project` territory.
547
571
  - **Not a per-frame action system.** `@batched` costs a measured thunk per call -- fine for "one call per user intent", wrong inside a render loop. Per-frame hot lanes stay on plain accessor writes (and frame *scheduling* belongs to `lite-raf`).
548
572
  - **Not a framework, renderer, or component model.** It ends at the reactive view-model; DOM binding is `lite-signal-dom`'s job.
549
- - **Not a general meta-programming kit.** Five decorators, one wiring law -- not an open decorator toolbox. It does one thing: turn a class into a reactive view-model with a provable lifetime.
573
+ - **Not a general meta-programming kit.** Six decorators, one wiring law -- not an open decorator toolbox. It does one thing: turn a class into a reactive view-model with a provable lifetime.
550
574
  - **Not a MobX API shim.** No `makeObservable`, no administration objects -- and no GC-based cleanup: disposal is explicit, deterministic, and verified, because "the collector will get it eventually" is not a lifecycle.
551
575
  - **Not a legacy-decorators consumer.** TypeScript `experimentalDecorators` emit is detected by call shape at decoration time and rejected with a named error -- never "works differently under legacy".
552
576
  - **Not usable as `@` syntax without a toolchain** -- that is exactly what `defineReactive` exists for.
@@ -566,7 +590,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
566
590
 
567
591
  ### The cookbook
568
592
 
569
- [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 18-export surface -- how to build the things this package deliberately does not ship a decorator for, by composing the ones it does. Its headline is the **MobX-parity-by-composition matrix**, mapping each remaining MobX construct (`observable.array`, `observable.map`, `observable.deep`, `toJS`, `when`, `runInAction`, `observe`/`intercept`) to a decorator, a suite member, or a recipe -- extending the migration tables above to the rest of MobX with the honest note per row. It walks the **two-plane fleet** (a sim plane of arena columns written raw per frame beside a reactive plane of a handful of committed members), the reactive-collection-without-a-node-per-element pattern, and the **lite-store boundary** where document state meets class state -- stated plainly as the one path that is *not* zero-GC, and why. Every code block is byte-verified against a runnable, GC-gated companion in `cookbook/` (`npm run cookbook`), so a quoted recipe cannot drift from working code. It is delivered GitHub-only -- the installed tarball stays the lean 7-file runtime surface (decisions/0009).
593
+ [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 19-export surface -- how to build the things this package deliberately does not ship a decorator for, by composing the ones it does. Its headline is the **MobX-parity-by-composition matrix**, mapping each remaining MobX construct (`observable.array`, `observable.map`, `observable.deep`, `toJS`, `when`, `runInAction`, `observe`/`intercept`) to a decorator, a suite member, or a recipe -- extending the migration tables above to the rest of MobX with the honest note per row. It walks the **two-plane fleet** (a sim plane of arena columns written raw per frame beside a reactive plane of a handful of committed members), the reactive-collection-without-a-node-per-element pattern, and the **lite-store boundary** where document state meets class state -- stated plainly as the one path that is *not* zero-GC, and why. Every code block is byte-verified against a runnable, GC-gated companion in `cookbook/` (`npm run cookbook`), so a quoted recipe cannot drift from working code. It is delivered GitHub-only -- the installed tarball stays the lean 7-file runtime surface (decisions/0009).
570
594
 
571
595
  ---
572
596
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-signal-decorators -- Stage-3 decorator layer over
2
+ * @zakkster/lite-signal-decorators -- Standard-decorators layer over
3
3
  * @zakkster/lite-signal.
4
4
  *
5
5
  * Public type surface for the JavaScript implementation in
@@ -30,6 +30,16 @@ export interface DerivedOptions<V> {
30
30
  equals?: (a: V, b: V) => boolean;
31
31
  }
32
32
 
33
+ /** Options for a `@localTo` member. `equals` governs the UPSTREAM compare only. */
34
+ export interface LocalToOptions<V> {
35
+ /**
36
+ * Custom equality predicate for the UPSTREAM compare (source vs the last-seen
37
+ * value). A coarse predicate widens how long a local override survives an
38
+ * upstream move. Default: `Object.is`. The write path never compares.
39
+ */
40
+ equals?: (a: V, b: V) => boolean;
41
+ }
42
+
33
43
  /** Options for a `@reactiveEffect` method. */
34
44
  export interface ReactiveEffectOptions {
35
45
  /** Optional scheduler forwarded straight to the underlying `effect(fn, { scheduler })`. */
@@ -101,6 +111,37 @@ export function derived<V>(
101
111
  ctx: ClassGetterDecoratorContext<This, V>,
102
112
  ) => (this: This) => V;
103
113
 
114
+ // --- localTo ------------------------------------------------------------------
115
+
116
+ /**
117
+ * `@localTo(source) accessor x = v` -- upstream-keyed resettable local state.
118
+ * Each read compares the tracked `source(self)` against a per-instance last-seen
119
+ * slot: an unchanged upstream yields the local override, a changed upstream
120
+ * resets to the upstream value (no write on read -- pure). A write always
121
+ * overrides. With an initializer the member STARTS there and resets on the first
122
+ * upstream move (the `@trackedReset` flavor); without one it FOLLOWS upstream
123
+ * from wiring (the `@localCopy` flavor). `source` is REQUIRED. `equals` governs
124
+ * the upstream compare only.
125
+ *
126
+ * The ABA contract (shipped, documented): the reset requires the upstream to
127
+ * change relative to the last adoption, not to have merely moved -- upstream
128
+ * A -> local write X -> upstream B -> upstream back to an equals-A value shows
129
+ * the STALE local X.
130
+ *
131
+ * @example
132
+ * class Editor {
133
+ * `@reactive` accessor saved = "";
134
+ * `@localTo`((self) => self.saved) accessor draft = "";
135
+ * }
136
+ */
137
+ export function localTo<This, V>(
138
+ source: (this: This, self: This) => V,
139
+ options?: LocalToOptions<V>,
140
+ ): <T>(
141
+ target: ClassAccessorDecoratorTarget<T, V>,
142
+ ctx: ClassAccessorDecoratorContext<T, V>,
143
+ ) => ClassAccessorDecoratorResult<T, V>;
144
+
104
145
  // --- reactiveHost -------------------------------------------------------------
105
146
 
106
147
  /**
@@ -177,6 +218,20 @@ export interface SignalSpec<This = unknown, V = unknown> {
177
218
  equals?: (a: V, b: V) => boolean;
178
219
  }
179
220
 
221
+ /** Local descriptor for a `defineReactive` `locals` map entry (`@localTo` twin). */
222
+ export interface LocalSpec<This = unknown, V = unknown> {
223
+ /** The tracked upstream read `(self) => value`. REQUIRED. */
224
+ source: (this: This, self: This) => V;
225
+ /** Custom equality predicate for the upstream compare. Default: `Object.is`. */
226
+ equals?: (a: V, b: V) => boolean;
227
+ /**
228
+ * The initial value. Present -> the member starts here and resets to upstream
229
+ * on the first upstream move (`@trackedReset`). Absent -> the member follows
230
+ * upstream from wiring (`@localCopy`).
231
+ */
232
+ initial?: V;
233
+ }
234
+
180
235
  /** Derived descriptor for a `defineReactive` `deriveds` map entry. */
181
236
  export interface DerivedSpec<This = unknown, V = unknown> {
182
237
  /** The compute body `(self) => value`. */
@@ -204,6 +259,8 @@ export interface DefineReactiveSpec<This = any> {
204
259
  * throw (ambiguous -- use `{ initial: fn }` or `{ init: (self) => value }`).
205
260
  */
206
261
  signals?: PropertyKey[] | Record<PropertyKey, unknown | SignalSpec<This>>;
262
+ /** Upstream-keyed locals (`@localTo` twin): a map of `key -> LocalSpec`. */
263
+ locals?: Record<PropertyKey, LocalSpec<This>>;
207
264
  /** Lazy deriveds: a map of `key -> (self) => value | DerivedSpec`. */
208
265
  deriveds?: Record<PropertyKey, ((this: This, self: This) => unknown) | DerivedSpec<This>>;
209
266
  /** Auto-effects: a map of `key -> (self) => void | EffectSpec`. */
@@ -289,12 +346,14 @@ export function rootOf(vm: object): NodeDescriptor;
289
346
 
290
347
  /** The measured per-instance cost of a reactive class, returned by {@link costOf}. */
291
348
  export interface ReactiveCost {
292
- /** Total nodes = `signals + deriveds + effects + 1` (the anchor). */
349
+ /** Total nodes = `signals + locals + deriveds + effects + 1` (the anchor). */
293
350
  nodes: number;
294
351
  /** Dependency links held after every `@derived` has been read once (0007). */
295
352
  links: number;
296
353
  /** Count of `@reactive` members. */
297
354
  signals: number;
355
+ /** Count of `@localTo` members (each one box node; its seen slot is a plain field). */
356
+ locals: number;
298
357
  /** Count of `@derived` members. */
299
358
  deriveds: number;
300
359
  /** Count of `@reactiveEffect` members. */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-signal-decorators v1.1.1
2
+ * @zakkster/lite-signal-decorators v1.2.0
3
3
  * --------------------
4
4
  * Standard-decorators layer over @zakkster/lite-signal. Turns a plain class into
5
5
  * a reactive view-model with measured per-instance cost and deterministic
@@ -96,6 +96,13 @@ const CLOSURES = Symbol("lite-signal-decorators.closures");
96
96
  // wins. Keyed by the frozen signal rec (bounded by the class member count).
97
97
  const SIG_INITIAL = new WeakMap();
98
98
 
99
+ // S8/PD-58: decorator-local field-initial for reinit reset. A @localTo member's
100
+ // field-initializer value is captured (per member, first-seen) in makeLocalInit --
101
+ // undefined means "no initializer" (the @localCopy flavor: reinit reseeds the box
102
+ // from the current upstream). Buildless locals carry hasInitial + initFn on the
103
+ // rec instead. Keyed by the frozen local rec (bounded by the class member count).
104
+ const LOCAL_INITIAL = new WeakMap();
105
+
99
106
  // Scratch-frame stack (D-2h): decorator signal boxes are created in accessor
100
107
  // `init` during super()'s field initialization -- BEFORE wireInstance's
101
108
  // try/catch exists. Each init pushes its box here; the wrapper constructor
@@ -139,6 +146,7 @@ let AUDIT_FR = null;
139
146
 
140
147
  // Known option keys per decorator (unknown-key did-you-mean sets, PD-8/PD-11).
141
148
  const KNOWN_OPTION_KEYS = ["equals"];
149
+ const LOCAL_OPTION_KEYS = ["equals"];
142
150
  const EFFECT_OPTION_KEYS = ["scheduler"];
143
151
  const HOST_OPTION_KEYS = ["registry"];
144
152
  const CAP_OPTION_KEYS = ["headroom"];
@@ -285,6 +293,12 @@ function throwBadScheduler(what) {
285
293
  );
286
294
  }
287
295
 
296
+ function throwLocalSource() {
297
+ throw new TypeError(
298
+ `${ERR}localTo requires a source function: write \`@localTo((self) => self.upstream) accessor x = ...\`. \`source\` is a tracked (self) -> value read, not an option.`,
299
+ );
300
+ }
301
+
288
302
  function throwUnknownOption(what, key, known) {
289
303
  const near = nearestKey(String(key), known);
290
304
  throw new TypeError(
@@ -423,9 +437,10 @@ function throwReinitInitials(ctorName) {
423
437
  function throwReinitInitialsKey(ctorName, key, plan) {
424
438
  const avail = [];
425
439
  for (let i = 0; i < plan.signals.length; i++) avail.push(keyLabel(plan.signals[i].key));
440
+ for (let i = 0; i < plan.locals.length; i++) avail.push(keyLabel(plan.locals[i].key));
426
441
  const near = nearestKey(keyLabel(key), avail);
427
442
  throw new Error(
428
- `${ERR}reinitReactive(${ctorName}) initials carries key \`${keyLabel(key)}\` that is not a @reactive signal${near ? ` -- did you mean \`${near}\`?` : ""} Signals: ${avail.join(", ")}.`,
443
+ `${ERR}reinitReactive(${ctorName}) initials carries key \`${keyLabel(key)}\` that is not a @reactive signal or @localTo member${near ? ` -- did you mean \`${near}\`?` : ""} Resettable keys: ${avail.join(", ")}.`,
429
444
  );
430
445
  }
431
446
 
@@ -451,9 +466,9 @@ function throwDefineSpec() {
451
466
  }
452
467
 
453
468
  function throwUnknownSection(key) {
454
- const near = nearestKey(keyLabel(key), ["signals", "deriveds", "effects", "host"]);
469
+ const near = nearestKey(keyLabel(key), ["signals", "locals", "deriveds", "effects", "host"]);
455
470
  throw new TypeError(
456
- `${ERR}defineReactive spec got unknown section \`${keyLabel(key)}\`${near ? ` -- did you mean \`${near}\`?` : ""} Known sections: signals, deriveds, effects, host.`,
471
+ `${ERR}defineReactive spec got unknown section \`${keyLabel(key)}\`${near ? ` -- did you mean \`${near}\`?` : ""} Known sections: signals, locals, deriveds, effects, host.`,
457
472
  );
458
473
  }
459
474
 
@@ -538,6 +553,25 @@ function throwBadEffect(key) {
538
553
  );
539
554
  }
540
555
 
556
+ function throwSpecLocals() {
557
+ throw new TypeError(
558
+ `${ERR}defineReactive spec.locals must be a map of key -> { source, equals?, initial? }.`,
559
+ );
560
+ }
561
+
562
+ function throwUnknownLocalDescKey(key, dk) {
563
+ const near = nearestKey(keyLabel(dk), ["source", "equals", "initial"]);
564
+ throw new TypeError(
565
+ `${ERR}defineReactive local ${keyLabel(key)} got unknown descriptor key \`${keyLabel(dk)}\`${near ? ` -- did you mean \`${near}\`?` : ""} Known keys: source, equals, initial.`,
566
+ );
567
+ }
568
+
569
+ function throwLocalSourceNotFn(key) {
570
+ throw new TypeError(
571
+ `${ERR}defineReactive local ${keyLabel(key)} \`source\` must be a function (self) -> value.`,
572
+ );
573
+ }
574
+
541
575
  function throwSpecCollision(className, key) {
542
576
  throw new TypeError(
543
577
  `${ERR}defineReactive spec declares ${keyLabel(key)}, but ${className}.prototype already owns that member -- a spec-declared member cannot collide with a hand-written one.`,
@@ -550,6 +584,49 @@ function makeGet(slot) { return function () { return this[slot].get(); }; }
550
584
  function makeSet(slot) { return function (v) { this[slot].set(v); }; }
551
585
  function makeDerivedGet(slot) { return function () { return this[slot].get(); }; }
552
586
 
587
+ // S8 hot bodies (0014): the @localTo accessor pair. NEW bodies -- the 1.0.0 canon
588
+ // above stays byte-identical (S8-A5); @localTo pays its own measured cost. Both
589
+ // are prebuilt ONCE per member and close over the member's slots + source + equals,
590
+ // so a read/write allocates nothing (the same prebuilt-closure discipline as
591
+ // makeGet/makeSet). Two per-instance stores back a local: the box slot (a signal
592
+ // node, the local value) and the seen slot (a PLAIN field, the last-adopted
593
+ // upstream value). makeLocalGet is PURE (0014 read law): it calls the tracked
594
+ // source, equals-compares it against the seen slot, and returns the local box on
595
+ // an UNCHANGED upstream, else the upstream value -- never writing any box on the
596
+ // read path, so a localTo read is legal inside any @derived compute.
597
+ function makeLocalGet(rec) {
598
+ const source = rec.source;
599
+ const eq = rec.eq;
600
+ const boxSlot = rec.slot;
601
+ const seenSlot = rec.seenSlot;
602
+ return function () {
603
+ const up = source.call(this, this);
604
+ if (eq(up, this[seenSlot])) return this[boxSlot].get();
605
+ return up;
606
+ };
607
+ }
608
+ // makeLocalSet: box.set (a write always overrides -- PD-56 never compares) + seen
609
+ // slot = upstream-at-write. The seen capture must NOT subscribe: a write inside an
610
+ // effect body runs under that effect's tracking scope, so a tracked source read
611
+ // there would silently link the effect to the upstream (a fail-open dep leak,
612
+ // measured: reg.isTracking() -> a bare source read re-fires the effect on an
613
+ // upstream move). The isTracking() gate untracks ONLY under an active scope --
614
+ // the same idiom makeEffectPublic uses (D-4b) -- so the plain-code write path
615
+ // (the hot path) stays a zero-alloc source.call, and only the rare in-effect write
616
+ // pays the untrack thunk. reg comes from the frozen rec.plan (set at claimPlan).
617
+ function makeLocalSet(rec) {
618
+ const source = rec.source;
619
+ const boxSlot = rec.slot;
620
+ const seenSlot = rec.seenSlot;
621
+ return function (v) {
622
+ this[boxSlot].set(v);
623
+ const reg = rec.plan.reg;
624
+ this[seenSlot] = reg.isTracking()
625
+ ? reg.untrack(() => source.call(this, this))
626
+ : source.call(this, this);
627
+ };
628
+ }
629
+
553
630
  function makeInit(rec) {
554
631
  return function (v) {
555
632
  if (rec.plan === null) throwMissingHost(rec);
@@ -563,6 +640,69 @@ function makeInit(rec) {
563
640
  };
564
641
  }
565
642
 
643
+ // S8 (cold): seed one @localTo member's two stores on an instance. seen = the
644
+ // source read at wiring, UNTRACKED (wiring/reinit must register no dependency);
645
+ // the box starts at the declared initial when present, else at that same upstream
646
+ // value (the initial-value unification rule, 0014: an initializer -> @trackedReset
647
+ // flavor; no initializer -> @localCopy flavor). Returns the box for SCRATCH
648
+ // rollback. Shared by the decorator init, the buildless wire loop, and reinit.
649
+ function seedLocal(inst, rec, reg, hasInitial, initialValue) {
650
+ const source = rec.source;
651
+ const seen = reg.isTracking()
652
+ ? reg.untrack(() => source.call(inst, inst))
653
+ : source.call(inst, inst);
654
+ // The box is created WITHOUT the equals opts: {equals} governs the UPSTREAM
655
+ // compare only (PD-56); the box uses default equals so a local write always
656
+ // propagates (a write overrides, never suppresses).
657
+ const box = reg.signalBox(hasInitial ? initialValue : seen);
658
+ inst[rec.slot] = box;
659
+ inst[rec.seenSlot] = seen;
660
+ return box;
661
+ }
662
+
663
+ // S8 (cold): the frozen local rec, shared by the decorator (@localTo) and buildless
664
+ // (spec.locals) paths. eq defaults to Object.is (0014 read law). initFn is null for
665
+ // the decorator path (the box is born at field-init time, like a decorator signal)
666
+ // and a per-instance factory for the buildless path (born in wireInstance).
667
+ function makeLocalRec(key, slot, seenSlot, source, opts, initFn, hasInitial) {
668
+ const eq = opts !== undefined && opts.equals !== undefined ? opts.equals : Object.is;
669
+ const rec = {
670
+ kind: "local",
671
+ key,
672
+ slot,
673
+ seenSlot,
674
+ source,
675
+ eq,
676
+ opts,
677
+ get: null,
678
+ set: null,
679
+ fn: null,
680
+ plan: null,
681
+ poison: null,
682
+ prewired: null,
683
+ parked: null,
684
+ initFn,
685
+ hasInitial,
686
+ };
687
+ rec.get = makeLocalGet(rec);
688
+ rec.set = makeLocalSet(rec);
689
+ return rec;
690
+ }
691
+
692
+ // S8 (cold): the decorator @localTo init -- mirrors makeInit. The box + seen are
693
+ // born during super()'s field initialization; the box joins the SCRATCH frame for
694
+ // init-phase rollback. The field-initial value is captured per member (undefined
695
+ // means "no initializer" -> the @localCopy reset flavor) for reinit.
696
+ function makeLocalInit(rec) {
697
+ return function (v) {
698
+ if (rec.plan === null) throwMissingHost(rec);
699
+ const box = seedLocal(this, rec, rec.plan.reg, v !== undefined, v);
700
+ SCRATCH.push(box); // D-2h: track for init-phase rollback
701
+ if (!LOCAL_INITIAL.has(rec)) LOCAL_INITIAL.set(rec, v);
702
+ return v; // emitter backing store, unused
703
+ };
704
+ }
705
+
566
706
  // --- Option validation (cold) -------------------------------------------------
567
707
 
568
708
  function isStandardContext(c) {
@@ -583,6 +723,21 @@ function validateOptions(what, opts) {
583
723
  return Object.freeze({ equals: opts.equals });
584
724
  }
585
725
 
726
+ function validateLocalOptions(opts) {
727
+ // localTo's OWN key set (equals only) -- NOT the shared reactive/derived
728
+ // validator: admitting `source` there would silently accept @derived({source})
729
+ // (fail-open, PLAN-S8 spelling call). Returns a frozen { equals } or undefined.
730
+ if (opts === undefined || opts === null) return undefined;
731
+ if (typeof opts !== "object") throwUsage("localTo");
732
+ const keys = Object.keys(opts);
733
+ for (let i = 0; i < keys.length; i++) {
734
+ if (keys[i] !== "equals") throwUnknownOption("localTo", keys[i], LOCAL_OPTION_KEYS);
735
+ }
736
+ if ("equals" in opts && opts.equals !== undefined && typeof opts.equals !== "function") throwBadEquals("localTo");
737
+ if (opts.equals === undefined) return undefined;
738
+ return Object.freeze({ equals: opts.equals });
739
+ }
740
+
586
741
  function validateEffectOptions(opts) {
587
742
  // Returns a frozen { scheduler } copy, or undefined for the bare form.
588
743
  if (opts === undefined || opts === null) return undefined;
@@ -668,6 +823,36 @@ export function reactive(target, ctx) {
668
823
  return function (t, c) { return applyReactive(t, c, opts); };
669
824
  }
670
825
 
826
+ // --- localTo (S8, 0014) -------------------------------------------------------
827
+
828
+ function applyLocalTo(target, ctx, source, opts) {
829
+ if (!isStandardContext(ctx)) throwLegacyEmit("localTo");
830
+ if (ctx.kind !== "accessor") {
831
+ throwWrongKind("localTo", "accessor", ctx.kind, "write `@localTo(source) accessor x = ...`.");
832
+ }
833
+ if (ctx.static === true) throwStatic("localTo", ctx.name);
834
+ if (ctx.private === true) throwPrivate("localTo", ctx.name);
835
+ const nm = typeof ctx.name === "symbol" ? "local" : "local:" + String(ctx.name);
836
+ const rec = makeLocalRec(ctx.name, Symbol(nm), Symbol(nm + ":seen"), source, opts, null, false);
837
+ PENDING.push(rec);
838
+ return { get: rec.get, set: rec.set, init: makeLocalInit(rec) };
839
+ }
840
+
841
+ /**
842
+ * `@localTo(source) accessor x = v` -- upstream-keyed resettable local state
843
+ * (0014). Reads compare the tracked `source(self)` against a per-instance
844
+ * last-seen slot: an unchanged upstream yields the local override, a changed
845
+ * upstream resets to it. A write always overrides. With an initializer the member
846
+ * STARTS there and resets on the first upstream move; without one it FOLLOWS
847
+ * upstream from wiring. `@localTo(source)` or `@localTo(source, { equals })` --
848
+ * `equals` governs the upstream compare ONLY. `source` is REQUIRED.
849
+ */
850
+ export function localTo(source, options) {
851
+ if (typeof source !== "function") throwLocalSource();
852
+ const opts = validateLocalOptions(options);
853
+ return function (t, c) { return applyLocalTo(t, c, source, opts); };
854
+ }
855
+
671
856
  // --- derived ------------------------------------------------------------------
672
857
 
673
858
  function applyDerived(value, ctx, opts) {
@@ -834,7 +1019,7 @@ function buildHandles(rec, ctorName) {
834
1019
  get() { throw new ReactiveDisposedError(ctorName, key); },
835
1020
  set(v) { throw new ReactiveDisposedError(ctorName, key); },
836
1021
  });
837
- const msg = rec.kind === "signal"
1022
+ const msg = rec.kind === "signal" || rec.kind === "local"
838
1023
  ? `${ERR}${ctorName}.${keyLabel(key)} read/write before its initializer ran (declaration order).`
839
1024
  : `${ERR}${ctorName}.${keyLabel(key)} read before construction completed (deriveds are available after wiring).`;
840
1025
  rec.prewired = Object.freeze({
@@ -882,6 +1067,7 @@ function claimPlan(C, ctorName, registry) {
882
1067
  }
883
1068
 
884
1069
  const signals = [];
1070
+ const locals = [];
885
1071
  const deriveds = [];
886
1072
  const effects = [];
887
1073
  const byKey = new Map();
@@ -891,6 +1077,12 @@ function claimPlan(C, ctorName, registry) {
891
1077
  signals.push(r);
892
1078
  byKey.set(r.key, r);
893
1079
  }
1080
+ // PD-55: locals live in their OWN array; L is a first-class accounting term.
1081
+ for (let i = 0; i < ancestor.locals.length; i++) {
1082
+ const r = ancestor.locals[i];
1083
+ locals.push(r);
1084
+ byKey.set(r.key, r);
1085
+ }
894
1086
  for (let i = 0; i < ancestor.deriveds.length; i++) {
895
1087
  const r = ancestor.deriveds[i];
896
1088
  deriveds.push(r);
@@ -920,7 +1112,7 @@ function claimPlan(C, ctorName, registry) {
920
1112
  const rec = own[i];
921
1113
  const desc = Object.getOwnPropertyDescriptor(proto, rec.key);
922
1114
  let installed;
923
- if (rec.kind === "signal" || rec.kind === "derived") {
1115
+ if (rec.kind === "signal" || rec.kind === "derived" || rec.kind === "local") {
924
1116
  installed = desc !== undefined && desc.get === rec.get;
925
1117
  } else {
926
1118
  installed = desc !== undefined && desc.value === rec.pub;
@@ -931,8 +1123,9 @@ function claimPlan(C, ctorName, registry) {
931
1123
 
932
1124
  for (let i = 0; i < own.length; i++) {
933
1125
  const rec = own[i];
934
- if (rec.kind === "signal" || rec.kind === "derived") buildHandles(rec, ctorName);
1126
+ if (rec.kind === "signal" || rec.kind === "derived" || rec.kind === "local") buildHandles(rec, ctorName);
935
1127
  if (rec.kind === "signal") signals.push(rec);
1128
+ else if (rec.kind === "local") locals.push(rec);
936
1129
  else if (rec.kind === "derived") deriveds.push(rec);
937
1130
  else if (rec.kind === "effect") effects.push(rec);
938
1131
  // batched recs join byKey only (no node) for reg resolution + diagnostics.
@@ -943,6 +1136,7 @@ function claimPlan(C, ctorName, registry) {
943
1136
  ctorName,
944
1137
  reg,
945
1138
  signals: Object.freeze(signals),
1139
+ locals: Object.freeze(locals),
946
1140
  deriveds: Object.freeze(deriveds),
947
1141
  effects: Object.freeze(effects),
948
1142
  byKey,
@@ -1041,6 +1235,13 @@ function wireInstance(inst, plan) {
1041
1235
  const r = sigs[i];
1042
1236
  if (r.initFn !== null) inst[r.slot] = reg.signalBox(r.initFn(inst), r.opts);
1043
1237
  }
1238
+ // Buildless locals: seed box + seen in spec order BEFORE the anchor.
1239
+ // Decorator locals already exist from field-init time (initFn === null).
1240
+ const locs = plan.locals;
1241
+ for (let i = 0; i < locs.length; i++) {
1242
+ const r = locs[i];
1243
+ if (r.initFn !== null) seedLocal(inst, r, reg, r.hasInitial, r.initFn(inst));
1244
+ }
1044
1245
  let a;
1045
1246
  reg.createRoot(() => { reg.effect(() => { a = reg.getOwner(); }); }); // R-A anchor
1046
1247
  inst[ANCHOR] = a;
@@ -1090,6 +1291,16 @@ function disposeCore(inst, plan) { // assumes not already disposed
1090
1291
  if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
1091
1292
  inst[r.slot] = r.poison;
1092
1293
  }
1294
+ // S8: locals dispose exactly like signals -- dispose the box, poison the box
1295
+ // slot (a touch throws the named ReactiveDisposedError, 0014 dispose lattice).
1296
+ // The seen slot is a plain field; it is left as-is (the poisoned box guards it).
1297
+ const locs = plan.locals;
1298
+ for (let i = 0; i < locs.length; i++) {
1299
+ const r = locs[i];
1300
+ const box = inst[r.slot];
1301
+ if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
1302
+ inst[r.slot] = r.poison;
1303
+ }
1093
1304
  const ders = plan.deriveds;
1094
1305
  for (let i = 0; i < ders.length; i++) {
1095
1306
  const r = ders[i];
@@ -1120,6 +1331,17 @@ function applyReactiveHost(C, ctx, registry) {
1120
1331
  enumerable: false,
1121
1332
  });
1122
1333
  }
1334
+ // S8: a local's box slot gets the same prewired guard (the seen slot is a
1335
+ // plain field, undefined until init -- no proto guard needed).
1336
+ for (let i = 0; i < plan.locals.length; i++) {
1337
+ const r = plan.locals[i];
1338
+ Object.defineProperty(C.prototype, r.slot, {
1339
+ value: r.prewired,
1340
+ writable: true,
1341
+ configurable: true,
1342
+ enumerable: false,
1343
+ });
1344
+ }
1123
1345
  for (let i = 0; i < plan.deriveds.length; i++) {
1124
1346
  const r = plan.deriveds[i];
1125
1347
  Object.defineProperty(C.prototype, r.slot, {
@@ -1261,6 +1483,38 @@ function normalizeSignals(spec, recs) {
1261
1483
  }
1262
1484
  }
1263
1485
 
1486
+ // S8/PD-57: the buildless `locals` section -- { key: { source, equals?, initial? } }.
1487
+ // Fail closed on a missing/non-fn source (a local without a source is meaningless).
1488
+ // An `initial` present selects the @trackedReset flavor; absent, the @localCopy
1489
+ // flavor (initFn returns undefined, hasInitial false -> seedLocal reads upstream).
1490
+ function normalizeLocalEntry(key, entry) {
1491
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) throwLocalSourceNotFn(key);
1492
+ const dkeys = Reflect.ownKeys(entry);
1493
+ for (let i = 0; i < dkeys.length; i++) {
1494
+ const dk = dkeys[i];
1495
+ if (dk !== "source" && dk !== "equals" && dk !== "initial") throwUnknownLocalDescKey(key, dk);
1496
+ }
1497
+ const source = entry.source;
1498
+ if (typeof source !== "function") throwLocalSourceNotFn(key);
1499
+ const opts = normalizeEquals(entry, `defineReactive local ${keyLabel(key)}`);
1500
+ const hasInitial = Object.prototype.hasOwnProperty.call(entry, "initial");
1501
+ const initial = hasInitial ? entry.initial : undefined;
1502
+ const initFn = function () { return initial; };
1503
+ const slot = Symbol(typeof key === "symbol" ? "local" : "local:" + String(key));
1504
+ const seenSlot = Symbol(typeof key === "symbol" ? "local:seen" : "local:" + String(key) + ":seen");
1505
+ return makeLocalRec(key, slot, seenSlot, source, opts, initFn, hasInitial);
1506
+ }
1507
+
1508
+ function normalizeLocals(spec, recs) {
1509
+ const l = spec.locals;
1510
+ if (l === undefined) return;
1511
+ if (l === null || typeof l !== "object" || Array.isArray(l)) throwSpecLocals();
1512
+ const keys = Reflect.ownKeys(l);
1513
+ for (let i = 0; i < keys.length; i++) {
1514
+ recs.push(normalizeLocalEntry(keys[i], l[keys[i]]));
1515
+ }
1516
+ }
1517
+
1264
1518
  function makeDerivedRec(key, fn, opts) {
1265
1519
  const slot = Symbol(typeof key === "symbol" ? "derived" : "derived:" + String(key));
1266
1520
  return {
@@ -1354,6 +1608,13 @@ function installRec(rec, proto) {
1354
1608
  enumerable: true,
1355
1609
  configurable: true,
1356
1610
  });
1611
+ } else if (rec.kind === "local") {
1612
+ Object.defineProperty(proto, rec.key, {
1613
+ get: rec.get,
1614
+ set: rec.set,
1615
+ enumerable: true,
1616
+ configurable: true,
1617
+ });
1357
1618
  } else if (rec.kind === "derived") {
1358
1619
  Object.defineProperty(proto, rec.key, {
1359
1620
  get: rec.get,
@@ -1382,13 +1643,14 @@ export function defineReactive(Class, spec) {
1382
1643
  const sections = Reflect.ownKeys(spec);
1383
1644
  for (let i = 0; i < sections.length; i++) {
1384
1645
  const k = sections[i];
1385
- if (k !== "signals" && k !== "deriveds" && k !== "effects" && k !== "host") {
1646
+ if (k !== "signals" && k !== "locals" && k !== "deriveds" && k !== "effects" && k !== "host") {
1386
1647
  throwUnknownSection(k);
1387
1648
  }
1388
1649
  }
1389
1650
 
1390
1651
  const recs = [];
1391
1652
  normalizeSignals(spec, recs); // signals first (spec/wire order)
1653
+ normalizeLocals(spec, recs); // locals second (PD-57)
1392
1654
  normalizeDeriveds(spec, recs);
1393
1655
  normalizeEffects(spec, recs);
1394
1656
  const registry = validateHostOptions(spec.host); // shared PD-11 validation
@@ -1487,6 +1749,16 @@ function releaseCore(inst, plan) { // assumes a LIVE instance
1487
1749
  if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
1488
1750
  inst[r.slot] = r.parked;
1489
1751
  }
1752
+ // S8: a parked local releases its box node (swap the box slot to the parked
1753
+ // handle) but RETAINS the seen slot as a plain record field (0014 park lattice);
1754
+ // reinit re-creates the box and resets both (PD-58).
1755
+ const locs = plan.locals;
1756
+ for (let i = 0; i < locs.length; i++) {
1757
+ const r = locs[i];
1758
+ const box = inst[r.slot];
1759
+ if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
1760
+ inst[r.slot] = r.parked;
1761
+ }
1490
1762
  const ders = plan.deriveds;
1491
1763
  for (let i = 0; i < ders.length; i++) {
1492
1764
  inst[ders[i].slot] = ders[i].parked; // cboxes already cascaded
@@ -1562,7 +1834,10 @@ export function reinitReactive(vm, initials) {
1562
1834
  const ikeys = Reflect.ownKeys(initials);
1563
1835
  for (let i = 0; i < ikeys.length; i++) {
1564
1836
  const rec = plan.byKey.get(ikeys[i]);
1565
- if (rec === undefined || rec.kind !== "signal") throwReinitInitialsKey(plan.ctorName, ikeys[i], plan);
1837
+ // PD-58: initials[] accepts @reactive signal keys AND @localTo keys.
1838
+ if (rec === undefined || (rec.kind !== "signal" && rec.kind !== "local")) {
1839
+ throwReinitInitialsKey(plan.ctorName, ikeys[i], plan);
1840
+ }
1566
1841
  }
1567
1842
  }
1568
1843
  const reg = plan.reg;
@@ -1585,6 +1860,30 @@ export function reinitReactive(vm, initials) {
1585
1860
  }
1586
1861
  vm[r.slot] = reg.signalBox(v, r.opts);
1587
1862
  }
1863
+ // PD-58: each local resets its box -> initial AND its seen slot -> the
1864
+ // CURRENT upstream (seedLocal reads source untracked). Precedence mirrors
1865
+ // the signal loop: caller override, then the buildless plan initFn (with its
1866
+ // hasInitial flag), then the decorator field-initial captured in
1867
+ // makeLocalInit (undefined -> the @localCopy flavor: reseed the box from
1868
+ // the current upstream). Locals rebuild BEFORE buildGraph so deriveds/effects
1869
+ // see them on the first synchronous run (D-4a), same as construction.
1870
+ const locs = plan.locals;
1871
+ for (let i = 0; i < locs.length; i++) {
1872
+ const r = locs[i];
1873
+ let hasInitial;
1874
+ let v;
1875
+ if (initials !== undefined && Object.prototype.hasOwnProperty.call(initials, r.key)) {
1876
+ hasInitial = true;
1877
+ v = initials[r.key];
1878
+ } else if (r.initFn !== null) {
1879
+ hasInitial = r.hasInitial;
1880
+ v = r.initFn(vm);
1881
+ } else {
1882
+ v = LOCAL_INITIAL.get(r);
1883
+ hasInitial = v !== undefined;
1884
+ }
1885
+ seedLocal(vm, r, reg, hasInitial, v);
1886
+ }
1588
1887
  buildGraph(vm, plan, closures);
1589
1888
  } catch (e) {
1590
1889
  disposeCore(vm, plan); // failed revival is terminal -> DISPOSED
@@ -1656,7 +1955,7 @@ function throwCostInconclusive(name, a, b) {
1656
1955
 
1657
1956
  function throwCostNodeMismatch(name, got, want) {
1658
1957
  throw new Error(
1659
- `${ERR}costOf(${name}) -- probed node count ${got} != P+D+E+1 (${want}); the bound registry was not quiet during the probe.`,
1958
+ `${ERR}costOf(${name}) -- probed node count ${got} != P+L+D+E+1 (${want}); the bound registry was not quiet during the probe.`,
1660
1959
  );
1661
1960
  }
1662
1961
 
@@ -1688,9 +1987,9 @@ function probeCost(Factory, plan, reg) {
1688
1987
  * Measure the settled per-instance cost of a reactive class on its bound
1689
1988
  * registry: construct, read every `@derived` once (forcing the lazy links),
1690
1989
  * snapshot, dispose, verify the floor -- twice, requiring identical deltas.
1691
- * Returns a frozen `{ nodes, links, signals, deriveds, effects }`; `nodes`
1692
- * equals P+D+E+1. Cached per class. Throws (never guesses) on an inconclusive
1693
- * or polluted probe. Constructs the probe instance with no arguments.
1990
+ * Returns a frozen `{ nodes, links, signals, locals, deriveds, effects }`;
1991
+ * `nodes` equals P+L+D+E+1. Cached per class. Throws (never guesses) on an
1992
+ * inconclusive or polluted probe. Constructs the probe instance with no arguments.
1694
1993
  */
1695
1994
  export function costOf(Factory) {
1696
1995
  if (typeof Factory !== "function") throwCostFactory();
@@ -1709,14 +2008,18 @@ export function costOf(Factory) {
1709
2008
  throwCostInconclusive(plan.ctorName, first, second);
1710
2009
  }
1711
2010
  const sig = plan.signals.length;
2011
+ const loc = plan.locals.length;
1712
2012
  const der = plan.deriveds.length;
1713
2013
  const eff = plan.effects.length;
1714
- const expected = sig + der + eff + 1;
2014
+ // S8: each @localTo member is exactly 1 box node (its seen slot is a plain
2015
+ // field, 0 nodes), so the node formula is P + L + D + E + 1 (0014 cost law).
2016
+ const expected = sig + loc + der + eff + 1;
1715
2017
  if (first.nodes !== expected) throwCostNodeMismatch(plan.ctorName, first.nodes, expected);
1716
2018
  const result = Object.freeze({
1717
2019
  nodes: first.nodes,
1718
2020
  links: first.links,
1719
2021
  signals: sig,
2022
+ locals: loc,
1720
2023
  deriveds: der,
1721
2024
  effects: eff,
1722
2025
  });
@@ -1820,11 +2123,13 @@ function labelStringsFor(plan) {
1820
2123
  const name = plan.ctorName;
1821
2124
  const sig = [];
1822
2125
  for (let i = 0; i < plan.signals.length; i++) sig.push(`${name}.${keyLabel(plan.signals[i].key)}`);
2126
+ const loc = [];
2127
+ for (let i = 0; i < plan.locals.length; i++) loc.push(`${name}.${keyLabel(plan.locals[i].key)}`);
1823
2128
  const der = [];
1824
2129
  for (let i = 0; i < plan.deriveds.length; i++) der.push(`${name}.${keyLabel(plan.deriveds[i].key)}`);
1825
2130
  const eff = [];
1826
2131
  for (let i = 0; i < plan.effects.length; i++) eff.push(`${name}#${keyLabel(plan.effects[i].key)}`);
1827
- s = { anchor: `${name}@anchor`, signals: sig, deriveds: der, effects: eff };
2132
+ s = { anchor: `${name}@anchor`, signals: sig, locals: loc, deriveds: der, effects: eff };
1828
2133
  LABEL_STRINGS.set(plan, s);
1829
2134
  return s;
1830
2135
  }
@@ -1844,6 +2149,11 @@ function registerLabels(inst, plan, reg, effHandles) {
1844
2149
  const id = reg.nodeId(inst[sigs[i].slot]);
1845
2150
  if (id !== undefined) { map.set(id, strings.signals[i]); ids.push(id); }
1846
2151
  }
2152
+ const locs = plan.locals;
2153
+ for (let i = 0; i < locs.length; i++) {
2154
+ const id = reg.nodeId(inst[locs[i].slot]);
2155
+ if (id !== undefined) { map.set(id, strings.locals[i]); ids.push(id); }
2156
+ }
1847
2157
  const ders = plan.deriveds;
1848
2158
  for (let i = 0; i < ders.length; i++) {
1849
2159
  const id = reg.nodeId(inst[ders[i].slot]);
@@ -1944,4 +2254,4 @@ export function auditReactive(on) {
1944
2254
  // --- Version ------------------------------------------------------------------
1945
2255
 
1946
2256
  /** Package version. Kept in lockstep with package.json and llms.txt. */
1947
- export const VERSION = "1.1.1";
2257
+ export const VERSION = "1.2.0";
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-signal-decorators
2
2
 
3
- VERSION 1.1.1
3
+ VERSION 1.2.0
4
4
 
5
5
  > Standard-decorators layer over @zakkster/lite-signal, built on the TC39
6
6
  > decorators proposal (Stage 2.7 since 2026-05; TS 5.x / Babel 2023-11 emit
@@ -22,12 +22,34 @@ zero decorator syntax, sharing the SAME core by function identity.
22
22
  slot for a poison handle, so any later read/write throws a named
23
23
  `ReactiveDisposedError`.
24
24
 
25
- ## Exports (18)
25
+ ## Exports (19)
26
26
 
27
27
  - `reactive` -- `@reactive accessor x = v` (bare) or `@reactive({ equals })`
28
28
  (factory). Declares a per-instance signal.
29
29
  - `derived` -- `@derived get y()` (bare) or `@derived({ equals })` (factory).
30
30
  Declares a lazy computed derived from other reactive members.
31
+ - `localTo` -- `@localTo(source) accessor x = v` or
32
+ `@localTo(source, { equals? })`. Upstream-keyed resettable local state
33
+ (decisions/0014). `source` is a REQUIRED tracked `(self) => value` fn, called
34
+ inline in the read body (no extra node, PD-54). The read is PURE (never writes
35
+ a box): it calls `source`, compares the value to a per-instance last-seen slot
36
+ via `equals` (default `Object.is`), and returns the local box `.get()` when
37
+ upstream is unchanged, else the upstream value (a changed upstream resets to
38
+ it). A write always overrides -- the write path never compares (PD-56).
39
+ Initial-value unification (0014): with an initializer the member STARTS at that
40
+ value and resets on the first upstream move (@trackedReset flavor); with NO
41
+ initializer the initial is the source evaluated once at wiring and the member
42
+ FOLLOWS upstream from the first read (@localCopy flavor) -- one decorator, both
43
+ semantics, selected by the natural syntax. `equals` governs the UPSTREAM
44
+ compare ONLY. A throwing source propagates from the read, mutating nothing
45
+ (fail closed). ABA contract (shipped, honest, never softened): no public epoch
46
+ exists (NodeDescriptor is {id, kind, value}), so the compare is VALUE-based --
47
+ upstream A -> local write X -> upstream B -> upstream back to an equals-A value
48
+ -> the read shows the STALE LOCAL X (the reset needs upstream to change
49
+ relative to the last adoption, not to have moved transitively). tracked-toolbox
50
+ @localCopy has the same property. Storage per member: ONE signal box + ONE
51
+ plain per-instance seen-slot (never reactive); cost delta +1 node, +0 for the
52
+ slot -- the per-instance formula becomes P+L+D+E+1.
31
53
  - `reactiveEffect` -- `@reactiveEffect m()` (bare) or
32
54
  `@reactiveEffect({ scheduler })` (factory). A method that auto-runs as an
33
55
  effect once the instance is wired. The auto-effect tracks; the public method is
@@ -45,7 +67,7 @@ slot for a poison handle, so any later read/write throws a named
45
67
  (factory). The single wiring site; wraps the class. A `registry` isolates the
46
68
  whole host chain (see the registry law).
47
69
  - `defineReactive(Class, spec) -> Class'` -- the buildless twin. `spec` is
48
- `{ signals, deriveds, effects, host }`; it installs the members on
70
+ `{ signals, deriveds, locals, effects, host }`; it installs the members on
49
71
  `Class.prototype` and wraps the class through the same host step. See the
50
72
  buildless contract.
51
73
  - `disposeReactive(vm) -> boolean` -- cascade + poison teardown. Idempotent: a
@@ -70,7 +92,7 @@ slot for a poison handle, so any later read/write throws a named
70
92
  - `costOf(Factory) -> { nodes, links, signals, deriveds, effects }` -- the
71
93
  measured settled per-instance cost on the class's bound registry (frozen,
72
94
  cached). Double-probed: an inconclusive or polluted probe throws, never
73
- guesses. `nodes` = P+D+E+1; `links` = the first-full-read link count. See
95
+ guesses. `nodes` = P+L+D+E+1; `links` = the first-full-read link count. See
74
96
  "Introspection & audit".
75
97
  - `capacityFor(inventory, { headroom }?) -> RegistryConfig` -- size a
76
98
  `createRegistry` config from `[Factory, count]` pairs. Nodes exact, links x
@@ -82,7 +104,7 @@ slot for a poison handle, so any later read/write throws a named
82
104
  `FinalizationRegistry` reports any instance GC'd without `disposeReactive`.
83
105
  - `ReactiveDisposedError` -- `extends Error`, `name` `"ReactiveDisposedError"`,
84
106
  fields `className` and `key`.
85
- - `VERSION` -- `"1.1.1"`.
107
+ - `VERSION` -- `"1.2.0"`.
86
108
 
87
109
  ## Registry law (one registry per host chain)
88
110
 
@@ -136,6 +158,12 @@ spec normalizes (fail closed; `Reflect.ownKeys`, so symbol keys work):
136
158
  throw (ambiguous -- wrap it in `{ initial: fn }` or `{ init }`).
137
159
  - `deriveds`: a map `{ key: (self) => value | { get, equals } }`.
138
160
  - `effects`: a MAP ONLY `{ key: (self) => void | { run, scheduler } }`.
161
+ - `locals`: a MAP ONLY `{ key: { source, equals?, initial? } }` -- the buildless
162
+ twin of `@localTo` (PD-57). `source` is REQUIRED and must be a `(self) => value`
163
+ function (a missing or non-function source is a named throw, fail closed);
164
+ `equals` governs the upstream compare only; `initial` (verbatim, when present)
165
+ selects the @trackedReset flavor, its absence the @localCopy flavor. Same
166
+ read/write laws and ABA contract as the decorator.
139
167
  - `host`: `{ registry }` or omitted -- the same validation as `@reactiveHost`.
140
168
 
141
169
  Spec-declared members that collide with an own property of `Class.prototype` are
@@ -153,7 +181,7 @@ probe on its bound registry: construct, read every `@derived` once (forcing the
153
181
  lazy links), snapshot, dispose, verify the floor -- run TWICE, requiring
154
182
  identical deltas. An inconclusive probe (a data-dependent derived read, or a
155
183
  registry mutated mid-probe) or a floor-verify failure THROWS a named error;
156
- costOf never returns a guess. `nodes` equals P+D+E+1 (the R-A law); `links` is
184
+ costOf never returns a guess. `nodes` equals P+L+D+E+1 (the R-A law); `links` is
157
185
  the first-full-read count. The result is frozen and cached per class. costOf
158
186
  constructs the probe instance with NO constructor arguments.
159
187
 
@@ -214,7 +242,10 @@ runtime requirement -- the shipped surface runs on 1.5.0.
214
242
  `reinitReactive` (an additive MINOR under that promise) -> 18 exports: the 13
215
243
  runtime exports plus `costOf`, `capacityFor`, `enableLabels`, `labelOf`, and
216
244
  `auditReactive` (all cold / opt-in; the hot accessor canon is byte-identical to
217
- 0.3.0). The semver promise from here: any change to an existing export's
245
+ 0.3.0). 1.2.0 adds `localTo` (decisions/0013 strategic track, 0014 contract) as
246
+ an additive MINOR -> 19 exports; the 1.0.0 canon (`makeGet`/`makeSet`/
247
+ `makeDerivedGet`) stays byte-identical -- @localTo ships its own accessor bodies
248
+ and pays its own measured cost. The semver promise from here: any change to an existing export's
218
249
  signature or behavior is a MAJOR, recorded in a decision file; new exports are
219
250
  minors; the hot accessor canon (`makeGet`/`makeSet`) does not move without a
220
251
  major. Also present since
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal-decorators",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Standard-decorators layer over @zakkster/lite-signal (TC39 decorators proposal, Stage 2.7 since 2026-05; TS 5.x / Babel 2023-11 emit unchanged). The reactive class layer where an instance has a measured cost, deterministic teardown, and a churn benchmark.",
5
5
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
6
  "license": "MIT",