@ceralive/modem-control 1.1.0 → 1.2.1

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 (56) hide show
  1. package/README.md +35 -6
  2. package/dist/backend/at-lease.d.ts +3 -1
  3. package/dist/backend/at-lease.js +11 -2
  4. package/dist/backend/recovery-ladder.d.ts +1 -1
  5. package/dist/backend/transition-preconditions.d.ts +35 -10
  6. package/dist/backend/transition-preconditions.js +78 -10
  7. package/dist/backend/usb-mode-transition.js +39 -19
  8. package/dist/capability/five-g-preference.d.ts +2 -2
  9. package/dist/capability/support-claim.d.ts +2 -2
  10. package/dist/domain/shadow-divergence.d.ts +1 -1
  11. package/dist/fcc/coverage.d.ts +20 -20
  12. package/dist/hardware/router-parsers.d.ts +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/journal/legacy-ceraui.d.ts +2 -2
  16. package/dist/observations/metric.d.ts +15 -1
  17. package/dist/observations/provenance.d.ts +2 -2
  18. package/dist/observations/reading.d.ts +1 -1
  19. package/dist/observations/state-separation.d.ts +1 -1
  20. package/dist/operation-ids.d.ts +2 -0
  21. package/dist/operation-ids.js +26 -0
  22. package/dist/ports/location.d.ts +1 -1
  23. package/dist/providers/contracts.d.ts +2 -2
  24. package/dist/providers/huawei-hilink/provider.d.ts +12 -12
  25. package/dist/providers/huawei-hilink/runtime.d.ts +2 -2
  26. package/dist/providers/modem-manager/errors.d.ts +1 -1
  27. package/dist/providers/modem-manager/generic-operations.d.ts +3 -1
  28. package/dist/providers/modem-manager/generic-operations.js +3 -1
  29. package/dist/providers/modem-manager/index.d.ts +1 -0
  30. package/dist/providers/modem-manager/index.js +1 -0
  31. package/dist/providers/modem-manager/provider.d.ts +2 -0
  32. package/dist/providers/modem-manager/provider.js +3 -0
  33. package/dist/providers/modem-manager/runtime-composition-operation.d.ts +32 -0
  34. package/dist/providers/modem-manager/runtime-composition-operation.js +153 -0
  35. package/dist/providers/modem-manager/types.d.ts +2 -0
  36. package/dist/providers/network-manager/types.d.ts +11 -3
  37. package/dist/providers/ufi-himi/operations.d.ts +1 -1
  38. package/dist/providers/ufi-himi/prohibitions.d.ts +22 -22
  39. package/dist/providers/ufi-himi/provider.d.ts +4 -4
  40. package/dist/providers/ufi-himi/qualcomm-evidence.d.ts +1 -1
  41. package/dist/providers/ufi-himi/transport.d.ts +2 -2
  42. package/dist/providers/zte-goform/provider.d.ts +18 -13
  43. package/dist/providers/zte-goform/provider.js +16 -16
  44. package/dist/providers/zte-goform/session.d.ts +8 -2
  45. package/dist/providers/zte-goform/session.js +95 -31
  46. package/dist/radio/mode-combinations.d.ts +11 -1
  47. package/dist/safety/flock-resource-ownership.js +18 -31
  48. package/dist/usb-mode/catalog-schema.d.ts +18 -18
  49. package/dist/usb-mode/index.d.ts +1 -0
  50. package/dist/usb-mode/index.js +1 -0
  51. package/dist/usb-mode/ingestion.d.ts +4 -4
  52. package/dist/usb-mode/runtime-capability.d.ts +59 -0
  53. package/dist/usb-mode/runtime-capability.js +157 -0
  54. package/dist/ussd/refusal.d.ts +1 -1
  55. package/dist/ussd/session.d.ts +24 -2
  56. package/package.json +1 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@ tree; see [Shape gate](#shape-gate).
16
16
  npm install @ceralive/modem-control # or: bun add @ceralive/modem-control
17
17
  ```
18
18
 
19
- ESM only (`"type": "module"`). Node 26 and Bun 1.3 are the runtimes the published
19
+ ESM only (`"type": "module"`). Node 26 and Bun 1.4 are the runtimes the published
20
20
  tarball is exercised against on every CI run.
21
21
 
22
22
  ## Public entry points
@@ -83,10 +83,11 @@ provider runtime. See [`../docs/HUAWEI-HILINK-PROVIDER.md`](../docs/HUAWEI-HILIN
83
83
 
84
84
  ### ZTE goform provider
85
85
 
86
- `createZteGoformDefinition()` exposes the incompatible `mf79u-legacy` and
87
- `mf266-salted` authentication profiles without fallback between them. MF79U sends one
88
- browser-shaped form login with a base64 password; MF266 performs the `LD` challenge,
89
- salted SHA-256 login, then derives `AD` from version data and `RD`. Session material stays
86
+ `createZteGoformDefinition()` exposes three incompatible authentication profiles without
87
+ fallback between them: MF79U legacy base64 under `LOGIN`, MF79U `LD`-salted SHA-256 under
88
+ the same bare `LOGIN`, and MF266 salted SHA-256 under `LOGIN_MULTI_USER`. One batched
89
+ pre-auth evidence GET selects the exact shape and refuses a reported lockout before any
90
+ credential POST. MF266 derives `AD` from the probed version data and `RD`. Session material stays
90
91
  in memory. Unknown ZTE firmware is fingerprinted into a read-only telemetry profile, and
91
92
  Wi-Fi writes are absent from every operation surface. See
92
93
  [`../docs/MF79U-DIAGNOSIS.md`](../docs/MF79U-DIAGNOSIS.md).
@@ -110,7 +111,10 @@ through `OperationEngine` are refused before execution too.
110
111
  `05c6:9024` is evidence of an RNDIS+ADB composition, not a permission. `05c6:9091` is a
111
112
  firmware-chosen product id and is **not** proof of DIAG — only an interface descriptor is,
112
113
  and production access stays `prohibited` regardless. The supervised, read-only, bench-only
113
- probe is documented in [`../docs/UFI-DIAG-PROBE.md`](../docs/UFI-DIAG-PROBE.md).
114
+ probe is documented in [`../docs/UFI-DIAG-PROBE.md`](../docs/UFI-DIAG-PROBE.md), together
115
+ with the read-only descriptor capture (`scripts/ufi-himi-capture.sh`) and its bundle
116
+ schema, interface-role classifier and redaction sweep (`scripts/ufi-himi-evidence.ts`).
117
+ Neither ships in the package: `files: ["dist"]`, and bench tooling is not a public surface.
114
118
 
115
119
  ### NetworkManager adapter — saved vs applied
116
120
 
@@ -160,6 +164,26 @@ ModemManager exposes no USB `vid:pid`, so the package cannot build a `BandSku` a
160
164
  Both operations expose `describe(context)` alongside their static `descriptor`, because
161
165
  a static descriptor cannot carry a device's own catalog or its certification state.
162
166
 
167
+ ### USB composition — runtime-derived targets, two proof tiers
168
+
169
+ `operations().usbComposition` asks a known vendor for its current and enumerated USB
170
+ composition modes and offers only targets from that reply after the reply also proves a
171
+ represented return path. Its suppression vocabulary is `unknown-vendor`,
172
+ `no-return-path`, `blocked-by-state`, and `provisioning-disabled`; suppressed states expose
173
+ no targets. Unknown/disabled/blocked decisions happen before transport contact, and a
174
+ capability read sends only the named READ/TEST forms, never a SET.
175
+
176
+ The current and enumeration replies must both carry a successful AT result before their raw
177
+ bodies are parsed. If either query fails, the operation returns the existing `no-return-path`
178
+ suppression with an empty offer; parseable-looking data followed by `ERROR` cannot make a
179
+ disruptive target writable.
180
+
181
+ A reviewed catalog transition still provides the strongest success proof: canonical mode
182
+ and USB descriptors must both match. Without one, the weaker fallback proof is the
183
+ re-enumerated device's own post-switch READ reporting the target. AT `OK` is never success.
184
+ The write remains disruptive and requires admission, journal, rollback, and readback hooks.
185
+ Band writes do not share this policy and remain behind their four-proof certification gate.
186
+
163
187
  ### SIM presence is evidence, never inference
164
188
 
165
189
  `readSimPresence` returns the presence together with the `SimPresenceEvidence` that
@@ -188,6 +212,11 @@ holder PID/start-time metadata, and clean release when the holder process dies.
188
212
  is mandatory input; `DEFAULT_MODEM_CONTROL_LOCK_PATH` is only a conventional value callers
189
213
  may select. There is no pass-through ownership implementation.
190
214
 
215
+ The adapter holds the lock with an external `/bin/cat` whose pipe round-trip acknowledges
216
+ successful acquisition. It never launches `process.execPath -e`: a compiled Bun executable's
217
+ `process.execPath` points back to the application, so re-executing it would parse `-e` as an
218
+ application option and misreport the resulting exit as contention.
219
+
191
220
  One `createModemControlCompositionRoot()` may be live per process. A second construction
192
221
  throws, and `actorFor(physicalModemId)` shares one actor for that modem across all callers in
193
222
  the root. `UhubctlPort` has no control-package implementation; an embedding process must
@@ -1,5 +1,7 @@
1
1
  import { type EpochMillis } from '../domain/index.js';
2
- /** The baseline allowlist identify only. Catalog commands are unioned in per SKU. */
2
+ /** Named read-only fence: the exact vendor READ/TEST forms reviewed for runtime discovery. */
3
+ export declare const AT_RUNTIME_QUERY_ALLOWLIST: ReadonlySet<string>;
4
+ /** Identify plus reviewed runtime queries. Exact catalog/runtime SET commands union in per use. */
3
5
  export declare const AT_BASELINE_ALLOWLIST: ReadonlySet<string>;
4
6
  /** Union the baseline allowlist with a catalog entry's declared transition commands. */
5
7
  export declare function computeAtAllowlist(commands: Iterable<string>): ReadonlySet<string>;
@@ -12,8 +12,17 @@
12
12
  // stripped before it is stored.
13
13
  import { epochMillis } from '../domain/index.js';
14
14
  import { redact } from '../redact.js';
15
- /** The baseline allowlist identify only. Catalog commands are unioned in per SKU. */
16
- export const AT_BASELINE_ALLOWLIST = new Set(['ATI']);
15
+ import { RUNTIME_COMPOSITION_QUERY_REGISTRY } from '../usb-mode/runtime-capability.js';
16
+ /** Named read-only fence: the exact vendor READ/TEST forms reviewed for runtime discovery. */
17
+ export const AT_RUNTIME_QUERY_ALLOWLIST = new Set(Object.values(RUNTIME_COMPOSITION_QUERY_REGISTRY).flatMap(({ current, enumerate }) => [
18
+ current,
19
+ enumerate,
20
+ ]));
21
+ /** Identify plus reviewed runtime queries. Exact catalog/runtime SET commands union in per use. */
22
+ export const AT_BASELINE_ALLOWLIST = new Set([
23
+ 'ATI',
24
+ ...AT_RUNTIME_QUERY_ALLOWLIST,
25
+ ]);
17
26
  /** Union the baseline allowlist with a catalog entry's declared transition commands. */
18
27
  export function computeAtAllowlist(commands) {
19
28
  return new Set([...AT_BASELINE_ALLOWLIST, ...commands]);
@@ -5,7 +5,7 @@ import type { ModemActor } from './modem-actor.js';
5
5
  import { type PowerHook } from './power-contract.js';
6
6
  import type { FaultAttribution } from './recovery-attribution.js';
7
7
  import { type RecoveryBudget, type RecoveryBudgetState } from './recovery-budget.js';
8
- export declare const LADDER_ORDER: readonly ["nmCycle", "mmCycle", "reset", "powerCycle"];
8
+ export declare const LADDER_ORDER: readonly ['nmCycle', 'mmCycle', 'reset', 'powerCycle'];
9
9
  export type RecoveryRung = (typeof LADDER_ORDER)[number];
10
10
  /** Context handed to each disruptive rung. */
11
11
  export interface RecoveryStepContext {
@@ -1,6 +1,6 @@
1
1
  import type { EpochMillis, IdentityConfidence } from '../domain/index.js';
2
2
  import type { ConnectionId, DeviceIfname } from '../ports/index.js';
3
- import { type CatalogEntry, type CertifiedCatalog, type MmUsbMode, type PermittedTransition, type SkuDiscriminator } from '../usb-mode/index.js';
3
+ import { type CatalogEntry, type CertifiedCatalog, type MmUsbMode, type PermittedTransition, type RuntimeCompositionCapability, type RuntimeCompositionMode, type RuntimeCompositionVendor, type SkuDiscriminator } from '../usb-mode/index.js';
4
4
  import type { InterlockTarget, LifecycleInterlock } from './lifecycle-interlock.js';
5
5
  /** A held "transition active" interlock — released when the transaction ends. */
6
6
  export interface InterlockHold {
@@ -21,12 +21,8 @@ export interface TransitionReadiness {
21
21
  /** The identity confidence from A3.2's ladder; `'low'` refuses the transition. */
22
22
  readonly identityConfidence: IdentityConfidence;
23
23
  }
24
- /** One USB-mode transition request. */
25
- export interface UsbModeTransitionRequest {
24
+ type UsbModeTransitionRequestBase = {
26
25
  readonly stableKey: string;
27
- readonly sku: SkuDiscriminator;
28
- readonly fromMode: MmUsbMode;
29
- readonly toMode: MmUsbMode;
30
26
  readonly connectionId: ConnectionId;
31
27
  readonly deviceIfname: DeviceIfname;
32
28
  /** Physical-topology UID captured BEFORE the switch — survives re-enumeration. */
@@ -40,7 +36,22 @@ export interface UsbModeTransitionRequest {
40
36
  readonly now: EpochMillis;
41
37
  /** Live readiness, re-polled at entry AND in-actor (TOCTOU-safe). */
42
38
  probeReadiness(): Promise<TransitionReadiness>;
43
- }
39
+ };
40
+ export type CatalogUsbModeTransitionRequest = UsbModeTransitionRequestBase & {
41
+ readonly strategy?: 'catalog';
42
+ readonly sku: SkuDiscriminator;
43
+ readonly fromMode: MmUsbMode;
44
+ readonly toMode: MmUsbMode;
45
+ };
46
+ export type RuntimeUsbModeTransitionRequest = UsbModeTransitionRequestBase & {
47
+ readonly strategy: 'runtime';
48
+ readonly vendor: RuntimeCompositionVendor;
49
+ readonly sku?: SkuDiscriminator;
50
+ readonly fromMode: RuntimeCompositionMode;
51
+ readonly toMode: RuntimeCompositionMode;
52
+ readonly capability: RuntimeCompositionCapability;
53
+ };
54
+ export type UsbModeTransitionRequest = CatalogUsbModeTransitionRequest | RuntimeUsbModeTransitionRequest;
44
55
  /** How a transition ended. */
45
56
  export type UsbModeTransitionOutcome = {
46
57
  readonly status: 'refused';
@@ -57,11 +68,24 @@ export type UsbModeTransitionOutcome = {
57
68
  readonly reason: string;
58
69
  readonly steps: readonly string[];
59
70
  };
60
- /** The result of a precondition check — the matched entry/transition, or a reason. */
71
+ export type UsbModeTransitionPlan = {
72
+ readonly atCommand: string;
73
+ readonly applyCommand?: string;
74
+ readonly proof: {
75
+ readonly tier: 'catalog-descriptors';
76
+ readonly transition: PermittedTransition;
77
+ } | {
78
+ readonly tier: 'runtime-requery';
79
+ readonly vendor: RuntimeCompositionVendor;
80
+ readonly target: RuntimeCompositionMode;
81
+ readonly currentQuery: string;
82
+ };
83
+ };
61
84
  export type PreconditionResult = {
62
85
  readonly ok: true;
63
- readonly entry: CatalogEntry;
64
- readonly transition: PermittedTransition;
86
+ readonly entry?: CatalogEntry;
87
+ readonly plan: UsbModeTransitionPlan;
88
+ readonly allowlistedCommands: readonly string[];
65
89
  } | {
66
90
  readonly ok: false;
67
91
  readonly reason: string;
@@ -74,3 +98,4 @@ export type PreconditionResult = {
74
98
  * (identity, interlock), so a refusal touches as little as possible.
75
99
  */
76
100
  export declare function checkTransitionPreconditions(request: UsbModeTransitionRequest, catalog: CertifiedCatalog, interlock: TransitionInterlock): Promise<PreconditionResult>;
101
+ export {};
@@ -12,7 +12,7 @@
12
12
  // transition→streaming gate (mark "transition active" until released). The Phase-A
13
13
  // stub allows both; Phase B wires CeraUI's streaming-admission check into the same
14
14
  // interface without changing the transaction.
15
- import { findCatalogEntry, findPermittedTransition, } from '../usb-mode/index.js';
15
+ import { buildRuntimeCompositionSetCommand, findCatalogEntry, findPermittedTransition, RUNTIME_COMPOSITION_QUERY_REGISTRY, } from '../usb-mode/index.js';
16
16
  const NO_OP_HOLD = {
17
17
  release() {
18
18
  return Promise.resolve();
@@ -27,6 +27,54 @@ export const ALLOW_ALL_TRANSITION_INTERLOCK = {
27
27
  return Promise.resolve(NO_OP_HOLD);
28
28
  },
29
29
  };
30
+ function sameRuntimeMode(left, right) {
31
+ return Object.is(left, right);
32
+ }
33
+ function runtimePlan(request, catalog) {
34
+ const capability = request.capability;
35
+ if (capability.status !== 'available')
36
+ return { ok: false, reason: 'runtime capability unknown' };
37
+ if (!sameRuntimeMode(capability.current, request.fromMode))
38
+ return { ok: false, reason: 'runtime current mode changed' };
39
+ if (!capability.returnPathProven ||
40
+ !capability.offerable.some((mode) => sameRuntimeMode(mode, request.toMode)))
41
+ return { ok: false, reason: 'no-return-path' };
42
+ const atCommand = buildRuntimeCompositionSetCommand(request.vendor, request.toMode);
43
+ if (atCommand === undefined)
44
+ return { ok: false, reason: 'runtime target command unavailable' };
45
+ const entry = request.sku === undefined ? undefined : findCatalogEntry(catalog, request.sku);
46
+ if (entry !== undefined) {
47
+ const reviewed = entry.permittedTransitions.find((transition) => transition.atCommand === atCommand);
48
+ if (reviewed !== undefined) {
49
+ return {
50
+ ok: true,
51
+ entry,
52
+ plan: {
53
+ atCommand: reviewed.atCommand,
54
+ ...(reviewed.applyCommand === undefined ? {} : { applyCommand: reviewed.applyCommand }),
55
+ proof: { tier: 'catalog-descriptors', transition: reviewed },
56
+ },
57
+ allowlistedCommands: entry.permittedTransitions.flatMap((transition) => transition.applyCommand === undefined
58
+ ? [transition.atCommand]
59
+ : [transition.atCommand, transition.applyCommand]),
60
+ };
61
+ }
62
+ }
63
+ return {
64
+ ok: true,
65
+ ...(entry === undefined ? {} : { entry }),
66
+ plan: {
67
+ atCommand,
68
+ proof: {
69
+ tier: 'runtime-requery',
70
+ vendor: request.vendor,
71
+ target: request.toMode,
72
+ currentQuery: RUNTIME_COMPOSITION_QUERY_REGISTRY[request.vendor].current,
73
+ },
74
+ },
75
+ allowlistedCommands: [atCommand],
76
+ };
77
+ }
30
78
  /**
31
79
  * Check every transition precondition against the LIVE inputs. Called at entry and
32
80
  * again in-actor; a request that passed at entry can fail here if the identity
@@ -41,17 +89,37 @@ export async function checkTransitionPreconditions(request, catalog, interlock)
41
89
  if (!request.maintenance) {
42
90
  return { ok: false, reason: 'maintenance flag is required' };
43
91
  }
44
- const entry = findCatalogEntry(catalog, request.sku);
45
- if (entry === undefined) {
46
- return { ok: false, reason: `uncertified SKU ${request.sku.vidPid} ${request.sku.model}` };
92
+ let staticResult;
93
+ if (request.strategy === 'runtime') {
94
+ staticResult = runtimePlan(request, catalog);
47
95
  }
48
- const transition = findPermittedTransition(entry, request.fromMode, request.toMode);
49
- if (transition === undefined) {
50
- return {
51
- ok: false,
52
- reason: `transition ${request.fromMode}->${request.toMode} not permitted for ${request.sku.model}`,
96
+ else {
97
+ const entry = findCatalogEntry(catalog, request.sku);
98
+ if (entry === undefined) {
99
+ return { ok: false, reason: `uncertified SKU ${request.sku.vidPid} ${request.sku.model}` };
100
+ }
101
+ const transition = findPermittedTransition(entry, request.fromMode, request.toMode);
102
+ if (transition === undefined) {
103
+ return {
104
+ ok: false,
105
+ reason: `transition ${request.fromMode}->${request.toMode} not permitted for ${request.sku.model}`,
106
+ };
107
+ }
108
+ staticResult = {
109
+ ok: true,
110
+ entry,
111
+ plan: {
112
+ atCommand: transition.atCommand,
113
+ ...(transition.applyCommand === undefined ? {} : { applyCommand: transition.applyCommand }),
114
+ proof: { tier: 'catalog-descriptors', transition },
115
+ },
116
+ allowlistedCommands: entry.permittedTransitions.flatMap((candidate) => candidate.applyCommand === undefined
117
+ ? [candidate.atCommand]
118
+ : [candidate.atCommand, candidate.applyCommand]),
53
119
  };
54
120
  }
121
+ if (!staticResult.ok)
122
+ return staticResult;
55
123
  const readiness = await request.probeReadiness();
56
124
  if (readiness.identityConfidence === 'low') {
57
125
  return { ok: false, reason: 'low-confidence identity — refusing to transition' };
@@ -60,5 +128,5 @@ export async function checkTransitionPreconditions(request, catalog, interlock)
60
128
  if (!verdict.allow) {
61
129
  return { ok: false, reason: `interlock held: ${verdict.reason}` };
62
130
  }
63
- return { ok: true, entry, transition };
131
+ return staticResult;
64
132
  }
@@ -11,13 +11,16 @@
11
11
  // 8 resolve new ifname → 9 reactivate (uuid, newIfname) → 10 release interlock
12
12
  // (finally, always).
13
13
  //
14
- // THE POSTCONDITION IS THE ONLY PROOF OF SUCCESS. An AT `OK` proves nothing only a
15
- // re-enumerated device whose descriptors AND observed mode equal the catalog target
16
- // counts. On a postcondition MISMATCH the whole transaction fails `degraded`, does NOT
14
+ // THE POSTCONDITION IS THE ONLY PROOF OF SUCCESS. An AT `OK` proves nothing. Tier 1 is
15
+ // the strongest proof and remains unchanged: a reviewed catalog transition must match
16
+ // both descriptors and canonical mode. Tier 2 exists only when no reviewed transition
17
+ // matches: the re-enumerated device must report the raw target through its own vendor READ.
18
+ // Tier 2 is explicitly weaker because it proves reported mode, not descriptor composition.
19
+ // On a postcondition MISMATCH the whole transaction fails `degraded`, does NOT
17
20
  // reactivate, and still releases the interlock via `finally`. A hung command trips the
18
21
  // AT watchdog, which force-uninhibits so the system reprobes rather than wedging.
19
22
  import { deviceIfname } from '../ports/index.js';
20
- import { CERTIFIED_CATALOG } from '../usb-mode/index.js';
23
+ import { CERTIFIED_CATALOG, readRuntimeCompositionCurrent, } from '../usb-mode/index.js';
21
24
  import { AtCommandLease, computeAtAllowlist, } from './at-lease.js';
22
25
  import { descriptorsMatch, detectUsbMode } from './device-classifier.js';
23
26
  import { ALLOW_ALL_TRANSITION_INTERLOCK, checkTransitionPreconditions, } from './transition-preconditions.js';
@@ -77,14 +80,14 @@ export class UsbModeTransition {
77
80
  }
78
81
  const hold = await this.#interlock.hold({ stableKey: request.stableKey });
79
82
  try {
80
- return await this.#runTransaction(request, recheck.entry.permittedTransitions, recheck.transition, steps);
83
+ return await this.#runTransaction(request, recheck.allowlistedCommands, recheck.plan, steps);
81
84
  }
82
85
  finally {
83
86
  steps.push('release-interlock');
84
87
  await hold.release().catch(() => undefined);
85
88
  }
86
89
  }
87
- async #runTransaction(request, allCommands, transition, steps) {
90
+ async #runTransaction(request, allowlistedCommands, plan, steps) {
88
91
  let inhibit;
89
92
  let reactivated = false;
90
93
  const forceUninhibit = async () => {
@@ -98,7 +101,7 @@ export class UsbModeTransition {
98
101
  };
99
102
  const lease = new AtCommandLease({
100
103
  sender: this.#atSender,
101
- allowlist: computeAtAllowlist(allCommands.flatMap((t) => t.applyCommand === undefined ? [t.atCommand] : [t.atCommand, t.applyCommand])),
104
+ allowlist: computeAtAllowlist(allowlistedCommands),
102
105
  timeoutMs: this.#watchdogMs,
103
106
  onWatchdog: forceUninhibit,
104
107
  ...(this.#audit !== undefined ? { audit: this.#audit } : {}),
@@ -110,10 +113,10 @@ export class UsbModeTransition {
110
113
  inhibit = await this.#modemManager.inhibit(request.inhibitUid);
111
114
  // AT `OK` is IGNORED for success — only the postcondition below decides.
112
115
  steps.push('at-command');
113
- await lease.run(transition.atCommand, { inhibitUid: request.inhibitUid });
114
- if (transition.applyCommand !== undefined) {
116
+ await lease.run(plan.atCommand, { inhibitUid: request.inhibitUid });
117
+ if (plan.applyCommand !== undefined) {
115
118
  steps.push('apply-command');
116
- await lease.run(transition.applyCommand, { inhibitUid: request.inhibitUid });
119
+ await lease.run(plan.applyCommand, { inhibitUid: request.inhibitUid });
117
120
  }
118
121
  steps.push('await-port-drop');
119
122
  await this.#awaitPortDrop(request.cachedPhysicalUid);
@@ -126,15 +129,32 @@ export class UsbModeTransition {
126
129
  steps.push('await-reenumeration');
127
130
  const device = await this.#awaitReenumeration(request.cachedPhysicalUid);
128
131
  steps.push('postcondition');
129
- const observedMode = detectUsbMode(device);
130
- const descriptorsOk = descriptorsMatch(device, transition.expectedDescriptors);
131
- if (observedMode !== request.toMode || !descriptorsOk) {
132
- return {
133
- status: 'failed',
134
- degraded: true,
135
- reason: `postcondition mismatch: observed ${observedMode ?? 'unknown'} vs target ${request.toMode}; descriptors ${descriptorsOk ? 'ok' : 'mismatch'}`,
136
- steps,
137
- };
132
+ if (plan.proof.tier === 'catalog-descriptors') {
133
+ const observedMode = detectUsbMode(device);
134
+ const descriptorsOk = descriptorsMatch(device, plan.proof.transition.expectedDescriptors);
135
+ if (observedMode !== plan.proof.transition.to || !descriptorsOk) {
136
+ return {
137
+ status: 'failed',
138
+ degraded: true,
139
+ reason: `postcondition mismatch: observed ${observedMode ?? 'unknown'} vs target ${plan.proof.transition.to}; descriptors ${descriptorsOk ? 'ok' : 'mismatch'}`,
140
+ steps,
141
+ };
142
+ }
143
+ }
144
+ else {
145
+ steps.push('postcondition-runtime-read');
146
+ const response = await lease.run(plan.proof.currentQuery, {
147
+ inhibitUid: request.inhibitUid,
148
+ });
149
+ const observed = readRuntimeCompositionCurrent(plan.proof.vendor, response.raw);
150
+ if (!Object.is(observed, plan.proof.target)) {
151
+ return {
152
+ status: 'failed',
153
+ degraded: true,
154
+ reason: `runtime readback mismatch: observed ${observed ?? 'unknown'} vs target ${plan.proof.target}`,
155
+ steps,
156
+ };
157
+ }
138
158
  }
139
159
  steps.push('resolve-ifname');
140
160
  const newIfname = this.#resolveIfname(device);
@@ -13,7 +13,7 @@ import type { CapabilityEvidence } from './support-claim.js';
13
13
  * selector structurally cannot express.
14
14
  * 5g-off — allow everything the modem supports EXCEPT 5G.
15
15
  */
16
- export declare const FIVE_G_PREFERENCES: readonly ["5g-only", "prefer-5g", "prefer-4g", "5g-off"];
16
+ export declare const FIVE_G_PREFERENCES: readonly ['5g-only', 'prefer-5g', 'prefer-4g', '5g-off'];
17
17
  export type FiveGPreference = (typeof FIVE_G_PREFERENCES)[number];
18
18
  /** One `(allowed, preferred)` pair, the shape MM's `CurrentModes` really carries. */
19
19
  export interface RadioModeSet {
@@ -44,7 +44,7 @@ export interface FiveGRadioTarget {
44
44
  * reads as "nobody asked"; a stated `not-exposed-by-modemmanager` tells an
45
45
  * operator looking for an SA toggle why there is none.
46
46
  */
47
- export declare const NR_MODE_UNSUPPORTED_REASON: "not-exposed-by-modemmanager";
47
+ export declare const NR_MODE_UNSUPPORTED_REASON: 'not-exposed-by-modemmanager';
48
48
  export interface NrModeSelection {
49
49
  readonly supported: false;
50
50
  readonly reason: typeof NR_MODE_UNSUPPORTED_REASON;
@@ -1,5 +1,5 @@
1
1
  /** The seven gated capability modules. */
2
- export declare const CAPABILITY_MODULES: readonly ["band-lock", "sms", "five-g-pref", "fcc-auto-unlock", "gps", "ussd", "esim"];
2
+ export declare const CAPABILITY_MODULES: readonly ['band-lock', 'sms', 'five-g-pref', 'fcc-auto-unlock', 'gps', 'ussd', 'esim'];
3
3
  export type CapabilityModule = (typeof CAPABILITY_MODULES)[number];
4
4
  /**
5
5
  * The ladder, lowest rung first. `resolveSupportClaim` answers with the highest
@@ -12,7 +12,7 @@ export type CapabilityModule = (typeof CAPABILITY_MODULES)[number];
12
12
  * certified — capable AND proven on this exact model+firmware. The ONLY rung
13
13
  * a support matrix or a doc may claim.
14
14
  */
15
- export declare const SUPPORT_CLAIM_STATES: readonly ["unavailable", "implemented", "enabled", "capable", "certified"];
15
+ export declare const SUPPORT_CLAIM_STATES: readonly ['unavailable', 'implemented', 'enabled', 'capable', 'certified'];
16
16
  export type SupportClaimState = (typeof SUPPORT_CLAIM_STATES)[number];
17
17
  /** What a probe found. `unknown` is an answer about the READ, not the device. */
18
18
  export type CapabilityEvidence = 'present' | 'absent' | 'unknown';
@@ -9,7 +9,7 @@ export interface ShadowModemState {
9
9
  readonly simPresent?: boolean;
10
10
  readonly networkType?: ShadowGeneration;
11
11
  }
12
- export declare const SHADOW_COMPARABLE_FIELDS: readonly ["present", "registration", "signalBucket", "operatorName", "simPresent", "networkType"];
12
+ export declare const SHADOW_COMPARABLE_FIELDS: readonly ['present', 'registration', 'signalBucket', 'operatorName', 'simPresent', 'networkType'];
13
13
  export type ShadowComparableField = (typeof SHADOW_COMPARABLE_FIELDS)[number];
14
14
  export interface ShadowFieldDivergence {
15
15
  readonly field: ShadowComparableField;
@@ -1,35 +1,35 @@
1
1
  /** The four vendor scripts ModemManager 1.24.2 ships, and what each one drives. */
2
2
  export declare const MM_FCC_UNLOCK_VENDOR_SCRIPTS: {
3
- readonly '105b': "mbimcli";
4
- readonly '1199': "qmicli";
5
- readonly '14c3': "mbimcli";
6
- readonly '2c7c': "qmicli";
3
+ readonly '105b': 'mbimcli';
4
+ readonly '1199': 'qmicli';
5
+ readonly '14c3': 'mbimcli';
6
+ readonly '2c7c': 'qmicli';
7
7
  };
8
8
  export type MmFccUnlockVendorScript = keyof typeof MM_FCC_UNLOCK_VENDOR_SCRIPTS;
9
9
  /** The interpreters those scripts invoke, and the packages that provide them. */
10
10
  export declare const MM_FCC_UNLOCK_RUNTIME_PACKAGES: {
11
- readonly qmicli: "libqmi-utils";
12
- readonly mbimcli: "libmbim-utils";
11
+ readonly qmicli: 'libqmi-utils';
12
+ readonly mbimcli: 'libmbim-utils';
13
13
  };
14
14
  /**
15
15
  * ModemManager 1.24.2's COMPLETE shipped mapping — `data/dispatcher-fcc-unlock/
16
16
  * meson.build`'s `vidpids` dict, verbatim. Fourteen entries; there are no others.
17
17
  */
18
18
  export declare const MM_FCC_UNLOCK_COVERAGE: {
19
- readonly '03f0:4e1d': "1199";
20
- readonly '105b:e0ab': "105b";
21
- readonly '105b:e0c3': "105b";
22
- readonly '1199:9079': "1199";
23
- readonly '14c3:4d75': "14c3";
24
- readonly '1eac:1001': "2c7c";
25
- readonly '1eac:1004': "2c7c";
26
- readonly '1eac:1007': "2c7c";
27
- readonly '2c7c:030a': "2c7c";
28
- readonly '2c7c:0313': "2c7c";
29
- readonly '2c7c:0314': "2c7c";
30
- readonly '2c7c:0801': "2c7c";
31
- readonly '413c:81a3': "1199";
32
- readonly '413c:81a8': "1199";
19
+ readonly '03f0:4e1d': '1199';
20
+ readonly '105b:e0ab': '105b';
21
+ readonly '105b:e0c3': '105b';
22
+ readonly '1199:9079': '1199';
23
+ readonly '14c3:4d75': '14c3';
24
+ readonly '1eac:1001': '2c7c';
25
+ readonly '1eac:1004': '2c7c';
26
+ readonly '1eac:1007': '2c7c';
27
+ readonly '2c7c:030a': '2c7c';
28
+ readonly '2c7c:0313': '2c7c';
29
+ readonly '2c7c:0314': '2c7c';
30
+ readonly '2c7c:0801': '2c7c';
31
+ readonly '413c:81a3': '1199';
32
+ readonly '413c:81a8': '1199';
33
33
  };
34
34
  export type MmFccUnlockKey = keyof typeof MM_FCC_UNLOCK_COVERAGE;
35
35
  /**
@@ -8,7 +8,7 @@ export type SimPresenceFacts = {
8
8
  /** The mmcli spelling of `MM_MODEM_STATE_FAILED_REASON_SIM_MISSING` — the ONLY proof of absence. */
9
9
  export declare const SIM_MISSING_FAILED_REASON = "sim-missing";
10
10
  /** The fields a presence decision may be drawn from, in the order they are inspected. */
11
- export declare const SIM_PRESENCE_FIELDS: readonly ["sim", "simSlots", "failedReason"];
11
+ export declare const SIM_PRESENCE_FIELDS: readonly ['sim', 'simSlots', 'failedReason'];
12
12
  export type SimPresenceField = (typeof SIM_PRESENCE_FIELDS)[number];
13
13
  /**
14
14
  * WHICH FACT decided a SIM presence — the whole point of this type.
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export * from './hardware/router-parsers.js';
8
8
  export * from './journal/index.js';
9
9
  export * from './location/index.js';
10
10
  export * from './observations/index.js';
11
+ export * from './operation-ids.js';
11
12
  export * from './operations/index.js';
12
13
  export * from './ports/index.js';
13
14
  export * from './providers/index.js';
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export * from './hardware/router-parsers.js';
15
15
  export * from './journal/index.js';
16
16
  export * from './location/index.js';
17
17
  export * from './observations/index.js';
18
+ export * from './operation-ids.js';
18
19
  export * from './operations/index.js';
19
20
  export * from './ports/index.js';
20
21
  export * from './providers/index.js';
@@ -5,9 +5,9 @@ import { type JournalOperationRecord, type JournalRecovery } from './recovery.js
5
5
  export declare const LEGACY_CERAUI_JOURNAL_VERSION = 1;
6
6
  /** CeraUI's cap on retained history entries per slot. */
7
7
  export declare const LEGACY_CERAUI_HISTORY_CAP = 32;
8
- export declare const LEGACY_CERAUI_MUTATION_STATES: readonly ["armed", "executing", "completed", "failed", "acknowledged", "device-absent-quarantine", "decommissioned", "recommission-pending"];
8
+ export declare const LEGACY_CERAUI_MUTATION_STATES: readonly ['armed', 'executing', 'completed', 'failed', 'acknowledged', 'device-absent-quarantine', 'decommissioned', 'recommission-pending'];
9
9
  export type LegacyCeraUiMutationState = (typeof LEGACY_CERAUI_MUTATION_STATES)[number];
10
- export declare const LEGACY_CERAUI_ACK_MODES: readonly ["verified-rollback", "force-rebaseline"];
10
+ export declare const LEGACY_CERAUI_ACK_MODES: readonly ['verified-rollback', 'force-rebaseline'];
11
11
  export type LegacyCeraUiAckMode = (typeof LEGACY_CERAUI_ACK_MODES)[number];
12
12
  export interface LegacyCeraUiHistoryEntry {
13
13
  readonly state: LegacyCeraUiMutationState;
@@ -1,6 +1,20 @@
1
1
  import type { RouterSignalMetric, RouterSignalUnknownReason } from '../hardware/router-parsers.js';
2
2
  import type { MetricProvenance } from './provenance.js';
3
- export declare const METRIC_UNKNOWN_REASONS: readonly ["unsupported", "not-reported", "not-observed", "malformed", "auth-expired", "refused", "unreachable"];
3
+ export declare const METRIC_UNKNOWN_REASONS: readonly [
4
+ /** A positive claim about the SOURCE: it cannot express this datum at all. */
5
+ 'unsupported',
6
+ /** The source answered and simply did not include this field. */
7
+ 'not-reported',
8
+ /** Nobody has read this yet. Says nothing about the source or the device. */
9
+ 'not-observed',
10
+ /** The source answered with something this layer could not decode. */
11
+ 'malformed',
12
+ /** The source refused the read; the session needs re-authentication. */
13
+ 'auth-expired',
14
+ /** The source answered with an explicit refusal code for this field. */
15
+ 'refused',
16
+ /** The source could not be reached for this read. */
17
+ 'unreachable'];
4
18
  export type MetricUnknownReason = (typeof METRIC_UNKNOWN_REASONS)[number];
5
19
  /**
6
20
  * The two classes an unknown reason falls into.
@@ -11,7 +11,7 @@ import type { EpochMillis, ObservationAuthority, SourceEpoch } from '../domain/i
11
11
  * reading. Giving it its own kind is what keeps a bearer observation attributable to
12
12
  * NM rather than laundered through whichever provider happened to read the modem.
13
13
  */
14
- export declare const OBSERVATION_SOURCE_KINDS: readonly ["modemmanager", "huawei-hilink", "zte-goform", "ufi-himiapi", "networkmanager"];
14
+ export declare const OBSERVATION_SOURCE_KINDS: readonly ['modemmanager', 'huawei-hilink', 'zte-goform', 'ufi-himiapi', 'networkmanager'];
15
15
  export type ObservationSourceKind = (typeof OBSERVATION_SOURCE_KINDS)[number];
16
16
  /**
17
17
  * A provider-native value, kept exactly as the provider expressed it.
@@ -45,7 +45,7 @@ export type MetricProvenance = {
45
45
  * and never carries its value: a body can hold an ICCID or a one-time code, and a
46
46
  * diagnostic is the one place a reviewer reads verbatim.
47
47
  */
48
- export declare const OBSERVATION_DIAGNOSTIC_CODES: readonly ["unparseable-body", "empty-body", "auth-expired", "field-shape-unrecognized"];
48
+ export declare const OBSERVATION_DIAGNOSTIC_CODES: readonly ['unparseable-body', 'empty-body', 'auth-expired', 'field-shape-unrecognized'];
49
49
  export type ObservationDiagnosticCode = (typeof OBSERVATION_DIAGNOSTIC_CODES)[number];
50
50
  export type ObservationDiagnosticNote = {
51
51
  readonly code: ObservationDiagnosticCode;
@@ -34,7 +34,7 @@ export type ObservationReading<T> = (ReadingBase & {
34
34
  readonly reason: MetricUnknownReason;
35
35
  readonly provenance: MetricProvenance;
36
36
  });
37
- export declare const OBSERVATION_READING_STATES: readonly ["fresh", "stale", "unavailable", "unknown"];
37
+ export declare const OBSERVATION_READING_STATES: readonly ['fresh', 'stale', 'unavailable', 'unknown'];
38
38
  export type ObservationReadingState = (typeof OBSERVATION_READING_STATES)[number];
39
39
  /**
40
40
  * Project one metric out of an envelope.
@@ -38,7 +38,7 @@ export type ModemStateView<TDesired, TApplied, TObserved> = {
38
38
  readonly applied: AppliedConfiguration<TApplied> | null;
39
39
  readonly observed: ObservedState<TObserved>;
40
40
  };
41
- export declare const STATE_VIEW_SLOTS: readonly ["desired", "applied", "observed"];
41
+ export declare const STATE_VIEW_SLOTS: readonly ['desired', 'applied', 'observed'];
42
42
  export type StateViewSlot = (typeof STATE_VIEW_SLOTS)[number];
43
43
  export type SlotComparison = {
44
44
  readonly status: 'aligned';