@camstack/system 1.2.313 → 1.2.315

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 (111) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/autotrack/index.js +1 -1
  10. package/dist/builtins/autotrack/index.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  13. package/dist/builtins/camera-grid/index.js +1 -1
  14. package/dist/builtins/camera-grid/index.mjs +1 -1
  15. package/dist/builtins/composer/claim-gate.d.ts +32 -0
  16. package/dist/builtins/composer/composed-device-set.d.ts +38 -0
  17. package/dist/builtins/composer/composed-device.d.ts +4 -3
  18. package/dist/builtins/composer/composer-grafts.d.ts +54 -0
  19. package/dist/builtins/composer/composer-plan.d.ts +96 -0
  20. package/dist/builtins/composer/composer-verdict.d.ts +19 -0
  21. package/dist/builtins/composer/composer.addon.js +2717 -477
  22. package/dist/builtins/composer/composer.addon.mjs +2722 -482
  23. package/dist/builtins/composer/composer.d.ts +71 -52
  24. package/dist/builtins/composer/composition-runtime.d.ts +93 -9
  25. package/dist/builtins/composer/confirmed-seed.d.ts +32 -0
  26. package/dist/builtins/composer/existing-target.d.ts +96 -0
  27. package/dist/builtins/composer/field-record.d.ts +50 -0
  28. package/dist/builtins/composer/graft-host.d.ts +47 -0
  29. package/dist/builtins/composer/held-claims.d.ts +12 -0
  30. package/dist/builtins/composer/owned-fields-target.d.ts +55 -0
  31. package/dist/builtins/composer/slice-assembler.d.ts +10 -0
  32. package/dist/builtins/composer/source-readings.d.ts +122 -0
  33. package/dist/builtins/composer/source-tracker.d.ts +35 -42
  34. package/dist/builtins/console-logging/index.js +1 -1
  35. package/dist/builtins/console-logging/index.mjs +1 -1
  36. package/dist/builtins/core-blocks/composition-api.d.ts +7 -4
  37. package/dist/builtins/core-blocks/composition-peers.d.ts +9 -0
  38. package/dist/builtins/core-blocks/composition-sources.d.ts +16 -3
  39. package/dist/builtins/core-blocks/core-block-store.d.ts +8 -1
  40. package/dist/builtins/core-blocks/core-blocks.addon.d.ts +4 -3
  41. package/dist/builtins/core-blocks/core-blocks.addon.js +255 -79
  42. package/dist/builtins/core-blocks/core-blocks.addon.mjs +255 -79
  43. package/dist/builtins/device-manager/claimed-status-overlay.d.ts +12 -0
  44. package/dist/builtins/device-manager/device-manager.addon.d.ts +16 -0
  45. package/dist/builtins/device-manager/device-manager.addon.js +1825 -317
  46. package/dist/builtins/device-manager/device-manager.addon.mjs +1825 -317
  47. package/dist/builtins/device-manager/device-provider-context.d.ts +24 -2
  48. package/dist/builtins/device-manager/device-row-store.d.ts +2 -0
  49. package/dist/builtins/device-manager/device-state-claim-views.d.ts +77 -0
  50. package/dist/builtins/device-manager/device-state-claims.d.ts +43 -0
  51. package/dist/builtins/device-manager/device-state-mirror.d.ts +145 -61
  52. package/dist/builtins/device-manager/field-claims-index.d.ts +69 -0
  53. package/dist/builtins/device-manager/field-ownership.d.ts +53 -0
  54. package/dist/builtins/device-manager/migrate-device.d.ts +27 -0
  55. package/dist/builtins/device-manager/migrate-guard.d.ts +7 -0
  56. package/dist/builtins/device-manager/migrate-hardware-state.d.ts +55 -0
  57. package/dist/builtins/device-manager/migration-refused.d.ts +23 -0
  58. package/dist/builtins/device-manager/mirror-row-writer.d.ts +138 -0
  59. package/dist/builtins/device-manager/runtime-state-persist-gate.d.ts +4 -0
  60. package/dist/builtins/doorbell/binding-mirror.d.ts +1 -0
  61. package/dist/builtins/doorbell/doorbell-composition-migration.d.ts +59 -0
  62. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +3 -0
  63. package/dist/builtins/doorbell/virtual-doorbell.addon.js +315 -10
  64. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +315 -10
  65. package/dist/builtins/hub-forwarder/index.js +1 -1
  66. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  67. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  68. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  69. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  70. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  71. package/dist/builtins/local-network/local-network.addon.js +1 -1
  72. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  73. package/dist/builtins/loki-logging/index.js +1 -1
  74. package/dist/builtins/loki-logging/index.mjs +1 -1
  75. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  76. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  77. package/dist/builtins/platform-probe/index.js +1 -1
  78. package/dist/builtins/platform-probe/index.mjs +1 -1
  79. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  80. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  81. package/dist/builtins/snapshot/index.js +1 -1
  82. package/dist/builtins/snapshot/index.mjs +1 -1
  83. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  84. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  85. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  86. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  87. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  88. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  89. package/dist/builtins/system-config/system-config.addon.js +1 -1
  90. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  91. package/dist/builtins/winston-logging/index.js +1 -1
  92. package/dist/builtins/winston-logging/index.mjs +1 -1
  93. package/dist/{child-cap-dispatch-CHTiHbsv.mjs → child-cap-dispatch-AAltfBUH.mjs} +1484 -1417
  94. package/dist/{child-cap-dispatch-Dyn_7ezH.js → child-cap-dispatch-DzcUd_ct.js} +1503 -1424
  95. package/dist/composition-sources-DYqQLRzF.js +122 -0
  96. package/dist/composition-sources-yonsRneP.mjs +111 -0
  97. package/dist/{dist-CkRwnhbo.js → dist-DVKv5i-e.js} +5396 -4331
  98. package/dist/{dist-Cj7pJmXq.mjs → dist-MzYJVCLE.mjs} +5330 -4331
  99. package/dist/index.js +137 -8
  100. package/dist/index.mjs +133 -8
  101. package/dist/kernel/capability-registry.d.ts +43 -1
  102. package/dist/kernel/index.d.ts +3 -1
  103. package/dist/kernel/moleculer/device-cap-proxy.d.ts +0 -10
  104. package/dist/kernel/status-overlay.d.ts +18 -0
  105. package/dist/kernel/transport/claimed-get-status.d.ts +15 -0
  106. package/dist/kernel/transport/index.d.ts +2 -0
  107. package/dist/kernel/transport/local-child-registry.d.ts +23 -0
  108. package/dist/kernel/transport/parent-unowned-call.d.ts +25 -0
  109. package/dist/{retired-settings-keys-C1duPikd.mjs → retired-settings-keys-8yY2sTc-.mjs} +1 -1
  110. package/dist/{retired-settings-keys-CGSuXhP8.js → retired-settings-keys-iY5I0nKM.js} +1 -1
  111. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
- import { $ as compositionSliceKey, K as buildCapabilityLookup, Ln as DeviceType, Q as composerCapability, Sn as toExpressionValue, Z as composedDeviceStableId, c as COMPOSER_ADDON_ID, et as compositionSourceKey, g as DeclaredDevices, i as BaseDevice, ir as EventCategory, jn as BaseAddon, kn as errMsg, t as ALL_CAPABILITY_DEFINITIONS, tn as planComposition, xt as evaluateCompositionField } from "../../dist-Cj7pJmXq.mjs";
2
- import { n as parseDeviceStateChanged, t as isRecord } from "../../device-state-changed-DoBbkyEI.mjs";
1
+ import { An as cloneStatefulExpressionMemory, Bn as toExpressionValue, Fn as compositionSliceKey, Gn as BaseAddon, In as compositionSourceKey, K as buildCapabilityLookup, Ln as createStatefulExpressionMemory, Mn as composedDeviceStableId, Nn as compositionFeatureEnabled, Pn as compositionItemFieldPath, Un as DeviceType, Wn as errMsg, X as composerCapability, Xt as planComposition, Z as compositionFieldKey, c as COMPOSED_ITEM_FIXED_FIELDS, h as DeclaredDevices, hr as EventCategory, i as BaseDevice, t as ALL_CAPABILITY_DEFINITIONS, tr as createEvent, vt as evaluateCompositionField, wn as COMPOSER_ADDON_ID } from "../../dist-MzYJVCLE.mjs";
2
+ import { n as parseDeviceStateChanged, t as isRecord$2 } from "../../device-state-changed-DoBbkyEI.mjs";
3
+ import { n as normalizedNativeOwner } from "../../composition-sources-yonsRneP.mjs";
3
4
  import { z } from "zod";
4
5
  //#region src/builtins/composer/composed-device.ts
5
6
  /**
@@ -80,6 +81,217 @@ function isComposedDevice(device) {
80
81
  return device instanceof ComposedDevice;
81
82
  }
82
83
  //#endregion
84
+ //#region src/builtins/composer/claim-gate.ts
85
+ function claimKey(k) {
86
+ return `${k.owner}@${k.deviceId}#${k.capName}`;
87
+ }
88
+ var RELEASE_CONFIRMATIONS = 2;
89
+ function entryOf$1(key) {
90
+ return {
91
+ key,
92
+ misses: 0,
93
+ attempts: 0
94
+ };
95
+ }
96
+ var ClaimGate = class {
97
+ logger;
98
+ known = /* @__PURE__ */ new Map();
99
+ constructor(logger) {
100
+ this.logger = logger;
101
+ }
102
+ /**
103
+ * The claims the hub holds before this process applied anything. Called once
104
+ * at boot, before the first `filter`. A claim the gate already knows keeps its
105
+ * entry and its miss count.
106
+ */
107
+ seed(claims) {
108
+ const fresh = claims.filter((c) => !this.known.has(claimKey(c)));
109
+ this.known = new Map([...this.known, ...fresh.map((c) => [claimKey(c), entryOf$1({
110
+ deviceId: c.deviceId,
111
+ capName: c.capName,
112
+ owner: c.owner
113
+ })])]);
114
+ }
115
+ /** Every claim known under one owner — what a block whose plan is held keeps (D659 placeholder rule). */
116
+ claimsOf(owner) {
117
+ return [...this.known.values()].filter((e) => e.key.owner === owner).map((e) => e.key);
118
+ }
119
+ /**
120
+ * Learn `wanted` and answer the claims to release NOW: those absent from
121
+ * `wanted` on two consecutive calls, plus those whose earlier release the hub
122
+ * has not confirmed yet. A claim wanted again starts from zero misses.
123
+ */
124
+ filter(wanted) {
125
+ const present = new Map(wanted.map((k) => [claimKey(k), k]));
126
+ const next = /* @__PURE__ */ new Map();
127
+ const releases = [];
128
+ for (const [key, entry] of this.known) {
129
+ if (present.has(key)) {
130
+ next.set(key, {
131
+ ...entry,
132
+ misses: 0,
133
+ attempts: 0
134
+ });
135
+ continue;
136
+ }
137
+ const misses = Math.min(entry.misses + 1, RELEASE_CONFIRMATIONS);
138
+ if (misses < RELEASE_CONFIRMATIONS) this.logger.warn("a claim vanished from one apply — it is kept until a second apply agrees", {
139
+ tags: this.tagsOf(entry.key),
140
+ meta: {
141
+ ...this.metaOf(entry.key),
142
+ misses
143
+ }
144
+ });
145
+ else {
146
+ if (entry.misses < RELEASE_CONFIRMATIONS) this.logger.info("a claim is unwanted on two consecutive applies — it is released to the native provider", {
147
+ tags: this.tagsOf(entry.key),
148
+ meta: this.metaOf(entry.key)
149
+ });
150
+ releases.push(entry.key);
151
+ }
152
+ next.set(key, {
153
+ ...entry,
154
+ misses
155
+ });
156
+ }
157
+ for (const [key, k] of present) if (!next.has(key)) next.set(key, entryOf$1(k));
158
+ this.known = next;
159
+ return releases;
160
+ }
161
+ /** The hub confirmed the release (or held nothing of ours): forgotten. */
162
+ released(key, how) {
163
+ const k = claimKey(key);
164
+ const attempts = this.known.get(k)?.attempts ?? 0;
165
+ this.known = new Map([...this.known].filter(([other]) => other !== k));
166
+ this.logger.info(attempts > 0 ? "claim release landed after failed attempts" : "claim release confirmed by the hub", {
167
+ tags: this.tagsOf(key),
168
+ meta: {
169
+ ...this.metaOf(key),
170
+ outcome: how,
171
+ ...attempts > 0 ? { attempts } : {}
172
+ }
173
+ });
174
+ }
175
+ /** The release did not land: kept, and returned by every later `filter` until it does. Never given up. */
176
+ releaseFailed(key, error) {
177
+ const k = claimKey(key);
178
+ const entry = this.known.get(k);
179
+ if (entry === void 0) return;
180
+ const attempts = entry.attempts + 1;
181
+ this.known = new Map([...this.known, [k, {
182
+ ...entry,
183
+ attempts
184
+ }]]);
185
+ if (attempts > 1) return;
186
+ this.logger.warn("claim release did not land — retried on every apply until it does", {
187
+ tags: this.tagsOf(key),
188
+ meta: {
189
+ ...this.metaOf(key),
190
+ error
191
+ }
192
+ });
193
+ }
194
+ tagsOf(key) {
195
+ return { deviceId: key.deviceId };
196
+ }
197
+ metaOf(key) {
198
+ return {
199
+ owner: key.owner,
200
+ cap: key.capName
201
+ };
202
+ }
203
+ };
204
+ //#endregion
205
+ //#region src/builtins/composer/composed-device-set.ts
206
+ /**
207
+ * The composed devices the composer DECLARED (D659): one row per `new`-target
208
+ * block, hung off the shared Blocks integration, adopted or built by
209
+ * `DeclaredDevices.reconcile`. This is the only place a composed row is created,
210
+ * kept or swept — a composition on an EXISTING device never enters it (D663).
211
+ *
212
+ * - `rows` is the set of stableIds whose row exists: seeded from the persisted
213
+ * rows at boot, then what every declare kept. Planning asks it to decide
214
+ * whether a block that stopped planning has a row to hold as a placeholder.
215
+ * - A changed shape (type, role, cap set, placeholder or not) needs a NEW device
216
+ * object: decommission, and let the reconcile adopt it with the new class. A
217
+ * failure keeps the old device and is retried on the next apply.
218
+ * - A failed reconcile changes nothing (D49): the devices already live stay.
219
+ */
220
+ var ComposedDeviceSet = class {
221
+ deps;
222
+ devices = /* @__PURE__ */ new Map();
223
+ existingRows = /* @__PURE__ */ new Set();
224
+ declaredFingerprint = null;
225
+ constructor(deps) {
226
+ this.deps = deps;
227
+ }
228
+ /** StableIds whose row exists. */
229
+ get rows() {
230
+ return this.existingRows;
231
+ }
232
+ seedRows(rows) {
233
+ this.existingRows = new Set(rows.map((r) => r.stableId));
234
+ }
235
+ get(stableId) {
236
+ return this.devices.get(stableId) ?? null;
237
+ }
238
+ idOf(stableId) {
239
+ return this.devices.get(stableId)?.id ?? null;
240
+ }
241
+ async rebuildShapes(planned, previousOf) {
242
+ const errors = /* @__PURE__ */ new Map();
243
+ for (const p of planned) {
244
+ const previous = previousOf(p.block.id);
245
+ const device = previous?.device ?? null;
246
+ if (previous === void 0 || device === null || p.shape === null) continue;
247
+ if (previous.shape === p.shape) continue;
248
+ try {
249
+ this.deps.logger.info("composition shape changed — rebuilding its device", {
250
+ tags: { deviceId: device.id },
251
+ meta: {
252
+ blockId: p.block.id,
253
+ from: previous.shape,
254
+ to: p.shape
255
+ }
256
+ });
257
+ if (p.spec !== null) await this.deps.devices.applyDerivedSpec(device.id, p.spec);
258
+ await this.deps.devices.decommission(device.id);
259
+ const stableId = composedDeviceStableId(p.block.id);
260
+ this.devices = new Map([...this.devices].filter(([s]) => s !== stableId));
261
+ this.declaredFingerprint = null;
262
+ } catch (err) {
263
+ const error = errMsg(err);
264
+ errors.set(p.block.id, error);
265
+ this.deps.logger.warn("rebuilding a composed device failed — the old one is kept", {
266
+ tags: { deviceId: device.id },
267
+ meta: {
268
+ blockId: p.block.id,
269
+ error
270
+ }
271
+ });
272
+ }
273
+ }
274
+ return errors;
275
+ }
276
+ async reconcile(integrationId, declarations, planned) {
277
+ const shapes = new Map(planned.flatMap((p) => p.declaration === null ? [] : [[p.declaration.stableId, p.shape]]));
278
+ const fingerprint = JSON.stringify(declarations.map((d) => [d.stableId, shapes.get(d.stableId) ?? null]).toSorted());
279
+ const allLive = declarations.every((d) => this.devices.has(d.stableId));
280
+ if (fingerprint === this.declaredFingerprint && allLive) return;
281
+ try {
282
+ const result = await this.deps.declare(integrationId, declarations);
283
+ this.devices = result.devices;
284
+ this.existingRows = new Set(declarations.map((d) => d.stableId).filter((s) => this.existingRows.has(s) || result.devices.has(s)));
285
+ this.declaredFingerprint = fingerprint;
286
+ } catch (err) {
287
+ this.deps.logger.warn("composer: the device declaration failed — keeping the devices already live", { meta: {
288
+ error: errMsg(err),
289
+ declarations: declarations.length
290
+ } });
291
+ }
292
+ }
293
+ };
294
+ //#endregion
83
295
  //#region src/builtins/composer/composition-declarations.ts
84
296
  /**
85
297
  * Which composed devices exist (D659): one declaration per composition block,
@@ -187,6 +399,423 @@ var WithdrawalGate = class {
187
399
  }
188
400
  };
189
401
  //#endregion
402
+ //#region src/builtins/composer/composer-plan.ts
403
+ /**
404
+ * What one apply PLANS for a block, before anything is declared, claimed or
405
+ * run (D659, D663). Pure over its inputs: the composer hands in what it knows
406
+ * (its rows, the previous plan, the tracker) and gets a `PlannedBlock` back.
407
+ *
408
+ * - A `new` target plans as in slice 1: a declaration, a shape, a placeholder
409
+ * when the composition stopped planning but its row exists.
410
+ * - An `existing` target declares NOTHING (the row is not the composer's). It
411
+ * is resolved through the tracker's path, described with one bindings read
412
+ * per plan change, and planned against that description. A settled plan is
413
+ * kept until the OPERATOR acts (edit, restart): a bindings change alone never
414
+ * re-plans (Task 10 rule 2, D659). While the claims index is unseeded or the
415
+ * target is unresolved or undescribed, the block WAITS by name and claims
416
+ * nothing (D49). A target gone on two agreeing index reads is `gone`.
417
+ * - An unreadable row names no target: whatever it holds is held (D49).
418
+ */
419
+ /** The shape of a placeholder: no caps, so no plan to take a shape from. */
420
+ var PLACEHOLDER_SHAPE = "placeholder";
421
+ var NO_PLAN = {
422
+ fields: [],
423
+ events: [],
424
+ problems: [],
425
+ declaration: null,
426
+ shape: null,
427
+ spec: null
428
+ };
429
+ /**
430
+ * A row that exists but does not parse is held exactly like a composition
431
+ * that stopped planning: its device (if it has a row) stays as an offline
432
+ * placeholder, its claims (if any) are kept, and the block fails naming the
433
+ * parse error. Absent, it would be withdrawn on the second apply that omitted
434
+ * it (D49).
435
+ */
436
+ function planUnreadable(block, rows) {
437
+ const stableId = composedDeviceStableId(block.id);
438
+ const hasRow = rows.has(stableId);
439
+ return {
440
+ ...NO_PLAN,
441
+ block: {
442
+ id: block.id,
443
+ name: block.name,
444
+ enabled: false,
445
+ updatedAt: 0
446
+ },
447
+ problems: [`the stored block does not parse — ${block.error}`],
448
+ declaration: hasRow ? placeholderDeclaration(stableId) : null,
449
+ shape: hasRow ? PLACEHOLDER_SHAPE : null,
450
+ target: { kind: "unreadable" }
451
+ };
452
+ }
453
+ /** `target` is the block's own `new` target, narrowed by the caller: this path never sees an existing one (M-5). */
454
+ function planNew(block, target, lookupCap, rows) {
455
+ const plan = planComposition(block.composition, lookupCap);
456
+ const planned = { kind: "new" };
457
+ if (plan.problems.length > 0) {
458
+ const stableId = composedDeviceStableId(block.id);
459
+ const hasRow = rows.has(stableId);
460
+ return {
461
+ ...NO_PLAN,
462
+ block,
463
+ problems: plan.problems.map((p) => `${p.path}: ${p.message}`),
464
+ declaration: hasRow ? placeholderDeclaration(stableId) : null,
465
+ shape: hasRow ? PLACEHOLDER_SHAPE : null,
466
+ target: planned
467
+ };
468
+ }
469
+ const caps = block.composition.features.flatMap((f) => {
470
+ if (!compositionFeatureEnabled(f)) return [];
471
+ const def = lookupCap(f.cap);
472
+ return def === null ? [] : [def];
473
+ });
474
+ const declarationInput = {
475
+ blockId: block.id,
476
+ name: block.name,
477
+ target,
478
+ caps
479
+ };
480
+ return {
481
+ block,
482
+ fields: plan.fields,
483
+ events: plan.events,
484
+ problems: [],
485
+ declaration: declarationFor(declarationInput),
486
+ shape: shapeOf(declarationInput),
487
+ spec: {
488
+ type: target.type,
489
+ ...target.role === void 0 ? {} : { role: target.role }
490
+ },
491
+ target: planned
492
+ };
493
+ }
494
+ async function planExisting(input) {
495
+ const { block, previous, operatorAction } = input;
496
+ const prior = previous?.target;
497
+ if (!operatorAction && previous !== void 0 && prior?.kind === "existing" && prior.waiting === null) return {
498
+ ...previous,
499
+ block
500
+ };
501
+ const ref = input.target.device;
502
+ const key = compositionSourceKey(ref);
503
+ const knownId = prior?.kind === "existing" ? prior.deviceId : null;
504
+ if (input.claimsWait !== null) return waitingPlan(block, ref, knownId, input.claimsWait);
505
+ const resolution = await input.tracker.resolve(ref, operatorAction);
506
+ if (resolution.kind === "unknown") return waitingPlan(block, ref, knownId, `the target device \`${key}\` could not be resolved (${resolution.error}) — nothing is claimed until it is; retried on the next apply`);
507
+ const existing = {
508
+ kind: "existing",
509
+ ref,
510
+ deviceId: resolution.kind === "found" ? resolution.deviceId : knownId,
511
+ waiting: null,
512
+ gone: null,
513
+ features: [],
514
+ nativeCaps: /* @__PURE__ */ new Map()
515
+ };
516
+ if (resolution.kind === "missing") return {
517
+ ...NO_PLAN,
518
+ block,
519
+ target: {
520
+ ...existing,
521
+ gone: `${resolution.reason} — two index reads agreed`
522
+ }
523
+ };
524
+ const description = await input.targets.describe(ref, resolution.deviceId, resolution.type, operatorAction);
525
+ if (description === null) return waitingPlan(block, ref, resolution.deviceId, `the target device \`${key}\` could not be described (its bindings did not read) — nothing is claimed until it is; retried on the next apply`);
526
+ const plan = planComposition(block.composition, input.lookupCap, description);
527
+ return {
528
+ ...NO_PLAN,
529
+ block,
530
+ fields: plan.problems.length > 0 ? [] : plan.fields,
531
+ events: plan.problems.length > 0 ? [] : plan.events,
532
+ problems: plan.problems.map((p) => `${p.path}: ${p.message}`),
533
+ target: {
534
+ ...existing,
535
+ features: plan.features,
536
+ nativeCaps: description.nativeCaps
537
+ }
538
+ };
539
+ }
540
+ function waitingPlan(block, ref, deviceId, waiting) {
541
+ return {
542
+ ...NO_PLAN,
543
+ block,
544
+ target: {
545
+ kind: "existing",
546
+ ref,
547
+ deviceId,
548
+ waiting,
549
+ gone: null,
550
+ features: [],
551
+ nativeCaps: /* @__PURE__ */ new Map()
552
+ }
553
+ };
554
+ }
555
+ /**
556
+ * Runtime preparation can still fail after a complete existing-target plan.
557
+ * Preserve its fields and description for retry, but mark it waiting so claim
558
+ * reconciliation cannot treat the unprepared candidate as live.
559
+ */
560
+ function withExistingTargetWait(p, waiting) {
561
+ return p.target.kind === "existing" ? {
562
+ ...p,
563
+ target: {
564
+ ...p.target,
565
+ waiting
566
+ }
567
+ } : p;
568
+ }
569
+ /** What `logPlanTransition` compares: problems, and for an existing target its wait or its loss. */
570
+ function planSummary(p) {
571
+ const target = p.target;
572
+ return (target.kind === "existing" ? [
573
+ p.problems.join("; "),
574
+ target.waiting ?? "",
575
+ target.gone ?? ""
576
+ ] : [p.problems.join("; ")]).filter((part) => part !== "").join(" | ");
577
+ }
578
+ /**
579
+ * Rule 5 on the PLAN path: `device.unregistered` is telemetry and may be
580
+ * dropped (D8), so a target is often found gone by the operator's next edit or
581
+ * restart. The previous live target is what must be ungrafted — exactly as on
582
+ * the event path. Null when nothing was lost by THIS plan.
583
+ */
584
+ function lostTarget(p, previous) {
585
+ const now = p.target;
586
+ const before = previous?.target;
587
+ if (now.kind !== "existing" || now.gone === null || before?.kind !== "existing") return null;
588
+ if (before.deviceId === null || before.gone !== null) return null;
589
+ return {
590
+ target: before,
591
+ deviceId: before.deviceId
592
+ };
593
+ }
594
+ /** Two plans write the same place: both new, or the very same existing-target plan (reused until the operator acts). */
595
+ function sameTarget(a, b) {
596
+ return a === b || a.kind === "new" && b.kind === "new";
597
+ }
598
+ /** One line when a block starts or stops planning (or waiting, or loses its target) — never one per apply. */
599
+ function logPlanTransition(logger, previous, p, liveTags) {
600
+ if (previous !== void 0 && planSummary(previous) === planSummary(p)) return;
601
+ if (previous === void 0 && planSummary(p) === "") return;
602
+ const target = p.target;
603
+ const extras = {
604
+ tags: target.kind === "existing" && target.deviceId !== null ? { deviceId: target.deviceId } : liveTags,
605
+ meta: {
606
+ blockId: p.block.id,
607
+ problems: p.problems,
608
+ placeholder: p.declaration !== null
609
+ }
610
+ };
611
+ if (target.kind === "existing" && target.gone !== null) logger.error("customization target device is gone — the block failed; only the operator ends this (edit or restart the block)", {
612
+ ...extras,
613
+ meta: {
614
+ ...extras.meta,
615
+ target: compositionSourceKey(target.ref)
616
+ }
617
+ });
618
+ else if (target.kind === "existing" && target.waiting !== null) logger.warn("customization waits — nothing is claimed or released until it plans", {
619
+ ...extras,
620
+ meta: {
621
+ ...extras.meta,
622
+ reason: target.waiting
623
+ }
624
+ });
625
+ else if (p.problems.length === 0) logger.info("composition plans again", extras);
626
+ else logger.warn(p.declaration === null ? "composition does not plan — no device is declared for it" : "composition no longer plans — its device is kept offline with no capabilities", extras);
627
+ }
628
+ //#endregion
629
+ //#region src/builtins/composer/slice-assembler.ts
630
+ /**
631
+ * From the runtime's flat field values to the slice a target takes (D663).
632
+ *
633
+ * A top-level field is written under its path. An item leaf
634
+ * (`consumables.items[desiccant].remaining.value`) is one entry of an ARRAY:
635
+ * every leaf of one item is grouped by `(cap, arrayPath, key)` and set, by
636
+ * path segment, onto the cap's OWN `status.itemArray.emptyItem` — so an
637
+ * unsourced nullable leaf is `null` because the cap says so, not because this
638
+ * module guessed. The key, the label and {@link COMPOSED_ITEM_FIXED_FIELDS}
639
+ * are the composer's; items are sorted by key so two writes of the same
640
+ * values are one slice.
641
+ *
642
+ * A cap the lookup does not know as an item array cannot be assembled: that
643
+ * is thrown by name, and the runtime's write path turns a throw into a named,
644
+ * retried failure — never a half-built array.
645
+ */
646
+ function isRecord$1(value) {
647
+ return typeof value === "object" && value !== null && !Array.isArray(value);
648
+ }
649
+ /** `setPath(item, ['remaining', 'value'], 0)` → `{ ...item, remaining: { ...item.remaining, value: 0 } }`. */
650
+ function setPath(record, segments, value) {
651
+ const [head, ...rest] = segments;
652
+ if (head === void 0) return record;
653
+ if (rest.length === 0) return {
654
+ ...record,
655
+ [head]: value
656
+ };
657
+ const current = record[head];
658
+ return {
659
+ ...record,
660
+ [head]: setPath(isRecord$1(current) ? current : {}, rest, value)
661
+ };
662
+ }
663
+ function itemArrayOf(group, lookupCap) {
664
+ const itemArray = lookupCap(group.cap)?.status?.itemArray;
665
+ if (itemArray === void 0 || itemArray.path !== group.arrayPath) throw new Error(`\`${group.cap}\` has no item array at \`${group.arrayPath}\` — \`${compositionItemFieldPath(group.arrayPath, group.key, "*")}\` cannot be assembled`);
666
+ return itemArray;
667
+ }
668
+ function assembleItem(group, lookupCap) {
669
+ const itemArray = itemArrayOf(group, lookupCap);
670
+ const base = {
671
+ ...itemArray.emptyItem,
672
+ [itemArray.keyField]: group.key,
673
+ ...itemArray.labelField === void 0 ? {} : { [itemArray.labelField]: group.label },
674
+ ...COMPOSED_ITEM_FIXED_FIELDS
675
+ };
676
+ return group.leaves.reduce((item, leaf) => setPath(item, leaf.plan.path.split("."), leaf.value), base);
677
+ }
678
+ function groupItems(entries) {
679
+ const groups = /* @__PURE__ */ new Map();
680
+ for (const entry of entries) {
681
+ const item = entry.plan.item;
682
+ if (item === void 0) continue;
683
+ const id = `${entry.plan.cap}\u0000${item.arrayPath}\u0000${item.key}`;
684
+ const group = groups.get(id) ?? {
685
+ cap: entry.plan.cap,
686
+ arrayPath: item.arrayPath,
687
+ key: item.key,
688
+ label: item.label,
689
+ leaves: []
690
+ };
691
+ groups.set(id, {
692
+ ...group,
693
+ leaves: [...group.leaves, entry]
694
+ });
695
+ }
696
+ return [...groups.values()];
697
+ }
698
+ function compareKeys(a, b) {
699
+ return a < b ? -1 : a > b ? 1 : 0;
700
+ }
701
+ function assembleSlice(entries, lookupCap) {
702
+ const top = Object.fromEntries(entries.filter((e) => e.plan.item === void 0).map((e) => [e.plan.path, e.value]));
703
+ const groups = groupItems(entries);
704
+ const arrays = [...new Set(groups.map((g) => g.arrayPath))].map((arrayPath) => [arrayPath, groups.filter((g) => g.arrayPath === arrayPath).toSorted((a, b) => compareKeys(a.key, b.key)).map((g) => assembleItem(g, lookupCap))]);
705
+ return {
706
+ ...top,
707
+ ...Object.fromEntries(arrays)
708
+ };
709
+ }
710
+ //#endregion
711
+ //#region src/builtins/composer/field-record.ts
712
+ /**
713
+ * One composed field's record and the pure judgements over it (D659, D663):
714
+ * what it may write, whether it is released (a CONFIRMED unavailability of a
715
+ * `release`-mode field, S1b) or HELD (no answer yet, D49), and how a report or a
716
+ * log line names it. Split from `composition-runtime.ts`, which holds the
717
+ * behaviour.
718
+ */
719
+ var NO_FIELDS = /* @__PURE__ */ new Set();
720
+ function fieldKey(plan) {
721
+ return compositionFieldKey(plan);
722
+ }
723
+ function isRecord(value) {
724
+ return typeof value === "object" && value !== null && !Array.isArray(value);
725
+ }
726
+ function valueAtPath$1(value, path) {
727
+ return path.split(".").reduce((node, segment) => isRecord(node) ? node[segment] : void 0, value);
728
+ }
729
+ function initialValue(plan, byCap, lookupCap) {
730
+ const slice = byCap?.get(plan.cap);
731
+ if (slice === void 0 || slice === null) return void 0;
732
+ const item = plan.item;
733
+ if (item === void 0) return valueAtPath$1(slice, plan.path);
734
+ const itemArray = lookupCap(plan.cap)?.status?.itemArray;
735
+ if (itemArray === void 0 || itemArray.path !== item.arrayPath) return void 0;
736
+ const entries = valueAtPath$1(slice, item.arrayPath);
737
+ if (!Array.isArray(entries)) return void 0;
738
+ return valueAtPath$1(entries.find((entry) => isRecord(entry) && entry[itemArray.keyField] === item.key), plan.path);
739
+ }
740
+ function createFieldRecord(plan, byCap, lookupCap) {
741
+ const initial = initialValue(plan, byCap, lookupCap);
742
+ return {
743
+ plan,
744
+ outcome: null,
745
+ initial,
746
+ hasWritten: false,
747
+ written: void 0,
748
+ expressionMemory: createStatefulExpressionMemory(),
749
+ eventHighWater: typeof initial === "number" && Number.isFinite(initial) ? initial : null
750
+ };
751
+ }
752
+ function currentOutput(record) {
753
+ return record.hasWritten ? record.written : record.initial;
754
+ }
755
+ /** The top-level field a record writes: an item leaf writes its whole array. */
756
+ function topLevelField(plan) {
757
+ return plan.item === void 0 ? plan.path : plan.item.arrayPath;
758
+ }
759
+ /** The field as a report or a log line names it: `items[desiccant].status` for an item leaf. */
760
+ function fieldPath(plan) {
761
+ return plan.item === void 0 ? plan.path : compositionItemFieldPath(plan.item.arrayPath, plan.item.key, plan.path);
762
+ }
763
+ function itemOf(plan) {
764
+ return plan.item === void 0 ? {} : { item: plan.item };
765
+ }
766
+ function sameSet(a, b) {
767
+ return a.size === b.size && [...a].every((x) => b.has(x));
768
+ }
769
+ function valueOf(outcome) {
770
+ return outcome === null || outcome.state === "unavailable" ? void 0 : outcome.value;
771
+ }
772
+ function reasonOf(outcome) {
773
+ if (outcome === null) return "not evaluated yet";
774
+ return outcome.reason;
775
+ }
776
+ /** State, value and reason — what a write or a log line is about. NEVER the deadline (R1). */
777
+ function sameOutcome(a, b) {
778
+ return a !== null && a.state === b.state && valueOf(a) === valueOf(b) && reasonOf(a) === reasonOf(b);
779
+ }
780
+ /** A source is GONE: the outcome that must never be masked by a non-fatal one. */
781
+ function isFatal(outcome) {
782
+ return outcome !== null && outcome.state === "unavailable" && outcome.fatal;
783
+ }
784
+ /** A `release`-mode field whose source is CONFIRMED unavailable: not the composition's to write (S1b). Stale is a value (D49). */
785
+ function isReleased(record) {
786
+ const outcome = record.outcome;
787
+ return record.plan.unavailable === "release" && outcome?.state === "unavailable" && outcome.confirmed === true;
788
+ }
789
+ /** A `release`-mode field with no value and no ANSWER yet (a failed, first or empty read): held on its last value, never released (D49). */
790
+ function isHeld(record) {
791
+ const outcome = record.outcome;
792
+ return record.plan.unavailable === "release" && outcome?.state === "unavailable" && outcome.confirmed !== true;
793
+ }
794
+ function namedField(r) {
795
+ return {
796
+ cap: r.plan.cap,
797
+ path: r.plan.path,
798
+ ...itemOf(r.plan),
799
+ reason: reasonOf(r.outcome) ?? "unavailable"
800
+ };
801
+ }
802
+ function writable(record) {
803
+ const outcome = record.outcome;
804
+ if (outcome === null) return void 0;
805
+ if (outcome.state !== "unavailable") return outcome.value;
806
+ switch (record.plan.unavailable) {
807
+ case "null": return null;
808
+ case "unknown": return "unknown";
809
+ default: return;
810
+ }
811
+ }
812
+ function errorText(error) {
813
+ return error instanceof Error ? error.message : String(error);
814
+ }
815
+ function pendingReason(cap, pending) {
816
+ return pending.kind === "refused" ? `the \`${cap}\` capability refused the value: ${pending.error}` : `writing the \`${cap}\` capability threw: ${pending.error}`;
817
+ }
818
+ //#endregion
190
819
  //#region src/builtins/composer/composition-runtime.ts
191
820
  /**
192
821
  * One running composition: per-field state, writes to the composed device, and
@@ -217,40 +846,47 @@ var WithdrawalGate = class {
217
846
  * - Nothing escapes: a pass that throws is logged, and the timer is re-armed
218
847
  * from the stored deadlines regardless.
219
848
  * - `stop()` is terminal: every entry point is a no-op afterwards.
849
+ *
850
+ * Slice 2 (D663):
851
+ * - Records are keyed by `compositionFieldKey`: two items' `status` are two
852
+ * records. An item-array cap is written WHOLE — a change to one item leaf
853
+ * re-sends every item, assembled by `assembleSlice` on the cap's `emptyItem`.
854
+ * A required item leaf with no fallback holds the array, named once.
855
+ * - `'unknown'` unavailable mode writes the enum's own word.
856
+ * - `'release'` unavailable mode hands the field BACK (ruling S1b): the target
857
+ * is told to `withhold` it, the field's record forgets what it wrote, and the
858
+ * source returning re-widens and writes it again. Stale is a value (D49),
859
+ * never a release — and neither is an UNCONFIRMED unavailability (a failed,
860
+ * first or empty read): the field is HELD on its last value, not written,
861
+ * not withheld, its claim kept, until the source answers (D49, fix round 2).
862
+ * `state().released` / `state().held` name every such field for the report.
863
+ * - A target whose writes are asynchronous (claims on the hub) reports what did
864
+ * not land through `onAsyncFailure`: the cap is marked write-pending by name
865
+ * and re-written whole on the next pass — no retry timer (D3).
866
+ *
867
+ * Slice 2b (D670): stateful expression memory commits only when the whole
868
+ * expression succeeds. A declared state-derived event advances its seeded
869
+ * high-water and is emitted before the corresponding target write; neither an
870
+ * event-bus failure nor a refused write can replay the event.
220
871
  */
221
- function errorText(error) {
222
- return error instanceof Error ? error.message : String(error);
223
- }
224
- function pendingReason(cap, pending) {
225
- return pending.kind === "refused" ? `the \`${cap}\` capability refused the value: ${pending.error}` : `writing the \`${cap}\` capability threw: ${pending.error}`;
226
- }
227
- function fieldKey(plan) {
228
- return `${plan.cap}.${plan.path}`;
229
- }
230
- function valueOf(outcome) {
231
- return outcome === null || outcome.state === "unavailable" ? void 0 : outcome.value;
232
- }
233
- function reasonOf(outcome) {
234
- if (outcome === null) return "not evaluated yet";
235
- return outcome.reason;
236
- }
237
- /** State, value and reason — what a write or a log line is about. NEVER the deadline (R1). */
238
- function sameOutcome(a, b) {
239
- return a !== null && a.state === b.state && valueOf(a) === valueOf(b) && reasonOf(a) === reasonOf(b);
240
- }
241
- /** A source is GONE: the outcome that must never be masked by a non-fatal one. */
242
- function isFatal(outcome) {
243
- return outcome !== null && outcome.state === "unavailable" && outcome.fatal;
244
- }
245
- function writable(record) {
246
- const outcome = record.outcome;
247
- if (outcome === null) return void 0;
248
- if (outcome.state !== "unavailable") return outcome.value;
249
- return record.plan.unavailable === "null" ? null : void 0;
872
+ /**
873
+ * A refusal nothing on this side can retry away: another owner holds the
874
+ * field, the slice is invalid for the cap, the device is not known. Every
875
+ * other outcome — a throw, an unreadable row, a migration in flight, a lost
876
+ * claim — is transient and retried on the next pass (D6: bounded, never a loop).
877
+ */
878
+ var TERMINAL_REFUSALS = new Set([
879
+ "owned-by-other",
880
+ "invalid-slice",
881
+ "unknown-device"
882
+ ]);
883
+ function strictFiniteAdvance(previous, next) {
884
+ return typeof next === "number" && Number.isFinite(next) && (previous === null || next > previous);
250
885
  }
251
886
  var CompositionRuntime = class {
252
887
  deps;
253
888
  records;
889
+ startedAtMs;
254
890
  seeded = /* @__PURE__ */ new Set();
255
891
  available = null;
256
892
  failure = null;
@@ -260,13 +896,21 @@ var CompositionRuntime = class {
260
896
  lastPassError = null;
261
897
  pending = /* @__PURE__ */ new Map();
262
898
  heldBack = /* @__PURE__ */ new Set();
899
+ /** Caps whose item array is not written because a required leaf has no value. */
900
+ itemsHeld = /* @__PURE__ */ new Set();
901
+ /** Record keys currently released (S1b), for transition logs and the report. */
902
+ released = /* @__PURE__ */ new Set();
903
+ /** Per cap, the top-level fields the target last accepted as withheld; absent = none. */
904
+ withheld = /* @__PURE__ */ new Map();
905
+ /** Per record key, how many times the field has been released — churn the transition line reports (D391). */
906
+ releaseFlaps = /* @__PURE__ */ new Map();
907
+ /** Caps the target refused for good (`TERMINAL_REFUSALS`): never written again by this runtime. */
908
+ terminal = /* @__PURE__ */ new Map();
263
909
  constructor(deps) {
264
910
  this.deps = deps;
265
- this.records = new Map(deps.fields.map((plan) => [fieldKey(plan), {
266
- plan,
267
- outcome: null,
268
- written: void 0
269
- }]));
911
+ this.startedAtMs = deps.now();
912
+ this.records = new Map(deps.fields.map((plan) => [fieldKey(plan), createFieldRecord(plan, deps.initialByCap, deps.lookupCap)]));
913
+ deps.target.onAsyncFailure?.((cap, failure) => this.onTargetWriteFailed(cap, failure));
270
914
  }
271
915
  start() {
272
916
  if (this.disabledReason !== null) return;
@@ -288,7 +932,8 @@ var CompositionRuntime = class {
288
932
  state: "unavailable",
289
933
  value: null,
290
934
  reason,
291
- fatal: false
935
+ fatal: false,
936
+ confirmed: true
292
937
  };
293
938
  const touched = [...this.records.values()].filter((r) => !isFatal(r.outcome) && !sameOutcome(r.outcome, outcome));
294
939
  for (const r of touched) this.logTransition(r, outcome);
@@ -310,10 +955,20 @@ var CompositionRuntime = class {
310
955
  failure: this.failure,
311
956
  available: this.available === true,
312
957
  fields: [...this.records.values()].map((r) => {
958
+ const terminal = this.terminal.get(r.plan.cap);
959
+ if (terminal !== void 0 && !isFatal(r.outcome)) return {
960
+ cap: r.plan.cap,
961
+ path: r.plan.path,
962
+ ...itemOf(r.plan),
963
+ state: "unavailable",
964
+ value: null,
965
+ reason: terminal
966
+ };
313
967
  const pending = this.pending.get(r.plan.cap);
314
968
  if (pending !== void 0 && !isFatal(r.outcome)) return {
315
969
  cap: r.plan.cap,
316
970
  path: r.plan.path,
971
+ ...itemOf(r.plan),
317
972
  state: "unavailable",
318
973
  value: null,
319
974
  reason: pendingReason(r.plan.cap, pending)
@@ -323,14 +978,63 @@ var CompositionRuntime = class {
323
978
  return {
324
979
  cap: r.plan.cap,
325
980
  path: r.plan.path,
981
+ ...itemOf(r.plan),
326
982
  state: r.outcome?.state ?? "unavailable",
327
983
  value: value === void 0 ? null : value,
328
984
  ...reason === null ? {} : { reason }
329
985
  };
330
- })
986
+ }),
987
+ released: [...this.records.values()].filter(isReleased).map(namedField),
988
+ held: [...this.records.values()].filter(isHeld).map(namedField)
331
989
  };
332
990
  }
333
991
  /**
992
+ * The target's enqueued write of `cap` did not land: the cap is write-pending
993
+ * by name and re-written WHOLE on the next pass that evaluates it (or the next
994
+ * deadline pass). A withhold sent to that target is forgotten too, so it is
995
+ * re-sent. No retry timer (D3).
996
+ */
997
+ onTargetWriteFailed(cap, failure) {
998
+ this.guarded("target-failure", false, () => {
999
+ if (!this.deps.fields.some((f) => f.cap === cap) || this.terminal.has(cap)) return;
1000
+ if (failure.kind === "refused" && TERMINAL_REFUSALS.has(failure.code)) {
1001
+ this.markTerminal(cap, failure.code, failure.message);
1002
+ return;
1003
+ }
1004
+ this.withheld = new Map([...this.withheld].filter(([c]) => c !== cap));
1005
+ this.markPending(cap, this.seeded.has(cap) ? "patch" : "seed", failure.kind === "threw" ? {
1006
+ kind: "threw",
1007
+ error: failure.error
1008
+ } : {
1009
+ kind: "refused",
1010
+ error: `${failure.code}: ${failure.message}`
1011
+ });
1012
+ this.updateAvailability();
1013
+ });
1014
+ }
1015
+ /**
1016
+ * The target refused this cap for good: nothing on this side can retry it
1017
+ * away (I-3). The cap is never written again by this runtime, its fields
1018
+ * report the refusal, and the block fails naming it — only the operator ends
1019
+ * this (edit or delete the block, or the competing one), like a gone source.
1020
+ */
1021
+ markTerminal(cap, code, message) {
1022
+ const reason = `the \`${cap}\` capability was refused for good: ${code}: ${message} — only the operator ends this (edit or delete the block)`;
1023
+ this.terminal = new Map([...this.terminal, [cap, reason]]);
1024
+ this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
1025
+ if (this.failure === null) this.failure = `composed write refused for good — ${cap}: ${code}: ${message}`;
1026
+ this.deps.logger.error("composed capability refused for good — it is not written again until the operator edits or deletes the block", {
1027
+ tags: { deviceId: this.deps.target.id },
1028
+ meta: {
1029
+ blockId: this.deps.blockId,
1030
+ cap,
1031
+ code,
1032
+ error: message
1033
+ }
1034
+ });
1035
+ this.updateAvailability();
1036
+ }
1037
+ /**
334
1038
  * Every entry point runs here: a no-op once stopped, nothing thrown escapes,
335
1039
  * and the one timer is re-armed from the stored deadlines whatever happened.
336
1040
  */
@@ -364,15 +1068,18 @@ var CompositionRuntime = class {
364
1068
  pass(keys, trigger) {
365
1069
  const initial = trigger === "start";
366
1070
  const now = this.deps.now();
367
- const derived = this.evaluate(keys.filter((k) => this.records.get(k)?.plan.role === "derived"), now);
368
- const followers = [...this.records.values()].filter((r) => r.plan.role === "clock" ? initial || derived.valueCaps.has(r.plan.cap) : r.plan.role === "constant" && initial).map((r) => fieldKey(r.plan));
1071
+ const calculated = this.evaluate(keys.filter((k) => {
1072
+ const role = this.records.get(k)?.plan.role;
1073
+ return role === "derived" || role === "accumulator";
1074
+ }), now);
1075
+ const followers = [...this.records.values()].filter((r) => r.plan.role === "clock" ? initial || calculated.valueCaps.has(r.plan.cap) : r.plan.role === "constant" && initial).map((r) => fieldKey(r.plan));
369
1076
  const extra = this.evaluate(followers, now);
370
1077
  const evaluatedCaps = new Set([...keys, ...followers].flatMap((k) => {
371
1078
  const cap = this.records.get(k)?.plan.cap;
372
1079
  return cap === void 0 ? [] : [cap];
373
1080
  }));
374
1081
  const retry = new Set([...this.pending.keys()].filter((cap) => trigger === "deadline" || evaluatedCaps.has(cap)));
375
- this.writeCaps(new Set([...derived.keys, ...extra.keys]), retry);
1082
+ this.writeCaps(new Set([...calculated.keys, ...extra.keys]), retry);
376
1083
  this.updateFailure();
377
1084
  this.updateAvailability();
378
1085
  }
@@ -384,15 +1091,35 @@ var CompositionRuntime = class {
384
1091
  for (const key of keys) {
385
1092
  const record = next.get(key);
386
1093
  if (record === void 0) continue;
1094
+ const candidateMemory = cloneStatefulExpressionMemory(record.expressionMemory);
387
1095
  const outcome = evaluateCompositionField({
388
1096
  source: record.plan.source,
389
1097
  read: this.deps.read,
390
- now
1098
+ now,
1099
+ stateful: {
1100
+ currentOutput: currentOutput(record),
1101
+ expressionMemory: candidateMemory,
1102
+ startedAtMs: this.startedAtMs,
1103
+ callPathPrefix: fieldKey(record.plan)
1104
+ }
391
1105
  });
392
- next.set(key, {
1106
+ const expressionSucceeded = outcome.state !== "unavailable";
1107
+ let evaluated = {
393
1108
  ...record,
394
- outcome
395
- });
1109
+ outcome,
1110
+ ...expressionSucceeded ? { expressionMemory: candidateMemory } : {}
1111
+ };
1112
+ const eventPlans = this.deps.eventPlans.filter((eventPlan) => eventPlan.fieldKey === fieldKey(record.plan));
1113
+ if (expressionSucceeded && eventPlans.length > 0 && strictFiniteAdvance(record.eventHighWater, outcome.value)) {
1114
+ const timestamp = outcome.value;
1115
+ evaluated = {
1116
+ ...evaluated,
1117
+ eventHighWater: timestamp
1118
+ };
1119
+ next.set(key, evaluated);
1120
+ this.records = next;
1121
+ for (const eventPlan of eventPlans) this.emitStateEvent(eventPlan, timestamp);
1122
+ } else next.set(key, evaluated);
396
1123
  if (sameOutcome(record.outcome, outcome)) continue;
397
1124
  if (record.outcome?.state !== outcome.state) this.logTransition(record, outcome);
398
1125
  if (valueOf(record.outcome) !== valueOf(outcome)) valueCaps.add(record.plan.cap);
@@ -406,6 +1133,39 @@ var CompositionRuntime = class {
406
1133
  valueCaps
407
1134
  };
408
1135
  }
1136
+ emitStateEvent(plan, timestamp) {
1137
+ const extras = {
1138
+ tags: { deviceId: this.deps.target.id },
1139
+ meta: {
1140
+ blockId: this.deps.blockId,
1141
+ cap: plan.cap,
1142
+ eventName: plan.eventName,
1143
+ category: plan.category,
1144
+ timestamp
1145
+ }
1146
+ };
1147
+ try {
1148
+ if (plan.category !== EventCategory.DoorbellOnPressed) throw new Error(`unsupported state-derived event category \`${plan.category}\``);
1149
+ const event = createEvent(EventCategory.DoorbellOnPressed, {
1150
+ type: "device",
1151
+ id: this.deps.target.id,
1152
+ deviceId: this.deps.target.id
1153
+ }, {
1154
+ deviceId: this.deps.target.id,
1155
+ timestamp
1156
+ });
1157
+ this.deps.events.emit(event);
1158
+ this.deps.logger.debug("composition: state-derived event emitted", extras);
1159
+ } catch (error) {
1160
+ this.deps.logger.warn("composition: state-derived event delivery failed", {
1161
+ ...extras,
1162
+ meta: {
1163
+ ...extras.meta,
1164
+ error: errorText(error)
1165
+ }
1166
+ });
1167
+ }
1168
+ }
409
1169
  recordsOf(cap) {
410
1170
  return [...this.records.values()].filter((r) => r.plan.cap === cap);
411
1171
  }
@@ -415,35 +1175,154 @@ var CompositionRuntime = class {
415
1175
  const cap = this.records.get(k)?.plan.cap;
416
1176
  return cap === void 0 ? [] : [cap];
417
1177
  }), ...retry]);
418
- for (const cap of caps) if (!this.seeded.has(cap)) this.seedCap(cap);
419
- else if (this.pending.has(cap)) this.patchWholeCap(cap);
420
- else this.patchCap(cap, touched);
1178
+ for (const cap of caps) {
1179
+ if (this.terminal.has(cap)) continue;
1180
+ const sync = this.syncReleased(cap);
1181
+ if (sync === "failed") continue;
1182
+ if (!this.seeded.has(cap)) this.seedCap(cap);
1183
+ else if (this.pending.has(cap) || sync === "changed") this.patchWholeCap(cap, sync === "changed");
1184
+ else this.patchCap(cap, touched);
1185
+ }
1186
+ }
1187
+ /**
1188
+ * S1b: tell the target which fields of `cap` are released, when that set
1189
+ * changed since it last accepted one. A released record forgets what it wrote,
1190
+ * so the source returning writes it again. False when the target refused or
1191
+ * threw: the cap is write-pending and the withhold is re-sent next pass.
1192
+ */
1193
+ syncReleased(cap) {
1194
+ const records = this.recordsOf(cap);
1195
+ this.logReleaseTransitions(cap, records);
1196
+ const releasedRecords = records.filter(isReleased);
1197
+ this.records = new Map([...this.records].map(([k, r]) => releasedRecords.includes(r) && r.hasWritten ? [k, {
1198
+ ...r,
1199
+ hasWritten: false,
1200
+ written: void 0
1201
+ }] : [k, r]));
1202
+ const withhold = this.deps.target.withhold;
1203
+ if (withhold === void 0) return "unchanged";
1204
+ const fields = new Set(releasedRecords.map((r) => topLevelField(r.plan)));
1205
+ if (sameSet(fields, this.withheld.get(cap) ?? NO_FIELDS)) return "unchanged";
1206
+ let failed;
1207
+ try {
1208
+ const outcome = withhold.call(this.deps.target, cap, [...fields].toSorted());
1209
+ failed = outcome.ok ? null : {
1210
+ kind: "refused",
1211
+ error: outcome.error
1212
+ };
1213
+ } catch (error) {
1214
+ failed = {
1215
+ kind: "threw",
1216
+ error: errorText(error)
1217
+ };
1218
+ }
1219
+ if (failed !== null) {
1220
+ this.markPending(cap, this.seeded.has(cap) ? "patch" : "seed", failed);
1221
+ return "failed";
1222
+ }
1223
+ this.withheld = new Map([...this.withheld.entries()].filter(([c]) => c !== cap).concat(fields.size === 0 ? [] : [[cap, fields]]));
1224
+ return "changed";
1225
+ }
1226
+ /** One info line per field per direction, never one per pass. */
1227
+ logReleaseTransitions(cap, records) {
1228
+ const now = new Set(records.filter(isReleased).map((r) => fieldKey(r.plan)));
1229
+ const before = new Set(records.filter((r) => this.released.has(fieldKey(r.plan))).map((r) => fieldKey(r.plan)));
1230
+ for (const r of records) {
1231
+ const key = fieldKey(r.plan);
1232
+ const direction = now.has(key) && !before.has(key) ? "released" : !now.has(key) && before.has(key) ? "reclaimed" : null;
1233
+ if (direction === null) continue;
1234
+ const flaps = (this.releaseFlaps.get(key) ?? 0) + (direction === "released" ? 1 : 0);
1235
+ this.releaseFlaps = new Map([...this.releaseFlaps, [key, flaps]]);
1236
+ this.deps.logger.info(direction === "released" ? "composed field released to the native provider — its source is unavailable" : "composed field reclaimed from the native provider — its source is back", {
1237
+ tags: { deviceId: this.deps.target.id },
1238
+ meta: {
1239
+ blockId: this.deps.blockId,
1240
+ cap,
1241
+ field: fieldPath(r.plan),
1242
+ direction,
1243
+ reason: reasonOf(r.outcome) ?? "value available again",
1244
+ flaps
1245
+ }
1246
+ });
1247
+ }
1248
+ this.released = new Set([...[...this.released].filter((k) => !before.has(k)), ...now]);
421
1249
  }
422
1250
  seedCap(cap) {
423
1251
  const records = this.recordsOf(cap);
424
- const missing = records.filter((r) => r.plan.required && writable(r) === void 0).map((r) => r.plan.path);
1252
+ const missing = records.filter((r) => (r.plan.required || isHeld(r)) && writable(r) === void 0 && !isReleased(r)).map((r) => fieldPath(r.plan));
425
1253
  if (missing.length > 0) return this.holdBackSeed(cap, missing);
426
1254
  this.heldBack = new Set([...this.heldBack].filter((c) => c !== cap));
427
- const entries = this.writableEntries(records);
1255
+ const entries = this.entriesFor(cap, records);
428
1256
  if (!this.write(cap, "seed", entries)) return;
429
1257
  this.seeded = new Set([...this.seeded, cap]);
430
1258
  this.markWritten(cap, entries);
431
1259
  }
432
1260
  patchCap(cap, touched) {
433
- const entries = this.writableEntries(this.recordsOf(cap).filter((r) => touched.has(fieldKey(r.plan)) && writable(r) !== r.written));
1261
+ const entries = this.entriesFor(cap, this.recordsOf(cap).filter((r) => touched.has(fieldKey(r.plan)) && (!r.hasWritten || writable(r) !== r.written)));
434
1262
  if (entries.length === 0) return;
435
1263
  if (this.write(cap, "patch", entries)) this.markWritten(cap, entries);
436
1264
  }
437
- /** After a failed write nothing is known about what landed: send every current value. */
438
- patchWholeCap(cap) {
439
- const entries = this.writableEntries(this.recordsOf(cap));
440
- if (entries.length === 0) return;
1265
+ /**
1266
+ * After a failed write nothing is known about what landed, and after a
1267
+ * changed withhold the claim must be re-stated: send every current value.
1268
+ * `afterWithhold` with nothing left to write means the cap was released
1269
+ * whole — a pending write is moot then, not retried (M-2).
1270
+ */
1271
+ patchWholeCap(cap, afterWithhold = false) {
1272
+ const entries = this.entriesFor(cap, this.recordsOf(cap));
1273
+ if (entries.length === 0) {
1274
+ if (afterWithhold && this.pending.has(cap)) this.dropPending(cap);
1275
+ return;
1276
+ }
441
1277
  if (this.write(cap, "patch", entries)) this.markWritten(cap, entries);
442
1278
  }
1279
+ dropPending(cap) {
1280
+ this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
1281
+ this.deps.logger.info("composed capability released whole — its pending write is moot, nothing is left to write", {
1282
+ tags: { deviceId: this.deps.target.id },
1283
+ meta: {
1284
+ blockId: this.deps.blockId,
1285
+ cap
1286
+ }
1287
+ });
1288
+ }
1289
+ /**
1290
+ * What one write of `cap` carries: the writable `candidates` — and, when any
1291
+ * of them is an item leaf, EVERY item leaf of the cap, because the array is
1292
+ * written whole (a patch of `items` replaces it). A required item leaf with
1293
+ * no value holds the whole array (its top-level siblings still go out).
1294
+ */
1295
+ entriesFor(cap, candidates) {
1296
+ const top = candidates.filter((r) => r.plan.item === void 0);
1297
+ if (!candidates.some((r) => r.plan.item !== void 0)) return this.writableEntries(top);
1298
+ const items = this.recordsOf(cap).filter((r) => r.plan.item !== void 0);
1299
+ const missing = items.filter((r) => r.plan.required && writable(r) === void 0).map((r) => fieldPath(r.plan));
1300
+ if (missing.length > 0) {
1301
+ this.holdItems(cap, missing);
1302
+ return this.writableEntries(top);
1303
+ }
1304
+ this.itemsHeld = new Set([...this.itemsHeld].filter((c) => c !== cap));
1305
+ return this.writableEntries([...top, ...items]);
1306
+ }
443
1307
  writableEntries(records) {
444
1308
  return records.flatMap((r) => {
445
- const v = writable(r);
446
- return v === void 0 ? [] : [[r.plan.path, v]];
1309
+ const value = writable(r);
1310
+ return value === void 0 ? [] : [{
1311
+ plan: r.plan,
1312
+ value
1313
+ }];
1314
+ });
1315
+ }
1316
+ holdItems(cap, missing) {
1317
+ if (this.itemsHeld.has(cap)) return;
1318
+ this.itemsHeld = new Set([...this.itemsHeld, cap]);
1319
+ this.deps.logger.warn("composed items held — a required item field has no value; the array is not written until it does", {
1320
+ tags: { deviceId: this.deps.target.id },
1321
+ meta: {
1322
+ blockId: this.deps.blockId,
1323
+ cap,
1324
+ missing
1325
+ }
447
1326
  });
448
1327
  }
449
1328
  holdBackSeed(cap, missing) {
@@ -458,11 +1337,11 @@ var CompositionRuntime = class {
458
1337
  }
459
1338
  });
460
1339
  }
461
- /** One write. A refusal or a throw marks the cap write-pending; true when it landed. */
1340
+ /** One write. A refusal or a throw marks the cap write-pending; true when the target took it. */
462
1341
  write(cap, op, entries) {
463
- const values = Object.fromEntries(entries);
464
1342
  let failed;
465
1343
  try {
1344
+ const values = assembleSlice(entries, this.deps.lookupCap);
466
1345
  const outcome = op === "seed" ? this.deps.target.seed(cap, values) : this.deps.target.patch(cap, values);
467
1346
  failed = outcome.ok ? null : {
468
1347
  kind: "refused",
@@ -507,82 +1386,814 @@ var CompositionRuntime = class {
507
1386
  }
508
1387
  });
509
1388
  }
510
- markWritten(cap, entries) {
511
- const written = new Map(entries);
512
- this.records = new Map([...this.records].map(([k, r]) => r.plan.cap === cap && written.has(r.plan.path) ? [k, {
513
- ...r,
514
- written: written.get(r.plan.path)
515
- }] : [k, r]));
516
- }
517
- updateAvailability() {
518
- const blocking = [...this.records.values()].filter((r) => r.plan.unavailable === "offline" && r.outcome?.state === "unavailable").map((r) => fieldKey(r.plan));
519
- const unseeded = [...new Set(this.deps.fields.map((f) => f.cap))].filter((c) => !this.seeded.has(c));
520
- const writePending = [...this.pending.keys()];
521
- const available = this.disabledReason === null && this.failure === null && blocking.length === 0 && unseeded.length === 0 && writePending.length === 0;
522
- if (available === this.available) return;
523
- this.deps.target.setAvailable(available);
524
- this.available = available;
1389
+ markWritten(cap, entries) {
1390
+ const written = new Map(entries.map((e) => [fieldKey(e.plan), e.value]));
1391
+ this.records = new Map([...this.records].map(([k, r]) => r.plan.cap === cap && written.has(k) ? [k, {
1392
+ ...r,
1393
+ hasWritten: true,
1394
+ written: written.get(k)
1395
+ }] : [k, r]));
1396
+ }
1397
+ updateAvailability() {
1398
+ const blocking = [...this.records.values()].filter((r) => r.plan.unavailable === "offline" && r.outcome?.state === "unavailable").map((r) => fieldKey(r.plan));
1399
+ const unseeded = [...new Set(this.deps.fields.map((f) => f.cap))].filter((c) => !this.seeded.has(c));
1400
+ const writePending = [...this.pending.keys()];
1401
+ const available = this.disabledReason === null && this.failure === null && blocking.length === 0 && unseeded.length === 0 && writePending.length === 0;
1402
+ if (available === this.available) return;
1403
+ this.deps.target.setAvailable(available);
1404
+ this.available = available;
1405
+ const extras = {
1406
+ tags: { deviceId: this.deps.target.id },
1407
+ meta: {
1408
+ blockId: this.deps.blockId,
1409
+ blocking,
1410
+ unseeded,
1411
+ writePending,
1412
+ failure: this.failure,
1413
+ disabled: this.disabledReason
1414
+ }
1415
+ };
1416
+ if (available) this.deps.logger.info("composed device available", extras);
1417
+ else this.deps.logger.warn("composed device unavailable — a field has no value, a write is pending, or the block failed", extras);
1418
+ }
1419
+ updateFailure() {
1420
+ if (this.failure !== null) return;
1421
+ const fatal = [...this.records.values()].find((r) => r.outcome?.state === "unavailable" && r.outcome.fatal);
1422
+ if (fatal === void 0 || fatal.outcome === null || fatal.outcome.state !== "unavailable") return;
1423
+ this.failure = `source missing — ${fatal.outcome.reason}`;
1424
+ this.deps.logger.error("composition failed: a source is gone — only the operator ends this (repair the source, edit or delete the block)", {
1425
+ tags: { deviceId: this.deps.target.id },
1426
+ meta: {
1427
+ blockId: this.deps.blockId,
1428
+ field: fieldKey(fatal.plan),
1429
+ reason: fatal.outcome.reason
1430
+ }
1431
+ });
1432
+ }
1433
+ /** `after`: arm only deadlines later than this instant; null arms the earliest stored one. */
1434
+ armDeadline(after) {
1435
+ const deadlines = [...this.records.values()].flatMap((r) => r.outcome !== null && r.outcome.state !== "unavailable" && r.outcome.deadline !== null && (after === null || r.outcome.deadline > after) ? [r.outcome.deadline] : []);
1436
+ if (deadlines.length === 0) {
1437
+ this.deps.timer.disarm();
1438
+ return;
1439
+ }
1440
+ this.deps.timer.arm(Math.min(...deadlines), () => this.onDeadline());
1441
+ }
1442
+ onDeadline() {
1443
+ this.guarded("deadline", true, () => {
1444
+ const now = this.deps.now();
1445
+ const due = [...this.records.values()].filter((r) => r.outcome !== null && r.outcome.state !== "unavailable" && r.outcome.deadline !== null && r.outcome.deadline <= now).map((r) => fieldKey(r.plan));
1446
+ if (due.length > 0) this.pass(due, "deadline");
1447
+ });
1448
+ }
1449
+ logTransition(record, outcome) {
1450
+ const from = record.outcome?.state ?? "pending";
1451
+ if (from === "pending" && outcome.state === "fresh") return;
1452
+ const extras = {
1453
+ tags: { deviceId: this.deps.target.id },
1454
+ meta: {
1455
+ blockId: this.deps.blockId,
1456
+ cap: record.plan.cap,
1457
+ field: record.plan.path,
1458
+ from,
1459
+ to: outcome.state,
1460
+ reason: reasonOf(outcome)
1461
+ }
1462
+ };
1463
+ const message = `composed field ${fieldKey(record.plan)}: ${from} → ${outcome.state}`;
1464
+ if (outcome.state === "fresh") this.deps.logger.info(message, extras);
1465
+ else this.deps.logger.warn(message, extras);
1466
+ }
1467
+ };
1468
+ //#endregion
1469
+ //#region src/builtins/composer/confirmed-seed.ts
1470
+ var ConfirmedSeed = class {
1471
+ deps;
1472
+ seeded = false;
1473
+ /** The previous read answered `[]`: one more `[]` confirms it (D49). */
1474
+ emptySeen = false;
1475
+ /** Consecutive applies whose read did not answer: warned once per run. */
1476
+ waitApplies = 0;
1477
+ constructor(deps) {
1478
+ this.deps = deps;
1479
+ }
1480
+ get done() {
1481
+ return this.seeded;
1482
+ }
1483
+ /** Null once seeded, else why this apply must wait. */
1484
+ async ensure() {
1485
+ if (this.seeded) return null;
1486
+ const read = await this.deps.read();
1487
+ if (read.kind === "unknown") {
1488
+ this.emptySeen = false;
1489
+ this.waitApplies += 1;
1490
+ if (this.waitApplies === 1) this.deps.logger.warn(`composer: the ${this.deps.subject} could not be read — nothing is declared, withdrawn, claimed or released from them; retried on the next apply`, { meta: {
1491
+ subject: this.deps.subject,
1492
+ error: read.error
1493
+ } });
1494
+ return this.deps.unreadReason;
1495
+ }
1496
+ if (read.items.length === 0 && !this.emptySeen) {
1497
+ this.emptySeen = true;
1498
+ this.deps.logger.warn(`composer: the ${this.deps.subject} read empty — waiting for the next apply's read to agree before trusting it`, { meta: { subject: this.deps.subject } });
1499
+ return this.deps.emptyUnconfirmedReason;
1500
+ }
1501
+ this.deps.onSeeded(read.items);
1502
+ this.seeded = true;
1503
+ this.deps.logger.info(`composer: seeded from the ${this.deps.subject}`, { meta: {
1504
+ subject: this.deps.subject,
1505
+ items: read.items.length,
1506
+ confirmedEmpty: read.items.length === 0,
1507
+ appliesRefused: this.waitApplies
1508
+ } });
1509
+ return null;
1510
+ }
1511
+ };
1512
+ //#endregion
1513
+ //#region src/builtins/composer/existing-target.ts
1514
+ /**
1515
+ * What the composer needs to run a composition on an EXISTING device (D663)
1516
+ * besides the runtime and the claim-backed target: the target's description,
1517
+ * the claim shapes derived from the plan, and the block report's words.
1518
+ *
1519
+ * - The target is DESCRIBED (bound caps → native addon, row type) with ONE
1520
+ * `getBindings` per plan change, cached until `device.bindings-changed` for
1521
+ * that device. A read that fails keeps the previous description; with none,
1522
+ * the block waits and nothing is claimed (D49).
1523
+ * - A claim's field set is the PLAN's (S5): the top-level fields of the cap, an
1524
+ * item leaf contributing its whole array. Its mode is the feature's.
1525
+ * - A self-read of the target (rule 6) is served from the NATIVE view unless it
1526
+ * reads an ADDED cap — that is the composition's own output. Whether a native
1527
+ * cap's `device.state-changed` is the native or the composition's own merge
1528
+ * is NOT decided here from the plan: it follows whether the claim has landed
1529
+ * (`HeldClaims`, C-1).
1530
+ */
1531
+ /**
1532
+ * Read every affected effective capability exactly once, before the composer
1533
+ * grafts or claims any of them. A null slice is a successful empty seed; a
1534
+ * rejection is deliberately left to the caller so ownership cannot begin.
1535
+ */
1536
+ async function readExistingTargetSeeds(targetDeviceId, caps, read) {
1537
+ const byCap = /* @__PURE__ */ new Map();
1538
+ for (const cap of new Set(caps)) byCap.set(cap, await read(targetDeviceId, cap));
1539
+ return { byCap };
1540
+ }
1541
+ /** The reason every ungraft of a gone target carries (Task 9 rule 5 / Task 10 rule 2). */
1542
+ var TARGET_GONE_UNGRAFT_REASON = "target device gone";
1543
+ /**
1544
+ * Rule 5, on either path that finds the target gone (`device.unregistered`, or
1545
+ * a later plan when that event was dropped): ungraft every cap the block ADDED
1546
+ * on that id, with the one ERROR that says so.
1547
+ */
1548
+ function ungraftGoneTarget(input) {
1549
+ const { grafts, deviceId } = input;
1550
+ const added = addedCapsOf(input.features);
1551
+ for (const cap of added) grafts?.ungraft(deviceId, cap, TARGET_GONE_UNGRAFT_REASON);
1552
+ input.logger.error("customization target device is gone — the block failed and its grafts are removed; only the operator ends this (edit or restart the block)", {
1553
+ tags: { deviceId },
1554
+ meta: {
1555
+ blockId: input.blockId,
1556
+ target: compositionSourceKey(input.ref),
1557
+ ungrafted: added,
1558
+ graftHost: grafts !== void 0
1559
+ }
1560
+ });
1561
+ }
1562
+ var TargetDescriptions = class {
1563
+ logger;
1564
+ readBoundCaps;
1565
+ cache = /* @__PURE__ */ new Map();
1566
+ constructor(logger, readBoundCaps) {
1567
+ this.logger = logger;
1568
+ this.readBoundCaps = readBoundCaps;
1569
+ }
1570
+ /** `device.bindings-changed` for this device: the next read is a real read. */
1571
+ invalidate(deviceId) {
1572
+ this.cache = new Map([...this.cache].filter(([, d]) => d.deviceId !== deviceId));
1573
+ }
1574
+ /**
1575
+ * The description of `ref`, resolved to `deviceId`. Served from the cache
1576
+ * unless `force` (the block's plan changed) or nothing is cached. A read that
1577
+ * fails keeps the previous description of the SAME device id, and answers
1578
+ * null when there is none: the caller waits, and claims nothing.
1579
+ */
1580
+ async describe(ref, deviceId, type, force) {
1581
+ const key = compositionSourceKey(ref);
1582
+ const cached = this.cache.get(key);
1583
+ const previous = cached !== void 0 && cached.deviceId === deviceId ? cached : null;
1584
+ if (!force && previous !== null) return previous;
1585
+ const read = await this.readBoundCaps(deviceId);
1586
+ if (read.kind === "unreadable") {
1587
+ this.logger.warn(previous === null ? "customization target: its bindings could not be read — nothing is claimed until they are; retried on the next apply" : "customization target: its bindings could not be read — the previous description is kept", {
1588
+ tags: { deviceId },
1589
+ meta: {
1590
+ target: key,
1591
+ error: read.error
1592
+ }
1593
+ });
1594
+ return previous;
1595
+ }
1596
+ const found = {
1597
+ kind: "found",
1598
+ deviceId,
1599
+ type,
1600
+ nativeCaps: read.caps
1601
+ };
1602
+ this.cache = new Map([...this.cache, [key, found]]);
1603
+ return found;
1604
+ }
1605
+ };
1606
+ /** The PLANNED field set a claim carries for `cap` (S5): an item leaf claims its whole array. */
1607
+ function claimFieldsOf(fields, cap) {
1608
+ return [...new Set(fields.filter((f) => f.cap === cap).map((f) => f.item === void 0 ? f.path : f.item.arrayPath))];
1609
+ }
1610
+ /** The claim mode of `cap`: `replace` for a cap the device serves natively, else `add`. */
1611
+ function claimModeOf(features, cap) {
1612
+ return features.find((f) => f.cap === cap)?.mode === "replace" ? "replace" : "add";
1613
+ }
1614
+ /** The caps a customization ADDS to its target — the ones it grafts a native for (Task 10). */
1615
+ function addedCapsOf(features) {
1616
+ return features.filter((f) => f.mode === "add").map((f) => f.cap);
1617
+ }
1618
+ function selfReadCaps(fields, target, features) {
1619
+ const targetKey = compositionSourceKey(target);
1620
+ const read = new Set(fields.flatMap((f) => f.reads.filter((r) => compositionSourceKey(r.source) === targetKey).map((r) => r.cap)));
1621
+ const added = new Set(addedCapsOf(features));
1622
+ return {
1623
+ effective: [...read].filter((cap) => added.has(cap)),
1624
+ native: [...read].filter((cap) => !added.has(cap))
1625
+ };
1626
+ }
1627
+ /** The `degraded` reason (rule 8): one entry per released field, the source's own reason after the dash. */
1628
+ function degradedReason(released) {
1629
+ return released.map((r) => {
1630
+ const path = r.item === void 0 ? r.path : compositionItemFieldPath(r.item.arrayPath, r.item.key, r.path);
1631
+ return `released to the native provider: ${r.cap}.${path} — ${r.reason}`;
1632
+ }).join("; ");
1633
+ }
1634
+ /** The `starting` reason of a block with HELD fields: each keeps its last value and claim until its source answers (D49). */
1635
+ function heldReason(held) {
1636
+ return held.map((r) => {
1637
+ const path = r.item === void 0 ? r.path : compositionItemFieldPath(r.item.arrayPath, r.item.key, r.path);
1638
+ return `held on its last value until its source answers (D49): ${r.cap}.${path} — ${r.reason}`;
1639
+ }).join("; ");
1640
+ }
1641
+ /**
1642
+ * Rule 4: a customization whose row exists but whose plan failed keeps its
1643
+ * claims, so its owned fields stay at their last value on the hub. The report
1644
+ * says so — each field that had a value is `stale`, with the reason.
1645
+ */
1646
+ function heldFieldsOf(fields, reason) {
1647
+ return fields.map((f) => f.state === "unavailable" ? f : {
1648
+ ...f,
1649
+ state: "stale",
1650
+ reason
1651
+ });
1652
+ }
1653
+ //#endregion
1654
+ //#region src/builtins/composer/composer-grafts.ts
1655
+ /**
1656
+ * The composer's side of grafts (D663, Task 10): WHEN a cap a customization
1657
+ * ADDS is grafted, removed or refused — `GraftHost` is only the how. Split from
1658
+ * `composer.ts`.
1659
+ *
1660
+ * - Decided at PLAN time only (rule 2): every added cap is grafted; a grafted
1661
+ * cap the plan now finds native is ungrafted (its event was dropped, D8).
1662
+ * - NATIVE ALWAYS WINS (I-2). A foreign `native-registered` on a grafted cap
1663
+ * ungrafts it (the claim stays). One that lands while a plan is in flight —
1664
+ * after the target's description was read, before the graft — is remembered
1665
+ * per described device, so that plan never grafts over it. A foreign
1666
+ * `native-unregistered` never re-grafts (D659).
1667
+ * - A cap no graft serves is NAMED for the block verdict (I-1): refused by the
1668
+ * host, or taken by a native. Every added cap unserved → the block fails.
1669
+ * - A binding a PREVIOUS composer process left (a respawn, D5) is retracted
1670
+ * when a settled plan does not want it (I-3), or when the gate releases the
1671
+ * add claim of a block that is gone — after reading that the binding IS the
1672
+ * composer's; a read that fails retracts nothing (D49).
1673
+ */
1674
+ /** A foreign native now serves the grafted cap: the claim stays (Task 10 rule 2). */
1675
+ var NATIVE_APPEARED_UNGRAFT_REASON = "a native provider appeared";
1676
+ /** The block's add claim was released by the D49 gate (deleted, disabled, feature removed): its graft goes with it. */
1677
+ var NO_LONGER_ADDED_UNGRAFT_REASON = "the block no longer adds this cap";
1678
+ /** A binding a previous composer process left, that nothing wants any more (I-3). */
1679
+ var STALE_GRAFT_RETRACT_REASON = "a graft left by a previous composer process that no plan wants";
1680
+ function withEntry(map, key, value) {
1681
+ return new Map([...map, [key, value]]);
1682
+ }
1683
+ function nativeTakenReason(addonId, when) {
1684
+ return `a native provider (${addonId}) ${when} — the cap is not grafted and its claim is kept; edit or restart the block to replace the native instead`;
1685
+ }
1686
+ var ComposerGrafts = class {
1687
+ ports;
1688
+ logger;
1689
+ readBindings;
1690
+ /** Per device: the added caps no graft serves, and why. Reset by each plan of that device. */
1691
+ unserved = /* @__PURE__ */ new Map();
1692
+ /** Per DESCRIBED device: foreign natives registered since its description was read (cap → addonId). */
1693
+ foreignSince = /* @__PURE__ */ new Map();
1694
+ constructor(ports, logger, readBindings) {
1695
+ this.ports = ports;
1696
+ this.logger = logger;
1697
+ this.readBindings = readBindings;
1698
+ }
1699
+ /** A description of `deviceId` is about to be read: it covers every registration before now. */
1700
+ describing(deviceId) {
1701
+ this.foreignSince = withEntry(this.foreignSince, deviceId, /* @__PURE__ */ new Map());
1702
+ }
1703
+ onBindingsChanged(data) {
1704
+ if (data.addonId === "composer") return;
1705
+ this.remember(data);
1706
+ const ports = this.ports;
1707
+ if (data.reason !== "native-registered" || ports === void 0) return;
1708
+ if (!ports.grafted(data.deviceId).has(data.capName)) return;
1709
+ this.logger.info("a native provider appeared on a grafted cap — the graft is removed; the claim stays", {
1710
+ tags: { deviceId: data.deviceId },
1711
+ meta: {
1712
+ cap: data.capName,
1713
+ addonId: data.addonId
1714
+ }
1715
+ });
1716
+ ports.ungraft(data.deviceId, data.capName, NATIVE_APPEARED_UNGRAFT_REASON);
1717
+ this.setUnserved(data.deviceId, data.capName, nativeTakenReason(data.addonId, "appeared"));
1718
+ }
1719
+ /** Rule 2, at plan time only: graft what the plan adds; ungraft what it finds native; retract what a previous process left. */
1720
+ plan(input) {
1721
+ const ports = this.ports;
1722
+ if (ports === void 0) return;
1723
+ const { deviceId } = input;
1724
+ const added = input.enabled && input.settled ? addedCapsOf(input.features) : [];
1725
+ const replaced = new Set(input.features.filter((f) => f.mode === "replace").map((f) => f.cap));
1726
+ for (const cap of ports.grafted(deviceId)) {
1727
+ if (!replaced.has(cap)) continue;
1728
+ this.logger.info("a native provider serves a grafted cap — the graft is removed; the claim stays", {
1729
+ tags: { deviceId },
1730
+ meta: { cap }
1731
+ });
1732
+ ports.ungraft(deviceId, cap, NATIVE_APPEARED_UNGRAFT_REASON);
1733
+ }
1734
+ if (input.settled) this.retractUnwanted(input, new Set(added));
1735
+ const foreign = this.foreignSince.get(deviceId) ?? /* @__PURE__ */ new Map();
1736
+ const unserved = /* @__PURE__ */ new Map();
1737
+ for (const cap of added) {
1738
+ const other = foreign.get(cap);
1739
+ if (other !== void 0) {
1740
+ const reason = nativeTakenReason(other, "registered while the block was planning");
1741
+ this.logger.warn("graft skipped — a native provider serves the cap (native always wins)", {
1742
+ tags: { deviceId },
1743
+ meta: {
1744
+ cap,
1745
+ addonId: other
1746
+ }
1747
+ });
1748
+ unserved.set(cap, reason);
1749
+ continue;
1750
+ }
1751
+ const outcome = ports.graft(deviceId, input.ref.stableId, cap);
1752
+ if (outcome.kind === "refused") unserved.set(cap, `graft refused: ${outcome.reason}`);
1753
+ }
1754
+ this.unserved = withEntry(this.unserved, deviceId, unserved);
1755
+ }
1756
+ /** The gate released a claim: a graft of that cap goes with it; a previous process's binding is retracted once read as the composer's. */
1757
+ async released(deviceId, capName) {
1758
+ const ports = this.ports;
1759
+ if (ports === void 0) return;
1760
+ this.setUnserved(deviceId, capName, null);
1761
+ if (ports.grafted(deviceId).has(capName)) {
1762
+ ports.ungraft(deviceId, capName, NO_LONGER_ADDED_UNGRAFT_REASON);
1763
+ return;
1764
+ }
1765
+ let read;
1766
+ try {
1767
+ read = await this.readBindings(deviceId);
1768
+ } catch (err) {
1769
+ read = {
1770
+ kind: "unreadable",
1771
+ error: errMsg(err)
1772
+ };
1773
+ }
1774
+ if (read.kind === "unreadable") {
1775
+ this.logger.warn("a released claim: the bindings could not be read — a graft left by a previous process, if any, is not retracted (D49)", {
1776
+ tags: { deviceId },
1777
+ meta: {
1778
+ cap: capName,
1779
+ error: read.error
1780
+ }
1781
+ });
1782
+ return;
1783
+ }
1784
+ if (read.caps.get(capName) === "composer") ports.retractStale(deviceId, capName, STALE_GRAFT_RETRACT_REASON);
1785
+ }
1786
+ /** The target row is gone: nothing about it is reported any more. */
1787
+ gone(deviceId) {
1788
+ this.unserved = new Map([...this.unserved].filter(([id]) => id !== deviceId));
1789
+ }
1790
+ verdict(deviceId, features) {
1791
+ const added = addedCapsOf(features);
1792
+ const unserved = this.unserved.get(deviceId) ?? /* @__PURE__ */ new Map();
1793
+ const named = added.flatMap((cap) => {
1794
+ const reason = unserved.get(cap);
1795
+ return reason === void 0 ? [] : [`${cap}: ${reason}`];
1796
+ });
1797
+ if (named.length === 0) return null;
1798
+ return {
1799
+ allUnserved: named.length === added.length && added.length === features.length,
1800
+ reason: `added caps no graft serves — ${named.join("; ")}`
1801
+ };
1802
+ }
1803
+ retractUnwanted(input, wanted) {
1804
+ const ports = this.ports;
1805
+ if (ports === void 0) return;
1806
+ const own = ports.grafted(input.deviceId);
1807
+ for (const [cap, addonId] of input.nativeCaps) {
1808
+ if (addonId !== "composer" || own.has(cap) || wanted.has(cap)) continue;
1809
+ this.logger.info("a graft left by a previous composer process is retracted", {
1810
+ tags: { deviceId: input.deviceId },
1811
+ meta: { cap }
1812
+ });
1813
+ ports.retractStale(input.deviceId, cap, STALE_GRAFT_RETRACT_REASON);
1814
+ }
1815
+ }
1816
+ remember(data) {
1817
+ const seen = this.foreignSince.get(data.deviceId);
1818
+ if (seen === void 0) return;
1819
+ if (data.reason === "native-registered") this.foreignSince = withEntry(this.foreignSince, data.deviceId, new Map([...seen, [data.capName, data.addonId]]));
1820
+ else if (data.reason === "native-unregistered" && seen.get(data.capName) === data.addonId) this.foreignSince = withEntry(this.foreignSince, data.deviceId, new Map([...seen].filter(([cap]) => cap !== data.capName)));
1821
+ }
1822
+ setUnserved(deviceId, cap, reason) {
1823
+ const current = this.unserved.get(deviceId) ?? /* @__PURE__ */ new Map();
1824
+ const next = new Map([...current].filter(([c]) => c !== cap));
1825
+ if (reason !== null) next.set(cap, reason);
1826
+ this.unserved = withEntry(this.unserved, deviceId, next);
1827
+ }
1828
+ };
1829
+ //#endregion
1830
+ //#region src/builtins/composer/composer-verdict.ts
1831
+ /** A runtime built by an apply, before the apply's sync decides whether it may start. */
1832
+ var NOT_STARTED = "the runtime has not started yet";
1833
+ function verdictOf(blockId, entry, unsynced, grafts = null) {
1834
+ if (entry === void 0) return {
1835
+ blockId,
1836
+ status: "starting",
1837
+ reason: "not applied yet"
1838
+ };
1839
+ if (entry.rebuildError !== null) return {
1840
+ blockId,
1841
+ status: "failed",
1842
+ reason: `rebuilding the composed device for its new shape failed: ${entry.rebuildError}`
1843
+ };
1844
+ if (entry.planned.problems.length > 0) {
1845
+ const kept = entry.planned.declaration === null ? "" : " — its device is kept offline with no capabilities";
1846
+ return {
1847
+ blockId,
1848
+ status: "failed",
1849
+ reason: `the composition does not validate: ${entry.planned.problems.join("; ")}${kept}`
1850
+ };
1851
+ }
1852
+ if (!entry.planned.block.enabled) return {
1853
+ blockId,
1854
+ status: "stopped"
1855
+ };
1856
+ const target = entry.planned.target;
1857
+ if (target.kind === "existing") {
1858
+ if (target.gone !== null) return {
1859
+ blockId,
1860
+ status: "failed",
1861
+ reason: `the target device is gone: ${target.gone}`
1862
+ };
1863
+ if (target.waiting !== null) return {
1864
+ blockId,
1865
+ status: "starting",
1866
+ reason: target.waiting
1867
+ };
1868
+ } else if (entry.device === null) return {
1869
+ blockId,
1870
+ status: "starting",
1871
+ reason: "the composed device has not been constructed yet"
1872
+ };
1873
+ const runtime = entry.runtime;
1874
+ if (runtime === null) {
1875
+ if (target.kind !== "existing" || target.features.length === 0) return {
1876
+ blockId,
1877
+ status: "running"
1878
+ };
1879
+ return {
1880
+ blockId,
1881
+ status: "starting",
1882
+ reason: NOT_STARTED
1883
+ };
1884
+ }
1885
+ if (unsynced) return {
1886
+ blockId,
1887
+ status: "starting",
1888
+ reason: NOT_STARTED
1889
+ };
1890
+ const state = runtime.state();
1891
+ if (state.failure !== null) return {
1892
+ blockId,
1893
+ status: "failed",
1894
+ reason: state.failure
1895
+ };
1896
+ if (grafts?.allUnserved === true) return {
1897
+ blockId,
1898
+ status: "failed",
1899
+ reason: grafts.reason
1900
+ };
1901
+ const degraded = [...state.released.length > 0 ? [degradedReason(state.released)] : [], ...grafts === null ? [] : [grafts.reason]];
1902
+ if (degraded.length > 0) return {
1903
+ blockId,
1904
+ status: "degraded",
1905
+ reason: degraded.join("; ")
1906
+ };
1907
+ if (state.held.length > 0) return {
1908
+ blockId,
1909
+ status: "starting",
1910
+ reason: heldReason(state.held)
1911
+ };
1912
+ return {
1913
+ blockId,
1914
+ status: "running"
1915
+ };
1916
+ }
1917
+ //#endregion
1918
+ //#region src/builtins/composer/owned-fields-target.ts
1919
+ /**
1920
+ * The write target of a composition on an EXISTING device (D663): its fields
1921
+ * are CLAIMED on the hub (`deviceState.claimFields`), then patched
1922
+ * (`patchOwnedFields`). The runtime's `seed`/`patch` are synchronous and never
1923
+ * fail here — the RPC is enqueued — so what did not land is reported through
1924
+ * `onAsyncFailure`, and the runtime marks the cap write-pending and re-writes
1925
+ * it whole on its next pass. No retry timer (D3).
1926
+ *
1927
+ * - **The claim is re-sent until it has LANDED (ruling S5).** A cap enters
1928
+ * `landed` only on an `ok` claim answer. Every write of a cap not in `landed`
1929
+ * is a claim carrying the FULL planned field set (`fieldsOf`, never inferred
1930
+ * from the values). `not-claimed` / `field-not-claimed` — the hub lost or
1931
+ * never had the claim — drop the cap from `landed` and are escalated by
1932
+ * name, so the next write claims again.
1933
+ * - **Concurrent partial patches are MERGED, never dropped (S5).** One chain
1934
+ * per cap: while an RPC is in flight, later patches fold into one pending
1935
+ * value set (later keys win; a seed replaces it wholly), and the landing log
1936
+ * says how many were absorbed (D391).
1937
+ * - **A released field is withheld (S1b).** `withhold(cap, fields)` narrows the
1938
+ * next claim to the rest and omits their values; nothing left → the claim is
1939
+ * released at once. `[]` re-widens.
1940
+ * - **Every claim RPC of a `replace` cap is reported** (`onClaim`, C-1/N-1):
1941
+ * `claiming` when it goes out, then `held` on an `ok`, `free` on a confirmed
1942
+ * release or `not-claimed`, `unsettled` on a throw or any other refusal (D49:
1943
+ * changes nothing). `field-not-claimed` on a patch drops `landed` so the next
1944
+ * write claims again, but the hub still holds the claim: `held` stays (N-3).
1945
+ * The tracker follows this — never the plan — to know whether
1946
+ * `device.state-changed` of the cap is the provider's native, the
1947
+ * composition's own merge, or (while a claim is in flight) neither.
1948
+ * - `setAvailable` is a no-op: `device-status` is the native device's.
1949
+ */
1950
+ var ACCEPTED = { ok: true };
1951
+ /** The hub has no claim to patch: the next write must claim again. */
1952
+ var CLAIM_LOST_CODES = new Set(["not-claimed", "field-not-claimed"]);
1953
+ /**
1954
+ * Later keys win; a seed replaces the pending values wholly; a release
1955
+ * supersedes any pending values (nothing is left to write) and is itself
1956
+ * superseded by a later write (a claim REPLACES the hub's field set anyway).
1957
+ */
1958
+ function fold(current, next) {
1959
+ if (current === void 0 || next.kind === "release" || current.kind === "release") return next;
1960
+ if (next.op === "seed") return {
1961
+ ...next,
1962
+ merged: current.merged + 1
1963
+ };
1964
+ return {
1965
+ kind: "write",
1966
+ op: current.op,
1967
+ values: {
1968
+ ...current.values,
1969
+ ...next.values
1970
+ },
1971
+ merged: current.merged + 1
1972
+ };
1973
+ }
1974
+ function omitKeys(values, keys) {
1975
+ if (keys === void 0 || keys.size === 0) return values;
1976
+ return Object.fromEntries(Object.entries(values).filter(([key]) => !keys.has(key)));
1977
+ }
1978
+ var OwnedFieldsTarget = class {
1979
+ deps;
1980
+ id;
1981
+ landed = /* @__PURE__ */ new Set();
1982
+ withheld = /* @__PURE__ */ new Map();
1983
+ inFlight = /* @__PURE__ */ new Set();
1984
+ pending = /* @__PURE__ */ new Map();
1985
+ listeners = [];
1986
+ /** Per cap, bumped by every `withhold`: a claim that settles under another epoch claimed a stale field set and does not land (I-2). */
1987
+ epoch = /* @__PURE__ */ new Map();
1988
+ constructor(deps) {
1989
+ this.deps = deps;
1990
+ this.id = deps.deviceId;
1991
+ }
1992
+ seed(cap, slice) {
1993
+ this.enqueue(cap, {
1994
+ kind: "write",
1995
+ op: "seed",
1996
+ values: slice,
1997
+ merged: 0
1998
+ });
1999
+ return ACCEPTED;
2000
+ }
2001
+ patch(cap, patch) {
2002
+ this.enqueue(cap, {
2003
+ kind: "write",
2004
+ op: "patch",
2005
+ values: patch,
2006
+ merged: 0
2007
+ });
2008
+ return ACCEPTED;
2009
+ }
2010
+ /** `device-status` belongs to the native device; availability lives in the block report only. */
2011
+ setAvailable(_available) {}
2012
+ onAsyncFailure(listener) {
2013
+ this.listeners = [...this.listeners, listener];
2014
+ }
2015
+ withhold(cap, fields) {
2016
+ this.withheld = new Map([...this.withheld.entries()].filter(([c]) => c !== cap).concat(fields.length === 0 ? [] : [[cap, new Set(fields)]]));
2017
+ this.landed = new Set([...this.landed].filter((c) => c !== cap));
2018
+ this.epoch = new Map([...this.epoch, [cap, (this.epoch.get(cap) ?? 0) + 1]]);
2019
+ if (this.claimableFields(cap).length === 0) this.enqueue(cap, { kind: "release" });
2020
+ return ACCEPTED;
2021
+ }
2022
+ claimableFields(cap) {
2023
+ const held = this.withheld.get(cap);
2024
+ return this.deps.fieldsOf(cap).filter((f) => held === void 0 || !held.has(f));
2025
+ }
2026
+ enqueue(cap, op) {
2027
+ if (!this.inFlight.has(cap)) {
2028
+ this.send(cap, op);
2029
+ return;
2030
+ }
2031
+ this.pending = new Map([...this.pending, [cap, fold(this.pending.get(cap), op)]]);
2032
+ }
2033
+ send(cap, op) {
2034
+ this.inFlight = new Set([...this.inFlight, cap]);
2035
+ (op.kind === "release" ? this.sendRelease(cap) : this.sendWrite(cap, op)).then(() => this.settled(cap), (err) => {
2036
+ this.deps.logger.error("composed write chain threw past its handlers — the chain continues", {
2037
+ tags: { deviceId: this.deps.deviceId },
2038
+ meta: {
2039
+ cap,
2040
+ owner: this.deps.owner,
2041
+ error: errMsg(err)
2042
+ }
2043
+ });
2044
+ this.settled(cap);
2045
+ });
2046
+ }
2047
+ settled(cap) {
2048
+ this.inFlight = new Set([...this.inFlight].filter((c) => c !== cap));
2049
+ const next = this.pending.get(cap);
2050
+ if (next === void 0) return;
2051
+ this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
2052
+ this.send(cap, next);
2053
+ }
2054
+ async sendWrite(cap, op) {
2055
+ const values = omitKeys(op.values, this.withheld.get(cap));
2056
+ const claim = !this.landed.has(cap);
2057
+ const fields = this.claimableFields(cap);
2058
+ const epoch = this.epoch.get(cap) ?? 0;
2059
+ if (claim && fields.length === 0) {
2060
+ this.deps.logger.debug("composed write skipped — every field of the capability is withheld", {
2061
+ tags: { deviceId: this.deps.deviceId },
2062
+ meta: {
2063
+ cap,
2064
+ owner: this.deps.owner,
2065
+ op: op.op
2066
+ }
2067
+ });
2068
+ return;
2069
+ }
2070
+ if (!claim && Object.keys(values).length === 0) return;
2071
+ if (claim) this.report(cap, "claiming");
2072
+ let outcome;
2073
+ try {
2074
+ outcome = claim ? await this.deps.api.claimFields({
2075
+ deviceId: this.deps.deviceId,
2076
+ capName: cap,
2077
+ owner: this.deps.owner,
2078
+ mode: this.deps.modeOf(cap),
2079
+ fields: [...fields],
2080
+ values
2081
+ }) : await this.deps.api.patchOwnedFields({
2082
+ deviceId: this.deps.deviceId,
2083
+ capName: cap,
2084
+ owner: this.deps.owner,
2085
+ values
2086
+ });
2087
+ } catch (err) {
2088
+ if (claim) this.report(cap, "unsettled");
2089
+ this.fail(cap, {
2090
+ kind: "threw",
2091
+ error: errMsg(err)
2092
+ });
2093
+ return;
2094
+ }
2095
+ if (outcome.ok) {
2096
+ if (claim) this.report(cap, "held");
2097
+ const current = (this.epoch.get(cap) ?? 0) === epoch;
2098
+ if (claim && current) this.landed = new Set([...this.landed, cap]);
2099
+ if (claim && !current) this.deps.logger.info("composed claim landed with a field set a withhold has since changed — the next write claims again", {
2100
+ tags: { deviceId: this.deps.deviceId },
2101
+ meta: {
2102
+ cap,
2103
+ owner: this.deps.owner,
2104
+ fields
2105
+ }
2106
+ });
2107
+ this.logLanded(cap, op, claim, fields);
2108
+ return;
2109
+ }
2110
+ if (CLAIM_LOST_CODES.has(outcome.code)) this.landed = new Set([...this.landed].filter((c) => c !== cap));
2111
+ if (outcome.code === "not-claimed") this.report(cap, "free");
2112
+ else if (claim) this.report(cap, "unsettled");
2113
+ this.fail(cap, {
2114
+ kind: "refused",
2115
+ code: outcome.code,
2116
+ message: outcome.message
2117
+ });
2118
+ }
2119
+ async sendRelease(cap) {
525
2120
  const extras = {
526
- tags: { deviceId: this.deps.target.id },
2121
+ tags: { deviceId: this.deps.deviceId },
527
2122
  meta: {
528
- blockId: this.deps.blockId,
529
- blocking,
530
- unseeded,
531
- writePending,
532
- failure: this.failure,
533
- disabled: this.disabledReason
2123
+ cap,
2124
+ owner: this.deps.owner
534
2125
  }
535
2126
  };
536
- if (available) this.deps.logger.info("composed device available", extras);
537
- else this.deps.logger.warn("composed device unavailable — a field has no value, a write is pending, or the block failed", extras);
2127
+ let outcome;
2128
+ try {
2129
+ outcome = await this.deps.api.releaseClaim({
2130
+ deviceId: this.deps.deviceId,
2131
+ capName: cap,
2132
+ owner: this.deps.owner
2133
+ });
2134
+ } catch (err) {
2135
+ this.fail(cap, {
2136
+ kind: "threw",
2137
+ error: errMsg(err)
2138
+ });
2139
+ return;
2140
+ }
2141
+ if (outcome.ok) {
2142
+ this.report(cap, "free");
2143
+ this.deps.logger.info("composed claim released — every field of the capability is handed back to the native provider", extras);
2144
+ return;
2145
+ }
2146
+ if (outcome.code === "not-claimed") {
2147
+ this.report(cap, "free");
2148
+ this.deps.logger.debug("composed claim release found nothing to hand back", extras);
2149
+ return;
2150
+ }
2151
+ this.fail(cap, {
2152
+ kind: "refused",
2153
+ code: outcome.code,
2154
+ message: outcome.message
2155
+ });
538
2156
  }
539
- updateFailure() {
540
- if (this.failure !== null) return;
541
- const fatal = [...this.records.values()].find((r) => r.outcome?.state === "unavailable" && r.outcome.fatal);
542
- if (fatal === void 0 || fatal.outcome === null || fatal.outcome.state !== "unavailable") return;
543
- this.failure = `source missing — ${fatal.outcome.reason}`;
544
- this.deps.logger.error("composition failed: a source is gone — only the operator ends this (repair the source, edit or delete the block)", {
545
- tags: { deviceId: this.deps.target.id },
2157
+ logLanded(cap, op, claim, fields) {
2158
+ const tags = { deviceId: this.deps.deviceId };
2159
+ if (op.merged > 0) this.deps.logger.info("composed write landed after absorbing merged patches", {
2160
+ tags,
546
2161
  meta: {
547
- blockId: this.deps.blockId,
548
- field: fieldKey(fatal.plan),
549
- reason: fatal.outcome.reason
2162
+ cap,
2163
+ owner: this.deps.owner,
2164
+ op: op.op,
2165
+ claim,
2166
+ merged: op.merged
550
2167
  }
551
2168
  });
552
- }
553
- /** `after`: arm only deadlines later than this instant; null arms the earliest stored one. */
554
- armDeadline(after) {
555
- const deadlines = [...this.records.values()].flatMap((r) => r.outcome !== null && r.outcome.state !== "unavailable" && r.outcome.deadline !== null && (after === null || r.outcome.deadline > after) ? [r.outcome.deadline] : []);
556
- if (deadlines.length === 0) {
557
- this.deps.timer.disarm();
558
- return;
559
- }
560
- this.deps.timer.arm(Math.min(...deadlines), () => this.onDeadline());
561
- }
562
- onDeadline() {
563
- this.guarded("deadline", true, () => {
564
- const now = this.deps.now();
565
- const due = [...this.records.values()].filter((r) => r.outcome !== null && r.outcome.state !== "unavailable" && r.outcome.deadline !== null && r.outcome.deadline <= now).map((r) => fieldKey(r.plan));
566
- if (due.length > 0) this.pass(due, "deadline");
2169
+ if (claim) this.deps.logger.info("composed claim landed", {
2170
+ tags,
2171
+ meta: {
2172
+ cap,
2173
+ owner: this.deps.owner,
2174
+ mode: this.deps.modeOf(cap),
2175
+ fields
2176
+ }
567
2177
  });
568
2178
  }
569
- logTransition(record, outcome) {
570
- const from = record.outcome?.state ?? "pending";
571
- if (from === "pending" && outcome.state === "fresh") return;
572
- const extras = {
573
- tags: { deviceId: this.deps.target.id },
2179
+ /** Only a `replace` cap has a native the tracker must tell apart from the merge (N-7). */
2180
+ report(cap, event) {
2181
+ if (this.deps.modeOf(cap) === "replace") this.deps.onClaim?.(cap, event);
2182
+ }
2183
+ /** Escalated to the runtime; a failure nobody listens to is logged — never silent (D391). */
2184
+ fail(cap, failure) {
2185
+ if (this.listeners.length === 0) this.deps.logger.warn("composed write did not land and no runtime listens for it", {
2186
+ tags: { deviceId: this.deps.deviceId },
574
2187
  meta: {
575
- blockId: this.deps.blockId,
576
- cap: record.plan.cap,
577
- field: record.plan.path,
578
- from,
579
- to: outcome.state,
580
- reason: reasonOf(outcome)
2188
+ cap,
2189
+ owner: this.deps.owner,
2190
+ ...failure.kind === "threw" ? { error: failure.error } : {
2191
+ code: failure.code,
2192
+ error: failure.message
2193
+ }
581
2194
  }
582
- };
583
- const message = `composed field ${fieldKey(record.plan)}: ${from} → ${outcome.state}`;
584
- if (outcome.state === "fresh") this.deps.logger.info(message, extras);
585
- else this.deps.logger.warn(message, extras);
2195
+ });
2196
+ for (const listener of this.listeners) listener(cap, failure);
586
2197
  }
587
2198
  };
588
2199
  //#endregion
@@ -602,38 +2213,139 @@ var CompositionRuntime = class {
602
2213
  * (`WithdrawalGate.seed`). A block whose row exists but whose composition no
603
2214
  * longer plans (a cap renamed by a release) keeps its device as an offline
604
2215
  * placeholder with no capabilities, and reports `failed` naming why.
2216
+ *
2217
+ * Slice 2 (D663) — a composition on an EXISTING device declares nothing: the
2218
+ * row is not the composer's, `DeclaredDevices` never sees it, and the owner
2219
+ * filter in `composer.addon.ts` stays load-bearing. Its fields are CLAIMED on
2220
+ * the hub under the block id (`OwnedFieldsTarget`), and released through the
2221
+ * `ClaimGate` — two applies must agree, seeded at boot from the claims index —
2222
+ * when the block is gone, disabled, or lost the feature. Its own replaced
2223
+ * fields are read in the NATIVE view (rule 6). Its target row gone on two
2224
+ * agreeing index reads fails the block by name and ungrafts what it added —
2225
+ * whether the loss was seen on `device.unregistered` or on a later plan; its
2226
+ * claims die with the row. Nothing is claimed or released while the claims
2227
+ * index has not loaded (`CLAIMS_NOT_LOADED`). Planning is `composer-plan.ts`.
2228
+ *
2229
+ * A cap the customization ADDS is GRAFTED (`GraftHost`, Task 10): a
2230
+ * method-serving native on the target, decided at plan time only; when and why
2231
+ * is `composer-grafts.ts` (native always wins; an unserved cap is named in the
2232
+ * verdict; a previous process's leftover is retracted).
2233
+ *
2234
+ * A replaced field is handed back only on a CONFIRMED unavailability of its
2235
+ * source (D49, fix round 2): a failed, first or empty read HOLDS it on its last
2236
+ * value with its claim kept — the runtime's `held`, reported `starting` by name.
2237
+ * A runtime built by an apply starts in that apply's sync, never earlier from
2238
+ * an event (N-5).
605
2239
  */
606
2240
  var INTEGRATION_WAIT = "waiting for the shared Blocks integration to reconcile";
607
2241
  var STORE_NOT_LOADED = "the core_blocks store has not loaded — nothing is declared or withdrawn until it does (D49)";
608
2242
  var ROWS_UNREAD = "the composer's own device rows could not be read — nothing is declared or withdrawn until they are (D49)";
609
2243
  var ROWS_EMPTY_UNCONFIRMED = "the composer's own device rows read empty once — a cold registry answers the same, so nothing is declared or withdrawn until the next apply's read agrees (D49)";
610
- /** The shape of a placeholder: no caps, so no plan to take a shape from. */
611
- var PLACEHOLDER_SHAPE = "placeholder";
2244
+ var CLAIMS_NOT_LOADED = "the claims index has not loaded — nothing is claimed or released until it does (D49)";
2245
+ var CLAIMS_EMPTY_UNCONFIRMED = "the claims index read empty once — nothing is claimed or released until the next apply's read agrees (D49)";
2246
+ /** A release answered with one of these hands back nothing of ours: the gate forgets the claim. */
2247
+ var RELEASE_SETTLED_CODES = new Set([
2248
+ "not-claimed",
2249
+ "unknown-device",
2250
+ "owned-by-other"
2251
+ ]);
612
2252
  var Composer = class {
613
2253
  deps;
614
2254
  live = /* @__PURE__ */ new Map();
615
- devices = /* @__PURE__ */ new Map();
616
- /** StableIds whose row exists: the seeded persisted rows, then what every declare kept. */
617
- rows = /* @__PURE__ */ new Set();
618
- seeded = false;
619
- /** The previous seed read answered `[]`: one more `[]` confirms it (D49). */
620
- emptySeedSeen = false;
2255
+ /** The composed devices of `new`-target blocks, and the rows that exist. */
2256
+ composed;
621
2257
  /** Consecutive applies refused because `core_blocks` had not loaded: warned once per run. */
622
2258
  storeWaitApplies = 0;
623
2259
  /** Consecutive applies refused because the Blocks integration had not reconciled: warned once per run. */
624
2260
  integrationWaitApplies = 0;
625
- declaredFingerprint = null;
626
2261
  gate;
2262
+ claimGate;
2263
+ targets;
2264
+ grafts;
2265
+ /** The rows the composer owns, read ONCE before its first withdrawal decision. */
2266
+ rowSeed;
2267
+ /**
2268
+ * The claims the hub holds, read ONCE before the first release decision (rule
2269
+ * 3). Every owner in it is a composer block id — the Task 6 guard lets nothing
2270
+ * else claim. `not-loaded` is UNKNOWN (D315), never empty. Until seeded, every
2271
+ * existing-target block waits by name; new-target blocks are not held.
2272
+ */
2273
+ claimSeed;
627
2274
  /** Applies never interleave: each one reads and replaces the whole live set. */
628
2275
  applyChain = Promise.resolve();
2276
+ /**
2277
+ * Runtimes built by an apply and not started yet: that apply's sync starts
2278
+ * them, with their sources tracked. An event in between never does (N-5) —
2279
+ * it would start one on sources "not tracked yet".
2280
+ */
2281
+ unsynced = /* @__PURE__ */ new Set();
629
2282
  constructor(deps) {
630
2283
  this.deps = deps;
631
- this.gate = new WithdrawalGate(deps.logger.child("withdrawal"), (stableId) => this.devices.get(stableId)?.id ?? null);
2284
+ this.composed = new ComposedDeviceSet({
2285
+ logger: deps.logger,
2286
+ declare: deps.declare,
2287
+ devices: deps.devices
2288
+ });
2289
+ this.gate = new WithdrawalGate(deps.logger.child("withdrawal"), (stableId) => this.composed.idOf(stableId));
2290
+ this.claimGate = new ClaimGate(deps.logger.child("claims"));
2291
+ this.grafts = new ComposerGrafts(deps.grafts, deps.logger.child("graft"), deps.describeTarget);
2292
+ this.targets = new TargetDescriptions(deps.logger.child("target"), (deviceId) => {
2293
+ this.grafts.describing(deviceId);
2294
+ return deps.describeTarget(deviceId);
2295
+ });
2296
+ this.rowSeed = new ConfirmedSeed({
2297
+ logger: deps.logger,
2298
+ subject: "composer's own device rows",
2299
+ read: async () => {
2300
+ try {
2301
+ return {
2302
+ kind: "items",
2303
+ items: await deps.readPersisted()
2304
+ };
2305
+ } catch (err) {
2306
+ return {
2307
+ kind: "unknown",
2308
+ error: errMsg(err)
2309
+ };
2310
+ }
2311
+ },
2312
+ onSeeded: (rows) => {
2313
+ this.gate.seed(rows);
2314
+ this.composed.seedRows(rows);
2315
+ },
2316
+ unreadReason: ROWS_UNREAD,
2317
+ emptyUnconfirmedReason: ROWS_EMPTY_UNCONFIRMED
2318
+ });
2319
+ this.claimSeed = new ConfirmedSeed({
2320
+ logger: deps.logger,
2321
+ subject: "claims index",
2322
+ read: async () => {
2323
+ try {
2324
+ const listing = await deps.claims.listClaims({ ownerPrefix: "" });
2325
+ return listing.state === "loaded" ? {
2326
+ kind: "items",
2327
+ items: listing.claims
2328
+ } : {
2329
+ kind: "unknown",
2330
+ error: listing.reason
2331
+ };
2332
+ } catch (err) {
2333
+ return {
2334
+ kind: "unknown",
2335
+ error: errMsg(err)
2336
+ };
2337
+ }
2338
+ },
2339
+ onSeeded: (claims) => {
2340
+ this.claimGate.seed(claims);
2341
+ this.deps.tracker.seedClaims(claims);
2342
+ },
2343
+ unreadReason: CLAIMS_NOT_LOADED,
2344
+ emptyUnconfirmedReason: CLAIMS_EMPTY_UNCONFIRMED
2345
+ });
632
2346
  }
633
2347
  apply(input) {
634
- const run = this.applyChain.then(() => this.applyNow(input), () => this.applyNow(input));
635
- this.applyChain = run.then(() => void 0, () => void 0);
636
- return run;
2348
+ return this.chained(() => this.applyNow(input));
637
2349
  }
638
2350
  getStates(blockIds) {
639
2351
  return blockIds.flatMap((blockId) => {
@@ -642,10 +2354,10 @@ var Composer = class {
642
2354
  const verdict = this.verdict(blockId);
643
2355
  return [{
644
2356
  blockId,
645
- deviceId: entry.device?.id ?? null,
2357
+ deviceId: this.deviceIdOf(entry),
646
2358
  status: verdict.status,
647
2359
  ...verdict.reason === void 0 ? {} : { reason: verdict.reason },
648
- fields: entry.runtime?.state().fields.map((f) => ({ ...f })) ?? []
2360
+ fields: entry.runtime?.state().fields.map((f) => ({ ...f })) ?? [...entry.heldFields]
649
2361
  }];
650
2362
  });
651
2363
  }
@@ -653,15 +2365,28 @@ var Composer = class {
653
2365
  async onStateChanged(data) {
654
2366
  this.dispatch(await this.deps.tracker.onStateChanged(data.deviceId, data.capName, data.slice));
655
2367
  }
2368
+ async onNativeChanged(data) {
2369
+ this.dispatch(await this.deps.tracker.onNativeChanged(data.deviceId, data.capName, data.native));
2370
+ }
656
2371
  async onDeviceUnregistered(deviceId) {
657
- this.dispatch(await this.deps.tracker.onDeviceUnregistered(deviceId));
2372
+ const changed = await this.deps.tracker.onDeviceUnregistered(deviceId);
2373
+ await this.chained(() => this.failGoneTargets(deviceId));
2374
+ this.dispatch(changed);
658
2375
  }
659
2376
  async onBindingsChanged(data) {
2377
+ this.targets.invalidate(data.deviceId);
2378
+ this.grafts.onBindingsChanged(data);
660
2379
  this.dispatch(await this.deps.tracker.onBindingsChanged(data.deviceId, data.capName, data.reason));
661
2380
  }
662
2381
  shutdown() {
663
2382
  for (const entry of this.live.values()) entry.runtime?.stop();
664
2383
  }
2384
+ /** Run `work` after every apply already queued, whether that settled or failed. */
2385
+ chained(work) {
2386
+ const run = this.applyChain.then(work, work);
2387
+ this.applyChain = run.then(() => void 0, () => void 0);
2388
+ return run;
2389
+ }
665
2390
  async applyNow(input) {
666
2391
  const ids = [...input.blocks.map((b) => b.id), ...input.unreadableBlocks.map((u) => u.id)];
667
2392
  if (!input.storeLoaded) {
@@ -685,15 +2410,26 @@ var Composer = class {
685
2410
  this.deps.logger.info("composer: the shared Blocks integration has reconciled — applying again", { meta: { appliesRefused: this.integrationWaitApplies } });
686
2411
  this.integrationWaitApplies = 0;
687
2412
  }
688
- const unseeded = await this.ensureSeeded();
2413
+ const unseeded = await this.rowSeed.ensure();
689
2414
  if (unseeded !== null) return this.waiting(ids, unseeded);
690
- const planned = [...input.blocks.map((block) => this.plan(block)), ...input.unreadableBlocks.map((block) => this.planUnreadable(block))];
691
- this.logPlanTransitions(planned);
2415
+ const claimsWait = await this.claimSeed.ensure();
2416
+ const restart = new Set(input.restartBlockIds);
2417
+ const planned = [];
2418
+ for (const block of input.blocks) planned.push(await this.plan(block, restart, claimsWait));
2419
+ for (const block of input.unreadableBlocks) planned.push(planUnreadable(block, this.composed.rows));
2420
+ for (const p of planned) logPlanTransition(this.deps.logger, this.live.get(p.block.id)?.planned, p, this.tagsOf(p.block.id));
692
2421
  const declarations = this.gate.filter(planned.flatMap((p) => p.declaration === null ? [] : [p.declaration]));
693
- const rebuildErrors = await this.rebuildShapes(planned);
694
- await this.reconcileDevices(input.integrationId, declarations, planned);
695
- const rebuilt = await this.rebuildRuntimes(planned, declarations, new Set(input.restartBlockIds), rebuildErrors);
2422
+ const rebuildErrors = await this.composed.rebuildShapes(planned, (blockId) => {
2423
+ const previous = this.live.get(blockId);
2424
+ return previous === void 0 ? void 0 : {
2425
+ device: previous.device,
2426
+ shape: previous.planned.shape
2427
+ };
2428
+ });
2429
+ await this.composed.reconcile(input.integrationId, declarations, planned);
2430
+ const rebuilt = await this.rebuildRuntimes(planned, declarations, restart, rebuildErrors);
696
2431
  await this.syncSources(rebuilt);
2432
+ await this.reconcileClaims();
697
2433
  return {
698
2434
  blocks: planned.map((p) => this.verdict(p.block.id)),
699
2435
  acted: true
@@ -709,177 +2445,20 @@ var Composer = class {
709
2445
  acted: false
710
2446
  };
711
2447
  }
712
- /**
713
- * Seed the withdrawal gate with the rows that exist, ONCE, before its first
714
- * `filter`; answers null once seeded, else why this apply must wait.
715
- *
716
- * An EMPTY answer is the destroying direction: a cold registry answers `[]`
717
- * too, and `DeclaredDevices.reconcile` sweeps from its OWN later read — so a
718
- * cold seed followed by a warm sweep would delete the row of a block that no
719
- * longer plans, with no placeholder to hold it. `[]` is therefore trusted
720
- * only when the next apply's read agrees (D49); that apply comes from the
721
- * supervisor's existing converge, never a timer here (D3). A failed read
722
- * changes nothing and breaks the agreement.
723
- */
724
- async ensureSeeded() {
725
- if (this.seeded) return null;
726
- let rows;
727
- try {
728
- rows = await this.deps.readPersisted();
729
- } catch (err) {
730
- this.emptySeedSeen = false;
731
- this.deps.logger.warn("composer: reading its own device rows failed — nothing is declared or withdrawn; retried on the next apply", { meta: { error: errMsg(err) } });
732
- return ROWS_UNREAD;
733
- }
734
- if (rows.length === 0 && !this.emptySeedSeen) {
735
- this.emptySeedSeen = true;
736
- this.deps.logger.warn("composer: its own device rows read empty — waiting for the next apply to confirm before declaring or withdrawing anything");
737
- return ROWS_EMPTY_UNCONFIRMED;
738
- }
739
- this.gate.seed(rows);
740
- this.rows = new Set(rows.map((r) => r.stableId));
741
- this.seeded = true;
742
- this.deps.logger.info("composer: seeded the withdrawal gate from the persisted rows", { meta: {
743
- rows: rows.length,
744
- confirmedEmpty: rows.length === 0
745
- } });
746
- return null;
747
- }
748
- /**
749
- * A row that exists but does not parse is held exactly like a composition
750
- * that stopped planning: its device (if it has a row) stays as an offline
751
- * placeholder, and the block fails naming the parse error. Absent, it would
752
- * be withdrawn on the second apply that omitted it (D49).
753
- */
754
- planUnreadable(block) {
755
- const stableId = composedDeviceStableId(block.id);
756
- const hasRow = this.rows.has(stableId);
757
- return {
758
- block: {
759
- id: block.id,
760
- name: block.name,
761
- enabled: false,
762
- updatedAt: 0
763
- },
764
- fields: [],
765
- problems: [`the stored block does not parse — ${block.error}`],
766
- declaration: hasRow ? placeholderDeclaration(stableId) : null,
767
- shape: hasRow ? PLACEHOLDER_SHAPE : null,
768
- spec: null
769
- };
770
- }
771
- plan(block) {
772
- const plan = planComposition(block.composition, this.deps.lookupCap);
2448
+ plan(block, restart, claimsWait) {
773
2449
  const target = block.composition.target;
774
- if (plan.problems.length > 0 || target.kind !== "new") {
775
- const problems = plan.problems.length > 0 ? plan.problems.map((p) => `${p.path}: ${p.message}`) : [`target: a \`${target.kind}\` target is not composable in this release`];
776
- const stableId = composedDeviceStableId(block.id);
777
- const hasRow = this.rows.has(stableId);
778
- return {
779
- block,
780
- fields: [],
781
- problems,
782
- declaration: hasRow ? placeholderDeclaration(stableId) : null,
783
- shape: hasRow ? PLACEHOLDER_SHAPE : null,
784
- spec: null
785
- };
786
- }
787
- const caps = block.composition.features.flatMap((f) => {
788
- const def = this.deps.lookupCap(f.cap);
789
- return def === null ? [] : [def];
790
- });
791
- const declarationInput = {
792
- blockId: block.id,
793
- name: block.name,
794
- target,
795
- caps
796
- };
797
- return {
2450
+ if (target.kind === "new") return Promise.resolve(planNew(block, target, this.deps.lookupCap, this.composed.rows));
2451
+ const previous = this.live.get(block.id)?.planned;
2452
+ return planExisting({
798
2453
  block,
799
- fields: plan.fields,
800
- problems: [],
801
- declaration: declarationFor(declarationInput),
802
- shape: shapeOf(declarationInput),
803
- spec: {
804
- type: target.type,
805
- ...target.role === void 0 ? {} : { role: target.role }
806
- }
807
- };
808
- }
809
- /** One line when a block starts or stops planning — never one per apply. */
810
- logPlanTransitions(planned) {
811
- for (const p of planned) {
812
- const before = this.live.get(p.block.id)?.planned.problems.join("; ") ?? "";
813
- const now = p.problems.join("; ");
814
- if (before === now) continue;
815
- const extras = {
816
- tags: this.tagsOf(p.block.id),
817
- meta: {
818
- blockId: p.block.id,
819
- problems: p.problems,
820
- placeholder: p.declaration !== null
821
- }
822
- };
823
- if (now === "") this.deps.logger.info("composition plans again", extras);
824
- else this.deps.logger.warn(p.declaration === null ? "composition does not plan — no device is declared for it" : "composition no longer plans — its device is kept offline with no capabilities", extras);
825
- }
826
- }
827
- /**
828
- * A changed type/role/cap set (or a switch to or from a placeholder) needs a
829
- * NEW device object: decommission, and let the reconcile adopt it with the
830
- * new class. A failure keeps the old device and is retried next apply.
831
- */
832
- async rebuildShapes(planned) {
833
- const errors = /* @__PURE__ */ new Map();
834
- for (const p of planned) {
835
- const previous = this.live.get(p.block.id);
836
- const device = previous?.device ?? null;
837
- if (previous === void 0 || device === null || p.shape === null) continue;
838
- if (previous.planned.shape === p.shape) continue;
839
- try {
840
- this.deps.logger.info("composition shape changed — rebuilding its device", {
841
- tags: { deviceId: device.id },
842
- meta: {
843
- blockId: p.block.id,
844
- from: previous.planned.shape,
845
- to: p.shape
846
- }
847
- });
848
- if (p.spec !== null) await this.deps.devices.applyDerivedSpec(device.id, p.spec);
849
- await this.deps.devices.decommission(device.id);
850
- const stableId = composedDeviceStableId(p.block.id);
851
- this.devices = new Map([...this.devices].filter(([s]) => s !== stableId));
852
- this.declaredFingerprint = null;
853
- } catch (err) {
854
- const error = errMsg(err);
855
- errors.set(p.block.id, error);
856
- this.deps.logger.warn("rebuilding a composed device failed — the old one is kept", {
857
- tags: { deviceId: device.id },
858
- meta: {
859
- blockId: p.block.id,
860
- error
861
- }
862
- });
863
- }
864
- }
865
- return errors;
866
- }
867
- async reconcileDevices(integrationId, declarations, planned) {
868
- const shapes = new Map(planned.flatMap((p) => p.declaration === null ? [] : [[p.declaration.stableId, p.shape]]));
869
- const fingerprint = JSON.stringify(declarations.map((d) => [d.stableId, shapes.get(d.stableId) ?? null]).toSorted());
870
- const allLive = declarations.every((d) => this.devices.has(d.stableId));
871
- if (fingerprint === this.declaredFingerprint && allLive) return;
872
- try {
873
- const result = await this.deps.declare(integrationId, declarations);
874
- this.devices = result.devices;
875
- this.rows = new Set(declarations.map((d) => d.stableId).filter((s) => this.rows.has(s) || result.devices.has(s)));
876
- this.declaredFingerprint = fingerprint;
877
- } catch (err) {
878
- this.deps.logger.warn("composer: the device declaration failed — keeping the devices already live", { meta: {
879
- error: errMsg(err),
880
- declarations: declarations.length
881
- } });
882
- }
2454
+ target,
2455
+ previous,
2456
+ operatorAction: previous === void 0 || restart.has(block.id) || previous.block.updatedAt !== block.updatedAt,
2457
+ claimsWait,
2458
+ tracker: this.deps.tracker,
2459
+ targets: this.targets,
2460
+ lookupCap: this.deps.lookupCap
2461
+ });
883
2462
  }
884
2463
  async rebuildRuntimes(planned, declarations, restart, rebuildErrors) {
885
2464
  const reset = /* @__PURE__ */ new Set();
@@ -887,6 +2466,8 @@ var Composer = class {
887
2466
  const next = /* @__PURE__ */ new Map();
888
2467
  for (const p of planned) {
889
2468
  const previous = this.live.get(p.block.id);
2469
+ const lost = lostTarget(p, previous?.planned);
2470
+ if (lost !== null) this.ungraftGone(p.block.id, lost.target, lost.deviceId);
890
2471
  const rebuildError = rebuildErrors.get(p.block.id) ?? null;
891
2472
  if (previous !== void 0 && rebuildError !== null) {
892
2473
  next.set(p.block.id, {
@@ -895,28 +2476,68 @@ var Composer = class {
895
2476
  });
896
2477
  continue;
897
2478
  }
898
- const device = this.devices.get(composedDeviceStableId(p.block.id)) ?? null;
2479
+ const device = p.target.kind === "existing" ? null : this.composed.get(composedDeviceStableId(p.block.id));
899
2480
  const appliedName = device === null ? null : p.block.name === null ? previous?.appliedName ?? device.name : await this.applyName(device, p.block.name, previous?.appliedName ?? null);
900
- if (previous?.runtime != null && previous.device === device && previous.planned.block.updatedAt === p.block.updatedAt && previous.planned.block.enabled === p.block.enabled && !restart.has(p.block.id)) {
2481
+ if (previous?.runtime != null && previous.device === device && sameTarget(previous.planned.target, p.target) && previous.planned.block.updatedAt === p.block.updatedAt && previous.planned.block.enabled === p.block.enabled && !restart.has(p.block.id)) {
901
2482
  next.set(p.block.id, {
902
2483
  planned: p,
903
2484
  device,
904
2485
  runtime: previous.runtime,
905
2486
  appliedName,
906
- rebuildError: null
2487
+ rebuildError: null,
2488
+ heldFields: []
2489
+ });
2490
+ continue;
2491
+ }
2492
+ let targetSeed;
2493
+ if (p.target.kind === "existing" && p.target.deviceId !== null && p.target.waiting === null && p.target.gone === null && p.block.enabled && p.fields.length > 0) try {
2494
+ targetSeed = await readExistingTargetSeeds(p.target.deviceId, p.fields.map((field) => field.cap), this.deps.readExistingTargetCap);
2495
+ } catch (err) {
2496
+ const reason = `the target device state could not be read before ownership (${errMsg(err)}) — nothing is grafted or claimed until it is; retried on the next apply`;
2497
+ this.deps.logger.warn(previous?.runtime === null || previous === void 0 ? "customization target state could not be read — ownership is held" : "customization edit target state could not be read — the previous runtime stays installed", {
2498
+ tags: { deviceId: p.target.deviceId },
2499
+ meta: {
2500
+ blockId: p.block.id,
2501
+ error: errMsg(err)
2502
+ }
2503
+ });
2504
+ if (previous !== void 0) next.set(p.block.id, previous);
2505
+ else next.set(p.block.id, {
2506
+ planned: withExistingTargetWait(p, reason),
2507
+ device: null,
2508
+ runtime: null,
2509
+ appliedName: null,
2510
+ rebuildError: null,
2511
+ heldFields: []
907
2512
  });
908
2513
  continue;
909
2514
  }
2515
+ const heldFields = this.heldFieldsFor(p, previous);
2516
+ const operatorAction = previous === void 0 || restart.has(p.block.id) || previous.planned.block.updatedAt !== p.block.updatedAt;
2517
+ const targetKey = p.target.kind === "existing" ? compositionSourceKey(p.target.ref) : null;
2518
+ if (operatorAction) for (const f of p.fields) for (const r of f.reads) {
2519
+ const key = compositionSourceKey(r.source);
2520
+ if (key !== targetKey) reset.add(key);
2521
+ }
2522
+ const writer = this.writerFor(p, device);
2523
+ const runtime = writer === null || p.fields.length === 0 ? null : this.newRuntime(p, writer, targetSeed);
2524
+ if (writer !== null && p.target.kind === "existing" && p.target.deviceId !== null) this.grafts.plan({
2525
+ deviceId: p.target.deviceId,
2526
+ ref: p.target.ref,
2527
+ features: p.target.features,
2528
+ nativeCaps: p.target.nativeCaps,
2529
+ enabled: p.block.enabled,
2530
+ settled: p.problems.length === 0
2531
+ });
910
2532
  previous?.runtime?.stop();
911
- if (previous === void 0 || restart.has(p.block.id) || previous.planned.block.updatedAt !== p.block.updatedAt) for (const f of p.fields) for (const r of f.reads) reset.add(compositionSourceKey(r.source));
912
- const runtime = device === null || p.fields.length === 0 ? null : this.newRuntime(p, device);
913
2533
  if (runtime !== null) fresh.add(runtime);
914
2534
  next.set(p.block.id, {
915
2535
  planned: p,
916
2536
  device,
917
2537
  runtime,
918
2538
  appliedName,
919
- rebuildError: null
2539
+ rebuildError: null,
2540
+ heldFields
920
2541
  });
921
2542
  }
922
2543
  const held = new Set(declarations.map((d) => d.stableId));
@@ -926,17 +2547,72 @@ var Composer = class {
926
2547
  else entry.runtime?.stop();
927
2548
  }
928
2549
  this.live = next;
929
- return {
930
- reset,
931
- fresh
932
- };
2550
+ const liveRuntimes = new Set([...next.values()].map((e) => e.runtime));
2551
+ this.unsynced = new Set([...[...this.unsynced].filter((r) => liveRuntimes.has(r)), ...fresh]);
2552
+ return { reset };
2553
+ }
2554
+ ungraftGone(blockId, target, deviceId) {
2555
+ this.grafts.gone(deviceId);
2556
+ ungraftGoneTarget({
2557
+ grafts: this.deps.grafts,
2558
+ logger: this.deps.logger,
2559
+ blockId,
2560
+ deviceId,
2561
+ ref: target.ref,
2562
+ features: target.features
2563
+ });
2564
+ }
2565
+ /** Where a block's runtime writes: its composed device, or a claim-backed target on the existing device. */
2566
+ writerFor(p, device) {
2567
+ const target = p.target;
2568
+ if (target.kind !== "existing") return device;
2569
+ if (target.deviceId === null || target.waiting !== null || target.gone !== null) return null;
2570
+ const deviceId = target.deviceId;
2571
+ const owner = p.block.id;
2572
+ return new OwnedFieldsTarget({
2573
+ deviceId,
2574
+ owner,
2575
+ modeOf: (cap) => claimModeOf(target.features, cap),
2576
+ fieldsOf: (cap) => claimFieldsOf(p.fields, cap),
2577
+ api: this.deps.claims,
2578
+ logger: this.deps.logger.child("owned"),
2579
+ onClaim: (cap, event) => void this.onClaimEvent(deviceId, cap, owner, event)
2580
+ });
933
2581
  }
934
- newRuntime(p, device) {
2582
+ /** C-1/N-1: the tracker follows the block's OWN claim as the hub answers it; every answer re-reads the native once. */
2583
+ async onClaimEvent(deviceId, cap, owner, event) {
2584
+ try {
2585
+ this.dispatch(await this.deps.tracker.claimEvent(deviceId, cap, owner, event));
2586
+ } catch (err) {
2587
+ this.deps.logger.warn("composer: following a claim transition failed", {
2588
+ tags: { deviceId },
2589
+ meta: {
2590
+ cap,
2591
+ owner,
2592
+ event,
2593
+ error: errMsg(err)
2594
+ }
2595
+ });
2596
+ }
2597
+ }
2598
+ heldFieldsFor(p, previous) {
2599
+ if (p.target.kind !== "existing" || p.problems.length === 0 || previous === void 0) return [];
2600
+ if (previous.runtime === null) return previous.heldFields;
2601
+ return heldFieldsOf(previous.runtime.state().fields, `the composition no longer plans — the field keeps its last value on the device: ${p.problems.join("; ")}`);
2602
+ }
2603
+ newRuntime(p, target, seed) {
2604
+ const self = p.target.kind === "existing" ? selfReadCaps(p.fields, p.target.ref, p.target.features) : null;
2605
+ const targetKey = p.target.kind === "existing" ? compositionSourceKey(p.target.ref) : null;
2606
+ const nativeCaps = new Set(self?.native ?? []);
935
2607
  return new CompositionRuntime({
936
2608
  blockId: p.block.id,
937
2609
  fields: p.fields,
938
- target: device,
939
- read: (read) => this.deps.tracker.read(read),
2610
+ eventPlans: p.events,
2611
+ events: this.deps.events,
2612
+ target,
2613
+ lookupCap: this.deps.lookupCap,
2614
+ ...seed === void 0 ? {} : { initialByCap: seed.byCap },
2615
+ read: (read) => this.deps.tracker.read(read, compositionSourceKey(read.source) === targetKey && nativeCaps.has(read.cap) ? "native" : "effective"),
940
2616
  now: this.deps.now,
941
2617
  timer: this.deps.newTimer(),
942
2618
  logger: this.deps.logger.child("runtime")
@@ -963,16 +2639,35 @@ var Composer = class {
963
2639
  }
964
2640
  }
965
2641
  async syncSources(rebuilt) {
966
- const requests = [...this.live.values()].filter((e) => e.runtime !== null && e.planned.block.enabled).flatMap((e) => e.planned.fields.flatMap((f) => f.reads.map((r) => ({
967
- ref: r.source,
968
- caps: [r.cap]
969
- }))));
2642
+ const requests = [];
2643
+ for (const e of this.live.values()) {
2644
+ const target = e.planned.target;
2645
+ const running = e.runtime !== null && e.planned.block.enabled;
2646
+ let targetKey = null;
2647
+ if (target.kind === "existing") {
2648
+ targetKey = compositionSourceKey(target.ref);
2649
+ const self = running ? selfReadCaps(e.planned.fields, target.ref, target.features) : null;
2650
+ requests.push({
2651
+ ref: target.ref,
2652
+ caps: self?.effective ?? [],
2653
+ nativeCaps: self?.native ?? [],
2654
+ nativeOwner: e.planned.block.id
2655
+ });
2656
+ }
2657
+ if (!running) continue;
2658
+ for (const f of e.planned.fields) for (const r of f.reads) if (compositionSourceKey(r.source) !== targetKey) requests.push({
2659
+ ref: r.source,
2660
+ caps: [r.cap]
2661
+ });
2662
+ }
970
2663
  const changed = await this.syncTracker(requests, rebuilt.reset);
971
2664
  for (const entry of this.live.values()) {
972
2665
  const runtime = entry.runtime;
973
2666
  if (runtime === null) continue;
2667
+ const fresh = this.unsynced.has(runtime);
2668
+ this.unsynced = new Set([...this.unsynced].filter((r) => r !== runtime));
974
2669
  if (!entry.planned.block.enabled) runtime.disable("the block is disabled");
975
- else if (rebuilt.fresh.has(runtime)) runtime.start();
2670
+ else if (fresh) runtime.start();
976
2671
  else runtime.onSourcesChanged(changed);
977
2672
  }
978
2673
  }
@@ -988,53 +2683,105 @@ var Composer = class {
988
2683
  return /* @__PURE__ */ new Set();
989
2684
  }
990
2685
  }
2686
+ /**
2687
+ * Rule 3: the claims every live customization WANTS this apply; everything
2688
+ * else the gate knows is a release candidate, handed back once two applies
2689
+ * agree. A block that cannot plan — waiting, unreadable, failed, or with its
2690
+ * target gone — HOLDS its claims (rule 4, rule 5): they are wanted as they
2691
+ * are. A disabled block wants none (rule 7).
2692
+ */
2693
+ async reconcileClaims() {
2694
+ if (!this.claimSeed.done) return;
2695
+ const wanted = [];
2696
+ const held = [];
2697
+ for (const [blockId, entry] of this.live) {
2698
+ const target = entry.planned.target;
2699
+ if (target.kind === "new") continue;
2700
+ if (target.kind === "unreadable" || entry.planned.problems.length > 0 || target.waiting !== null || target.gone !== null || target.deviceId === null) {
2701
+ held.push(blockId);
2702
+ continue;
2703
+ }
2704
+ if (!entry.planned.block.enabled) continue;
2705
+ const deviceId = target.deviceId;
2706
+ for (const f of target.features) wanted.push({
2707
+ deviceId,
2708
+ capName: f.cap,
2709
+ owner: blockId
2710
+ });
2711
+ }
2712
+ for (const owner of held) wanted.push(...this.claimGate.claimsOf(owner));
2713
+ for (const key of this.claimGate.filter(wanted)) await this.release(key);
2714
+ }
2715
+ async release(key) {
2716
+ let outcome;
2717
+ try {
2718
+ outcome = await this.deps.claims.releaseClaim(key);
2719
+ } catch (err) {
2720
+ this.claimGate.releaseFailed(key, errMsg(err));
2721
+ return;
2722
+ }
2723
+ if (!outcome.ok && !RELEASE_SETTLED_CODES.has(outcome.code)) {
2724
+ this.claimGate.releaseFailed(key, `${outcome.code}: ${outcome.message}`);
2725
+ return;
2726
+ }
2727
+ this.claimGate.released(key, outcome.ok ? "released" : outcome.code);
2728
+ await this.grafts.released(key.deviceId, key.capName);
2729
+ await this.onClaimEvent(key.deviceId, key.capName, key.owner, "free");
2730
+ }
2731
+ /**
2732
+ * Rule 5: a customization whose target row is gone — the tracker's two
2733
+ * agreeing reads — fails naming it, stops writing, and ungrafts every cap it
2734
+ * added on that id (Task 10's seam). Its claims are left as they are: the row
2735
+ * and its `$owned` died with the device. No auto-repair: an operator edit or
2736
+ * restart re-resolves the target.
2737
+ */
2738
+ failGoneTargets(deviceId) {
2739
+ const next = new Map(this.live);
2740
+ for (const [blockId, entry] of this.live) {
2741
+ const target = entry.planned.target;
2742
+ if (target.kind !== "existing" || target.deviceId !== deviceId || target.gone !== null) continue;
2743
+ const resolution = this.deps.tracker.resolution(target.ref);
2744
+ if (resolution.kind !== "missing") continue;
2745
+ entry.runtime?.stop();
2746
+ this.unsynced = new Set([...this.unsynced].filter((r) => r !== entry.runtime));
2747
+ this.ungraftGone(blockId, target, deviceId);
2748
+ next.set(blockId, {
2749
+ ...entry,
2750
+ runtime: null,
2751
+ planned: {
2752
+ ...entry.planned,
2753
+ target: {
2754
+ ...target,
2755
+ gone: `${resolution.reason} — two index reads agreed`
2756
+ }
2757
+ }
2758
+ });
2759
+ }
2760
+ this.live = next;
2761
+ }
991
2762
  dispatch(changed) {
992
2763
  if (changed.size === 0) return;
993
- for (const entry of this.live.values()) if (entry.planned.block.enabled) entry.runtime?.onSourcesChanged(changed);
2764
+ for (const entry of this.live.values()) {
2765
+ const runtime = entry.runtime;
2766
+ if (runtime === null || !entry.planned.block.enabled) continue;
2767
+ if (!this.unsynced.has(runtime)) runtime.onSourcesChanged(changed);
2768
+ }
2769
+ }
2770
+ deviceIdOf(entry) {
2771
+ if (entry.device !== null) return entry.device.id;
2772
+ return entry.planned.target.kind === "existing" ? entry.planned.target.deviceId : null;
994
2773
  }
995
2774
  tagsOf(blockId) {
996
- const deviceId = this.devices.get(composedDeviceStableId(blockId))?.id ?? null;
2775
+ const entry = this.live.get(blockId);
2776
+ const deviceId = this.composed.idOf(composedDeviceStableId(blockId)) ?? (entry === void 0 ? null : this.deviceIdOf(entry));
997
2777
  return deviceId === null ? {} : { deviceId };
998
2778
  }
999
2779
  verdict(blockId) {
1000
2780
  const entry = this.live.get(blockId);
1001
- if (entry === void 0) return {
1002
- blockId,
1003
- status: "starting",
1004
- reason: "not applied yet"
1005
- };
1006
- if (entry.rebuildError !== null) return {
1007
- blockId,
1008
- status: "failed",
1009
- reason: `rebuilding the composed device for its new shape failed: ${entry.rebuildError}`
1010
- };
1011
- if (entry.planned.problems.length > 0) {
1012
- const kept = entry.planned.declaration === null ? "" : " — its device is kept offline with no capabilities";
1013
- return {
1014
- blockId,
1015
- status: "failed",
1016
- reason: `the composition does not validate: ${entry.planned.problems.join("; ")}${kept}`
1017
- };
1018
- }
1019
- if (!entry.planned.block.enabled) return {
1020
- blockId,
1021
- status: "stopped"
1022
- };
1023
- if (entry.device === null) return {
1024
- blockId,
1025
- status: "starting",
1026
- reason: "the composed device has not been constructed yet"
1027
- };
1028
- const failure = entry.runtime?.state().failure ?? null;
1029
- if (failure !== null) return {
1030
- blockId,
1031
- status: "failed",
1032
- reason: failure
1033
- };
1034
- return {
1035
- blockId,
1036
- status: "running"
1037
- };
2781
+ const runtime = entry?.runtime ?? null;
2782
+ const target = entry?.planned.target;
2783
+ const grafts = target?.kind === "existing" && target.deviceId !== null ? this.grafts.verdict(target.deviceId, target.features) : null;
2784
+ return verdictOf(blockId, entry, runtime !== null && this.unsynced.has(runtime), grafts);
1038
2785
  }
1039
2786
  };
1040
2787
  //#endregion
@@ -1075,6 +2822,262 @@ var NodeDeadlineTimer = class {
1075
2822
  }
1076
2823
  };
1077
2824
  //#endregion
2825
+ //#region src/builtins/composer/graft-host.ts
2826
+ /**
2827
+ * Grafts (D663): a method-serving native provider for a cap a customization
2828
+ * ADDS to a device the composer does not own — a battery on an HA pet feeder
2829
+ * that registers none. Without it the added cap has claimed values on the hub
2830
+ * but nothing answers its methods, so `getBindings` never lists it and a caller
2831
+ * of `battery.wakeForStream` finds no provider.
2832
+ *
2833
+ * - The provider is built ONLY from the cap's `composedMethods` (Task 1): an
2834
+ * `answer` resolves to its value, a `refuse` rejects naming its reason. A cap
2835
+ * with a served method that has no composed answer is refused here as it is
2836
+ * at plan time (`has-commands`, slice 1). `getStatus` reads the MERGED slice
2837
+ * (`readMerged`): the hub overlays it anyway (Task 7), but a direct call to
2838
+ * this runner must never answer `{}`.
2839
+ * - The context is `createContext(stableId, id)` on a FOREIGN device id: the
2840
+ * second use of the forked-runner native path D659 lists as unverified live
2841
+ * (D659 live item 1). Task 16 step 4.1 is its live proof.
2842
+ * - `ungraft` removes only what THIS host registered, through the context's
2843
+ * optional `unregisterNativeCap`. An older kernel without it leaves the graft
2844
+ * routable until the composer respawns — warned once per device, never
2845
+ * silent.
2846
+ */
2847
+ /** The served methods of `def` that have no composed answer — non-empty means the cap cannot be grafted. */
2848
+ function unanswered(def) {
2849
+ return Object.entries(def.methods).filter(([name, m]) => m.systemOnly !== true && def.composedMethods?.[name] === void 0).map(([name]) => name);
2850
+ }
2851
+ function composedMethod(answer) {
2852
+ if (answer.kind === "answer") return async () => answer.value;
2853
+ return async () => {
2854
+ throw new Error(answer.reason);
2855
+ };
2856
+ }
2857
+ var GraftHost = class {
2858
+ ports;
2859
+ lookupCap;
2860
+ readMerged;
2861
+ /** One context per foreign device: `registerNativeCap` is per context, and so is its unregister. */
2862
+ contexts = /* @__PURE__ */ new Map();
2863
+ caps = /* @__PURE__ */ new Map();
2864
+ /** Devices already warned that this kernel cannot unregister a graft. */
2865
+ warnedNoUnregister = /* @__PURE__ */ new Set();
2866
+ constructor(ports, lookupCap, readMerged) {
2867
+ this.ports = ports;
2868
+ this.lookupCap = lookupCap;
2869
+ this.readMerged = readMerged;
2870
+ }
2871
+ graft(deviceId, stableId, capName) {
2872
+ if (this.grafted(deviceId).has(capName)) return { kind: "already" };
2873
+ const def = this.lookupCap(capName);
2874
+ if (def === null) return this.refuse(deviceId, capName, `unknown capability \`${capName}\``);
2875
+ const missing = unanswered(def);
2876
+ if (missing.length > 0) return this.refuse(deviceId, capName, `\`${capName}\` has ${missing.map((m) => `\`${m}\``).join(", ")} with no composed answer — a graft cannot serve it until commands arrive (slice 3)`);
2877
+ const ctx = this.contextFor(deviceId, stableId);
2878
+ try {
2879
+ ctx.registerNativeCap(def, this.providerFor(deviceId, def));
2880
+ } catch (err) {
2881
+ return this.refuse(deviceId, capName, `registerNativeCap threw — ${errMsg(err)}`);
2882
+ }
2883
+ this.caps = new Map([...this.caps, [deviceId, new Set([...this.grafted(deviceId), capName])]]);
2884
+ this.ports.logger.info("graft: an added cap is served on a device the composer does not own", {
2885
+ tags: { deviceId },
2886
+ meta: {
2887
+ cap: capName,
2888
+ stableId
2889
+ }
2890
+ });
2891
+ return { kind: "grafted" };
2892
+ }
2893
+ ungraft(deviceId, capName, reason) {
2894
+ if (!this.grafted(deviceId).has(capName)) return;
2895
+ const ctx = this.contexts.get(deviceId);
2896
+ if (ctx === void 0) return;
2897
+ const outcome = this.unregister(ctx, deviceId, capName, reason);
2898
+ if (outcome === "threw") return;
2899
+ const remaining = new Set([...this.grafted(deviceId)].filter((c) => c !== capName));
2900
+ this.caps = new Map([...this.caps, [deviceId, remaining]]);
2901
+ if (outcome === "unregistered") this.ports.logger.info("graft removed", {
2902
+ tags: { deviceId },
2903
+ meta: {
2904
+ cap: capName,
2905
+ reason
2906
+ }
2907
+ });
2908
+ }
2909
+ /**
2910
+ * I-3: a binding a PREVIOUS composer process left on `deviceId` — this one
2911
+ * never registered it. The kernel retracts it under the composer's addon id;
2912
+ * the hub drops only an entry the composer registered (rule 4). The caller has
2913
+ * read that the binding is the composer's.
2914
+ */
2915
+ retractStale(deviceId, capName, reason) {
2916
+ if (this.grafted(deviceId).has(capName)) {
2917
+ this.ungraft(deviceId, capName, reason);
2918
+ return;
2919
+ }
2920
+ const ctx = this.contextFor(deviceId, `graft-retract:${deviceId}`);
2921
+ if (this.unregister(ctx, deviceId, capName, reason) === "unregistered") this.ports.logger.info("graft left by a previous composer process retracted", {
2922
+ tags: { deviceId },
2923
+ meta: {
2924
+ cap: capName,
2925
+ reason
2926
+ }
2927
+ });
2928
+ }
2929
+ grafted(deviceId) {
2930
+ return this.caps.get(deviceId) ?? /* @__PURE__ */ new Set();
2931
+ }
2932
+ /** A throw keeps the graft known, so a later plan retries (M-2); an older kernel forgets it (warned once). */
2933
+ unregister(ctx, deviceId, capName, reason) {
2934
+ if (ctx.unregisterNativeCap === void 0) {
2935
+ if (!this.warnedNoUnregister.has(deviceId)) {
2936
+ this.warnedNoUnregister = new Set([...this.warnedNoUnregister, deviceId]);
2937
+ this.ports.logger.warn("graft: this kernel cannot unregister a native — the graft stays routable until the composer respawns", {
2938
+ tags: { deviceId },
2939
+ meta: {
2940
+ cap: capName,
2941
+ reason
2942
+ }
2943
+ });
2944
+ }
2945
+ return "unsupported";
2946
+ }
2947
+ try {
2948
+ ctx.unregisterNativeCap(capName);
2949
+ return "unregistered";
2950
+ } catch (err) {
2951
+ this.ports.logger.error("graft: unregistering the added cap threw — it stays routable and is retried by the next plan", {
2952
+ tags: { deviceId },
2953
+ meta: {
2954
+ cap: capName,
2955
+ reason,
2956
+ error: errMsg(err)
2957
+ }
2958
+ });
2959
+ return "threw";
2960
+ }
2961
+ }
2962
+ contextFor(deviceId, stableId) {
2963
+ const existing = this.contexts.get(deviceId);
2964
+ if (existing !== void 0) return existing;
2965
+ const ctx = this.ports.createContext(stableId, deviceId);
2966
+ this.contexts = new Map([...this.contexts, [deviceId, ctx]]);
2967
+ return ctx;
2968
+ }
2969
+ providerFor(deviceId, def) {
2970
+ const answers = Object.entries(def.composedMethods ?? {}).map(([name, answer]) => [name, composedMethod(answer)]);
2971
+ const withStatus = def.status === void 0 && def.runtimeState === void 0 ? [] : [["getStatus", async () => this.readMerged(deviceId, def.name)]];
2972
+ return Object.fromEntries([...answers, ...withStatus]);
2973
+ }
2974
+ refuse(deviceId, capName, reason) {
2975
+ this.ports.logger.error("graft refused — the added cap has no method-serving native", {
2976
+ tags: { deviceId },
2977
+ meta: {
2978
+ cap: capName,
2979
+ reason
2980
+ }
2981
+ });
2982
+ return {
2983
+ kind: "refused",
2984
+ reason
2985
+ };
2986
+ }
2987
+ };
2988
+ //#endregion
2989
+ //#region src/builtins/composer/held-claims.ts
2990
+ function slot(deviceId, cap, owner) {
2991
+ return `${owner}@${deviceId}#${cap}`;
2992
+ }
2993
+ function without(set, key) {
2994
+ return new Set([...set].filter((k) => k !== key));
2995
+ }
2996
+ var HeldClaims = class {
2997
+ held = /* @__PURE__ */ new Set();
2998
+ claiming = /* @__PURE__ */ new Set();
2999
+ /** The `replace` claims the hub holds before this process wrote any (the boot seed). */
3000
+ seed(claims) {
3001
+ const replaced = claims.filter((c) => c.mode === "replace");
3002
+ this.held = new Set([...this.held, ...replaced.map((c) => slot(c.deviceId, c.capName, c.owner))]);
3003
+ }
3004
+ apply(deviceId, cap, owner, event) {
3005
+ const k = slot(deviceId, cap, owner);
3006
+ if (event === "claiming") {
3007
+ this.claiming = new Set([...this.claiming, k]);
3008
+ return;
3009
+ }
3010
+ this.claiming = without(this.claiming, k);
3011
+ if (event === "held") this.held = new Set([...this.held, k]);
3012
+ else if (event === "free") this.held = without(this.held, k);
3013
+ }
3014
+ /** `device.state-changed` of `cap` is NOT the native for `owner`'s self-reads: its claim is held, or being asked for. */
3015
+ shadowsNative(deviceId, cap, owner) {
3016
+ const k = slot(deviceId, cap, owner);
3017
+ return this.held.has(k) || this.claiming.has(k);
3018
+ }
3019
+ };
3020
+ //#endregion
3021
+ //#region src/builtins/composer/source-readings.ts
3022
+ var DEVICE_STATUS_CAP = "device-status";
3023
+ var NATIVE_SUFFIX = "@native";
3024
+ var NO_CAPS = [];
3025
+ function entryKey(entry) {
3026
+ return entry.view === "native" ? `${entry.cap}${NATIVE_SUFFIX}` : entry.cap;
3027
+ }
3028
+ function entryOf(key) {
3029
+ return key.endsWith("@native") ? {
3030
+ cap: key.slice(0, -7),
3031
+ view: "native"
3032
+ } : {
3033
+ cap: key,
3034
+ view: "effective"
3035
+ };
3036
+ }
3037
+ function entriesOf(request) {
3038
+ return [...request.caps.map((cap) => ({
3039
+ cap,
3040
+ view: "effective"
3041
+ })), ...(request.nativeCaps ?? NO_CAPS).map((cap) => ({
3042
+ cap,
3043
+ view: "native"
3044
+ }))];
3045
+ }
3046
+ function valueAtPath(slice, path) {
3047
+ return path.split(".").reduce((node, segment) => isRecord$2(node) ? node[segment] : void 0, slice);
3048
+ }
3049
+ function onlineOf(slice) {
3050
+ const online = slice["online"];
3051
+ return typeof online === "boolean" ? online : null;
3052
+ }
3053
+ function findRow(read, stableId) {
3054
+ if (read.kind === "unreadable") return {
3055
+ kind: "unknown",
3056
+ error: read.error
3057
+ };
3058
+ const row = read.rows.find((r) => r.stableId === stableId);
3059
+ return row === void 0 ? { kind: "absent" } : {
3060
+ kind: "found",
3061
+ deviceId: row.id,
3062
+ type: row.type
3063
+ };
3064
+ }
3065
+ function resolutionOf(source) {
3066
+ if (source.missingReason !== null) return {
3067
+ kind: "missing",
3068
+ reason: source.missingReason
3069
+ };
3070
+ if (source.deviceId !== null && source.type !== null) return {
3071
+ kind: "found",
3072
+ deviceId: source.deviceId,
3073
+ type: source.type
3074
+ };
3075
+ return {
3076
+ kind: "unknown",
3077
+ error: source.indexError ?? "the device index has not been read for it yet"
3078
+ };
3079
+ }
3080
+ //#endregion
1078
3081
  //#region src/builtins/composer/source-tracker.ts
1079
3082
  /**
1080
3083
  * What a composition's sources currently say, per source device and per cap
@@ -1116,29 +3119,35 @@ var NodeDeadlineTimer = class {
1116
3119
  * It is served to nobody else — no cap method, no getter for another addon
1117
3120
  * reads this map; the composed device's slice is the only output. A consumer
1118
3121
  * that wants a source's value asks the addon that owns that source.
3122
+ *
3123
+ * Slice 2 (D663): a cap is read in one of two VIEWS. The `effective` view is
3124
+ * what every consumer sees. The `native` view is what the provider wrote — the
3125
+ * shadow under a `replace` claim — and it is how a composition reads its OWN
3126
+ * target's fields without seeing its own output (rule 6, no self-cycle). The hub
3127
+ * emits the shadow (`device.native-shadow-changed`, owner-only) only for a cap
3128
+ * under a claim, and for an unclaimed cap `device.state-changed` IS the native;
3129
+ * so a native-view cap under the block's OWN claim ignores `device.state-changed`,
3130
+ * and one it does not takes it. What is held is what LANDED (`claimEvent`, the
3131
+ * boot seed), never what a plan wants (C-1); while the block's claim RPC is in
3132
+ * flight, `device.state-changed` is neither (N-1). Every claim or release reply
3133
+ * re-reads the native once — the read that closes the window between the RPC
3134
+ * and its answer (N-4). The target ROW is resolved through the same path as a
3135
+ * source (`resolve`): one list read, two agreeing absent reads to call it
3136
+ * missing, sticky until an operator reset.
3137
+ *
3138
+ * An unavailability is CONFIRMED only when it is an answer (D49, fix round 2):
3139
+ * a GONE row (two reads agreed), a cap the provider unregistered that a
3140
+ * bindings re-read agrees is unbound, a field the source reported without. A
3141
+ * failed read (`unread`, retried by the next `sync` — the converge is the
3142
+ * retry), a first read that finds the cap unbound, an empty read, a source not
3143
+ * tracked yet: unconfirmed. Only a confirmed one lets a `release`-mode field
3144
+ * hand its claim back; an unconfirmed one holds it on its last value.
1119
3145
  */
1120
- var DEVICE_STATUS_CAP = "device-status";
1121
- function valueAtPath(slice, path) {
1122
- return path.split(".").reduce((node, segment) => isRecord(node) ? node[segment] : void 0, slice);
1123
- }
1124
- function onlineOf(slice) {
1125
- const online = slice["online"];
1126
- return typeof online === "boolean" ? online : null;
1127
- }
1128
- function findRow(read, stableId) {
1129
- if (read.kind === "unreadable") return {
1130
- kind: "unknown",
1131
- error: read.error
1132
- };
1133
- const row = read.rows.find((r) => r.stableId === stableId);
1134
- return row === void 0 ? { kind: "absent" } : {
1135
- kind: "found",
1136
- deviceId: row.id
1137
- };
1138
- }
1139
3146
  var SourceTracker = class {
1140
3147
  ports;
1141
3148
  sources = /* @__PURE__ */ new Map();
3149
+ /** The claims the hub holds — what LANDED, never what a plan wants (C-1). */
3150
+ held = new HeldClaims();
1142
3151
  /** The one chain every entry point joins (R3); the pattern is `BlockSupervisor.inFlight`. */
1143
3152
  chain = Promise.resolve();
1144
3153
  constructor(ports) {
@@ -1152,9 +3161,67 @@ var SourceTracker = class {
1152
3161
  sync(requests, reset) {
1153
3162
  return this.serialized(() => this.syncNow(requests, reset));
1154
3163
  }
3164
+ /**
3165
+ * Resolve a ref against the device index through the same path as a source
3166
+ * (D663 rule 1): one list read; two agreeing absent reads make it `missing`,
3167
+ * sticky until `reset` (an operator edit or restart). A source already
3168
+ * resolved is answered from memory; one whose index read FAILED is read
3169
+ * again. The bare source stays tracked, so a later `sync` that names it keeps
3170
+ * its resolution and reads only the caps it asks for.
3171
+ */
3172
+ resolve(ref, reset) {
3173
+ return this.serialized(async () => {
3174
+ const k = compositionSourceKey(ref);
3175
+ const prior = this.sources.get(k);
3176
+ const before = reset ? void 0 : prior;
3177
+ if (before !== void 0 && (before.deviceId !== null || before.missingReason !== null)) return resolutionOf(before);
3178
+ const after = await this.hydrate({
3179
+ ref,
3180
+ caps: []
3181
+ }, before, /* @__PURE__ */ new Map());
3182
+ const kept = prior !== void 0 && after.deviceId !== null && prior.deviceId === after.deviceId ? {
3183
+ ...after,
3184
+ caps: prior.caps,
3185
+ online: prior.online
3186
+ } : after;
3187
+ this.sources = new Map([...this.sources, [k, kept]]);
3188
+ return resolutionOf(kept);
3189
+ });
3190
+ }
3191
+ /** The claims the hub held at boot (the claims index seed). Nothing is read: no native self-read is tracked before it. */
3192
+ seedClaims(claims) {
3193
+ this.held.seed(claims);
3194
+ }
3195
+ /**
3196
+ * A claim RPC of `owner` on `(deviceId, cap)`: dispatched (`claiming`), or
3197
+ * answered — the hub holds it (`held`), holds none of it (`free`), or said
3198
+ * nothing that changes it (`unsettled`). Every ANSWER re-reads the owner's
3199
+ * native-view readings of that cap ONCE: while the RPC was in flight (or a
3200
+ * release was being applied) the provider's writes arrived as a
3201
+ * `device.state-changed` the tracker could not take as the native.
3202
+ */
3203
+ claimEvent(deviceId, cap, owner, event) {
3204
+ return this.serialized(async () => {
3205
+ this.held.apply(deviceId, cap, owner, event);
3206
+ if (event === "claiming") return /* @__PURE__ */ new Set();
3207
+ return this.rereadNative(deviceId, cap, owner);
3208
+ });
3209
+ }
3210
+ /** The last settled resolution of a ref; `unknown` when it was never resolved. Synchronous, from memory. */
3211
+ resolution(ref) {
3212
+ const source = this.sources.get(compositionSourceKey(ref));
3213
+ return source === void 0 ? {
3214
+ kind: "unknown",
3215
+ error: `source \`${compositionSourceKey(ref)}\` is not tracked`
3216
+ } : resolutionOf(source);
3217
+ }
1155
3218
  onStateChanged(deviceId, capName, slice) {
1156
3219
  return this.serialized(async () => this.applyStateChanged(deviceId, capName, slice));
1157
3220
  }
3221
+ /** `device.native-shadow-changed`: the provider's own slice of a cap under a claim (owner-only, D224). */
3222
+ onNativeChanged(deviceId, capName, native) {
3223
+ return this.serialized(async () => this.applyNativeChanged(deviceId, capName, native));
3224
+ }
1158
3225
  onDeviceUnregistered(deviceId) {
1159
3226
  return this.serialized(() => this.confirmUnregistered(deviceId));
1160
3227
  }
@@ -1174,7 +3241,9 @@ var SourceTracker = class {
1174
3241
  const prior = merged.get(k);
1175
3242
  merged.set(k, {
1176
3243
  ref: request.ref,
1177
- caps: [...new Set([...prior?.caps ?? [], ...request.caps])]
3244
+ caps: [...new Set([...prior?.caps ?? [], ...request.caps])],
3245
+ nativeCaps: [...new Set([...prior?.nativeCaps ?? [], ...request.nativeCaps ?? NO_CAPS])],
3246
+ ...(request.nativeOwner ?? prior?.nativeOwner) === void 0 ? {} : { nativeOwner: request.nativeOwner ?? prior?.nativeOwner }
1178
3247
  });
1179
3248
  }
1180
3249
  const lists = /* @__PURE__ */ new Map();
@@ -1184,12 +3253,15 @@ var SourceTracker = class {
1184
3253
  const before = reset.has(k) ? void 0 : this.sources.get(k);
1185
3254
  const after = await this.hydrate(request, before, lists);
1186
3255
  next.set(k, after);
1187
- for (const cap of request.caps) if (before?.caps.get(cap) !== after.caps.get(cap) || before?.missingReason !== after.missingReason) changed.add(compositionSliceKey(request.ref, cap));
3256
+ for (const entry of entriesOf(request)) {
3257
+ const key = entryKey(entry);
3258
+ if (before?.caps.get(key) !== after.caps.get(key) || before?.missingReason !== after.missingReason) changed.add(compositionSliceKey(request.ref, entry.cap));
3259
+ }
1188
3260
  }
1189
3261
  this.sources = next;
1190
3262
  return changed;
1191
3263
  }
1192
- read(read) {
3264
+ read(read, view = "effective") {
1193
3265
  const label = `${compositionSourceKey(read.source)} ${read.cap}.${read.fieldPath}`;
1194
3266
  const tracked = this.sources.get(compositionSourceKey(read.source));
1195
3267
  if (tracked === void 0) return {
@@ -1202,22 +3274,30 @@ var SourceTracker = class {
1202
3274
  reason: tracked.missingReason,
1203
3275
  fatal: true
1204
3276
  };
1205
- const cap = tracked.caps.get(read.cap);
3277
+ const cap = tracked.caps.get(entryKey({
3278
+ cap: read.cap,
3279
+ view
3280
+ }));
1206
3281
  if (cap === void 0) return {
1207
3282
  state: "unavailable",
1208
3283
  reason: `${label}: not tracked yet`,
1209
3284
  fatal: false
1210
3285
  };
1211
- if (cap.kind === "none" || cap.kind === "unbound") return {
1212
- state: "unavailable",
1213
- reason: `${label}: ${cap.reason}`,
1214
- fatal: false
1215
- };
3286
+ if (cap.kind !== "slice") {
3287
+ const confirmed = cap.kind === "none" && cap.confirmed;
3288
+ return {
3289
+ state: "unavailable",
3290
+ reason: `${label}: ${cap.reason}`,
3291
+ fatal: false,
3292
+ confirmed
3293
+ };
3294
+ }
1216
3295
  const value = toExpressionValue(valueAtPath(cap.slice, read.fieldPath));
1217
3296
  if (value === void 0) return {
1218
3297
  state: "unavailable",
1219
3298
  reason: `${label}: the field is absent or not a scalar`,
1220
- fatal: false
3299
+ fatal: false,
3300
+ confirmed: true
1221
3301
  };
1222
3302
  if (cap.staleReason !== null) return {
1223
3303
  state: "stale",
@@ -1240,22 +3320,28 @@ var SourceTracker = class {
1240
3320
  for (const [k, source] of this.sources) {
1241
3321
  if (source.deviceId !== deviceId || source.missingReason !== null) continue;
1242
3322
  let updated = source;
1243
- if (capName === DEVICE_STATUS_CAP) {
3323
+ if (capName === "device-status") {
1244
3324
  const online = onlineOf(slice);
1245
3325
  if (online !== source.online) {
1246
3326
  updated = {
1247
3327
  ...updated,
1248
3328
  online
1249
3329
  };
1250
- for (const cap of source.caps.keys()) changed.add(compositionSliceKey(source.ref, cap));
3330
+ for (const key of source.caps.keys()) changed.add(compositionSliceKey(source.ref, entryOf(key).cap));
1251
3331
  }
1252
3332
  }
1253
- const current = source.caps.get(capName);
1254
- if (current !== void 0) {
3333
+ const views = source.nativeOwner !== null && this.held.shadowsNative(deviceId, capName, source.nativeOwner) ? ["effective"] : ["effective", "native"];
3334
+ for (const view of views) {
3335
+ const key = entryKey({
3336
+ cap: capName,
3337
+ view
3338
+ });
3339
+ const current = source.caps.get(key);
3340
+ if (current === void 0) continue;
1255
3341
  if (current.kind === "unbound") this.logHealed(deviceId, capName, "device.state-changed");
1256
3342
  updated = {
1257
3343
  ...updated,
1258
- caps: new Map([...updated.caps, [capName, {
3344
+ caps: new Map([...updated.caps, [key, {
1259
3345
  kind: "slice",
1260
3346
  slice,
1261
3347
  staleReason: null
@@ -1268,6 +3354,31 @@ var SourceTracker = class {
1268
3354
  this.sources = next;
1269
3355
  return changed;
1270
3356
  }
3357
+ applyNativeChanged(deviceId, capName, native) {
3358
+ const changed = /* @__PURE__ */ new Set();
3359
+ const next = new Map(this.sources);
3360
+ const key = entryKey({
3361
+ cap: capName,
3362
+ view: "native"
3363
+ });
3364
+ for (const [k, source] of this.sources) {
3365
+ if (source.deviceId !== deviceId || source.missingReason !== null) continue;
3366
+ const current = source.caps.get(key);
3367
+ if (current === void 0) continue;
3368
+ if (current.kind === "unbound") this.logHealed(deviceId, capName, "device.native-shadow-changed");
3369
+ next.set(k, {
3370
+ ...source,
3371
+ caps: new Map([...source.caps, [key, {
3372
+ kind: "slice",
3373
+ slice: native,
3374
+ staleReason: null
3375
+ }]])
3376
+ });
3377
+ changed.add(compositionSliceKey(source.ref, capName));
3378
+ }
3379
+ this.sources = next;
3380
+ return changed;
3381
+ }
1271
3382
  async confirmUnregistered(deviceId) {
1272
3383
  const changed = /* @__PURE__ */ new Set();
1273
3384
  for (const [k, source] of this.sources) {
@@ -1287,8 +3398,9 @@ var SourceTracker = class {
1287
3398
  const next = resolution.kind === "missing" ? {
1288
3399
  ...source,
1289
3400
  deviceId: null,
3401
+ type: null,
1290
3402
  missingReason: `source \`${k}\` (device ${deviceId}) was removed — two reads agreed`
1291
- } : await this.rereadUnderNewId(source, resolution.deviceId);
3403
+ } : await this.rereadUnderNewId(source, resolution);
1292
3404
  this.ports.logger.warn(resolution.kind === "missing" ? "composition source removed — its fields are unavailable until the operator acts" : "composition source re-registered under a new id — re-read under it", {
1293
3405
  tags: { deviceId },
1294
3406
  meta: {
@@ -1297,43 +3409,61 @@ var SourceTracker = class {
1297
3409
  }
1298
3410
  });
1299
3411
  this.sources = new Map([...this.sources, [k, next]]);
1300
- for (const cap of source.caps.keys()) changed.add(compositionSliceKey(source.ref, cap));
3412
+ for (const key of source.caps.keys()) changed.add(compositionSliceKey(source.ref, entryOf(key).cap));
1301
3413
  }
1302
3414
  return changed;
1303
3415
  }
1304
3416
  async applyBindingsChanged(deviceId, capName, reason) {
1305
3417
  const changed = /* @__PURE__ */ new Set();
3418
+ let confirm = null;
1306
3419
  for (const [k, source] of this.sources) {
1307
3420
  if (source.deviceId !== deviceId || source.missingReason !== null) continue;
1308
- const current = source.caps.get(capName);
3421
+ const entries = [{
3422
+ cap: capName,
3423
+ view: "effective"
3424
+ }, {
3425
+ cap: capName,
3426
+ view: "native"
3427
+ }];
1309
3428
  let updated = source;
1310
- if (reason === "native-unregistered") {
1311
- if (current?.kind === "slice") {
1312
- const reading = {
3429
+ let healed = false;
3430
+ for (const entry of entries) {
3431
+ const key = entryKey(entry);
3432
+ const current = source.caps.get(key);
3433
+ if (current === void 0) continue;
3434
+ if (reason === "native-unregistered") {
3435
+ confirm ??= (await this.readBindings(deviceId, [capName])).get(capName) ?? "unknown";
3436
+ const reading = confirm === "bound" ? await this.readCap(deviceId, entry, "bound", current) : {
1313
3437
  kind: "none",
1314
- reason: `the provider unregistered \`${capName}\` — no value until it registers again`
3438
+ reason: confirm === "unbound" ? `the provider unregistered \`${capName}\` — a bindings re-read agreed; no value until it registers again` : `the provider unregistered \`${capName}\` — not confirmed (the bindings re-read failed); no value, nothing released`,
3439
+ confirmed: confirm === "unbound"
1315
3440
  };
1316
3441
  updated = {
1317
3442
  ...updated,
1318
- caps: new Map([...updated.caps, [capName, reading]])
3443
+ caps: new Map([...updated.caps, [key, reading]])
1319
3444
  };
3445
+ continue;
1320
3446
  }
1321
- } else {
1322
- const reading = current !== void 0 ? await this.readCap(deviceId, capName, "bound", current) : null;
1323
- if (reading !== null) updated = {
3447
+ const reading = await this.readCap(deviceId, entry, "bound", current);
3448
+ updated = {
1324
3449
  ...updated,
1325
- caps: new Map([...updated.caps, [capName, reading]])
3450
+ caps: new Map([...updated.caps, [key, reading]])
1326
3451
  };
1327
- if (current?.kind === "unbound" && reading !== null && reading.kind !== "unbound") this.logHealed(deviceId, capName, reason);
1328
- if (capName === DEVICE_STATUS_CAP) {
1329
- const online = reading === null ? await this.readOnline(deviceId) : reading.kind === "slice" ? onlineOf(reading.slice) : null;
1330
- if (online !== null && online !== source.online) {
1331
- updated = {
1332
- ...updated,
1333
- online
1334
- };
1335
- for (const cap of source.caps.keys()) changed.add(compositionSliceKey(source.ref, cap));
1336
- }
3452
+ if (current.kind === "unbound" && reading.kind !== "unbound") healed = true;
3453
+ }
3454
+ if (healed) this.logHealed(deviceId, capName, reason);
3455
+ if (reason !== "native-unregistered" && capName === "device-status") {
3456
+ const reading = updated.caps.get(entryKey({
3457
+ cap: capName,
3458
+ view: "effective"
3459
+ }));
3460
+ const online = reading === void 0 ? await this.readOnline(deviceId) : reading.kind === "slice" ? onlineOf(reading.slice) : null;
3461
+ if (online !== null && online !== source.online) {
3462
+ updated = {
3463
+ ...updated,
3464
+ online
3465
+ };
3466
+ for (const key of source.caps.keys()) changed.add(compositionSliceKey(source.ref, entryOf(key).cap));
1337
3467
  }
1338
3468
  }
1339
3469
  if (updated === source) continue;
@@ -1343,26 +3473,33 @@ var SourceTracker = class {
1343
3473
  return changed;
1344
3474
  }
1345
3475
  /** A source found under a NEW id is a re-registration: read it there now (R7c). */
1346
- async rereadUnderNewId(source, deviceId) {
3476
+ async rereadUnderNewId(source, found) {
1347
3477
  const caps = /* @__PURE__ */ new Map();
1348
- for (const [cap, prior] of source.caps) caps.set(cap, await this.readCap(deviceId, cap, "bound", prior));
1349
- const online = await this.readOnline(deviceId) ?? source.online;
3478
+ for (const [key, prior] of source.caps) caps.set(key, await this.readCap(found.deviceId, entryOf(key), "bound", prior));
3479
+ const online = await this.readOnline(found.deviceId) ?? source.online;
1350
3480
  return {
1351
3481
  ...source,
1352
- deviceId,
3482
+ deviceId: found.deviceId,
3483
+ type: found.type,
1353
3484
  online,
1354
3485
  caps
1355
3486
  };
1356
3487
  }
1357
3488
  async hydrate(request, before, lists) {
3489
+ const entries = entriesOf(request);
1358
3490
  if (before !== void 0 && before.missingReason !== null) return before;
1359
3491
  if (before !== void 0 && before.deviceId !== null) {
1360
- const added = request.caps.filter((cap) => !before.caps.has(cap));
1361
- if (added.length === 0 && request.caps.length === before.caps.size) return before;
1362
- const caps = await this.readCaps(before.deviceId, request.caps, before.caps, added);
3492
+ const toRead = entries.filter((entry) => {
3493
+ const held = before.caps.get(entryKey(entry));
3494
+ return held === void 0 || held.kind === "unread";
3495
+ });
3496
+ const nativeOwner = request.nativeOwner ?? null;
3497
+ if (toRead.length === 0 && entries.length === before.caps.size && nativeOwner === before.nativeOwner) return before;
3498
+ const caps = await this.readCaps(before.deviceId, entries, before.caps, toRead);
1363
3499
  return {
1364
3500
  ...before,
1365
- caps
3501
+ caps,
3502
+ nativeOwner
1366
3503
  };
1367
3504
  }
1368
3505
  const k = compositionSourceKey(request.ref);
@@ -1374,9 +3511,12 @@ var SourceTracker = class {
1374
3511
  return {
1375
3512
  ref: request.ref,
1376
3513
  deviceId: null,
3514
+ type: null,
1377
3515
  missingReason: `source \`${k}\` does not exist — two reads agreed`,
3516
+ indexError: null,
1378
3517
  online: null,
1379
- caps: /* @__PURE__ */ new Map()
3518
+ caps: /* @__PURE__ */ new Map(),
3519
+ nativeOwner: request.nativeOwner ?? null
1380
3520
  };
1381
3521
  }
1382
3522
  if (resolution.kind === "unknown") {
@@ -1387,34 +3527,41 @@ var SourceTracker = class {
1387
3527
  return {
1388
3528
  ref: request.ref,
1389
3529
  deviceId: null,
3530
+ type: null,
1390
3531
  missingReason: null,
3532
+ indexError: resolution.error,
1391
3533
  online: null,
1392
- caps: new Map(request.caps.map((cap) => [cap, {
1393
- kind: "none",
3534
+ caps: new Map(entries.map((entry) => [entryKey(entry), {
3535
+ kind: "unread",
1394
3536
  reason: `the source could not be resolved: ${resolution.error}`
1395
- }]))
3537
+ }])),
3538
+ nativeOwner: request.nativeOwner ?? null
1396
3539
  };
1397
3540
  }
1398
- const deviceId = resolution.deviceId;
1399
3541
  const known = before?.caps ?? /* @__PURE__ */ new Map();
1400
- const toRead = request.caps.filter((cap) => known.get(cap)?.kind !== "slice");
1401
- const caps = await this.readCaps(deviceId, request.caps, known, toRead);
1402
- const online = before?.online ?? await this.readOnline(deviceId);
3542
+ const toRead = entries.filter((entry) => known.get(entryKey(entry))?.kind !== "slice");
3543
+ const caps = await this.readCaps(resolution.deviceId, entries, known, toRead);
3544
+ const online = before?.online ?? await this.readOnline(resolution.deviceId);
1403
3545
  return {
1404
3546
  ref: request.ref,
1405
- deviceId,
3547
+ deviceId: resolution.deviceId,
3548
+ type: resolution.type,
1406
3549
  missingReason: null,
3550
+ indexError: null,
1407
3551
  online,
1408
- caps
3552
+ caps,
3553
+ nativeOwner: request.nativeOwner ?? null
1409
3554
  };
1410
3555
  }
1411
- /** Read `toRead` (bindings first, then slices); every other requested cap keeps what `known` holds. */
3556
+ /** Read `toRead` (bindings first, then slices); every other requested entry keeps what `known` holds. */
1412
3557
  async readCaps(deviceId, requested, known, toRead) {
1413
- const bound = toRead.length > 0 ? await this.readBindings(deviceId, toRead) : /* @__PURE__ */ new Map();
3558
+ const bound = toRead.length > 0 ? await this.readBindings(deviceId, [...new Set(toRead.map((e) => e.cap))]) : /* @__PURE__ */ new Map();
3559
+ const reading = new Set(toRead.map(entryKey));
1414
3560
  const caps = /* @__PURE__ */ new Map();
1415
- for (const cap of requested) {
1416
- const prior = known.get(cap);
1417
- caps.set(cap, prior !== void 0 && !toRead.includes(cap) ? prior : await this.readCap(deviceId, cap, bound.get(cap) ?? "unknown", prior));
3561
+ for (const entry of requested) {
3562
+ const key = entryKey(entry);
3563
+ const prior = known.get(key);
3564
+ caps.set(key, prior !== void 0 && !reading.has(key) ? prior : await this.readCap(deviceId, entry, bound.get(entry.cap) ?? "unknown", prior));
1418
3565
  }
1419
3566
  return caps;
1420
3567
  }
@@ -1429,7 +3576,10 @@ var SourceTracker = class {
1429
3576
  });
1430
3577
  return second;
1431
3578
  }
1432
- return second.kind === "absent" ? { kind: "missing" } : second;
3579
+ return second.kind === "absent" ? {
3580
+ kind: "missing",
3581
+ reason: `source \`${compositionSourceKey(ref)}\` does not exist`
3582
+ } : second;
1433
3583
  }
1434
3584
  /**
1435
3585
  * One bindings read. A second one would not confirm anything: back to back
@@ -1443,13 +3593,15 @@ var SourceTracker = class {
1443
3593
  return [c, read.caps.has(c) ? "bound" : "unbound"];
1444
3594
  }));
1445
3595
  }
1446
- async readCap(deviceId, cap, binding, prior) {
3596
+ async readCap(deviceId, entry, binding, prior) {
3597
+ const { cap, view } = entry;
1447
3598
  if (binding === "unbound") {
1448
3599
  const reason = `capability \`${cap}\` is not bound on device ${deviceId} — waiting for \`native-registered\``;
1449
3600
  if (prior?.kind !== "unbound") this.ports.logger.warn("composition source: a requested capability is not bound — the field is unavailable until it registers", {
1450
3601
  tags: { deviceId },
1451
3602
  meta: {
1452
3603
  cap,
3604
+ view,
1453
3605
  reason
1454
3606
  }
1455
3607
  });
@@ -1458,34 +3610,67 @@ var SourceTracker = class {
1458
3610
  reason
1459
3611
  };
1460
3612
  }
1461
- const read = await this.ports.readSlice(deviceId, cap);
3613
+ const read = await this.ports.readSlice(deviceId, cap, view);
1462
3614
  if (read.kind === "slice") return {
1463
3615
  kind: "slice",
1464
3616
  slice: read.slice,
1465
3617
  staleReason: null
1466
3618
  };
1467
- if (read.kind === "none") return prior?.kind === "slice" ? {
1468
- ...prior,
1469
- staleReason: `a re-read found no \`${cap}\` report — the last value is kept`
1470
- } : {
1471
- kind: "none",
1472
- reason: `the source has not reported \`${cap}\` yet`
1473
- };
3619
+ if (read.kind === "none") {
3620
+ if (prior?.kind === "slice") return {
3621
+ ...prior,
3622
+ staleReason: `a re-read found no \`${cap}\` report — the last value is kept`
3623
+ };
3624
+ return {
3625
+ kind: "none",
3626
+ reason: `the source has not reported \`${cap}\` yet`,
3627
+ confirmed: false
3628
+ };
3629
+ }
1474
3630
  this.ports.logger.warn("composition source: a slice read failed — the last value is kept", {
1475
3631
  tags: { deviceId },
1476
3632
  meta: {
1477
3633
  cap,
3634
+ view,
1478
3635
  error: read.error
1479
3636
  }
1480
3637
  });
1481
- return prior?.kind === "slice" ? {
3638
+ if (prior?.kind === "slice") return {
1482
3639
  ...prior,
1483
3640
  staleReason: `re-read failed: ${read.error}`
1484
- } : {
1485
- kind: "none",
3641
+ };
3642
+ if (prior !== void 0 && prior.kind !== "unread") return prior;
3643
+ return {
3644
+ kind: "unread",
1486
3645
  reason: `the slice could not be read: ${read.error}`
1487
3646
  };
1488
3647
  }
3648
+ /** A claim or release of `owner` on `(deviceId, cap)` was answered: read the owner's native-view readings of it once, in place. */
3649
+ async rereadNative(deviceId, cap, owner) {
3650
+ const changed = /* @__PURE__ */ new Set();
3651
+ const key = entryKey({
3652
+ cap,
3653
+ view: "native"
3654
+ });
3655
+ for (const [k, source] of this.sources) {
3656
+ if (source.deviceId !== deviceId || source.missingReason !== null) continue;
3657
+ if (source.nativeOwner !== owner) continue;
3658
+ const current = source.caps.get(key);
3659
+ if (current === void 0) continue;
3660
+ const reading = await this.readCap(deviceId, {
3661
+ cap,
3662
+ view: "native"
3663
+ }, "bound", current);
3664
+ if (reading === current) continue;
3665
+ const latest = this.sources.get(k) ?? source;
3666
+ this.sources = new Map([...this.sources, [k, {
3667
+ ...latest,
3668
+ caps: new Map([...latest.caps, [key, reading]])
3669
+ }]]);
3670
+ changed.add(compositionSliceKey(source.ref, cap));
3671
+ }
3672
+ return changed;
3673
+ }
1489
3674
  logHealed(deviceId, cap, via) {
1490
3675
  this.ports.logger.info("composition source: an unbound capability is served again", {
1491
3676
  tags: { deviceId },
@@ -1496,7 +3681,7 @@ var SourceTracker = class {
1496
3681
  });
1497
3682
  }
1498
3683
  async readOnline(deviceId) {
1499
- const read = await this.ports.readSlice(deviceId, DEVICE_STATUS_CAP);
3684
+ const read = await this.ports.readSlice(deviceId, DEVICE_STATUS_CAP, "effective");
1500
3685
  if (read.kind === "unreadable") this.ports.logger.warn("composition source: `device-status` could not be read — online unknown", {
1501
3686
  tags: { deviceId },
1502
3687
  meta: { error: read.error }
@@ -1514,9 +3699,28 @@ var SourceTracker = class {
1514
3699
  * enforced by `scripts/check-runner-group-isolation.ts`). `CrashSupervisor`
1515
3700
  * bounds it like every forked runner (D6). Hub-only: declarations are cluster
1516
3701
  * state.
3702
+ *
3703
+ * Slice 2 (D663): a composition on an EXISTING device claims its fields on the
3704
+ * hub (`deviceState.claimFields` …) and reads its own replaced fields from the
3705
+ * native shadow (`getNativeCapSlice`, `device.native-shadow-changed`) — the one
3706
+ * subscriber that category has (`scripts/check-owner-only-events.ts`).
1517
3707
  */
1518
3708
  /** Ignored by `DeclaredDevices` whenever `integrationId` is supplied, which is always here. */
1519
3709
  var DECLARED_INTEGRATION_NAME_UNUSED = "Blocks";
3710
+ /** The owner-only shadow payload is not in the typed catalog: narrowed at the boundary, like `device.state-changed`. */
3711
+ function parseNativeShadowChanged(data) {
3712
+ if (!isRecord$2(data)) return null;
3713
+ const deviceId = data["deviceId"];
3714
+ const capName = data["capName"];
3715
+ const native = data["native"];
3716
+ if (typeof deviceId !== "number" || typeof capName !== "string") return null;
3717
+ if (!isRecord$2(native)) return null;
3718
+ return {
3719
+ deviceId,
3720
+ capName,
3721
+ native
3722
+ };
3723
+ }
1520
3724
  var ComposerAddon = class extends BaseAddon {
1521
3725
  composer = null;
1522
3726
  constructor() {
@@ -1525,6 +3729,24 @@ var ComposerAddon = class extends BaseAddon {
1525
3729
  async onInitialize() {
1526
3730
  const api = this.ctx.api;
1527
3731
  const logger = this.ctx.logger;
3732
+ /** ONE `getBindings`: the tracker's binding check and the target description share it. */
3733
+ const readBoundCaps = async (deviceId) => {
3734
+ try {
3735
+ const bindings = await api.deviceManager.getBindings.query({ deviceId });
3736
+ return {
3737
+ kind: "caps",
3738
+ caps: new Map(bindings.entries.flatMap((entry) => {
3739
+ const owner = normalizedNativeOwner(entry.nativeAddonId);
3740
+ return owner === null ? [] : [[entry.capName, owner]];
3741
+ }))
3742
+ };
3743
+ } catch (err) {
3744
+ return {
3745
+ kind: "unreadable",
3746
+ error: errMsg(err)
3747
+ };
3748
+ }
3749
+ };
1528
3750
  const tracker = new SourceTracker({
1529
3751
  logger: logger.child("sources"),
1530
3752
  listDevicesOf: async (addonId) => {
@@ -1536,7 +3758,8 @@ var ComposerAddon = class extends BaseAddon {
1536
3758
  projection: "slim"
1537
3759
  })).map((r) => ({
1538
3760
  id: r.id,
1539
- stableId: r.stableId
3761
+ stableId: r.stableId,
3762
+ type: r.type
1540
3763
  }))
1541
3764
  };
1542
3765
  } catch (err) {
@@ -1546,9 +3769,12 @@ var ComposerAddon = class extends BaseAddon {
1546
3769
  };
1547
3770
  }
1548
3771
  },
1549
- readSlice: async (deviceId, capName) => {
3772
+ readSlice: async (deviceId, capName, view) => {
1550
3773
  try {
1551
- const slice = await api.deviceState.getCapSlice.query({
3774
+ const slice = view === "native" ? await api.deviceState.getNativeCapSlice.query({
3775
+ deviceId,
3776
+ capName
3777
+ }) : await api.deviceState.getCapSlice.query({
1552
3778
  deviceId,
1553
3779
  capName
1554
3780
  });
@@ -1563,20 +3789,7 @@ var ComposerAddon = class extends BaseAddon {
1563
3789
  };
1564
3790
  }
1565
3791
  },
1566
- readBoundCaps: async (deviceId) => {
1567
- try {
1568
- const bindings = await api.deviceManager.getBindings.query({ deviceId });
1569
- return {
1570
- kind: "caps",
1571
- caps: new Set(bindings.entries.map((e) => e.capName))
1572
- };
1573
- } catch (err) {
1574
- return {
1575
- kind: "unreadable",
1576
- error: errMsg(err)
1577
- };
1578
- }
1579
- }
3792
+ readBoundCaps
1580
3793
  });
1581
3794
  const listOwnDevices = async () => api.deviceManager.listAll.query({ addonId: COMPOSER_ADDON_ID });
1582
3795
  const declared = new DeclaredDevices({
@@ -1596,10 +3809,18 @@ var ComposerAddon = class extends BaseAddon {
1596
3809
  listOwnDevices
1597
3810
  });
1598
3811
  const devices = this.ctx.kernel.devices;
3812
+ const lookupCap = buildCapabilityLookup(ALL_CAPABILITY_DEFINITIONS);
3813
+ const grafts = new GraftHost({
3814
+ createContext: (stableId, id) => devices.createContext(stableId, id),
3815
+ logger: logger.child("graft")
3816
+ }, lookupCap, async (deviceId, capName) => await api.deviceState.getCapSlice.query({
3817
+ deviceId,
3818
+ capName
3819
+ }) ?? null);
1599
3820
  this.composer = new Composer({
1600
3821
  logger: logger.child("composer"),
1601
3822
  tracker,
1602
- lookupCap: buildCapabilityLookup(ALL_CAPABILITY_DEFINITIONS),
3823
+ lookupCap,
1603
3824
  readPersisted: async () => (await listOwnDevices()).map((r) => ({
1604
3825
  id: r.id,
1605
3826
  stableId: r.stableId
@@ -1623,6 +3844,19 @@ var ComposerAddon = class extends BaseAddon {
1623
3844
  decommission: (deviceId) => devices.decommission(deviceId),
1624
3845
  applyDerivedSpec: (deviceId, derived) => devices.applyDerivedSpec(deviceId, derived)
1625
3846
  },
3847
+ claims: {
3848
+ claimFields: (input) => api.deviceState.claimFields.mutate(input),
3849
+ patchOwnedFields: (input) => api.deviceState.patchOwnedFields.mutate(input),
3850
+ releaseClaim: (input) => api.deviceState.releaseClaim.mutate(input),
3851
+ listClaims: (input) => api.deviceState.listClaims.query(input)
3852
+ },
3853
+ events: this.ctx.eventBus,
3854
+ readExistingTargetCap: async (deviceId, capName) => await api.deviceState.getCapSlice.query({
3855
+ deviceId,
3856
+ capName
3857
+ }) ?? null,
3858
+ describeTarget: readBoundCaps,
3859
+ grafts,
1626
3860
  now: () => Date.now(),
1627
3861
  newTimer: () => new NodeDeadlineTimer(() => Date.now())
1628
3862
  });
@@ -1632,6 +3866,12 @@ var ComposerAddon = class extends BaseAddon {
1632
3866
  this.logEventFailure("device.state-changed", data.deviceId, err);
1633
3867
  });
1634
3868
  });
3869
+ this.subscribe({ category: EventCategory.DeviceNativeShadowChanged }, (event) => {
3870
+ const data = parseNativeShadowChanged(event.data);
3871
+ if (data !== null) this.composer?.onNativeChanged(data).catch((err) => {
3872
+ this.logEventFailure(EventCategory.DeviceNativeShadowChanged, data.deviceId, err);
3873
+ });
3874
+ });
1635
3875
  this.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
1636
3876
  this.composer?.onDeviceUnregistered(event.data.deviceId).catch((err) => {
1637
3877
  this.logEventFailure("device.unregistered", event.data.deviceId, err);