@fundamental-engine/core 0.10.1 → 0.10.2

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.
Files changed (67) hide show
  1. package/README.md +1 -1
  2. package/dist/.metadata_never_index +0 -0
  3. package/dist/.noindex +0 -0
  4. package/dist/config/manual.d.ts.map +1 -1
  5. package/dist/config/manual.js +18 -0
  6. package/dist/config/manual.js.map +1 -1
  7. package/dist/config/presets.d.ts +10 -2
  8. package/dist/config/presets.d.ts.map +1 -1
  9. package/dist/config/presets.js +26 -2
  10. package/dist/config/presets.js.map +1 -1
  11. package/dist/conformance/experiments.d.ts.map +1 -1
  12. package/dist/conformance/experiments.js +91 -0
  13. package/dist/conformance/experiments.js.map +1 -1
  14. package/dist/conformance/run.d.ts.map +1 -1
  15. package/dist/conformance/run.js +34 -4
  16. package/dist/conformance/run.js.map +1 -1
  17. package/dist/conformance/types.d.ts +12 -0
  18. package/dist/conformance/types.d.ts.map +1 -1
  19. package/dist/contracts/passport.d.ts.map +1 -1
  20. package/dist/contracts/passport.js +1 -0
  21. package/dist/contracts/passport.js.map +1 -1
  22. package/dist/diagnostics/probes.d.ts.map +1 -1
  23. package/dist/diagnostics/probes.js +5 -0
  24. package/dist/diagnostics/probes.js.map +1 -1
  25. package/dist/engine/agent-read-share.d.ts +34 -0
  26. package/dist/engine/agent-read-share.d.ts.map +1 -0
  27. package/dist/engine/agent-read-share.js +50 -0
  28. package/dist/engine/agent-read-share.js.map +1 -0
  29. package/dist/engine/field.d.ts.map +1 -1
  30. package/dist/engine/field.js +170 -102
  31. package/dist/engine/field.js.map +1 -1
  32. package/dist/engine/integrator.d.ts +12 -1
  33. package/dist/engine/integrator.d.ts.map +1 -1
  34. package/dist/engine/integrator.js +44 -0
  35. package/dist/engine/integrator.js.map +1 -1
  36. package/dist/engine/scalar-grid.d.ts +25 -2
  37. package/dist/engine/scalar-grid.d.ts.map +1 -1
  38. package/dist/engine/scalar-grid.js +38 -1
  39. package/dist/engine/scalar-grid.js.map +1 -1
  40. package/dist/engine/scanner.d.ts +5 -3
  41. package/dist/engine/scanner.d.ts.map +1 -1
  42. package/dist/engine/scanner.js +29 -5
  43. package/dist/engine/scanner.js.map +1 -1
  44. package/dist/engine/shadow.d.ts +58 -2
  45. package/dist/engine/shadow.d.ts.map +1 -1
  46. package/dist/engine/shadow.js +86 -1
  47. package/dist/engine/shadow.js.map +1 -1
  48. package/dist/engine/snapshot-policy.d.ts +28 -0
  49. package/dist/engine/snapshot-policy.d.ts.map +1 -0
  50. package/dist/engine/snapshot-policy.js +95 -0
  51. package/dist/engine/snapshot-policy.js.map +1 -0
  52. package/dist/engine/streamlines.d.ts +24 -7
  53. package/dist/engine/streamlines.d.ts.map +1 -1
  54. package/dist/engine/streamlines.js +96 -5
  55. package/dist/engine/streamlines.js.map +1 -1
  56. package/dist/engine/types.d.ts +98 -8
  57. package/dist/engine/types.d.ts.map +1 -1
  58. package/dist/forces/extended.d.ts +1 -0
  59. package/dist/forces/extended.d.ts.map +1 -1
  60. package/dist/forces/extended.js +68 -0
  61. package/dist/forces/extended.js.map +1 -1
  62. package/dist/forces/index.d.ts.map +1 -1
  63. package/dist/forces/index.js +4 -1
  64. package/dist/forces/index.js.map +1 -1
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/package.json +6 -6
@@ -49,6 +49,7 @@ import { forceAt, netField } from "./streamlines.js";
49
49
  import { traceFieldLines } from "./fieldlines.js";
50
50
  import { fieldLineSeeds } from "./fieldline-seeds.js";
51
51
  import { flowBiasInto, makeFlowFocus } from "./flow.js";
52
+ import { admits as admitsShare } from "./agent-read-share.js";
52
53
  import { devWarnNoOp } from "../contracts/guards.js";
53
54
  import { FIELD_VERSION } from "../version.js";
54
55
  import { energyReport } from "../diagnostics/energy.js";
@@ -56,100 +57,13 @@ import { accumulateAt } from "../diagnostics/probes.js";
56
57
  import { diffFieldSnapshots, replayFieldSnapshots } from "./field-snapshot.js";
57
58
  import { lintProjections } from "./governance.js";
58
59
  import { applyLens } from "./query-lens.js";
60
+ import { clonePolicy, applyRedactions, resolveSnapshotInclusion } from "./snapshot-policy.js";
59
61
  // Shared draw/integrate scratch — reused across the per-particle and per-cell hot loops so an
60
62
  // active flow focus and the particle draw don't allocate a `{x,y}` / `[r,g,b]` each iteration.
61
63
  // Safe to share module-wide: each field's frame runs synchronously, and every read consumes the
62
64
  // scratch before the next write (no overlapping lifetimes, no cross-instance interleaving).
63
65
  const _flowB = { x: 0, y: 0 };
64
66
  const _rgb = [0, 0, 0];
65
- /** Deep-copy a {@link FieldPolicy} (shallow is unsafe — `budgets` is nested). `undefined` → `{}` (the
66
- * unbounded default). Used on set + read so callers can neither mutate the field's live policy nor
67
- * observe later mutations of the object they passed in. */
68
- function clonePolicy(p) {
69
- if (!p)
70
- return {};
71
- const out = {};
72
- if (p.allowBodyDataInSnapshots != null)
73
- out.allowBodyDataInSnapshots = p.allowBodyDataInSnapshots;
74
- if (p.allowMotionProjection != null)
75
- out.allowMotionProjection = p.allowMotionProjection;
76
- if (p.maxMotionBudget != null)
77
- out.maxMotionBudget = p.maxMotionBudget;
78
- if (p.budgets)
79
- out.budgets = { ...p.budgets };
80
- return out;
81
- }
82
- /**
83
- * Resolve {@link FieldSnapshotOptions} — an optional {@link SnapshotProfile} composed with the explicit
84
- * `include*` flags — to concrete inclusion, TIGHTEST-wins. A profile establishes a baseline; an explicit
85
- * flag may tighten it further but never widen it (an explicit `true` cannot re-enable what the profile
86
- * turned off). `includeData` additionally passes through the policy gate at the call site (this only
87
- * governs whether the CALLER asked for it). Relationships default true when nothing narrows them.
88
- */
89
- function resolveSnapshotInclusion(opts) {
90
- // Per-profile baselines. `debug` = everything; `agent` = structure + attribution, no opaque data;
91
- // `bug-report` = structural + versions, no data; `public` = ids + shape only.
92
- const base = {
93
- debug: { includeParticles: true, includeRelationships: true, includeData: true, includeInfluences: true },
94
- agent: { includeParticles: false, includeRelationships: true, includeData: false, includeInfluences: true },
95
- 'bug-report': { includeParticles: false, includeRelationships: true, includeData: false, includeInfluences: true },
96
- public: { includeParticles: false, includeRelationships: false, includeData: false, includeInfluences: false },
97
- };
98
- const p = opts.profile;
99
- if (!p) {
100
- // No profile: today's defaults — relationships default true, the rest default false.
101
- return {
102
- includeParticles: opts.includeParticles === true,
103
- includeRelationships: opts.includeRelationships !== false,
104
- includeData: opts.includeData === true,
105
- includeInfluences: opts.includeInfluences === true,
106
- };
107
- }
108
- const b = base[p];
109
- // TIGHTEST wins: a flag is on only if the profile allows it AND the caller didn't explicitly turn it
110
- // off. An explicit `true` can never widen past the profile's baseline.
111
- return {
112
- includeParticles: b.includeParticles && opts.includeParticles !== false,
113
- includeRelationships: b.includeRelationships && opts.includeRelationships !== false,
114
- includeData: b.includeData && opts.includeData !== false,
115
- includeInfluences: b.includeInfluences && opts.includeInfluences !== false,
116
- };
117
- }
118
- /**
119
- * Strip a set of dotted `redactions` paths from a plain reading (query result or snapshot). A top-level
120
- * key (`'metrics'`, `'relationships'`) deletes that key from the object. A `<prefix>.<key>` path where
121
- * prefix ∈ {body, relationship, influence, projection} strips `<key>` from each entry of the matching
122
- * list; `body.data` strips per-body `data`. Mutates the passed object (it's always a fresh result/copy).
123
- */
124
- function applyRedactions(reading, redactions) {
125
- const listKeyFor = { body: 'bodies', relationship: 'relationships', influence: 'influences', projection: 'projections' };
126
- for (const path of redactions) {
127
- const dot = path.indexOf('.');
128
- if (dot < 0) {
129
- delete reading[path];
130
- continue;
131
- }
132
- const prefix = path.slice(0, dot);
133
- const key = path.slice(dot + 1);
134
- const listKey = listKeyFor[prefix];
135
- if (listKey && Array.isArray(reading[listKey])) {
136
- for (const entry of reading[listKey]) {
137
- if (entry && typeof entry === 'object')
138
- delete entry[key];
139
- }
140
- }
141
- else if (prefix === 'metrics' && reading['metrics'] && typeof reading['metrics'] === 'object') {
142
- delete reading['metrics'][key];
143
- }
144
- else {
145
- // an unrecognized prefix addresses a nested top-level object key.
146
- const target = reading[prefix];
147
- if (target && typeof target === 'object')
148
- delete target[key];
149
- }
150
- }
151
- return reading;
152
- }
153
67
  // ── Focus / attention substrate (experimental) constants ────────────────────────────────────────
154
68
  /** default half-life of a focus deposit, in the field's simulation-clock unit (env.t SECONDS) — attention
155
69
  * halves in ~8s. freshness() is unit-agnostic, so at/now/halfLife share env.t's unit (seconds). */
@@ -219,6 +133,45 @@ export function createField(canvas, opts = {}) {
219
133
  const store = new FieldStore();
220
134
  let nextParticleId = 1; // monotonic stable particle identity (readParticleIds); never reused
221
135
  const grids = new Map(); // §20.1 class [C] field buffers, lazy
136
+ // ── declared potentials (#443) ──────────────────────────────────────────────────────────────
137
+ // A host channel (`addField`) admitted as a scalar potential Φ and rasterised into a HELD grid the
138
+ // `relief` force reads as −∇Φ. This raster is the ONE place the engine caches a channel sampler
139
+ // (the amended `addField` contract), so its invalidation set is the entire correctness story:
140
+ //
141
+ // addField(name, …) a channel appeared ─┐
142
+ // handle.set(next) the sampler was swapped │ bump the epoch ⇒ the next read re-rasters
143
+ // handle.remove() the channel is gone │ (remove ALSO drops the grid, so a removed
144
+ // resize() the buffers were rebuilt ─┘ channel can never be read stale)
145
+ //
146
+ // There is deliberately NO schedule. Nothing re-rasters on a frame cadence, so held state cannot be
147
+ // frame-phase dependent — registering a channel on frame 3 gives the same trajectory as on frame 0 —
148
+ // and it cannot outlive its channel. `potentialAt` is pulled BY the force during the force pass and
149
+ // re-rasters exactly when the epoch it last saw has moved. Under reduced motion the integrator
150
+ // returns before the force pass (`dt === 0`), so nothing is sampled at all: the raster freezes with
151
+ // the motion, with no dt special-case here.
152
+ let channelEpoch = 0;
153
+ const potentialStamp = new Map(); // grid key → the epoch its cells were filled at
154
+ const potentialKey = (name) => `potential:${name}`;
155
+ function invalidatePotentials() {
156
+ channelEpoch++;
157
+ }
158
+ function potentialAt(name) {
159
+ const sampler = fieldChannels.get(name);
160
+ if (!sampler)
161
+ return undefined; // never registered, or removed — the force no-ops (never stale)
162
+ const key = potentialKey(name);
163
+ let g = grids.get(key);
164
+ if (!g) {
165
+ g = new ScalarGridImpl(W, H, 'held');
166
+ grids.set(key, g);
167
+ potentialStamp.set(key, -1);
168
+ }
169
+ if (potentialStamp.get(key) !== channelEpoch) {
170
+ g.fillFrom(sampler); // non-finite samples are clamped to 0 inside fillFrom, at the boundary
171
+ potentialStamp.set(key, channelEpoch);
172
+ }
173
+ return g;
174
+ }
222
175
  const reg = createRegistry();
223
176
  // host-agnostic discrete event bus (the read side): plain-data push for occurrences a non-DOM
224
177
  // host reacts to (a sink caught/released matter, the swarm settled) — no DOM, no polling. Cheap
@@ -328,7 +281,7 @@ export function createField(canvas, opts = {}) {
328
281
  // through its `data-body` token (e.g. `data-body="lens crystallize"`); an unused force costs nothing.
329
282
  registerCoreForces(reg); // the canonical nine (§6)
330
283
  registerNaturalForces(reg); // 8 natural primitives: gravity, charge, magnetism, thermal, … (§20.10)
331
- registerExtendedForces(reg); // 19 designed extended forces: lens, crystallize, link, morph, … (§20.3)
284
+ registerExtendedForces(reg); // 20 designed extended forces: lens, crystallize, link, morph, relief, … (§20.3)
332
285
  // the environment seam: all DOM access goes through this injected host — core imports zero DOM.
333
286
  // In the browser, pass `browserHost()` from @fundamental-engine/dom (or use createBrowserField); the
334
287
  // @fundamental-engine/{elements,react,vanilla} entry points wire it for you.
@@ -427,6 +380,13 @@ export function createField(canvas, opts = {}) {
427
380
  // full above start·H, gone by (start+span)·H. Defaults reproduce start=0.3, span=0.85.
428
381
  heatmapFadeStart: opts.heatmapFade && Number.isFinite(opts.heatmapFade.start) ? opts.heatmapFade.start : 0.3,
429
382
  heatmapFadeSpan: opts.heatmapFade && Number.isFinite(opts.heatmapFade.span) && opts.heatmapFade.span > 0 ? opts.heatmapFade.span : 0.85,
383
+ // the resting-motion floor (declared, default OFF): validated to the two modes; strength ≥ 0 (default 1).
384
+ restingMotion: opts.restingMotion && (opts.restingMotion.mode === 'thermal' || opts.restingMotion.mode === 'flow')
385
+ ? {
386
+ mode: opts.restingMotion.mode,
387
+ strength: opts.restingMotion.strength != null && opts.restingMotion.strength >= 0 ? opts.restingMotion.strength : 1,
388
+ }
389
+ : undefined,
430
390
  // the integration scheme (substrate doc 04 §Step 3, #659); 'legacy' (default) is the shipped engine.
431
391
  integrator: (opts.integrator === 'fixed' || opts.integrator === 'velocity-verlet'
432
392
  ? opts.integrator
@@ -926,11 +886,23 @@ export function createField(canvas, opts = {}) {
926
886
  neighbors: (p, r) => store.neighbors(p, r),
927
887
  // scalar field-buffer service (§20.1 class [C]): created on demand, so a page
928
888
  // with no diffuse/propagate body allocates nothing. Grids named "wave…" use the
929
- // wave scheme; everything else diffuses.
889
+ // wave scheme, "memory…" the slow-decay scheme, "potential:…" the HELD raster of a
890
+ // declared potential (#443 — it never steps); everything else diffuses.
891
+ //
892
+ // THE ONE NON-BYTE-IDENTICAL CHANGE in #443, stated rather than buried: a host that was
893
+ // already calling `grid('potential:…')` got a DIFFUSING grid before this change and gets a
894
+ // held one after. No shipped code, recipe or doc opens such a name; it is called out here and
895
+ // in the CHANGELOG because it is the only way any existing field could move.
930
896
  grid: (name) => {
931
897
  let g = grids.get(name);
932
898
  if (!g) {
933
- const mode = name.startsWith('wave') ? 'wave' : name.startsWith('memory') ? 'memory' : 'diffuse';
899
+ const mode = name.startsWith('wave')
900
+ ? 'wave'
901
+ : name.startsWith('memory')
902
+ ? 'memory'
903
+ : name.startsWith('potential:')
904
+ ? 'held'
905
+ : 'diffuse';
934
906
  g = new ScalarGridImpl(W, H, mode);
935
907
  grids.set(name, g);
936
908
  }
@@ -1005,10 +977,12 @@ export function createField(canvas, opts = {}) {
1005
977
  const scanned = scanBodies(host.root);
1006
978
  // merge event-registered shadow-DOM hosts (deduped — a light-DOM host that also fires
1007
979
  // a registration event is counted once). Registration is the canonical discovery path;
1008
- // light-DOM scanning is the compatibility fallback (shadow-dom.md §16).
980
+ // light-DOM scanning is the compatibility fallback (shadow-dom.md §16). The scan root is
981
+ // passed so a host registered with `scope: 'nearest'` / a `field` target (§17–§19) is built
982
+ // only by the field that owns it; a detail without those keys is built as before.
1009
983
  if (shadow.size > 0) {
1010
984
  const seen = new Set(scanned.map((b) => b.el));
1011
- bodies = scanned.concat(shadow.bodies(bodyFromElement).filter((b) => !seen.has(b.el)));
985
+ bodies = scanned.concat(shadow.bodies(bodyFromElement, host.root).filter((b) => !seen.has(b.el)));
1012
986
  }
1013
987
  else {
1014
988
  bodies = scanned;
@@ -1653,6 +1627,11 @@ export function createField(canvas, opts = {}) {
1653
1627
  maxScroll = (host.scrollHeight?.() ?? H) - H || 1;
1654
1628
  for (const g of grids.values())
1655
1629
  g.resize(W, H); // keep field buffers viewport-sized
1630
+ // resize() rebuilds the buffers and preserves NOTHING — every held raster is now all zeros, i.e.
1631
+ // a flat potential. Invalidate so the next force pass refills it; `relief` would otherwise read a
1632
+ // zero gradient (no transport at all) rather than stale data. The refill happens on the next PULL,
1633
+ // which is necessarily after the scan() below has re-detected the bodies.
1634
+ invalidatePotentials();
1656
1635
  if (cfg.heatmap) {
1657
1636
  if (!heatmap)
1658
1637
  heatmap = new Heatmap(W, H);
@@ -3023,7 +3002,18 @@ export function createField(canvas, opts = {}) {
3023
3002
  resolvedWaveCenter = null;
3024
3003
  }
3025
3004
  updateWarpTargets(); // refresh warp relocate targets from paired bodies (§22.3) before the step
3026
- step({ store, bodies, env, forces: reg.forces, conditions: reg.conditions, waves, waveStyle: cfg.waveStyle, waveCenter: resolvedWaveCenter, separation: cfg.separation });
3005
+ // `Env.potential` follows the `fieldAt?` / `accum?` precedent: an opt-in service, present only
3006
+ // while some body actually declares `relief`. A field that never declares one — INCLUDING every
3007
+ // field whose host called `addField` — carries no such property, so `applyForce` never sees it and
3008
+ // the default hot path is byte-identical. This is the "registration does not couple" guarantee the
3009
+ // shipped docs make, enforced structurally rather than by a flag. Decided from the live body list
3010
+ // each frame rather than at scan time, because `addBody` appends a programmatic body without a
3011
+ // rescan — deciding in `scan()` would leave a programmatic `relief` body permanently uncoupled.
3012
+ if (bodies.some((b) => b.tokens.includes('relief')))
3013
+ env.potential = potentialAt;
3014
+ else if (env.potential)
3015
+ delete env.potential;
3016
+ step({ store, bodies, env, forces: reg.forces, conditions: reg.conditions, waves, waveStyle: cfg.waveStyle, waveCenter: resolvedWaveCenter, separation: cfg.separation, restingMotion: cfg.restingMotion });
3027
3017
  // hover-focus (field.focusAt): hold the focused particle still and light it up — the dwell
3028
3018
  // affordance ("it stops and does something") before a click opens its record.
3029
3019
  if (focusP) {
@@ -3266,13 +3256,50 @@ export function createField(canvas, opts = {}) {
3266
3256
  const caps = new Set(viewOpts.capabilities ?? []);
3267
3257
  const redactions = (viewOpts.redactions ?? []).slice();
3268
3258
  const has = (c) => caps.has(c);
3269
- // If a future `budgets.agentRead` budget is 0, the agent surface is closed entirely: the most
3270
- // restricted view (empty caps → ids + shape only). SEAM: only the 0 boundary is wired today; the
3271
- // fractional 0<b<1 gradient (partial agent read) is DECLARED-not-yet-enforced (see FieldBudgets).
3259
+ // `budgets.agentRead` is a CONSERVATION LAW on the agent surface (#915): `b` is the SHARE of the
3260
+ // field's readable body population this view may consume. `b == null` or `b >= 1` is the whole
3261
+ // field; `b <= 0` closes the surface entirely (ids + shape only); `0 < b < 1` grants a partial read.
3272
3262
  const agentReadOpen = () => {
3273
3263
  const b = policy.budgets?.agentRead;
3274
3264
  return b == null || b > 0;
3275
3265
  };
3266
+ const agentReadShare = () => {
3267
+ const b = policy.budgets?.agentRead;
3268
+ return b == null ? 1 : b;
3269
+ };
3270
+ // Which bodies a partial read admits. The selection is DETERMINISTIC, STABLE PER BODY ID, and
3271
+ // NOT POSITIONAL — each property is load-bearing, and the obvious implementations fail one:
3272
+ // · deterministic, because a random draw makes an agent read unreplayable, and record/replay
3273
+ // is a contract this engine keeps everywhere else (it threads a seeded `rng` for exactly this);
3274
+ // · stable, because a subset RESAMPLED each frame leaks the whole field to a patient reader —
3275
+ // the union of enough independent 10% samples is 100%. A budget that can be defeated by
3276
+ // calling `query()` in a loop is not a budget. This is the security core of the gate;
3277
+ // · not positional, because `take the first ceil(n·b)` leaks scan order, hands every agent the
3278
+ // same prefix, and makes the withheld tail identical for everyone.
3279
+ // FNV-1a over the body id gives a fixed [0,1) coordinate per body; admit those under the share.
3280
+ // The expected admitted count is n·b, not exactly n·b — that is inherent to a per-body stable
3281
+ // rule, and the alternative (an exact count) is necessarily positional or unstable.
3282
+ /**
3283
+ * Narrow a reading to the admitted share. Edges are filtered to SURVIVING ENDPOINTS: a
3284
+ * relationship or influence naming a withheld body is itself a disclosure of that body's id and
3285
+ * existence, so an edge survives only when every body it names does. Field-wide `metrics` are
3286
+ * aggregates over the whole field, not per-body readings, and are left to `read:metrics`.
3287
+ */
3288
+ const sampleBodies = (res, share) => {
3289
+ if (!(share < 1))
3290
+ return res;
3291
+ const kept = new Set();
3292
+ for (const b of res.bodies)
3293
+ if (admitsShare(b.id, share))
3294
+ kept.add(b.id);
3295
+ res.bodies = res.bodies.filter((b) => kept.has(b.id));
3296
+ if (res.relationships)
3297
+ res.relationships = res.relationships.filter((r) => kept.has(r.from) && kept.has(r.to));
3298
+ if (res.influences) {
3299
+ res.influences = res.influences.filter((i) => kept.has(i.source) && (i.target == null || kept.has(i.target)));
3300
+ }
3301
+ return res;
3302
+ };
3276
3303
  const scopeQuery = (q = {}) => {
3277
3304
  if (!agentReadOpen()) {
3278
3305
  // closed budget → most-restricted reading: shape only, no metrics/relationships/influences.
@@ -3305,6 +3332,9 @@ export function createField(canvas, opts = {}) {
3305
3332
  res.influences = [];
3306
3333
  if (!has('read:projections'))
3307
3334
  res.projections = [];
3335
+ // #915: the fractional budget narrows the population AFTER capability scoping — tighten-only,
3336
+ // and it composes with the caps rather than replacing them.
3337
+ sampleBodies(res, agentReadShare());
3308
3338
  return applyRedactions(res, redactions);
3309
3339
  };
3310
3340
  const scopeSnapshot = (snapOpts = {}) => {
@@ -3313,8 +3343,11 @@ export function createField(canvas, opts = {}) {
3313
3343
  return applyRedactions(snap, redactions);
3314
3344
  }
3315
3345
  // read:body-data is the gate for opaque `data`: without it, force `includeData` off (tightens,
3316
- // never widens — even if a profile or explicit flag asked for it). Everything else composes with
3317
- // resolveSnapshotInclusion's TIGHTEST-wins rule + the field's own privacy policy downstream.
3346
+ // never widens — even if a profile or explicit flag asked for it). read:diagnostics is the gate
3347
+ // for the RAW PARTICLE POOL — the engine's own internal state, not a modelled reading of bodies /
3348
+ // relationships / metrics — so without it `includeParticles` is forced off even under `profile:
3349
+ // 'debug'`. Everything else composes with resolveSnapshotInclusion's TIGHTEST-wins rule + the
3350
+ // field's own privacy policy downstream.
3318
3351
  const scoped = { ...snapOpts };
3319
3352
  if (!has('read:body-data'))
3320
3353
  scoped.includeData = false;
@@ -3322,17 +3355,33 @@ export function createField(canvas, opts = {}) {
3322
3355
  scoped.includeRelationships = false;
3323
3356
  if (!has('read:influences'))
3324
3357
  scoped.includeInfluences = false;
3358
+ if (!has('read:diagnostics'))
3359
+ scoped.includeParticles = false;
3325
3360
  const snap = handle.snapshot(scoped);
3326
3361
  if (!has('read:projections'))
3327
3362
  snap.projections = [];
3363
+ // #915: the same share applies to a capture, or `snapshot()` would hand back the whole field
3364
+ // that `query()` just withheld. The raw particle pool carries no body identity, so it has no
3365
+ // stable key to sample by; it stays gated by `read:diagnostics` alone (documented boundary).
3366
+ sampleBodies(snap, agentReadShare());
3328
3367
  return applyRedactions(snap, redactions);
3329
3368
  };
3330
3369
  const view = {
3331
3370
  get capabilities() { return Object.freeze([...caps]); },
3332
3371
  get redactions() { return Object.freeze([...redactions]); },
3333
3372
  query: scopeQuery,
3334
- snapshot: scopeSnapshot,
3335
3373
  };
3374
+ // `snapshot` is present ONLY when granted — the facade's shape reflects the grant, exactly as
3375
+ // `replay`/`focusState` do. read:snapshots gates the CAPTURE SURFACE, not a lane within a reading:
3376
+ // the per-lane caps (body-data / relationships / influences / projections / diagnostics) narrow what
3377
+ // a capture contains, this one decides whether a capture may be taken at all. Withholding it closes
3378
+ // the call rather than returning an empty shell — an empty capture is indistinguishable from an
3379
+ // empty field, and a silent-but-permissive-looking reading is the failure this gate exists to stop.
3380
+ // `query()` stays available under the base grant, so a denied agent is never blinded, only barred
3381
+ // from the portable, serializable, replayable artifact.
3382
+ if (has('read:snapshots')) {
3383
+ view.snapshot = scopeSnapshot;
3384
+ }
3336
3385
  // `replay` is present ONLY when granted — the facade's shape reflects the capability.
3337
3386
  if (has('read:replay')) {
3338
3387
  view.replay = (a, b, replayOpts) => handle.replay(a, b, replayOpts);
@@ -3512,6 +3561,14 @@ export function createField(canvas, opts = {}) {
3512
3561
  attrs['data-angle'] = String(spec.angle);
3513
3562
  if (spec.color != null)
3514
3563
  attrs['data-color'] = spec.color;
3564
+ // the declared-potential channel (#443) — the programmatic mirror of data-potential, so a
3565
+ // non-DOM host (a game, an agent runtime) can declare terrain-coupled matter through addBody.
3566
+ if (spec.potential != null)
3567
+ attrs['data-potential'] = spec.potential;
3568
+ // the capture horizon (#1177) — the programmatic mirror of data-absorb, so a non-DOM host can
3569
+ // size a sink's or a warp throat's radius without reaching past the public API.
3570
+ if (spec.absorbR != null)
3571
+ attrs['data-absorb'] = String(spec.absorbR);
3515
3572
  const toRect = () => {
3516
3573
  const r = spec.rect();
3517
3574
  return {
@@ -3810,10 +3867,21 @@ export function createField(canvas, opts = {}) {
3810
3867
  replay: (a, b, opts) => replayFieldSnapshots(a, b, opts),
3811
3868
  addField: (name, sampler) => {
3812
3869
  fieldChannels.set(name, sampler);
3870
+ invalidatePotentials(); // a channel registered AFTER a `relief` body was declared must couple
3813
3871
  return {
3814
3872
  name,
3815
- set: (next) => { fieldChannels.set(name, next); },
3816
- remove: () => { fieldChannels.delete(name); },
3873
+ set: (next) => {
3874
+ fieldChannels.set(name, next);
3875
+ invalidatePotentials(); // the swap is live: the next force pass reads the NEW surface
3876
+ },
3877
+ remove: () => {
3878
+ fieldChannels.delete(name);
3879
+ // Drop the raster with the channel. Without this the held cells would outlive the channel
3880
+ // and `relief` would keep transporting matter down a terrain the host has withdrawn.
3881
+ grids.delete(potentialKey(name));
3882
+ potentialStamp.delete(potentialKey(name));
3883
+ invalidatePotentials();
3884
+ },
3817
3885
  };
3818
3886
  },
3819
3887
  sampleField: (name, x, y) => {