@ceralive/modem-control 0.2.0 → 1.0.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.
@@ -0,0 +1,207 @@
1
+ // `setUsagePolicy` — the WRITE half of the data-usage surface.
2
+ //
3
+ // The read half already existed (`UsageSampler.snapshot()` reports `cycleBytes`,
4
+ // `thresholdBytes` and `thresholdExceeded`), but nothing could SET the two
5
+ // numbers those readings are computed against: `DesiredUsage` was a shape the
6
+ // planner echoed into a receipt, with no persistence and no apply path. This
7
+ // module closes that, mirroring the read side's file-store idiom exactly.
8
+ //
9
+ // It is a LOCAL write, not a modem write — see `policy-store.ts`'s header for the
10
+ // ModemManager API evidence. Nothing here touches D-Bus, `mmcli`, or any bearer.
11
+ //
12
+ // TYPED RESULTS, NEVER THROWS ON BAD INPUT. Following the `PowerHook` precedent
13
+ // (`power-contract.ts`: `applied` / `unsupported` / `failed`), an out-of-range
14
+ // day is a `rejected` result carrying a named reason rather than an exception —
15
+ // this is called from an RPC boundary where a throw becomes an opaque 500.
16
+
17
+ import type { DesiredUsage } from '../../domain';
18
+ import {
19
+ isValidCycleDay,
20
+ isValidThresholdBytes,
21
+ type PersistedUsagePolicySlot,
22
+ selectUsagePolicy,
23
+ USAGE_POLICY_SCHEMA_VERSION,
24
+ type UsagePolicyStore,
25
+ } from './policy-store';
26
+
27
+ /**
28
+ * The live-apply seam. `UsageSampler` implements it; a caller with no running
29
+ * sampler simply omits it and the write is persistence-only.
30
+ */
31
+ export interface UsagePolicyTarget {
32
+ applyUsagePolicy(
33
+ logicalSlotId: string,
34
+ usage: DesiredUsage,
35
+ atMs?: number,
36
+ ): UsagePolicyApplication;
37
+ }
38
+
39
+ /** What a live apply did to the slot's accounting window. */
40
+ export interface UsagePolicyApplication {
41
+ /** The UTC start of the cycle the slot is now accruing into. */
42
+ readonly cycleStartMs: number;
43
+ /** True when the cycle ANCHOR moved, so the per-cycle total restarted at 0. */
44
+ readonly cycleReset: boolean;
45
+ }
46
+
47
+ export interface SetUsagePolicyDeps {
48
+ readonly store: UsagePolicyStore;
49
+ /** Optional live sampler to apply the change to immediately. */
50
+ readonly sampler?: UsagePolicyTarget;
51
+ /** Injectable clock (defaults to `Date.now`). */
52
+ readonly now?: () => number;
53
+ }
54
+
55
+ /**
56
+ * The requested change.
57
+ *
58
+ * Tri-state per field, and the distinction is the whole point: `undefined`
59
+ * leaves the persisted value ALONE (so a caller changing only the threshold
60
+ * cannot silently drop a cycle day it never mentioned), while an explicit `null`
61
+ * CLEARS it. A caller that cannot express `null` can never unset a policy.
62
+ */
63
+ export interface SetUsagePolicyRequest {
64
+ readonly logicalSlotId: string;
65
+ readonly cycleDay?: number | null;
66
+ readonly thresholdBytes?: number | null;
67
+ }
68
+
69
+ export type SetUsagePolicyRejection =
70
+ | 'invalid-slot-id'
71
+ | 'invalid-cycle-day'
72
+ | 'invalid-threshold-bytes';
73
+
74
+ export type SetUsagePolicyResult =
75
+ | {
76
+ readonly status: 'applied';
77
+ readonly logicalSlotId: string;
78
+ /** The policy now persisted for this slot (post-merge). */
79
+ readonly usage: DesiredUsage;
80
+ /** Present only when a live sampler was supplied. */
81
+ readonly applied?: UsagePolicyApplication;
82
+ }
83
+ | {
84
+ readonly status: 'rejected';
85
+ readonly logicalSlotId: string;
86
+ readonly reason: SetUsagePolicyRejection;
87
+ }
88
+ | {
89
+ readonly status: 'failed';
90
+ readonly logicalSlotId: string;
91
+ readonly reason: string;
92
+ };
93
+
94
+ function validateRequest(request: SetUsagePolicyRequest): SetUsagePolicyRejection | undefined {
95
+ if (typeof request.logicalSlotId !== 'string' || request.logicalSlotId.length === 0) {
96
+ return 'invalid-slot-id';
97
+ }
98
+ if (
99
+ request.cycleDay !== undefined &&
100
+ request.cycleDay !== null &&
101
+ !isValidCycleDay(request.cycleDay)
102
+ ) {
103
+ return 'invalid-cycle-day';
104
+ }
105
+ if (
106
+ request.thresholdBytes !== undefined &&
107
+ request.thresholdBytes !== null &&
108
+ !isValidThresholdBytes(request.thresholdBytes)
109
+ ) {
110
+ return 'invalid-threshold-bytes';
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ /** Fold the request onto the currently-persisted policy (tri-state merge). */
116
+ function mergePolicy(current: DesiredUsage, request: SetUsagePolicyRequest): DesiredUsage {
117
+ const cycleDay =
118
+ request.cycleDay === undefined ? current.cycleDay : (request.cycleDay ?? undefined);
119
+ const thresholdBytes =
120
+ request.thresholdBytes === undefined
121
+ ? current.thresholdBytes
122
+ : (request.thresholdBytes ?? undefined);
123
+ return {
124
+ ...(cycleDay !== undefined ? { cycleDay } : {}),
125
+ ...(thresholdBytes !== undefined ? { thresholdBytes } : {}),
126
+ };
127
+ }
128
+
129
+ function toSlot(logicalSlotId: string, usage: DesiredUsage): PersistedUsagePolicySlot {
130
+ return {
131
+ logicalSlotId,
132
+ ...(usage.cycleDay !== undefined ? { cycleDay: usage.cycleDay } : {}),
133
+ ...(usage.thresholdBytes !== undefined ? { thresholdBytes: usage.thresholdBytes } : {}),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Persist a slot's usage policy and, when a live sampler is supplied, apply it
139
+ * to that sampler in the same call.
140
+ *
141
+ * ORDER IS LOAD → VALIDATE → PERSIST → APPLY, and it is deliberate. The store is
142
+ * the source of truth (the composition root rebuilds every `UsageObservation`
143
+ * from it), so a live apply that landed while the write failed would leave the
144
+ * running process disagreeing with what a restart would restore.
145
+ */
146
+ export async function setUsagePolicy(
147
+ deps: SetUsagePolicyDeps,
148
+ request: SetUsagePolicyRequest,
149
+ ): Promise<SetUsagePolicyResult> {
150
+ const logicalSlotId = typeof request.logicalSlotId === 'string' ? request.logicalSlotId : '';
151
+ const rejection = validateRequest(request);
152
+ if (rejection !== undefined) {
153
+ return { status: 'rejected', logicalSlotId, reason: rejection };
154
+ }
155
+
156
+ const now = deps.now ?? Date.now;
157
+ const at = now();
158
+
159
+ let usage: DesiredUsage;
160
+ try {
161
+ const state = await deps.store.load(at);
162
+ usage = mergePolicy(selectUsagePolicy(state, logicalSlotId), request);
163
+ const others = state.slots.filter((slot) => slot.logicalSlotId !== logicalSlotId);
164
+ // An empty policy is REMOVED rather than stored as an empty row: "no policy"
165
+ // and "a policy that sets nothing" are the same fact, and keeping the row
166
+ // would grow the file by one entry per slot an operator ever cleared.
167
+ const slots =
168
+ usage.cycleDay === undefined && usage.thresholdBytes === undefined
169
+ ? others
170
+ : [...others, toSlot(logicalSlotId, usage)];
171
+ await deps.store.save({
172
+ schemaVersion: USAGE_POLICY_SCHEMA_VERSION,
173
+ savedAtMs: at,
174
+ slots,
175
+ });
176
+ } catch (error) {
177
+ return {
178
+ status: 'failed',
179
+ logicalSlotId,
180
+ reason: error instanceof Error ? error.message : 'persist-failed',
181
+ };
182
+ }
183
+
184
+ if (deps.sampler === undefined) {
185
+ return { status: 'applied', logicalSlotId, usage };
186
+ }
187
+ try {
188
+ const applied = deps.sampler.applyUsagePolicy(logicalSlotId, usage, at);
189
+ return { status: 'applied', logicalSlotId, usage, applied };
190
+ } catch (error) {
191
+ return {
192
+ status: 'failed',
193
+ logicalSlotId,
194
+ reason: error instanceof Error ? error.message : 'apply-failed',
195
+ };
196
+ }
197
+ }
198
+
199
+ /** Read one slot's persisted policy. The read counterpart of `setUsagePolicy`. */
200
+ export async function getUsagePolicy(
201
+ deps: Pick<SetUsagePolicyDeps, 'store' | 'now'>,
202
+ logicalSlotId: string,
203
+ ): Promise<DesiredUsage> {
204
+ const now = deps.now ?? Date.now;
205
+ const state = await deps.store.load(now());
206
+ return selectUsagePolicy(state, logicalSlotId);
207
+ }
@@ -217,3 +217,111 @@ describe('UsageSampler — reboot (new boot id) re-baselines without losing the
217
217
  expect(rebooted.snapshot().slots[0]?.cycleBytes).toBe(350);
218
218
  });
219
219
  });
220
+
221
+ describe('UsageSampler — applyUsagePolicy (the setUsagePolicy live-apply seam)', () => {
222
+ const AUG_16 = Date.UTC(2026, 7, 16, 12, 0, 0);
223
+
224
+ async function samplerAt(now: number) {
225
+ const counters = new FakeCounters();
226
+ const sampler = await createUsageSampler({
227
+ bootId: 'boot-1',
228
+ source: counters,
229
+ store: new MemStore(),
230
+ now: () => now,
231
+ });
232
+ return { counters, sampler };
233
+ }
234
+
235
+ test('a threshold-only change takes effect immediately and resets nothing', async () => {
236
+ const { counters, sampler } = await samplerAt(AUG_16);
237
+ counters.set('wwan0', 100);
238
+ await sampler.sample([obs(SLOT_A, 'wwan0')]);
239
+ counters.set('wwan0', 900);
240
+ await sampler.sample([obs(SLOT_A, 'wwan0')]);
241
+
242
+ const applied = sampler.applyUsagePolicy(SLOT_A, { thresholdBytes: 500 });
243
+
244
+ expect(applied.cycleReset).toBe(false);
245
+ const slot = sampler.snapshot().slots[0];
246
+ expect(slot?.cycleBytes).toBe(800);
247
+ expect(slot?.thresholdBytes).toBe(500);
248
+ expect(slot?.thresholdExceeded).toBe(true);
249
+ });
250
+
251
+ test('a CHANGED cycle day restarts the window at zero and re-anchors it', async () => {
252
+ const { counters, sampler } = await samplerAt(AUG_16);
253
+ counters.set('wwan0', 100);
254
+ await sampler.sample([obs(SLOT_A, 'wwan0')]);
255
+ counters.set('wwan0', 900);
256
+ await sampler.sample([obs(SLOT_A, 'wwan0')]);
257
+ expect(sampler.snapshot().slots[0]?.cycleBytes).toBe(800);
258
+
259
+ const applied = sampler.applyUsagePolicy(SLOT_A, { cycleDay: 20 });
260
+
261
+ expect(applied.cycleReset).toBe(true);
262
+ expect(applied.cycleStartMs).toBe(Date.UTC(2026, 6, 20));
263
+ const slot = sampler.snapshot().slots[0];
264
+ expect(slot?.cycleBytes).toBe(0);
265
+ expect(slot?.cycleStartMs).toBe(Date.UTC(2026, 6, 20));
266
+ expect(slot?.cycleDay).toBe(20);
267
+ });
268
+
269
+ test('the BASELINE survives the reset, so the next sample attributes no jump', async () => {
270
+ const { counters, sampler } = await samplerAt(AUG_16);
271
+ counters.set('wwan0', 1_000_000);
272
+ await sampler.sample([obs(SLOT_A, 'wwan0')]);
273
+
274
+ sampler.applyUsagePolicy(SLOT_A, { cycleDay: 20 });
275
+ counters.set('wwan0', 1_000_150);
276
+ await sampler.sample([obs(SLOT_A, 'wwan0', { cycleDay: 20 })]);
277
+
278
+ expect(sampler.snapshot().slots[0]?.cycleBytes).toBe(150);
279
+ });
280
+
281
+ test('re-applying the SAME cycle day is a no-op — repeated saves never zero a window', async () => {
282
+ const { counters, sampler } = await samplerAt(AUG_16);
283
+ counters.set('wwan0', 100);
284
+ await sampler.sample([obs(SLOT_A, 'wwan0', { cycleDay: 20 })]);
285
+ counters.set('wwan0', 400);
286
+ await sampler.sample([obs(SLOT_A, 'wwan0', { cycleDay: 20 })]);
287
+
288
+ const applied = sampler.applyUsagePolicy(SLOT_A, { cycleDay: 20 });
289
+
290
+ expect(applied.cycleReset).toBe(false);
291
+ expect(sampler.snapshot().slots[0]?.cycleBytes).toBe(300);
292
+ });
293
+
294
+ test('an applied policy OUTRANKS a stale observation on the next sample', async () => {
295
+ const { counters, sampler } = await samplerAt(AUG_16);
296
+ counters.set('wwan0', 100);
297
+ await sampler.sample([obs(SLOT_A, 'wwan0', { thresholdBytes: 10 })]);
298
+
299
+ sampler.applyUsagePolicy(SLOT_A, { thresholdBytes: 999 });
300
+ // The composition root has not rebuilt its observations yet and still
301
+ // carries the OLD policy — the write must not silently revert.
302
+ await sampler.sample([obs(SLOT_A, 'wwan0', { thresholdBytes: 10 })]);
303
+
304
+ expect(sampler.snapshot().slots[0]?.thresholdBytes).toBe(999);
305
+ });
306
+
307
+ test('applying to a slot never sampled creates it without claiming any bytes', async () => {
308
+ const { sampler } = await samplerAt(AUG_16);
309
+
310
+ const applied = sampler.applyUsagePolicy(SLOT_B, { cycleDay: 3, thresholdBytes: 7 });
311
+
312
+ expect(applied.cycleReset).toBe(false);
313
+ const slot = sampler.snapshot().slots[0];
314
+ expect(slot?.logicalSlotId).toBe('slot-b');
315
+ expect(slot?.cycleBytes).toBe(0);
316
+ expect(slot?.cycleDay).toBe(3);
317
+ expect(slot?.thresholdBytes).toBe(7);
318
+ });
319
+
320
+ test('a slot with no policy reports no cycleDay rather than the sampler default', async () => {
321
+ const { counters, sampler } = await samplerAt(AUG_16);
322
+ counters.set('wwan0', 10);
323
+ await sampler.sample([obs(SLOT_A, 'wwan0')]);
324
+
325
+ expect(sampler.snapshot().slots[0]?.cycleDay).toBeUndefined();
326
+ });
327
+ });
@@ -36,6 +36,8 @@ export interface SlotUsageSnapshot {
36
36
  readonly cycleBytes: number;
37
37
  readonly cycleStartMs: number;
38
38
  readonly paused: boolean;
39
+ /** The cycle day in force for this slot, when the operator set one. */
40
+ readonly cycleDay?: number;
39
41
  readonly thresholdBytes?: number;
40
42
  /** Advisory-only: `cycleBytes > thresholdBytes`. Never gates the connection. */
41
43
  readonly thresholdExceeded: boolean;
@@ -85,6 +87,13 @@ export class UsageSampler {
85
87
  readonly #defaultCycleDay: number;
86
88
  readonly #accounts = new Map<string, SlotAccount>();
87
89
  readonly #policies = new Map<string, DesiredUsage>();
90
+ // Policies written through `applyUsagePolicy` OUTRANK whatever an observation
91
+ // carries, for the life of the process. Without this, the next `sample()` would
92
+ // clobber a just-applied write with the policy the composition root happened to
93
+ // build its observation from — and the operator would watch their setting
94
+ // revert. The durable store is the source of truth for both, so an override and
95
+ // an observation can only ever disagree inside that window.
96
+ readonly #policyOverrides = new Map<string, DesiredUsage>();
88
97
  #lastPersistMs: number;
89
98
  #dirty = false;
90
99
 
@@ -145,8 +154,9 @@ export class UsageSampler {
145
154
  const now = this.#now();
146
155
  for (const obs of observations) {
147
156
  const slotId = obs.logicalSlotId as string;
148
- this.#policies.set(slotId, obs.usage);
149
- const cycleDay = obs.usage.cycleDay ?? this.#defaultCycleDay;
157
+ const usage = this.#policyOverrides.get(slotId) ?? obs.usage;
158
+ this.#policies.set(slotId, usage);
159
+ const cycleDay = usage.cycleDay ?? this.#defaultCycleDay;
150
160
  const cycleStartMs = cycleStart(epochMillis(now), cycleDay);
151
161
  const current = counters.get(obs.ifname);
152
162
  if (current === undefined) {
@@ -179,12 +189,14 @@ export class UsageSampler {
179
189
  const generatedAtMs = this.#now();
180
190
  const slots: SlotUsageSnapshot[] = [];
181
191
  for (const [slotId, account] of this.#accounts) {
182
- const thresholdBytes = this.#policies.get(slotId)?.thresholdBytes;
192
+ const policy = this.#policies.get(slotId);
193
+ const thresholdBytes = policy?.thresholdBytes;
183
194
  slots.push({
184
195
  logicalSlotId: slotId,
185
196
  cycleBytes: account.cycleBytes,
186
197
  cycleStartMs: account.cycleStartMs,
187
198
  paused: account.paused,
199
+ ...(policy?.cycleDay !== undefined ? { cycleDay: policy.cycleDay } : {}),
188
200
  ...(thresholdBytes !== undefined ? { thresholdBytes } : {}),
189
201
  thresholdExceeded: thresholdBytes !== undefined && account.cycleBytes > thresholdBytes,
190
202
  });
@@ -192,6 +204,48 @@ export class UsageSampler {
192
204
  return { bootId: this.#bootId, generatedAtMs, slots };
193
205
  }
194
206
 
207
+ /**
208
+ * Apply an operator's usage policy to this slot immediately, without waiting
209
+ * for the next sampling pass.
210
+ *
211
+ * A CHANGED CYCLE ANCHOR RESTARTS THE WINDOW AT ZERO, and keeps the counter
212
+ * BASELINE. Those two halves are the honest answer to a question with no
213
+ * truthful one: bytes already accrued were measured under the OLD window, so
214
+ * carrying them into the new one over-reports it, and there is no record of
215
+ * how they were distributed within it. Starting fresh states plainly that the
216
+ * new window began now; keeping `lastObserved` means the next sample still
217
+ * attributes only genuinely new bytes, never a jump. A threshold-only change
218
+ * moves no anchor and therefore resets nothing.
219
+ */
220
+ applyUsagePolicy(
221
+ logicalSlotId: string,
222
+ usage: DesiredUsage,
223
+ atMs?: number,
224
+ ): {
225
+ cycleStartMs: number;
226
+ cycleReset: boolean;
227
+ } {
228
+ const now = atMs ?? this.#now();
229
+ this.#policyOverrides.set(logicalSlotId, usage);
230
+ this.#policies.set(logicalSlotId, usage);
231
+ const cycleStartMs = cycleStart(
232
+ epochMillis(now),
233
+ usage.cycleDay ?? this.#defaultCycleDay,
234
+ ) as number;
235
+ const account = this.#accounts.get(logicalSlotId);
236
+ if (account === undefined) {
237
+ this.#accounts.set(logicalSlotId, initialAccount(cycleStartMs));
238
+ this.#dirty = true;
239
+ return { cycleStartMs, cycleReset: false };
240
+ }
241
+ if (account.cycleStartMs === cycleStartMs) {
242
+ return { cycleStartMs, cycleReset: false };
243
+ }
244
+ this.#accounts.set(logicalSlotId, { ...account, cycleBytes: 0, cycleStartMs });
245
+ this.#dirty = true;
246
+ return { cycleStartMs, cycleReset: true };
247
+ }
248
+
195
249
  /** Flush unpersisted state immediately — the shutdown hook (bounds loss to ≤1 min). */
196
250
  async flush(): Promise<void> {
197
251
  if (this.#dirty) {
@@ -1,8 +1,13 @@
1
- // The certified USB-mode catalog — schema, data, and lookups.
1
+ // The certified USB-mode catalog — schema, data, lookups, and the evidence-bundle
2
+ // ingestion seam.
2
3
  //
3
4
  // A6.1's bench CLI (`set-usb-mode`) and A6.2's `certify` tool both consume this: the
4
5
  // CLI looks up the permitted transition for a target mode, `certify` validates a
5
6
  // candidate entry against the schema before a human commits it.
7
+ //
8
+ // The ingestion seam (`./ingestion`, `./promotion-review`) is the documented path from a
9
+ // captured `certify` bundle to a reviewed catalog commit — see `docs/CATALOG-INGESTION.md`.
10
+ // It refuses a `synthetic: true` bundle for catalog promotion, by construction.
6
11
 
7
12
  export {
8
13
  CERTIFIED_CATALOG,
@@ -25,3 +30,29 @@ export {
25
30
  permittedTransitionSchema,
26
31
  type SkuDiscriminator,
27
32
  } from './catalog-schema';
33
+ export {
34
+ buildCatalogEntryCandidate,
35
+ buildClassifierFixture,
36
+ type CatalogClaim,
37
+ CLAIMABLE_CANONICAL_MODES,
38
+ type ClassifierFixture,
39
+ type EvidenceBundleView,
40
+ evidenceBundleViewSchema,
41
+ type FixtureProvenance,
42
+ type IngestionOutcome,
43
+ type IngestionRefusal,
44
+ type IngestionRefusalReason,
45
+ type IngestionRequest,
46
+ parseIngestionRequest,
47
+ } from './ingestion';
48
+ export {
49
+ type PromotionContext,
50
+ type PromotionRequest,
51
+ renderPromotionReview,
52
+ } from './promotion-review';
53
+ export {
54
+ type ParsedUsbDevice,
55
+ type ParsedUsbInterface,
56
+ parseUsbDevices,
57
+ selectUniqueDevice,
58
+ } from './usb-devices-parse';