@camstack/system 1.2.313 → 1.2.314

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