@camstack/addon-matter-broker 0.2.36 → 0.2.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.js CHANGED
@@ -5930,6 +5930,40 @@ var BaseAddon = class {
5930
5930
  deviceSettingsSchema() {
5931
5931
  return null;
5932
5932
  }
5933
+ /**
5934
+ * INTEGRATION-LEVEL SETTINGS — declare which of this addon's global sections
5935
+ * ARE the configuration of its integration.
5936
+ *
5937
+ * Return the `ConfigSection.id`s, from {@link globalSettingsSchema}, that an
5938
+ * operator should find on the addon's integration page (System →
5939
+ * Integrations → <name>) rather than only in the cluster-wide list of every
5940
+ * addon. Empty (the default) means the addon has no integration-level
5941
+ * settings and no such surface is offered — this is opt-in, because whether
5942
+ * an addon's configuration IS its integration's configuration depends on the
5943
+ * nature of the integration.
5944
+ *
5945
+ * WHAT THIS IS NOT. It is not a scope. The selected sections keep living in
5946
+ * the ONE global schema, in the ONE addon store, written by the ONE
5947
+ * `updateGlobalSettings` path. There is deliberately no
5948
+ * `updateIntegrationSettings`: a second write path is how a surface acquires
5949
+ * a second store key, and this repo has shipped that twice (`btmPath@hub`,
5950
+ * D266). Selecting sections cannot introduce a key that selecting cannot.
5951
+ *
5952
+ * WHY IT IS A LIST OF SECTION IDS AND NOT A MARKER ON THE SECTION.
5953
+ * `ConfigFieldBase` used to carry `scope?: 'device' | 'global'` and it was
5954
+ * removed with the reason recorded at
5955
+ * `packages/types/src/interfaces/config-ui.ts:249` — *"a field's scope is
5956
+ * determined by WHICH schema it lives in, not by a field-level marker."* A
5957
+ * marker sprinkled across sections also has to borrow a field that already
5958
+ * means something else; borrowing `section.tab` put the literal word
5959
+ * "integration" into an operator-facing tab bar, because `tab` means "how to
5960
+ * GROUP this visually" and cannot also mean "where this lives" (D269
5961
+ * supersedes D268). One declaration, in one place, next to the schema whose
5962
+ * ids it names.
5963
+ */
5964
+ integrationSettingSections() {
5965
+ return [];
5966
+ }
5933
5967
  async getGlobalSettings(overlay, cap, nodeId) {
5934
5968
  const schema = this.globalSettingsSchema(cap);
5935
5969
  if (!schema) return { sections: [] };
@@ -5940,6 +5974,55 @@ var BaseAddon = class {
5940
5974
  } : projected);
5941
5975
  }
5942
5976
  /**
5977
+ * The integration-level view of this addon's settings: exactly the sections
5978
+ * named by {@link integrationSettingSections}, hydrated from the SAME store
5979
+ * `getGlobalSettings` reads, and narrowed to cluster-scoped fields.
5980
+ *
5981
+ * Returns `null` when the addon declared nothing — an addon that opts out has
5982
+ * no integration settings surface at all, rather than an empty one that reads
5983
+ * as a failed load.
5984
+ *
5985
+ * Three properties hold BY CONSTRUCTION, which is why they are here in core
5986
+ * and not in whichever UI happens to render this:
5987
+ *
5988
+ * 1. **One key.** The payload is a SUBSET of the global schema, so a field
5989
+ * shown here is the same field, with the same bare key, that the addon's
5990
+ * own page shows. There is no integration-specific writer — callers save
5991
+ * through `updateGlobalSettings` — so a second store key is unreachable,
5992
+ * not merely discouraged.
5993
+ * 2. **No node scope.** `perNode: true` fields are DROPPED. Their store key
5994
+ * is `<key>@<nodeId>` and an integration is not a node; whichever node
5995
+ * such a field silently picked would be a wrong answer for the operator
5996
+ * who opened the page (D266).
5997
+ * 3. **No silent typo.** A declared id that names no section throws. The
5998
+ * alternative — skip it — turns a rename into a surface that quietly
5999
+ * empties, which looks exactly like an addon with nothing to configure.
6000
+ */
6001
+ async getIntegrationSettings(nodeId) {
6002
+ const declared = this.integrationSettingSections();
6003
+ if (declared.length === 0) return null;
6004
+ const schema = this.globalSettingsSchema();
6005
+ if (!schema) throw new Error(`${this.constructor.name}: integrationSettingSections() names [${declared.join(", ")}] but globalSettingsSchema() returns null.`);
6006
+ const byId = new Map(schema.sections.map((section) => [section.id, section]));
6007
+ const sections = [];
6008
+ for (const id of declared) {
6009
+ const section = byId.get(id);
6010
+ if (!section) throw new Error(`${this.constructor.name}: integrationSettingSections() names unknown section "${id}". Known sections: [${[...byId.keys()].join(", ")}].`);
6011
+ const fields = dropPerNodeFields(section.fields);
6012
+ if (fields.length === 0) continue;
6013
+ sections.push({
6014
+ ...section,
6015
+ fields
6016
+ });
6017
+ }
6018
+ if (sections.length === 0) return null;
6019
+ const projected = await this.resolveGlobalStore(nodeId);
6020
+ return hydrateSchema({
6021
+ ...schema,
6022
+ sections
6023
+ }, projected);
6024
+ }
6025
+ /**
5943
6026
  * The raw addon store PROJECTED onto the target node's bare per-node keys:
5944
6027
  * every `perNode: true` field carries THAT node's scoped value on its bare
5945
6028
  * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
@@ -6243,6 +6326,41 @@ var BaseAddon = class {
6243
6326
  * `hydrateSchema` does. Valueless structural fields (separator/info/…)
6244
6327
  * don't declare `perNode` and are excluded by the `in` narrowing.
6245
6328
  */
6329
+ /**
6330
+ * The same fields with every `perNode: true` one removed, recursing into layout
6331
+ * containers exactly as {@link collectPerNodeFieldKeys} does. A container left
6332
+ * with no child is dropped rather than rendered empty.
6333
+ *
6334
+ * Used by `getIntegrationSettings`: an integration is not a node, so a field
6335
+ * whose store key is `<key>@<nodeId>` has no node to belong to there.
6336
+ */
6337
+ function dropPerNodeFields(fields) {
6338
+ const kept = [];
6339
+ for (const field of fields) {
6340
+ if (field.type === "group") {
6341
+ const inner = dropPerNodeFields(field.fields);
6342
+ if (inner.length > 0) kept.push({
6343
+ ...field,
6344
+ fields: inner
6345
+ });
6346
+ continue;
6347
+ }
6348
+ if (field.type === "sub-tabs") {
6349
+ const tabs = field.tabs.map((tab) => ({
6350
+ ...tab,
6351
+ fields: dropPerNodeFields(tab.fields)
6352
+ })).filter((tab) => tab.fields.length > 0);
6353
+ if (tabs.length > 0) kept.push({
6354
+ ...field,
6355
+ tabs
6356
+ });
6357
+ continue;
6358
+ }
6359
+ if ("perNode" in field && field.perNode === true) continue;
6360
+ kept.push(field);
6361
+ }
6362
+ return kept;
6363
+ }
6246
6364
  function collectPerNodeFieldKeys(fields) {
6247
6365
  const collected = [];
6248
6366
  for (const field of fields) {
@@ -9412,6 +9530,9 @@ method(object({
9412
9530
  kind: "mutation",
9413
9531
  auth: "admin"
9414
9532
  }), method(object({
9533
+ addonId: string$2(),
9534
+ nodeId: string$2().optional()
9535
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9415
9536
  addonId: string$2(),
9416
9537
  deviceId: number(),
9417
9538
  nodeId: string$2().optional()
@@ -13426,6 +13547,114 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13426
13547
  limit: number().optional(),
13427
13548
  tags: record(string$2(), string$2()).optional()
13428
13549
  }), array(LogEntrySchema).readonly());
13550
+ /**
13551
+ * `failure-contribution` — the capability an addon reports its OWN losses
13552
+ * through, per camera, with the denominator attached. It stores nothing.
13553
+ *
13554
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13555
+ *
13556
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13557
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13558
+ * copied: the contributor reports what it already knows, hub-main adds only
13559
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13560
+ * somebody to forget to edit.
13561
+ *
13562
+ * They are not merged, because their invariants are opposites:
13563
+ *
13564
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13565
+ * claim a camera cost nothing, which is a measurement nobody made;
13566
+ * - a `failure-contribution` zero is the **most valuable value on the
13567
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13568
+ * and it is exactly what an absent entry cannot say.
13569
+ *
13570
+ * Putting a loss counter on a cost entry would also break the reconciliation
13571
+ * that gives `load-contribution` its point: contributions are subtracted from
13572
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13573
+ * has no process.
13574
+ *
13575
+ * ## Why not a log line, since the counters already exist
13576
+ *
13577
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13578
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13579
+ * ends in a log line, and a log line is the thing the operator asked to stop
13580
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13581
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13582
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13583
+ * media blackout were both diagnosed. The counters stay; this is where they can
13584
+ * be READ.
13585
+ *
13586
+ * ## The rate is served with its denominator or not at all
13587
+ *
13588
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13589
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13590
+ * than yesterday" and was **flat across twelve hours** once divided by the
13591
+ * successes on the same path. A surface that publishes only the numerator
13592
+ * reproduces that mistake on every read.
13593
+ *
13594
+ * ## Shape
13595
+ *
13596
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13597
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13598
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13599
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13600
+ * a forked runner's entries reach hub-main over transport that already exists.
13601
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13602
+ * result through `system.getFailureContributions`.
13603
+ */
13604
+ var FailureReasonCountSchema = object({
13605
+ /**
13606
+ * Why the attempt did not land, in the contributor's own vocabulary —
13607
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13608
+ * strings that already appear in this repo's logs and, where one exists, the
13609
+ * same string the per-track `previewMissReason` records (D276): a second
13610
+ * vocabulary for the same loss would make the row and the counter
13611
+ * un-joinable.
13612
+ */
13613
+ reason: string$2(),
13614
+ count: number().int().nonnegative()
13615
+ });
13616
+ var FailureContributionSchema = object({
13617
+ /**
13618
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13619
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13620
+ * `unit` free: the families are owned by different addons and a shared enum
13621
+ * is a central list that rots invisibly.
13622
+ */
13623
+ family: string$2(),
13624
+ /**
13625
+ * The NUMERIC device id — the same value every log line carries as
13626
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13627
+ * cannot name the camera must not emit the entry, because a fleet total
13628
+ * cannot answer the only question anybody asks of this surface.
13629
+ */
13630
+ deviceId: number().int().positive(),
13631
+ /**
13632
+ * A second dimension inside the family: the model / step id for an inference
13633
+ * timeout, so "which camera AND which model" is one read. Absent when the
13634
+ * family has a single variant.
13635
+ */
13636
+ variant: string$2().optional(),
13637
+ /**
13638
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13639
+ * differencing two reads must drop the interval when it changes, because the
13640
+ * counter restarted from zero in a respawned runner. Same discipline as
13641
+ * `LoadContribution.startedAtMs`.
13642
+ */
13643
+ sinceMs: number(),
13644
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13645
+ atMs: number(),
13646
+ /**
13647
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13648
+ * window. A failure count published without it is the mistake this schema
13649
+ * exists to make impossible.
13650
+ */
13651
+ attempts: number().int().nonnegative(),
13652
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13653
+ succeeded: number().int().nonnegative(),
13654
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13655
+ reasons: array(FailureReasonCountSchema).readonly()
13656
+ });
13657
+ method(_void(), array(FailureContributionSchema).readonly());
13429
13658
  var LoadContributionSchema = object({
13430
13659
  role: _enum([
13431
13660
  "decode",
@@ -18016,6 +18245,20 @@ var TrackSchema = object({
18016
18245
  * `=== true` and render nothing otherwise — never infer "no rider".
18017
18246
  */
18018
18247
  hasRider: boolean().optional(),
18248
+ /**
18249
+ * WHY this track ended without a NATIVE best-shot tile
18250
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18251
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18252
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18253
+ * the late-keyFrame upgrade when a native tile lands after all. The
18254
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18255
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18256
+ *
18257
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18258
+ * that predates the field, and every track whose tile landed native all
18259
+ * omit it. Render nothing when absent.
18260
+ */
18261
+ previewMissReason: string$2().optional(),
18019
18262
  ...TrackFlagFields,
18020
18263
  ...TrackRetrainFields
18021
18264
  });
@@ -29208,6 +29451,13 @@ var LoggingSettingsPatchSchema = object({
29208
29451
  * anyone but its owner.
29209
29452
  */
29210
29453
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string$2() });
29454
+ /**
29455
+ * One per-camera failure counter, plus WHO reported it.
29456
+ *
29457
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
29458
+ * the hub as it enumerates providers, never by the contributor.
29459
+ */
29460
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string$2() });
29211
29461
  var GetLoggingSettingsInputSchema = object({
29212
29462
  scopeNodeId: string$2().optional(),
29213
29463
  /**
@@ -29266,7 +29516,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
29266
29516
  }), method(_void(), SiteLocationStatusSchema, {
29267
29517
  kind: "mutation",
29268
29518
  auth: "admin"
29269
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
29519
+ }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(_void(), array(ReportedFailureContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
29270
29520
  kind: "mutation",
29271
29521
  auth: "admin"
29272
29522
  });
@@ -31588,6 +31838,12 @@ Object.freeze({
31588
31838
  addonId: null,
31589
31839
  access: "view"
31590
31840
  },
31841
+ "addonSettings.getIntegrationSettings": {
31842
+ capName: "addon-settings",
31843
+ capScope: "system",
31844
+ addonId: null,
31845
+ access: "view"
31846
+ },
31591
31847
  "addonSettings.updateDeviceSettings": {
31592
31848
  capName: "addon-settings",
31593
31849
  capScope: "system",
@@ -33250,6 +33506,12 @@ Object.freeze({
33250
33506
  addonId: null,
33251
33507
  access: "create"
33252
33508
  },
33509
+ "failureContribution.list": {
33510
+ capName: "failure-contribution",
33511
+ capScope: "system",
33512
+ addonId: null,
33513
+ access: "view"
33514
+ },
33253
33515
  "fanControl.setDirection": {
33254
33516
  capName: "fan-control",
33255
33517
  capScope: "device",
@@ -36556,6 +36818,12 @@ Object.freeze({
36556
36818
  addonId: null,
36557
36819
  access: "create"
36558
36820
  },
36821
+ "system.getFailureContributions": {
36822
+ capName: "system",
36823
+ capScope: "system",
36824
+ addonId: null,
36825
+ access: "view"
36826
+ },
36559
36827
  "system.getLoadContributions": {
36560
36828
  capName: "system",
36561
36829
  capScope: "system",
package/dist/addon.mjs CHANGED
@@ -5928,6 +5928,40 @@ var BaseAddon = class {
5928
5928
  deviceSettingsSchema() {
5929
5929
  return null;
5930
5930
  }
5931
+ /**
5932
+ * INTEGRATION-LEVEL SETTINGS — declare which of this addon's global sections
5933
+ * ARE the configuration of its integration.
5934
+ *
5935
+ * Return the `ConfigSection.id`s, from {@link globalSettingsSchema}, that an
5936
+ * operator should find on the addon's integration page (System →
5937
+ * Integrations → <name>) rather than only in the cluster-wide list of every
5938
+ * addon. Empty (the default) means the addon has no integration-level
5939
+ * settings and no such surface is offered — this is opt-in, because whether
5940
+ * an addon's configuration IS its integration's configuration depends on the
5941
+ * nature of the integration.
5942
+ *
5943
+ * WHAT THIS IS NOT. It is not a scope. The selected sections keep living in
5944
+ * the ONE global schema, in the ONE addon store, written by the ONE
5945
+ * `updateGlobalSettings` path. There is deliberately no
5946
+ * `updateIntegrationSettings`: a second write path is how a surface acquires
5947
+ * a second store key, and this repo has shipped that twice (`btmPath@hub`,
5948
+ * D266). Selecting sections cannot introduce a key that selecting cannot.
5949
+ *
5950
+ * WHY IT IS A LIST OF SECTION IDS AND NOT A MARKER ON THE SECTION.
5951
+ * `ConfigFieldBase` used to carry `scope?: 'device' | 'global'` and it was
5952
+ * removed with the reason recorded at
5953
+ * `packages/types/src/interfaces/config-ui.ts:249` — *"a field's scope is
5954
+ * determined by WHICH schema it lives in, not by a field-level marker."* A
5955
+ * marker sprinkled across sections also has to borrow a field that already
5956
+ * means something else; borrowing `section.tab` put the literal word
5957
+ * "integration" into an operator-facing tab bar, because `tab` means "how to
5958
+ * GROUP this visually" and cannot also mean "where this lives" (D269
5959
+ * supersedes D268). One declaration, in one place, next to the schema whose
5960
+ * ids it names.
5961
+ */
5962
+ integrationSettingSections() {
5963
+ return [];
5964
+ }
5931
5965
  async getGlobalSettings(overlay, cap, nodeId) {
5932
5966
  const schema = this.globalSettingsSchema(cap);
5933
5967
  if (!schema) return { sections: [] };
@@ -5938,6 +5972,55 @@ var BaseAddon = class {
5938
5972
  } : projected);
5939
5973
  }
5940
5974
  /**
5975
+ * The integration-level view of this addon's settings: exactly the sections
5976
+ * named by {@link integrationSettingSections}, hydrated from the SAME store
5977
+ * `getGlobalSettings` reads, and narrowed to cluster-scoped fields.
5978
+ *
5979
+ * Returns `null` when the addon declared nothing — an addon that opts out has
5980
+ * no integration settings surface at all, rather than an empty one that reads
5981
+ * as a failed load.
5982
+ *
5983
+ * Three properties hold BY CONSTRUCTION, which is why they are here in core
5984
+ * and not in whichever UI happens to render this:
5985
+ *
5986
+ * 1. **One key.** The payload is a SUBSET of the global schema, so a field
5987
+ * shown here is the same field, with the same bare key, that the addon's
5988
+ * own page shows. There is no integration-specific writer — callers save
5989
+ * through `updateGlobalSettings` — so a second store key is unreachable,
5990
+ * not merely discouraged.
5991
+ * 2. **No node scope.** `perNode: true` fields are DROPPED. Their store key
5992
+ * is `<key>@<nodeId>` and an integration is not a node; whichever node
5993
+ * such a field silently picked would be a wrong answer for the operator
5994
+ * who opened the page (D266).
5995
+ * 3. **No silent typo.** A declared id that names no section throws. The
5996
+ * alternative — skip it — turns a rename into a surface that quietly
5997
+ * empties, which looks exactly like an addon with nothing to configure.
5998
+ */
5999
+ async getIntegrationSettings(nodeId) {
6000
+ const declared = this.integrationSettingSections();
6001
+ if (declared.length === 0) return null;
6002
+ const schema = this.globalSettingsSchema();
6003
+ if (!schema) throw new Error(`${this.constructor.name}: integrationSettingSections() names [${declared.join(", ")}] but globalSettingsSchema() returns null.`);
6004
+ const byId = new Map(schema.sections.map((section) => [section.id, section]));
6005
+ const sections = [];
6006
+ for (const id of declared) {
6007
+ const section = byId.get(id);
6008
+ if (!section) throw new Error(`${this.constructor.name}: integrationSettingSections() names unknown section "${id}". Known sections: [${[...byId.keys()].join(", ")}].`);
6009
+ const fields = dropPerNodeFields(section.fields);
6010
+ if (fields.length === 0) continue;
6011
+ sections.push({
6012
+ ...section,
6013
+ fields
6014
+ });
6015
+ }
6016
+ if (sections.length === 0) return null;
6017
+ const projected = await this.resolveGlobalStore(nodeId);
6018
+ return hydrateSchema({
6019
+ ...schema,
6020
+ sections
6021
+ }, projected);
6022
+ }
6023
+ /**
5941
6024
  * The raw addon store PROJECTED onto the target node's bare per-node keys:
5942
6025
  * every `perNode: true` field carries THAT node's scoped value on its bare
5943
6026
  * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
@@ -6241,6 +6324,41 @@ var BaseAddon = class {
6241
6324
  * `hydrateSchema` does. Valueless structural fields (separator/info/…)
6242
6325
  * don't declare `perNode` and are excluded by the `in` narrowing.
6243
6326
  */
6327
+ /**
6328
+ * The same fields with every `perNode: true` one removed, recursing into layout
6329
+ * containers exactly as {@link collectPerNodeFieldKeys} does. A container left
6330
+ * with no child is dropped rather than rendered empty.
6331
+ *
6332
+ * Used by `getIntegrationSettings`: an integration is not a node, so a field
6333
+ * whose store key is `<key>@<nodeId>` has no node to belong to there.
6334
+ */
6335
+ function dropPerNodeFields(fields) {
6336
+ const kept = [];
6337
+ for (const field of fields) {
6338
+ if (field.type === "group") {
6339
+ const inner = dropPerNodeFields(field.fields);
6340
+ if (inner.length > 0) kept.push({
6341
+ ...field,
6342
+ fields: inner
6343
+ });
6344
+ continue;
6345
+ }
6346
+ if (field.type === "sub-tabs") {
6347
+ const tabs = field.tabs.map((tab) => ({
6348
+ ...tab,
6349
+ fields: dropPerNodeFields(tab.fields)
6350
+ })).filter((tab) => tab.fields.length > 0);
6351
+ if (tabs.length > 0) kept.push({
6352
+ ...field,
6353
+ tabs
6354
+ });
6355
+ continue;
6356
+ }
6357
+ if ("perNode" in field && field.perNode === true) continue;
6358
+ kept.push(field);
6359
+ }
6360
+ return kept;
6361
+ }
6244
6362
  function collectPerNodeFieldKeys(fields) {
6245
6363
  const collected = [];
6246
6364
  for (const field of fields) {
@@ -9410,6 +9528,9 @@ method(object({
9410
9528
  kind: "mutation",
9411
9529
  auth: "admin"
9412
9530
  }), method(object({
9531
+ addonId: string$2(),
9532
+ nodeId: string$2().optional()
9533
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9413
9534
  addonId: string$2(),
9414
9535
  deviceId: number(),
9415
9536
  nodeId: string$2().optional()
@@ -13424,6 +13545,114 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13424
13545
  limit: number().optional(),
13425
13546
  tags: record(string$2(), string$2()).optional()
13426
13547
  }), array(LogEntrySchema).readonly());
13548
+ /**
13549
+ * `failure-contribution` — the capability an addon reports its OWN losses
13550
+ * through, per camera, with the denominator attached. It stores nothing.
13551
+ *
13552
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13553
+ *
13554
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13555
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13556
+ * copied: the contributor reports what it already knows, hub-main adds only
13557
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13558
+ * somebody to forget to edit.
13559
+ *
13560
+ * They are not merged, because their invariants are opposites:
13561
+ *
13562
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13563
+ * claim a camera cost nothing, which is a measurement nobody made;
13564
+ * - a `failure-contribution` zero is the **most valuable value on the
13565
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13566
+ * and it is exactly what an absent entry cannot say.
13567
+ *
13568
+ * Putting a loss counter on a cost entry would also break the reconciliation
13569
+ * that gives `load-contribution` its point: contributions are subtracted from
13570
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13571
+ * has no process.
13572
+ *
13573
+ * ## Why not a log line, since the counters already exist
13574
+ *
13575
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13576
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13577
+ * ends in a log line, and a log line is the thing the operator asked to stop
13578
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13579
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13580
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13581
+ * media blackout were both diagnosed. The counters stay; this is where they can
13582
+ * be READ.
13583
+ *
13584
+ * ## The rate is served with its denominator or not at all
13585
+ *
13586
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13587
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13588
+ * than yesterday" and was **flat across twelve hours** once divided by the
13589
+ * successes on the same path. A surface that publishes only the numerator
13590
+ * reproduces that mistake on every read.
13591
+ *
13592
+ * ## Shape
13593
+ *
13594
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13595
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13596
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13597
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13598
+ * a forked runner's entries reach hub-main over transport that already exists.
13599
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13600
+ * result through `system.getFailureContributions`.
13601
+ */
13602
+ var FailureReasonCountSchema = object({
13603
+ /**
13604
+ * Why the attempt did not land, in the contributor's own vocabulary —
13605
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13606
+ * strings that already appear in this repo's logs and, where one exists, the
13607
+ * same string the per-track `previewMissReason` records (D276): a second
13608
+ * vocabulary for the same loss would make the row and the counter
13609
+ * un-joinable.
13610
+ */
13611
+ reason: string$2(),
13612
+ count: number().int().nonnegative()
13613
+ });
13614
+ var FailureContributionSchema = object({
13615
+ /**
13616
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13617
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13618
+ * `unit` free: the families are owned by different addons and a shared enum
13619
+ * is a central list that rots invisibly.
13620
+ */
13621
+ family: string$2(),
13622
+ /**
13623
+ * The NUMERIC device id — the same value every log line carries as
13624
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13625
+ * cannot name the camera must not emit the entry, because a fleet total
13626
+ * cannot answer the only question anybody asks of this surface.
13627
+ */
13628
+ deviceId: number().int().positive(),
13629
+ /**
13630
+ * A second dimension inside the family: the model / step id for an inference
13631
+ * timeout, so "which camera AND which model" is one read. Absent when the
13632
+ * family has a single variant.
13633
+ */
13634
+ variant: string$2().optional(),
13635
+ /**
13636
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13637
+ * differencing two reads must drop the interval when it changes, because the
13638
+ * counter restarted from zero in a respawned runner. Same discipline as
13639
+ * `LoadContribution.startedAtMs`.
13640
+ */
13641
+ sinceMs: number(),
13642
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13643
+ atMs: number(),
13644
+ /**
13645
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13646
+ * window. A failure count published without it is the mistake this schema
13647
+ * exists to make impossible.
13648
+ */
13649
+ attempts: number().int().nonnegative(),
13650
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13651
+ succeeded: number().int().nonnegative(),
13652
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13653
+ reasons: array(FailureReasonCountSchema).readonly()
13654
+ });
13655
+ method(_void(), array(FailureContributionSchema).readonly());
13427
13656
  var LoadContributionSchema = object({
13428
13657
  role: _enum([
13429
13658
  "decode",
@@ -18014,6 +18243,20 @@ var TrackSchema = object({
18014
18243
  * `=== true` and render nothing otherwise — never infer "no rider".
18015
18244
  */
18016
18245
  hasRider: boolean().optional(),
18246
+ /**
18247
+ * WHY this track ended without a NATIVE best-shot tile
18248
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18249
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18250
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18251
+ * the late-keyFrame upgrade when a native tile lands after all. The
18252
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18253
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18254
+ *
18255
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18256
+ * that predates the field, and every track whose tile landed native all
18257
+ * omit it. Render nothing when absent.
18258
+ */
18259
+ previewMissReason: string$2().optional(),
18017
18260
  ...TrackFlagFields,
18018
18261
  ...TrackRetrainFields
18019
18262
  });
@@ -29206,6 +29449,13 @@ var LoggingSettingsPatchSchema = object({
29206
29449
  * anyone but its owner.
29207
29450
  */
29208
29451
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string$2() });
29452
+ /**
29453
+ * One per-camera failure counter, plus WHO reported it.
29454
+ *
29455
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
29456
+ * the hub as it enumerates providers, never by the contributor.
29457
+ */
29458
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string$2() });
29209
29459
  var GetLoggingSettingsInputSchema = object({
29210
29460
  scopeNodeId: string$2().optional(),
29211
29461
  /**
@@ -29264,7 +29514,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
29264
29514
  }), method(_void(), SiteLocationStatusSchema, {
29265
29515
  kind: "mutation",
29266
29516
  auth: "admin"
29267
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
29517
+ }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(_void(), array(ReportedFailureContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
29268
29518
  kind: "mutation",
29269
29519
  auth: "admin"
29270
29520
  });
@@ -31586,6 +31836,12 @@ Object.freeze({
31586
31836
  addonId: null,
31587
31837
  access: "view"
31588
31838
  },
31839
+ "addonSettings.getIntegrationSettings": {
31840
+ capName: "addon-settings",
31841
+ capScope: "system",
31842
+ addonId: null,
31843
+ access: "view"
31844
+ },
31589
31845
  "addonSettings.updateDeviceSettings": {
31590
31846
  capName: "addon-settings",
31591
31847
  capScope: "system",
@@ -33248,6 +33504,12 @@ Object.freeze({
33248
33504
  addonId: null,
33249
33505
  access: "create"
33250
33506
  },
33507
+ "failureContribution.list": {
33508
+ capName: "failure-contribution",
33509
+ capScope: "system",
33510
+ addonId: null,
33511
+ access: "view"
33512
+ },
33251
33513
  "fanControl.setDirection": {
33252
33514
  capName: "fan-control",
33253
33515
  capScope: "device",
@@ -36554,6 +36816,12 @@ Object.freeze({
36554
36816
  addonId: null,
36555
36817
  access: "create"
36556
36818
  },
36819
+ "system.getFailureContributions": {
36820
+ capName: "system",
36821
+ capScope: "system",
36822
+ addonId: null,
36823
+ access: "view"
36824
+ },
36557
36825
  "system.getLoadContributions": {
36558
36826
  capName: "system",
36559
36827
  capScope: "system",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-matter-broker",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "Matter broker addon for CamStack — owns a Matter fabric (commissioning + the long-lived controller) via the matter.js controller and brokers commissioned Matter nodes into CamStack",
5
5
  "keywords": [
6
6
  "camstack",