@ceralive/modem-control 0.1.0

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 (108) hide show
  1. package/package.json +31 -0
  2. package/src/backend/at-lease.test.ts +106 -0
  3. package/src/backend/at-lease.ts +158 -0
  4. package/src/backend/cell-info.test.ts +154 -0
  5. package/src/backend/cell-info.ts +160 -0
  6. package/src/backend/constants.ts +35 -0
  7. package/src/backend/device-classifier.test.ts +168 -0
  8. package/src/backend/device-classifier.ts +240 -0
  9. package/src/backend/enrichment.ts +96 -0
  10. package/src/backend/features.test.ts +158 -0
  11. package/src/backend/features.ts +179 -0
  12. package/src/backend/identity-ladder.test.ts +117 -0
  13. package/src/backend/identity-ladder.ts +221 -0
  14. package/src/backend/identity-registry.test.ts +89 -0
  15. package/src/backend/identity-registry.ts +151 -0
  16. package/src/backend/index.ts +221 -0
  17. package/src/backend/lifecycle-interlock.ts +38 -0
  18. package/src/backend/managed-objects.ts +108 -0
  19. package/src/backend/mapping.ts +160 -0
  20. package/src/backend/mm-backend.ts +191 -0
  21. package/src/backend/mm-mutations.ts +228 -0
  22. package/src/backend/modem-actor.test.ts +95 -0
  23. package/src/backend/modem-actor.ts +112 -0
  24. package/src/backend/nm-auto-apn.ts +161 -0
  25. package/src/backend/nm-gsm-fields.ts +122 -0
  26. package/src/backend/nmcli-nm-port.ts +228 -0
  27. package/src/backend/nmcli-runner.ts +52 -0
  28. package/src/backend/observer.ts +297 -0
  29. package/src/backend/power-contract.test.ts +40 -0
  30. package/src/backend/power-contract.ts +83 -0
  31. package/src/backend/recovery-attribution.test.ts +102 -0
  32. package/src/backend/recovery-attribution.ts +86 -0
  33. package/src/backend/recovery-budget.test.ts +64 -0
  34. package/src/backend/recovery-budget.ts +84 -0
  35. package/src/backend/recovery-ladder.test.ts +257 -0
  36. package/src/backend/recovery-ladder.ts +249 -0
  37. package/src/backend/router-ethernet.test.ts +71 -0
  38. package/src/backend/router-ethernet.ts +90 -0
  39. package/src/backend/row-store.ts +105 -0
  40. package/src/backend/signal-setup.ts +112 -0
  41. package/src/backend/sim-unlock.ts +193 -0
  42. package/src/backend/transition-preconditions.ts +149 -0
  43. package/src/backend/usage/accounting.test.ts +147 -0
  44. package/src/backend/usage/accounting.ts +123 -0
  45. package/src/backend/usage/billing-cycle.test.ts +62 -0
  46. package/src/backend/usage/billing-cycle.ts +45 -0
  47. package/src/backend/usage/boot-id.ts +18 -0
  48. package/src/backend/usage/index.ts +37 -0
  49. package/src/backend/usage/proc-net-dev.test.ts +56 -0
  50. package/src/backend/usage/proc-net-dev.ts +77 -0
  51. package/src/backend/usage/sampler.test.ts +219 -0
  52. package/src/backend/usage/sampler.ts +228 -0
  53. package/src/backend/usage/store.test.ts +148 -0
  54. package/src/backend/usage/store.ts +177 -0
  55. package/src/backend/usb-enumerator.test.ts +87 -0
  56. package/src/backend/usb-enumerator.ts +181 -0
  57. package/src/backend/usb-mode-transition.test.ts +323 -0
  58. package/src/backend/usb-mode-transition.ts +253 -0
  59. package/src/domain/brand.ts +29 -0
  60. package/src/domain/errors.ts +77 -0
  61. package/src/domain/guards.test.ts +218 -0
  62. package/src/domain/guards.ts +144 -0
  63. package/src/domain/identity.test.ts +83 -0
  64. package/src/domain/identity.ts +165 -0
  65. package/src/domain/index.ts +12 -0
  66. package/src/domain/policy.ts +137 -0
  67. package/src/domain/snapshot.test.ts +266 -0
  68. package/src/domain/snapshot.ts +120 -0
  69. package/src/domain/state.ts +189 -0
  70. package/src/index.test.ts +6 -0
  71. package/src/index.ts +15 -0
  72. package/src/ports/README.md +61 -0
  73. package/src/ports/forbidden-surface.test.ts +80 -0
  74. package/src/ports/index.ts +16 -0
  75. package/src/ports/modem-manager.ts +72 -0
  76. package/src/ports/network-manager.ts +87 -0
  77. package/src/ports/observation.ts +48 -0
  78. package/src/ports/ops.ts +60 -0
  79. package/src/ports/ops.type-test.ts +39 -0
  80. package/src/ports/receipts.test.ts +153 -0
  81. package/src/ports/receipts.ts +43 -0
  82. package/src/ports/reconcile.test.ts +152 -0
  83. package/src/ports/reconcile.ts +338 -0
  84. package/src/ports/router.ts +29 -0
  85. package/src/redact.test.ts +82 -0
  86. package/src/redact.ts +73 -0
  87. package/src/transport/README.md +65 -0
  88. package/src/transport/codec.test.ts +118 -0
  89. package/src/transport/codec.ts +240 -0
  90. package/src/transport/conformance-python.test.ts +152 -0
  91. package/src/transport/conformance-same-lib.test.ts +115 -0
  92. package/src/transport/dbus-native-lib.d.ts +19 -0
  93. package/src/transport/dbus-native.ts +85 -0
  94. package/src/transport/errors.ts +74 -0
  95. package/src/transport/index.ts +30 -0
  96. package/src/transport/no-library-leak.test.ts +50 -0
  97. package/src/transport/reliability.test.ts +173 -0
  98. package/src/transport/signature.ts +110 -0
  99. package/src/transport/test-support/fake-service.ts +143 -0
  100. package/src/transport/test-support/independent-producer.py +110 -0
  101. package/src/transport/test-support/private-bus.ts +66 -0
  102. package/src/transport/transport.ts +439 -0
  103. package/src/transport/types.ts +118 -0
  104. package/src/usb-mode/catalog-schema.test.ts +181 -0
  105. package/src/usb-mode/catalog-schema.ts +113 -0
  106. package/src/usb-mode/catalog.ts +60 -0
  107. package/src/usb-mode/certified-catalog.json +67 -0
  108. package/src/usb-mode/index.ts +27 -0
@@ -0,0 +1,137 @@
1
+ // Durable desired-state policy — the operator's intent for one modem.
2
+ //
3
+ // A2.1 stubbed this shape so the domain could enforce "an ambiguous identity may
4
+ // never bind durable policy" (see `policyBindingKey`). A2.2 owns the REAL
5
+ // definition below: the full connection / roaming / radio / simSlot / recovery /
6
+ // usage intent the desired-state planner reconciles (see `../ports/reconcile`).
7
+
8
+ import type { EquipmentId, LogicalSlotId, ModemIdentity } from './identity';
9
+ import { assertCanBindPolicy } from './identity';
10
+ import type { RadioAccessTechnology } from './state';
11
+
12
+ /**
13
+ * The subset of identity a durable policy is allowed to key off.
14
+ *
15
+ * Deliberately excludes `runtimePath` (never persisted) and `subscriptionId`
16
+ * (sensitive). Durable policy binds to the physical slot when known, always
17
+ * anchored by the equipment id — never to a per-boot handle or a subscriber id.
18
+ */
19
+ export interface PolicyBindingKey {
20
+ readonly logicalSlotId?: LogicalSlotId;
21
+ readonly equipmentId: EquipmentId;
22
+ }
23
+
24
+ /** IP address family a connection requests. */
25
+ export type IpFamily = 'ipv4' | 'ipv6' | 'ipv4v6';
26
+
27
+ /**
28
+ * Connection credentials. TODAY'S SEMANTICS (draft §round-5 auth): these are
29
+ * persisted ONLY in the NetworkManager profile; the controller keeps them
30
+ * transient in memory and NEVER writes them to its own store. `password` is
31
+ * SENSITIVE and is ALWAYS redacted in logs / output / receipts (see `../redact`).
32
+ */
33
+ export interface DesiredAuth {
34
+ readonly username?: string;
35
+ /** SENSITIVE — always redacted; NM-profile-persisted only, transient in memory. */
36
+ readonly password?: string;
37
+ }
38
+
39
+ /** The data-connection intent. `apn: "auto"` selects NM Auto-APN (A4.1). */
40
+ export interface DesiredConnection {
41
+ readonly apn: 'auto' | string;
42
+ readonly ipFamily: IpFamily;
43
+ readonly auth?: DesiredAuth;
44
+ /**
45
+ * Manual operator selection (`gsm.network-id`) — pin registration to a specific
46
+ * PLMN. Honored only while roaming (A4.1 amendment): the NM profile writes
47
+ * `gsm.network-id = roaming ? networkId : ""`, so it is cleared when roaming is off.
48
+ */
49
+ readonly networkId?: string;
50
+ }
51
+
52
+ /**
53
+ * Radio access-technology intent. `preferenceOrdered` is ranked most-preferred
54
+ * first ("prefer 5G" ⇒ `['5gnr', 'lte', …]`); it must be honored as a PREFERENCE,
55
+ * never silently narrowed to an exclusive set — the planner reports `unsupported`
56
+ * when the top preference is unavailable (never a silent downgrade). `allowedSet`,
57
+ * when present, hard-limits the technologies the modem may use at all.
58
+ */
59
+ export interface DesiredRadio {
60
+ readonly preferenceOrdered: readonly RadioAccessTechnology[];
61
+ readonly allowedSet?: ReadonlySet<RadioAccessTechnology>;
62
+ }
63
+
64
+ /**
65
+ * Recovery intent. DISABLED BY DEFAULT — the evidence-gated recovery ladder (A3.4)
66
+ * adds the per-step budgets / cooldowns; in Phase A `enabled` defaults to `false`
67
+ * and no recovery action is ever taken unless it is explicitly turned on.
68
+ */
69
+ export interface DesiredRecovery {
70
+ readonly enabled: boolean;
71
+ }
72
+
73
+ /** Data-usage policy — local-controller owned (see ports README ownership table). */
74
+ export interface DesiredUsage {
75
+ /** Day of month (1–31) the usage cycle resets; UTC, month-length clamped (A4.3). */
76
+ readonly cycleDay?: number;
77
+ /** Advisory threshold in bytes; crossing it raises an advisory, never gates. */
78
+ readonly thresholdBytes?: number;
79
+ }
80
+
81
+ /**
82
+ * Operator-authored durable cellular policy, bound to a high/medium-confidence
83
+ * identity via `boundTo` (a low-confidence identity cannot produce a binding key,
84
+ * so it can never be named here).
85
+ *
86
+ * - enabled — desired NM activation state (`enabled ≙ NM-activation`).
87
+ * - connection — APN (or `"auto"`), IP family, optional (redacted) credentials.
88
+ * - roaming — allow the modem to register while roaming.
89
+ * - radio — ranked RAT preference (+ optional hard-allowed set).
90
+ * - simSlot — preferred primary SIM slot (1-based; multi-slot modems).
91
+ * - recovery — recovery-ladder intent (disabled by default).
92
+ * - usage — data-usage cycle + advisory threshold.
93
+ */
94
+ export interface DesiredCellularPolicy {
95
+ readonly boundTo: PolicyBindingKey;
96
+ readonly enabled: boolean;
97
+ readonly connection: DesiredConnection;
98
+ readonly roaming: boolean;
99
+ readonly radio: DesiredRadio;
100
+ readonly simSlot?: number;
101
+ readonly recovery: DesiredRecovery;
102
+ readonly usage: DesiredUsage;
103
+ }
104
+
105
+ /** Recovery disabled — the Phase-A default (A3.4 adds budgets / cooldowns). */
106
+ export const RECOVERY_DISABLED: DesiredRecovery = { enabled: false };
107
+
108
+ /**
109
+ * Build a sensible default policy for a bindable identity: NM activation on,
110
+ * Auto-APN, dual-stack IP, roaming off, prefer newest RAT down to GSM, recovery
111
+ * disabled, no usage limits. Concrete adapters and the planner refine from here.
112
+ */
113
+ export function defaultCellularPolicy(boundTo: PolicyBindingKey): DesiredCellularPolicy {
114
+ return {
115
+ boundTo,
116
+ enabled: true,
117
+ connection: { apn: 'auto', ipFamily: 'ipv4v6' },
118
+ roaming: false,
119
+ radio: { preferenceOrdered: ['5gnr', 'lte', 'umts', 'gsm'] },
120
+ recovery: RECOVERY_DISABLED,
121
+ usage: {},
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Derive the durable binding key for an identity, REFUSING low-confidence
127
+ * (ambiguous) identities. This is the structural gate that makes it impossible to
128
+ * bind durable policy to a duplicate/zero-IMEI modem: no key can be produced, so
129
+ * no `DesiredCellularPolicy` can name it. Throws `PolicyBindingRefusedError`.
130
+ */
131
+ export function policyBindingKey(identity: ModemIdentity): PolicyBindingKey {
132
+ assertCanBindPolicy(identity);
133
+ // exactOptionalPropertyTypes: only include logicalSlotId when actually present.
134
+ return identity.logicalSlotId !== undefined
135
+ ? { logicalSlotId: identity.logicalSlotId, equipmentId: identity.equipmentId }
136
+ : { equipmentId: identity.equipmentId };
137
+ }
@@ -0,0 +1,266 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { ImpossibleStateError, RevisionMonotonicityError } from './errors';
3
+ import { imeiEquipmentId, type ModemIdentity, runtimePath } from './identity';
4
+ import {
5
+ applyTransition,
6
+ type CellularSnapshot,
7
+ createSnapshot,
8
+ initialSnapshot,
9
+ markSourceUnavailable,
10
+ revision,
11
+ supersede,
12
+ } from './snapshot';
13
+ import {
14
+ type DataInterface,
15
+ epochMillis,
16
+ isRegistered,
17
+ MM_STATES_REQUIRING_RADIO,
18
+ type MmState,
19
+ type NmActivation,
20
+ type RadioAccessTechnology,
21
+ type RadioPower,
22
+ type RecoveryStage,
23
+ type RecoveryState,
24
+ type RegistrationStatus,
25
+ SIM_LOCK_REQUIRES_CARD,
26
+ type SimLock,
27
+ type SimSlot,
28
+ } from './state';
29
+
30
+ const IDENTITY: ModemIdentity = {
31
+ equipmentId: imeiEquipmentId('490154203237518'),
32
+ runtimePath: runtimePath('/org/freedesktop/ModemManager1/Modem/0'),
33
+ };
34
+
35
+ describe('reducers', () => {
36
+ test('initialSnapshot is a valid absent baseline at revision 0', () => {
37
+ const snapshot = initialSnapshot(IDENTITY);
38
+ expect(snapshot.presence).toBe('absent');
39
+ expect(snapshot.revision).toBe(revision(0));
40
+ expect(() => createSnapshot(snapshot)).not.toThrow();
41
+ });
42
+
43
+ test('applyTransition bumps the revision by exactly one', () => {
44
+ const start = initialSnapshot(IDENTITY);
45
+ const next = applyTransition(start, { reconcileStatus: 'reconciling' });
46
+ expect(next.revision).toBe(revision(1));
47
+ expect(next.reconcileStatus).toBe('reconciling');
48
+ });
49
+
50
+ test('applyTransition re-validates and throws on an impossible patch', () => {
51
+ const start = applyTransition(initialSnapshot(IDENTITY), {
52
+ presence: 'present',
53
+ radioPower: 'on',
54
+ mmState: 'registered',
55
+ registration: { status: 'home', activeRats: new Set(['lte']) },
56
+ });
57
+ expect(() => applyTransition(start, { presence: 'absent' })).toThrow(ImpossibleStateError);
58
+ });
59
+
60
+ test('markSourceUnavailable keeps presence and marks the source, bumping revision', () => {
61
+ const present = applyTransition(initialSnapshot(IDENTITY), { presence: 'present' });
62
+ const unavailable = markSourceUnavailable(present);
63
+ expect(unavailable.presence).toBe('present');
64
+ expect(unavailable.sourceHealth).toBe('sourceUnavailable');
65
+ expect(unavailable.revision).toBe(revision(2));
66
+ });
67
+
68
+ test('supersede rejects a stale or equal revision', () => {
69
+ const current = applyTransition(initialSnapshot(IDENTITY), { reconcileStatus: 'converged' });
70
+ const stale: CellularSnapshot = { ...initialSnapshot(IDENTITY), revision: revision(0) };
71
+ expect(() => supersede(current, stale)).toThrow(RevisionMonotonicityError);
72
+ });
73
+
74
+ test('supersede accepts a strictly-newer valid snapshot', () => {
75
+ const current = initialSnapshot(IDENTITY);
76
+ const newer: CellularSnapshot = { ...initialSnapshot(IDENTITY), revision: revision(9) };
77
+ expect(supersede(current, newer).revision).toBe(revision(9));
78
+ });
79
+ });
80
+
81
+ // --- randomized property test ----------------------------------------------
82
+
83
+ const MM_STATES: readonly MmState[] = [
84
+ 'failed',
85
+ 'unknown',
86
+ 'initializing',
87
+ 'locked',
88
+ 'disabled',
89
+ 'enabled',
90
+ 'searching',
91
+ 'registered',
92
+ 'connecting',
93
+ 'connected',
94
+ ];
95
+ const REG_STATUSES: readonly RegistrationStatus[] = [
96
+ 'idle',
97
+ 'home',
98
+ 'searching',
99
+ 'denied',
100
+ 'unknown',
101
+ 'roaming',
102
+ ];
103
+ const NM_STATES: readonly NmActivation[] = [
104
+ 'unmanaged',
105
+ 'unavailable',
106
+ 'disconnected',
107
+ 'activating',
108
+ 'activated',
109
+ 'failed',
110
+ ];
111
+ const POWERS: readonly RadioPower[] = ['unknown', 'off', 'low', 'on'];
112
+ const STAGES: readonly RecoveryStage[] = [
113
+ 'idle',
114
+ 'attributing',
115
+ 'nm-cycle',
116
+ 'mm-cycle',
117
+ 'reset',
118
+ 'power-cycle',
119
+ 'cooldown',
120
+ 'exhausted',
121
+ ];
122
+ const LOCKS: readonly SimLock[] = [
123
+ 'unknown',
124
+ 'none',
125
+ 'sim-pin',
126
+ 'sim-puk',
127
+ 'net-pers',
128
+ 'permanently-blocked',
129
+ ];
130
+ const RATS: readonly RadioAccessTechnology[] = ['gsm', 'umts', 'lte', '5gnr'];
131
+
132
+ /** Deterministic xorshift32 PRNG so a failing case is always reproducible. */
133
+ function makePrng(seed: number): () => number {
134
+ let state = seed >>> 0 || 1;
135
+ return () => {
136
+ state ^= state << 13;
137
+ state >>>= 0;
138
+ state ^= state >> 17;
139
+ state ^= state << 5;
140
+ state >>>= 0;
141
+ return state / 0xffffffff;
142
+ };
143
+ }
144
+
145
+ function pick<T>(rng: () => number, values: readonly T[]): T {
146
+ const index = Math.min(values.length - 1, Math.floor(rng() * values.length));
147
+ return values[index] as T;
148
+ }
149
+
150
+ function randomSnapshot(rng: () => number): CellularSnapshot {
151
+ const rats = new Set<RadioAccessTechnology>();
152
+ for (const rat of RATS) {
153
+ if (rng() < 0.5) {
154
+ rats.add(rat);
155
+ }
156
+ }
157
+
158
+ const slotCount = Math.floor(rng() * 3);
159
+ const simSlots: SimSlot[] = [];
160
+ for (let i = 0; i < slotCount; i += 1) {
161
+ simSlots.push({
162
+ index: i + 1,
163
+ occupied: rng() < 0.7,
164
+ active: rng() < 0.5,
165
+ lock: pick(rng, LOCKS),
166
+ });
167
+ }
168
+
169
+ let dataInterface: DataInterface;
170
+ if (rng() < 0.6) {
171
+ dataInterface = rng() < 0.5 ? { present: true, name: 'wwan0' } : { present: true };
172
+ } else {
173
+ dataInterface = rng() < 0.5 ? { present: false } : { present: false, name: 'wwan0' };
174
+ }
175
+
176
+ const stage = pick(rng, STAGES);
177
+ const attempts = Math.floor(rng() * 4);
178
+ const recoveryState: RecoveryState =
179
+ rng() < 0.5
180
+ ? { stage, attempts, cooldownUntil: epochMillis(1_700_000_000_000) }
181
+ : { stage, attempts };
182
+
183
+ return {
184
+ identity: IDENTITY,
185
+ presence: rng() < 0.5 ? 'present' : 'absent',
186
+ sourceHealth: pick(rng, ['live', 'stale', 'sourceUnavailable'] as const),
187
+ simSlots,
188
+ radioPower: pick(rng, POWERS),
189
+ mmState: pick(rng, MM_STATES),
190
+ registration: { status: pick(rng, REG_STATUSES), activeRats: rats },
191
+ nmActivation: pick(rng, NM_STATES),
192
+ dataInterface,
193
+ reconcileStatus: pick(rng, [
194
+ 'converged',
195
+ 'reconciling',
196
+ 'pending',
197
+ 'divergent',
198
+ 'unsupported',
199
+ ] as const),
200
+ recoveryState,
201
+ revision: revision(Math.floor(rng() * 1_000_000)),
202
+ };
203
+ }
204
+
205
+ /** Independent restatement of the invariants — never calls the guards under test. */
206
+ function assertInvariants(s: CellularSnapshot): void {
207
+ if (isRegistered(s.registration.status)) {
208
+ expect(s.presence).toBe('present');
209
+ expect(s.radioPower).not.toBe('off');
210
+ expect(s.registration.activeRats.size).toBeGreaterThan(0);
211
+ }
212
+ if (MM_STATES_REQUIRING_RADIO.has(s.mmState)) {
213
+ expect(s.presence).toBe('present');
214
+ }
215
+ if (s.nmActivation === 'activated') {
216
+ expect(s.presence).toBe('present');
217
+ expect(s.dataInterface.present).toBe(true);
218
+ expect(s.mmState).toBe('connected');
219
+ }
220
+ if (s.radioPower === 'off') {
221
+ const onAir =
222
+ MM_STATES_REQUIRING_RADIO.has(s.mmState) ||
223
+ s.registration.status === 'searching' ||
224
+ s.nmActivation === 'activating' ||
225
+ s.nmActivation === 'activated';
226
+ expect(onAir).toBe(false);
227
+ }
228
+ expect(s.simSlots.filter((slot) => slot.active).length).toBeLessThanOrEqual(1);
229
+ for (const slot of s.simSlots) {
230
+ if (!slot.occupied) {
231
+ expect(SIM_LOCK_REQUIRES_CARD.has(slot.lock)).toBe(false);
232
+ }
233
+ }
234
+ if (!s.dataInterface.present) {
235
+ expect(s.dataInterface.name).toBeUndefined();
236
+ }
237
+ expect(s.recoveryState.attempts).toBeGreaterThanOrEqual(0);
238
+ expect(s.recoveryState.cooldownUntil !== undefined).toBe(s.recoveryState.stage === 'cooldown');
239
+ }
240
+
241
+ describe('randomized property test', () => {
242
+ test('every constructed snapshot upholds the invariants; every rejection is typed', () => {
243
+ const rng = makePrng(0x9e3779b9);
244
+ let constructed = 0;
245
+ let rejected = 0;
246
+
247
+ for (let i = 0; i < 5000; i += 1) {
248
+ const candidate = randomSnapshot(rng);
249
+ let built: CellularSnapshot | null = null;
250
+ try {
251
+ built = createSnapshot(candidate);
252
+ } catch (error) {
253
+ rejected += 1;
254
+ expect(error).toBeInstanceOf(ImpossibleStateError);
255
+ continue;
256
+ }
257
+ constructed += 1;
258
+ assertInvariants(built);
259
+ // The plan's headline invariant, stated on its own.
260
+ expect(isRegistered(built.registration.status) && built.presence === 'absent').toBe(false);
261
+ }
262
+
263
+ expect(constructed).toBeGreaterThan(0);
264
+ expect(rejected).toBeGreaterThan(0);
265
+ });
266
+ });
@@ -0,0 +1,120 @@
1
+ // CellularSnapshot — the whole coherent state of one modem at one revision.
2
+ //
3
+ // A snapshot composes the identity and all eight orthogonal dimensions plus a
4
+ // monotonic `revision`. Every snapshot that exists has passed the guards: the
5
+ // constructors here are the ONLY sanctioned way to build or advance one, so an
6
+ // impossible combination can never be observed downstream. Revisions strictly
7
+ // increase, letting the observer (A3.1) and consumers order and dedupe events.
8
+
9
+ import type { Brand } from './brand';
10
+ import { nonNegativeInteger } from './brand';
11
+ import { RevisionMonotonicityError } from './errors';
12
+ import { assertSnapshot } from './guards';
13
+ import type { ModemIdentity } from './identity';
14
+ import type {
15
+ DataInterface,
16
+ MmState,
17
+ NmActivation,
18
+ Presence,
19
+ RadioPower,
20
+ ReconcileStatus,
21
+ RecoveryState,
22
+ Registration,
23
+ SimSlot,
24
+ SourceHealth,
25
+ } from './state';
26
+
27
+ /** A monotonically increasing snapshot revision. */
28
+ export type Revision = Brand<number, 'Revision'>;
29
+
30
+ /** Construct a `Revision` from a non-negative integer. */
31
+ export function revision(value: number): Revision {
32
+ return nonNegativeInteger(value, 'revision') as Revision;
33
+ }
34
+
35
+ /** The revision every fresh identity starts at. */
36
+ export const INITIAL_REVISION: Revision = revision(0);
37
+
38
+ /** The next revision after `current`. */
39
+ export function nextRevision(current: Revision): Revision {
40
+ return (current + 1) as Revision;
41
+ }
42
+
43
+ /** The full coherent state of one modem at one point in time. */
44
+ export interface CellularSnapshot {
45
+ readonly identity: ModemIdentity;
46
+ readonly presence: Presence;
47
+ readonly sourceHealth: SourceHealth;
48
+ readonly simSlots: readonly SimSlot[];
49
+ readonly radioPower: RadioPower;
50
+ readonly mmState: MmState;
51
+ readonly registration: Registration;
52
+ readonly nmActivation: NmActivation;
53
+ readonly dataInterface: DataInterface;
54
+ readonly reconcileStatus: ReconcileStatus;
55
+ readonly recoveryState: RecoveryState;
56
+ readonly revision: Revision;
57
+ }
58
+
59
+ /** A partial update to a snapshot's dimensions; `revision` is managed, not patched. */
60
+ export type SnapshotPatch = Partial<Omit<CellularSnapshot, 'revision'>>;
61
+
62
+ /**
63
+ * Validate and return a snapshot. The guards run here: an impossible combination
64
+ * throws `ImpossibleStateError` rather than producing an incoherent value. This
65
+ * is the sole sanctioned constructor for an arbitrary snapshot.
66
+ */
67
+ export function createSnapshot(fields: CellularSnapshot): CellularSnapshot {
68
+ assertSnapshot(fields);
69
+ return fields;
70
+ }
71
+
72
+ /** A valid baseline for a freshly-observed-but-absent modem, at revision 0. */
73
+ export function initialSnapshot(identity: ModemIdentity): CellularSnapshot {
74
+ return {
75
+ identity,
76
+ presence: 'absent',
77
+ sourceHealth: 'live',
78
+ simSlots: [],
79
+ radioPower: 'unknown',
80
+ mmState: 'unknown',
81
+ registration: { status: 'unknown', activeRats: new Set() },
82
+ nmActivation: 'unavailable',
83
+ dataInterface: { present: false },
84
+ reconcileStatus: 'pending',
85
+ recoveryState: { stage: 'idle', attempts: 0 },
86
+ revision: INITIAL_REVISION,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Apply a dimension patch, bump the revision, and re-validate. Monotonicity is
92
+ * automatic (revision always advances by one); an impossible result throws.
93
+ */
94
+ export function applyTransition(prev: CellularSnapshot, patch: SnapshotPatch): CellularSnapshot {
95
+ const next: CellularSnapshot = { ...prev, ...patch, revision: nextRevision(prev.revision) };
96
+ assertSnapshot(next);
97
+ return next;
98
+ }
99
+
100
+ /**
101
+ * Replace a snapshot with a fully-formed successor that carries its own revision
102
+ * (the observer path). Enforces strict monotonicity — a stale or equal revision
103
+ * throws `RevisionMonotonicityError` — and validates the successor.
104
+ */
105
+ export function supersede(prev: CellularSnapshot, next: CellularSnapshot): CellularSnapshot {
106
+ if (next.revision <= prev.revision) {
107
+ throw new RevisionMonotonicityError(prev.revision, next.revision);
108
+ }
109
+ assertSnapshot(next);
110
+ return next;
111
+ }
112
+
113
+ /**
114
+ * The source (MM daemon / bus) dropped: mark the data `sourceUnavailable` while
115
+ * KEEPING presence and all other facts. Stale is never removal — only an
116
+ * authoritative snapshot confirms absence (draft §Oracle round-1 lifecycle).
117
+ */
118
+ export function markSourceUnavailable(prev: CellularSnapshot): CellularSnapshot {
119
+ return applyTransition(prev, { sourceHealth: 'sourceUnavailable' });
120
+ }