diff --git a/.omo/evidence/task-30c-b2b6-pr.md b/.omo/evidence/task-30c-b2b6-pr.md new file mode 100644 index 0000000..60e9bdf --- /dev/null +++ b/.omo/evidence/task-30c-b2b6-pr.md @@ -0,0 +1,19 @@ +# Task 30c — B2/B5/B6 fix PR + +- Branch: `fix/usb-mode-certify-b2-b5-b6` +- Base: current `origin/main` (`4b4b206`, PR #12 squash) +- PR: https://github.com/CERALIVE/modem-stack/pull/14 +- Status: open; not merged +- CI: green — `Lint, typecheck, test (control + cli)` (run `32389680098`) + +The branch contains the five post-consolidation commits fixing the hardware-confirmed B2/B5/B6 issues. The real-hardware findings and fix rationale are documented in: + +- `.omo/evidence/task-30-real-hardware.md` +- `.omo/evidence/task-30b-b2-b5-b6-fix.md` + +Local verification after cherry-picking onto `origin/main`: + +- `bun test`: 1321 passed, 0 failed +- `bun run typecheck`: passed + +The rebased tree matches the combined net changes from the five commits originally based on `90cb14a`; no AI trailers were present in the five resulting commits. diff --git a/AGENTS.md b/AGENTS.md index 16e5f05..a3a1509 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -457,20 +457,18 @@ transform in `control/src/usb-mode/{ingestion,promotion-review,usb-devices-parse - Per-SKU capture runbooks are `docs/BENCH.md` **RB-11 … RB-15** (RB-16 is the FM350 USB-vs-PCIe probe — its 2026-08-16 bench run found the unit not connected, so `docs/FM350-DECISION.md`'s three-gate ledger stays OPEN with the probe evidence recorded; - RB-17 is modem-flap resilience). All are `[PARTIAL]` — **six** named blockers are recorded - in `docs/BENCH.md` § "Per-SKU certification", re-verified live on 2026-08-18: - **B1 CLEARED** (`usbutils` is now on the board), **B3 downgraded** (`socat` is present, so - a manual query-only AT session works; the CLI's `benchAtSender` still rejects every send), - and **B2 promoted to hardware-proven** — `certify` matches its device by `ifname`, which - the enumerator never populates, so **every real bundle comes out with no `sku` and empty - `udevProperties`** and the ingestion seam refuses it `sku-missing`. Two blockers are new: - **B5**, the shared redactor does not mask `imei` / `equipment-identifier`, so a real - bundle carries every bench modem's IMEI and must not be committed or pasted into a review - comment; and **B6**, `skuOf` reads `firmwarePrefix` from udev `ID_REVISION`, which is the - USB `bcdDevice` rather than the modem firmware revision, so a catalog entry built from a - capture would not actually be firmware-keyed. B2 and B6 are pinned by - `control/src/usb-mode/ingestion.hardware.test.ts`. No SKU is certified and no matrix row - is promoted. + RB-17 is modem-flap resilience). All are `[PARTIAL]` — the **six-entry** status ledger is + in `docs/BENCH.md` § "Per-SKU certification". B1 is cleared; B3 is partially cleared + (`socat` permits a manual query-only AT session, but `benchAtSender` still rejects sends); + B4 remains open. The hardware-proven capture defects B2/B5/B6 were fixed in software on + 2026-08-20: USB snapshots retain their `/sys` path, `certify` correlates MM + `Device`/`Physdev` to the most-specific USB parent, `skuOf` receives MM `Modem.Revision` + rather than USB `ID_REVISION`, and the shared redactor masks MM/mmcli IMEI and equipment- + identifier spellings while preserving model/vendor/SKU facts. Realistic RM530N tests pin + `0504` versus `RM530NGLAAR05A01M4G`. The snapshot contract lives in + `control/src/backend/usb-device-snapshot.ts` and remains re-exported by the classifier. The + fixed build has **not** been rerun on the board, so no post-fix hardware bundle, certified + SKU, or promoted matrix row is claimed. - **Bench composition evidence** for the SIMCom SIM7600G-H and the carrier-mounted Fibocom FM350-GL is recorded in [`docs/COMPOSITION-EVIDENCE.md`](docs/COMPOSITION-EVIDENCE.md) — descriptors, driver bindings, firmware revisions, and the read-back state of each vendor's @@ -688,10 +686,10 @@ returns an IMSI and an ICCID, and they ARE retained — normalization does not g what a diagnostician may need. Anything that logs, serializes or files a diagnostics block must route it through `redactObservationDiagnostics`, which runs the package's own key-based `redact`, so the classes masked here are the classes masked everywhere else. Retention and -disclosure are separate decisions; this layer only guarantees the first. Note the recorded -`B5` finding still applies: the shared redactor does not mask `imei` / -`equipment-identifier`, so a caller that puts `Modem.EquipmentIdentifier` in a raw record -owns that exposure. + disclosure are separate decisions; this layer only guarantees the first. The former B5 + gap is closed: the shared redactor masks `imei`, `EquipmentIdentifier`, separator variants, + and dotted mmcli spellings. The raw diagnostics block still contains source values in + memory, so every serialization boundary must continue to call the redactor. **PER-METRIC PROVENANCE, INCLUDING PER-METRIC AUTHORITY.** One normalized observation folds several provider reads together — HiLink answers `monitoring_status` and `device_signal` diff --git a/cli/README.md b/cli/README.md index b96378a..eb6a43d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -12,7 +12,7 @@ to mature the package and (with `certify`) capture per-SKU certification bundles | `watch` | Live event stream. Prints `+ ADDED` / `~ CHANGED` / `- REMOVED` per change and `! SOURCE-UNAVAILABLE` on a bus drop. A bus drop / MM restart marks a modem source-unavailable with its row **retained** — never a removal (A3.1 epoch authority). `--duration ` / `--events ` bound the run; otherwise it runs until Ctrl-C. | | `apply --policy ` | Reads a JSON/YAML desired-state policy, derives the modem's durable binding key (refusing an ambiguous identity), runs the reconcile planner, applies the ops, and prints one receipt per policy dimension. | | `set-usb-mode --confirm` | Runs a certified USB-mode transition. **Omitting `--confirm` refuses the transition with zero side effects.** `` is one of `qmi` / `mbim` / `ecm-ncm`. | -| `certify ` | Captures a redacted, schema-validated certification bundle: `lsusb -v`, `usb-devices`, the slot's udev properties, an `mmcli -K` dump, a redacted `GetManagedObjects`, and a bounded signal window. `--transition ` adds transition evidence (before/after descriptors, the executed AT command, and the port-drop / re-enumeration timeline) shaped to drop straight into an A4.2 catalog entry. Prints `CERTIFY OK: sha256= …` — the sha256 is the value a reviewer records in a catalog entry's `evidenceBundleSha256`. Real captures are marked `synthetic: false`; ICCID / IMSI / EID are masked; a malformed capture exits non-zero with a clear error rather than writing a broken bundle. `--output ` writes the bundle JSON (default stdout). | +| `certify ` | Captures a redacted, schema-validated certification bundle: `lsusb -v`, `usb-devices`, the slot's udev properties, an `mmcli -K` dump, a redacted `GetManagedObjects`, and a bounded signal window. The selected MM object's `Device`/`Physdev` sysfs path is matched to the most-specific enumerated USB parent (never by `ifname`), and `sku.firmwarePrefix` comes from MM `Modem.Revision`, not USB `ID_REVISION`. `--transition ` adds transition evidence shaped to drop into an A4.2 catalog entry. Prints `CERTIFY OK: sha256= …`; real captures are `synthetic: false`, and ICCID / IMSI / EID / IMEI / equipment identifiers are masked while model/vendor/SKU facts remain visible. A malformed capture exits non-zero rather than writing a broken bundle. `--output ` writes JSON (default stdout). | | `usage` | Prints the data-usage sampler snapshot (per-slot cumulative-cycle bytes; advisory threshold). | | `unlock-pin [slot]` / `unlock-puk [slot]` | Prompts for the PIN / PUK with **terminal echo disabled** (the secret is never printed back) and submits it. | | `hil-cycle --hub-map ` | The hardware-in-the-loop port-cycle harness (bench runbook **RB-10**). The executable adapter lives in the CLI, not the published control provider set, and first acquires non-blocking exclusive ownership at `/run/ceralive/modem-control.lock`; contention refuses immediately. It captures pre-state (the `/sys/bus/usb/devices/*` sweep — there is no `lsusb` on the bench image — plus `mmcli -L`), drives the mapped port, then asserts the modem **left** the USB bus, **came back** at the same udev `ID_PATH` within the deadline, and that ModemManager re-detects the **same** `modem.generic.device` slot UID. Prints `HIL-CYCLE PASS slot= disappeared= reenumerated=`, or `HIL-CYCLE FAIL slot= reason=` with a named reason and a non-zero exit. **A zero exit from `uhubctl` is never the gate** — the modem's disappearance from the bus is. Needs `sudo` for `uhubctl`; refuses with zero side effects when the slot is not in the map. | diff --git a/cli/src/certify/bundle.test.ts b/cli/src/certify/bundle.test.ts index 332426d..90c9ec9 100644 --- a/cli/src/certify/bundle.test.ts +++ b/cli/src/certify/bundle.test.ts @@ -18,18 +18,22 @@ const ICCID = '8900000000000000123'; const IMSI = '001010000000123'; const EID = '89033024000000000000000000012345'; const IMEI = '350000000000001'; +const MODEM_REVISION = 'RM530NGLAAR05A01M4G'; +const USB_REVISION = '0504'; +const RM530N_SYSFS_PATH = + '/sys/devices/platform/fc400000.usb/xhci-hcd.0.auto/usb4/4-1/4-1.4/4-1.4.4'; -const LSUSB = `Bus 001 Device 004: ID 2c7c:0125 Quectel EG25-G +const LSUSB = `Bus 004 Device 009: ID 2c7c:0801 Quectel RM530N-GL Device Descriptor: bLength 18 idVendor 0x2c7c Quectel - idProduct 0x0125 + idProduct 0x0801 iSerial 3 abcdef123456 `; -const USB_DEVICES = `T: Bus=01 Dev#= 4 Spd=480 -P: Vendor=2c7c ProdID=0125 Rev=03.18 -S: Product=EG25-G +const USB_DEVICES = `T: Bus=04 Dev#= 9 Spd=5000 +P: Vendor=2c7c ProdID=0801 Rev=05.04 +S: Product=RM530N-GL `; const MMCLI_K = `modem.generic.device-identifier : 0123456789abcdef @@ -41,7 +45,7 @@ sim.properties.eid : ${EID} sim.properties.operator-name : CeraTel `; -const TREE = [ +const TREE: DecodedManagedObjects = [ [ '/org/freedesktop/ModemManager1/Modem/0', [ @@ -49,6 +53,9 @@ const TREE = [ 'org.freedesktop.ModemManager1.Modem', [ ['EquipmentIdentifier', { signature: 's', value: IMEI }], + ['Device', { signature: 's', value: RM530N_SYSFS_PATH }], + ['Physdev', { signature: 's', value: RM530N_SYSFS_PATH }], + ['Revision', { signature: 's', value: MODEM_REVISION }], ['Sim', { signature: 'o', value: '/org/freedesktop/ModemManager1/SIM/0' }], ['State', { signature: 'i', value: 8 }], ], @@ -69,24 +76,24 @@ const TREE = [ ], ], ], -] as unknown as DecodedManagedObjects; +]; const DEVICE: UsbDeviceSnapshot = { vendorId: '2c7c', - productId: '0125', - model: 'EG25-G', - firmwareRevision: 'SYNTHETICFW01', + productId: '0801', + model: 'RM530N-GL', + firmwareRevision: USB_REVISION, bDeviceClass: 0, interfaces: [ { interfaceClass: 255, interfaceSubClass: 255, interfaceProtocol: 255, driver: 'qmi_wwan' }, ], - physicalUid: 'usb-1-2', - ifname: 'wwan0', + physicalUid: 'platform-xhci-hcd.0.auto-usb-0:1.4.4', + ifname: 'wwan3', udevProperties: { ID_VENDOR_ID: '2c7c', - ID_MODEL_ID: '0125', - ID_MODEL: 'EG25-G', - ID_REVISION: 'SYNTHETICFW01', + ID_MODEL_ID: '0801', + ID_MODEL: 'RM530N-GL', + ID_REVISION: USB_REVISION, ID_SERIAL_SHORT: 'abcdef123456', // A udev rule that surfaced the SIM ICCID — proves redaction reaches udev props. iccid: ICCID, @@ -120,12 +127,16 @@ function fakeDeps(script: RunnerScript = {}): BaseCaptureDeps { if (command === 'mmcli') return Promise.resolve(script.mmcli ?? ok(MMCLI_K)); return Promise.resolve({ stdout: '', stderr: `unknown ${command}`, exitCode: 127 }); }, - fetchManagedObjects: () => Promise.resolve(TREE), captureSignalWindow: () => Promise.resolve(SIGNALS), }; } -const INPUT = { mmcliTarget: '/org/freedesktop/ModemManager1/Modem/0', device: DEVICE }; +const INPUT = { + managedObjects: TREE, + modemPath: '/org/freedesktop/ModemManager1/Modem/0', + mmcliTarget: '/org/freedesktop/ModemManager1/Modem/0', + device: DEVICE, +}; const buildFrom = (base: BaseCaptureParts, synthetic: boolean) => buildCertificationBundle({ slot: 'Modem/0', synthetic, capturedAtMs: 1000, base }); @@ -135,9 +146,9 @@ test('builds a schema-valid synthetic bundle with a stable, reproducible sha256' expect(first.bundle.schemaVersion).toBe(1); expect(first.bundle.synthetic).toBe(true); expect(first.bundle.sku).toEqual({ - vidPid: '2c7c:0125', - model: 'EG25-G', - firmwarePrefix: 'SYNTHETICFW01', + vidPid: '2c7c:0801', + model: 'RM530N-GL', + firmwarePrefix: MODEM_REVISION, }); expect(first.sha256).toMatch(/^[0-9a-f]{64}$/); @@ -145,23 +156,36 @@ test('builds a schema-valid synthetic bundle with a stable, reproducible sha256' expect(second.sha256).toBe(first.sha256); }); +test('uses Modem.Revision rather than USB ID_REVISION for the firmware-keyed SKU', async () => { + // Given + const deps = fakeDeps(); + + // When + const base = await captureBase(deps, INPUT); + + // Then + expect(base.sku?.firmwarePrefix).toBe(MODEM_REVISION); + expect(base.sku?.firmwarePrefix).not.toBe(USB_REVISION); +}); + test('a real capture is unambiguously marked synthetic:false', async () => { const { bundle } = buildFrom(await captureBase(fakeDeps(), INPUT), false); expect(bundle.synthetic).toBe(false); }); -test('redacts ICCID / IMSI / EID across mmcli-K, managed objects, and udev', async () => { +test('redacts subscriber and modem identifiers across mmcli-K, managed objects, and udev', async () => { const { bundle } = buildFrom(await captureBase(fakeDeps(), INPUT), true); const serialized = JSON.stringify(bundle); expect(serialized).not.toContain(ICCID); expect(serialized).not.toContain(IMSI); expect(serialized).not.toContain(EID); + expect(serialized).not.toContain(IMEI); - // mmcli -K keyfile: subscriber secrets masked by dotted-segment; IMEI retained. expect(bundle.modemManager.mmcliKeyfile['sim.properties.iccid']).toBe('[redacted]'); expect(bundle.modemManager.mmcliKeyfile['sim.properties.imsi']).toBe('[redacted]'); expect(bundle.modemManager.mmcliKeyfile['sim.properties.eid']).toBe('[redacted]'); - expect(bundle.modemManager.mmcliKeyfile['modem.3gpp.imei']).toBe(IMEI); + expect(bundle.modemManager.mmcliKeyfile['modem.generic.equipment-identifier']).toBe('[redacted]'); + expect(bundle.modemManager.mmcliKeyfile['modem.3gpp.imei']).toBe('[redacted]'); // GetManagedObjects: MM's SimIdentifier surfaced as `iccid` and masked; Imsi/Eid masked. const sim = bundle.modemManager.managedObjects['/org/freedesktop/ModemManager1/SIM/0']?.[ @@ -171,6 +195,11 @@ test('redacts ICCID / IMSI / EID across mmcli-K, managed objects, and udev', asy expect(sim.Imsi).toBe('[redacted]'); expect(sim.Eid).toBe('[redacted]'); expect(sim.OperatorName).toBe('CeraTel'); + const modem = + bundle.modemManager.managedObjects['/org/freedesktop/ModemManager1/Modem/0']?.[ + 'org.freedesktop.ModemManager1.Modem' + ]; + expect(modem).toEqual(expect.objectContaining({ EquipmentIdentifier: '[redacted]' })); // udev: redaction reaches the props (the ICCID-bearing rule is masked); the device // serial is equipment identity (non-subscriber), retained per the A2.1 policy. diff --git a/cli/src/certify/capture.ts b/cli/src/certify/capture.ts index 485939e..cbe2953 100644 --- a/cli/src/certify/capture.ts +++ b/cli/src/certify/capture.ts @@ -7,10 +7,11 @@ // broken bundle. The subscriber secrets live in the `mmcli -K` keyfile and the managed // objects, both captured as OBJECTS so the shared key-based redactor can mask them. -import type { - DecodedManagedObjects, - SkuDiscriminator, - UsbDeviceSnapshot, +import { + type DecodedManagedObjects, + readRevision, + type SkuDiscriminator, + type UsbDeviceSnapshot, } from '@ceralive/modem-control'; import type { SignalRecord } from './bundle-schema'; import type { CommandResult } from './command-runner'; @@ -35,7 +36,6 @@ export interface BaseCaptureParts { /** The injectable seams the base capture reads from (fakes drive the synthetic tests). */ export interface BaseCaptureDeps { run(command: string, args: readonly string[]): Promise; - fetchManagedObjects(): Promise; captureSignalWindow(): Promise; } @@ -43,6 +43,8 @@ export interface BaseCaptureDeps { export interface BaseCaptureInput { /** The matched target USB device — source of the slot's udev properties and SKU. */ readonly device?: UsbDeviceSnapshot; + readonly managedObjects: DecodedManagedObjects; + readonly modemPath: string; /** The `mmcli -m ` modem selector (a modem index or D-Bus path). */ readonly mmcliTarget: string; } @@ -110,10 +112,11 @@ export async function captureBase( const lsusb = await captureLsusb(deps); const usbDevices = await captureUsbDevices(deps); const mmcliKeyfile = await captureMmcli(deps, input.mmcliTarget); - const managedObjects = objectifyManagedObjects(await deps.fetchManagedObjects()); + const managedObjects = objectifyManagedObjects(input.managedObjects); const signalWindow = await deps.captureSignalWindow(); const udevProperties = { ...(input.device?.udevProperties ?? {}) }; - const sku = input.device !== undefined ? skuOf(input.device) : undefined; + const firmwareRevision = readRevision(input.managedObjects, input.modemPath); + const sku = input.device !== undefined ? skuOf(input.device, firmwareRevision) : undefined; return { ...(sku !== undefined ? { sku } : {}), diff --git a/cli/src/certify/transform.ts b/cli/src/certify/transform.ts index 7d07f3c..047b0eb 100644 --- a/cli/src/certify/transform.ts +++ b/cli/src/certify/transform.ts @@ -120,17 +120,19 @@ export function descriptorsOf(device: UsbDeviceSnapshot): ExpectedDescriptors { } /** - * Build the SKU discriminator (VID:PID + model + firmware prefix) from a device, or - * `undefined` when the device lacks a model or firmware string — the three parts the - * certified catalog matches on. A partial SKU is not a certified device. + * Build the SKU discriminator from USB identity plus ModemManager's firmware revision. + * A partial SKU is not a certified device. */ -export function skuOf(device: UsbDeviceSnapshot): SkuDiscriminator | undefined { - if (device.model === undefined || device.firmwareRevision === undefined) { +export function skuOf( + device: UsbDeviceSnapshot, + firmwareRevision: string | undefined, +): SkuDiscriminator | undefined { + if (device.model === undefined || firmwareRevision === undefined) { return undefined; } return { vidPid: `${device.vendorId}:${device.productId}`, model: device.model, - firmwarePrefix: device.firmwareRevision, + firmwarePrefix: firmwareRevision, }; } diff --git a/cli/src/certify/transition-evidence.test.ts b/cli/src/certify/transition-evidence.test.ts index fc3f3ea..b7bf4d6 100644 --- a/cli/src/certify/transition-evidence.test.ts +++ b/cli/src/certify/transition-evidence.test.ts @@ -17,11 +17,13 @@ import { buildCertificationBundle } from './bundle'; import type { BaseCaptureParts } from './capture'; import { captureTransitionEvidence, type TransitionCaptureDeps } from './transition-evidence'; +const MM_FIRMWARE_REVISION = 'SYNTHETICFW01'; + const QMI_DEVICE: UsbDeviceSnapshot = { vendorId: '2c7c', productId: '0125', model: 'CERALIVE-SYNTHETIC-TEST-SKU', - firmwareRevision: 'SYNTHETICFW01', + firmwareRevision: '0504', bDeviceClass: 0, physicalUid: 'usb-1-2', ifname: 'wwan0', @@ -34,7 +36,7 @@ const MBIM_DEVICE: UsbDeviceSnapshot = { vendorId: '2c7c', productId: '0125', model: 'CERALIVE-SYNTHETIC-TEST-SKU', - firmwareRevision: 'SYNTHETICFW01', + firmwareRevision: '0504', bDeviceClass: 0, physicalUid: 'usb-1-2', ifname: 'wwan0', @@ -84,6 +86,7 @@ test('captures shape-compatible transition evidence with the catalog AT command' const evidence = await captureTransitionEvidence(deps, { targetMode: 'mbim', device: QMI_DEVICE, + firmwareRevision: MM_FIRMWARE_REVISION, }); expect(evidence.from).toBe('qmi'); @@ -120,6 +123,7 @@ test('the evidence embeds into a bundle and validates against the bundle schema' const transition = await captureTransitionEvidence(deps, { targetMode: 'mbim', device: QMI_DEVICE, + firmwareRevision: MM_FIRMWARE_REVISION, }); const base: BaseCaptureParts = { usb: { lsusb: 'Device Descriptor:', usbDevices: 'T:', udevProperties: {} }, @@ -140,14 +144,22 @@ test('an uncertified SKU fails loudly', async () => { const { deps } = scripted(); const uncertified: UsbDeviceSnapshot = { ...QMI_DEVICE, model: 'UNKNOWN-MODEL' }; await expect( - captureTransitionEvidence(deps, { targetMode: 'mbim', device: uncertified }), + captureTransitionEvidence(deps, { + targetMode: 'mbim', + device: uncertified, + firmwareRevision: MM_FIRMWARE_REVISION, + }), ).rejects.toThrow(/no certified catalog entry/); }); test('a non-permitted transition (mbim -> ecm-ncm) fails loudly', async () => { const { deps } = scripted(); await expect( - captureTransitionEvidence(deps, { targetMode: 'ecm-ncm', device: MBIM_DEVICE }), + captureTransitionEvidence(deps, { + targetMode: 'ecm-ncm', + device: MBIM_DEVICE, + firmwareRevision: MM_FIRMWARE_REVISION, + }), ).rejects.toThrow(/no permitted transition mbim -> ecm-ncm/); }); @@ -155,6 +167,10 @@ test('a device without a stable physical UID fails loudly', async () => { const { deps } = scripted(); const { physicalUid: _drop, ...noUid } = QMI_DEVICE; await expect( - captureTransitionEvidence(deps, { targetMode: 'mbim', device: noUid }), + captureTransitionEvidence(deps, { + targetMode: 'mbim', + device: noUid, + firmwareRevision: MM_FIRMWARE_REVISION, + }), ).rejects.toThrow(/no stable physical UID/); }); diff --git a/cli/src/certify/transition-evidence.ts b/cli/src/certify/transition-evidence.ts index 6875056..e56e812 100644 --- a/cli/src/certify/transition-evidence.ts +++ b/cli/src/certify/transition-evidence.ts @@ -40,6 +40,7 @@ export interface TransitionCaptureDeps { export interface TransitionCaptureInput { readonly targetMode: MmUsbMode; readonly device: UsbDeviceSnapshot; + readonly firmwareRevision: string | undefined; } const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -95,9 +96,11 @@ export async function captureTransitionEvidence( `device is not in an MM-manageable mode (detected ${fromMode ?? 'none'}); cannot certify a transition`, ); } - const sku = skuOf(before); + const sku = skuOf(before, input.firmwareRevision); if (sku === undefined) { - throw new CertifyError('device is missing a model or firmware string; SKU is not certifiable'); + throw new CertifyError( + 'device is missing a model or ModemManager firmware revision; SKU is not certifiable', + ); } const entry = findCatalogEntry(deps.catalog ?? CERTIFIED_CATALOG, sku); if (entry === undefined) { diff --git a/cli/src/commands/certify.ts b/cli/src/commands/certify.ts index 5e9a727..8db5979 100644 --- a/cli/src/commands/certify.ts +++ b/cli/src/commands/certify.ts @@ -15,6 +15,7 @@ import { type DecodedManagedObjects, fetchManagedObjects, type MmUsbMode, + readRevision, type UsbDeviceSnapshot, } from '@ceralive/modem-control'; import { buildCertificationBundle } from '../certify/bundle'; @@ -31,6 +32,7 @@ import { captureTransitionEvidence } from '../certify/transition-evidence'; import type { StackContext } from '../context'; import type { CliIo } from '../io'; import { selectModem } from '../select'; +import { matchUsbDevice } from '../usb-device-match'; /** Parsed `certify` arguments. */ export interface CertifyArgs { @@ -102,22 +104,24 @@ export async function runCertify( io.err(`certify: no modem matching slot '${args.slot}'`); return 1; } - const ifname = modem.dataInterface.present ? modem.dataInterface.name : undefined; - const devices = await deps.enumerate().catch(() => []); - const device = devices.find((d) => d.ifname !== undefined && d.ifname === ifname); - - if (args.transition !== undefined && device === undefined) { - io.err( - `certify: --transition needs a matched USB device for slot '${args.slot}' (hardware-gated)`, - ); - return 1; - } - // A malformed / failed capture throws a `CertifyError`; catch it so the tool exits // non-zero with a clear message rather than crashing — a broken bundle is never written. try { + const tree = await deps.fetchManagedObjects(); + const devices = await deps.enumerate().catch(() => []); + const modemPath = String(modem.identity.runtimePath); + const device = matchUsbDevice(tree, modemPath, devices); + const firmwareRevision = readRevision(tree, modemPath); + if (args.transition !== undefined && device === undefined) { + io.err( + `certify: --transition needs a matched USB device for slot '${args.slot}' (hardware-gated)`, + ); + return 1; + } const base = await captureBase(deps, { - mmcliTarget: String(modem.identity.runtimePath), + managedObjects: tree, + modemPath, + mmcliTarget: modemPath, ...(device !== undefined ? { device } : {}), }); @@ -125,7 +129,7 @@ export async function runCertify( if (args.transition !== undefined && device !== undefined) { transition = await captureTransitionEvidence( { enumerate: () => deps.enumerate(), atSender: deps.atSender, now: () => deps.now() }, - { targetMode: args.transition, device }, + { targetMode: args.transition, device, firmwareRevision }, ); } diff --git a/cli/src/usb-device-match.test.ts b/cli/src/usb-device-match.test.ts new file mode 100644 index 0000000..fd63ea6 --- /dev/null +++ b/cli/src/usb-device-match.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test'; +import { type DecodedManagedObjects, MODEM_IFACE } from '@ceralive/modem-control'; +import { variant } from '@ceralive/modem-control/transport'; +import { matchUsbDevice } from './usb-device-match'; + +const MODEM_PATH = '/org/freedesktop/ModemManager1/Modem/39'; +const RM530N_SYSFS_PATH = + '/sys/devices/platform/fc400000.usb/xhci-hcd.0.auto/usb4/4-1/4-1.4/4-1.4.4'; +const HUB_SYSFS_PATH = '/sys/devices/platform/fc400000.usb/xhci-hcd.0.auto/usb4/4-1/4-1.4'; + +function modemTree(device: string, physdev?: string): DecodedManagedObjects { + const properties: Array]> = [ + ['Device', variant('s', device)], + ]; + if (physdev !== undefined) { + properties.push(['Physdev', variant('s', physdev)]); + } + return [[MODEM_PATH, [[MODEM_IFACE, properties]]]]; +} + +const hub = { + vendorId: '0bda', + productId: '0411', + bDeviceClass: 9, + interfaces: [], + sysfsPath: HUB_SYSFS_PATH, +}; + +const rm530n = { + vendorId: '2c7c', + productId: '0801', + model: 'RM530N-GL', + bDeviceClass: 0, + interfaces: [ + { interfaceClass: 0xff, interfaceSubClass: 0xff, interfaceProtocol: 0xff, driver: 'qmi_wwan' }, + ], + sysfsPath: RM530N_SYSFS_PATH, +}; + +describe('matchUsbDevice', () => { + test('matches the real RM530N USB parent from Modem.Device and Physdev without an ifname', () => { + // Given + const tree = modemTree(RM530N_SYSFS_PATH, RM530N_SYSFS_PATH); + + // When + const matched = matchUsbDevice(tree, MODEM_PATH, [hub, rm530n]); + + // Then + expect(matched).toBe(rm530n); + }); + + test('chooses the most specific USB ancestor when Modem.Device names an interface child', () => { + // Given + const interfacePath = `${RM530N_SYSFS_PATH}/4-1.4.4:1.4/net/wwan3`; + const tree = modemTree(interfacePath); + + // When + const matched = matchUsbDevice(tree, MODEM_PATH, [hub, rm530n]); + + // Then + expect(matched).toBe(rm530n); + }); +}); diff --git a/cli/src/usb-device-match.ts b/cli/src/usb-device-match.ts new file mode 100644 index 0000000..a346653 --- /dev/null +++ b/cli/src/usb-device-match.ts @@ -0,0 +1,34 @@ +import { + type DecodedManagedObjects, + modemIdentityFactsFromTree, + type UsbDeviceSnapshot, +} from '@ceralive/modem-control'; + +export function matchUsbDevice( + tree: DecodedManagedObjects, + modemPath: string, + devices: readonly UsbDeviceSnapshot[], +): UsbDeviceSnapshot | undefined { + const facts = modemIdentityFactsFromTree(tree, modemPath); + const modemPaths: string[] = []; + for (const path of [facts.physdev, facts.device]) { + if (path?.startsWith('/sys/')) { + modemPaths.push(path.replace(/\/+$/, '')); + } + } + + let bestMatch: UsbDeviceSnapshot | undefined; + for (const device of devices) { + const sysfsPath = device.sysfsPath?.replace(/\/+$/, ''); + if (sysfsPath === undefined) { + continue; + } + const matches = modemPaths.some( + (path) => path === sysfsPath || path.startsWith(`${sysfsPath}/`), + ); + if (matches && sysfsPath.length > (bestMatch?.sysfsPath?.length ?? 0)) { + bestMatch = device; + } + } + return bestMatch; +} diff --git a/cli/src/wiring.ts b/cli/src/wiring.ts index efdaca5..d715a94 100644 --- a/cli/src/wiring.ts +++ b/cli/src/wiring.ts @@ -26,6 +26,7 @@ import { import type { RequestResolver, UsbModeArgs } from './commands/set-usb-mode'; import type { StackContext } from './context'; import { selectModem } from './select'; +import { matchUsbDevice } from './usb-device-match'; /** Where per-slot usage state is persisted on device. */ const USAGE_STORE_PATH = @@ -70,8 +71,7 @@ export function buildRequestResolver(ctx: StackContext): RequestResolver { const index = paths.indexOf(String(modem.identity.runtimePath)); const stableKey = resolved[index]?.stableKey ?? String(modem.identity.runtimePath); const devices = await ctx.enumerate().catch(() => []); - const ifname = modem.dataInterface.present ? modem.dataInterface.name : undefined; - const device = devices.find((d) => d.ifname !== undefined && d.ifname === ifname); + const device = matchUsbDevice(tree, String(modem.identity.runtimePath), devices); if (device === undefined || device.physicalUid === undefined) { return { ok: false, diff --git a/control/README.md b/control/README.md index 01d33c4..2b37336 100644 --- a/control/README.md +++ b/control/README.md @@ -58,6 +58,10 @@ USB-network classification and labels, capability-module selection, and shadow-b divergence folding. Every helper consumes caller-supplied values or snapshots; none discovers devices, opens a transport, persists state, or performs a modem write. +USB snapshots retain the udev `P:` record as an absolute `sysfsPath`, allowing consumers to +correlate ModemManager `Device`/`Physdev` paths to the most-specific USB parent without relying +on a network-interface name. + ### Typed ModemManager provider `createModemManagerProvider({ transport })` returns the concrete `ModemManagerProvider` and its diff --git a/control/src/backend/device-classifier.ts b/control/src/backend/device-classifier.ts index 1332bef..554cdc8 100644 --- a/control/src/backend/device-classifier.ts +++ b/control/src/backend/device-classifier.ts @@ -19,37 +19,9 @@ // evidence and the three-gate ledger behind that decision. import type { CanonicalUsbMode, ExpectedDescriptors } from '../usb-mode'; +import type { UsbDeviceSnapshot, UsbInterface } from './usb-device-snapshot'; -/** One USB interface's descriptor bytes plus its bound kernel driver, if any. */ -export interface UsbInterface { - readonly interfaceClass: number; - readonly interfaceSubClass: number; - readonly interfaceProtocol: number; - /** The bound kernel driver (`qmi_wwan`, `cdc_mbim`, `option`, `cdc_ether`, …). */ - readonly driver?: string; -} - -/** A single USB device as observed from udev/sysfs — the classifier's whole input. */ -export interface UsbDeviceSnapshot { - readonly vendorId: string; - readonly productId: string; - readonly model?: string; - readonly firmwareRevision?: string; - readonly manufacturer?: string; - readonly product?: string; - readonly databaseVendor?: string; - readonly databaseModel?: string; - readonly serialNumber?: string; - /** The device-descriptor `bDeviceClass` byte (0 ⇒ class is per-interface). */ - readonly bDeviceClass: number; - readonly interfaces: readonly UsbInterface[]; - /** Stable physical-topology UID (udev `ID_PATH` / physdev) — survives a mode change. */ - readonly physicalUid?: string; - /** The bound network interface name, if the device presents one (`wwan0`, `usb0`). */ - readonly ifname?: string; - /** Raw udev properties (`ID_USB_MODESWITCH`, `ID_MM_CANDIDATE`, …). */ - readonly udevProperties?: Readonly>; -} +export type { UsbDeviceSnapshot, UsbInterface } from './usb-device-snapshot'; /** The four device classes. `pending-modeswitch` is distinct from `unmanaged`. */ export type DeviceClass = 'mm-managed' | 'router-mode' | 'unmanaged' | 'pending-modeswitch'; diff --git a/control/src/backend/usb-device-snapshot.ts b/control/src/backend/usb-device-snapshot.ts new file mode 100644 index 0000000..a78d4b3 --- /dev/null +++ b/control/src/backend/usb-device-snapshot.ts @@ -0,0 +1,32 @@ +/** One USB interface's descriptor bytes plus its bound kernel driver, if any. */ +export interface UsbInterface { + readonly interfaceClass: number; + readonly interfaceSubClass: number; + readonly interfaceProtocol: number; + /** The bound kernel driver (`qmi_wwan`, `cdc_mbim`, `option`, `cdc_ether`, …). */ + readonly driver?: string; +} + +/** A single USB device as observed from udev/sysfs — the classifier's whole input. */ +export interface UsbDeviceSnapshot { + readonly vendorId: string; + readonly productId: string; + readonly model?: string; + readonly firmwareRevision?: string; + readonly manufacturer?: string; + readonly product?: string; + readonly databaseVendor?: string; + readonly databaseModel?: string; + readonly serialNumber?: string; + /** The device-descriptor `bDeviceClass` byte (0 ⇒ class is per-interface). */ + readonly bDeviceClass: number; + readonly interfaces: readonly UsbInterface[]; + /** Stable physical-topology UID (udev `ID_PATH` / physdev) — survives a mode change. */ + readonly physicalUid?: string; + /** Absolute sysfs path for the USB device, derived from udev's `P:` record. */ + readonly sysfsPath?: string; + /** The bound network interface name, if the device presents one (`wwan0`, `usb0`). */ + readonly ifname?: string; + /** Raw udev properties (`ID_USB_MODESWITCH`, `ID_MM_CANDIDATE`, …). */ + readonly udevProperties?: Readonly>; +} diff --git a/control/src/backend/usb-enumerator.test.ts b/control/src/backend/usb-enumerator.test.ts index e2e7618..3a564af 100644 --- a/control/src/backend/usb-enumerator.test.ts +++ b/control/src/backend/usb-enumerator.test.ts @@ -45,6 +45,7 @@ describe('parseUdevDatabase', () => { expect(quectel?.model).toBe('EG25-G'); expect(quectel?.firmwareRevision).toBe('0318'); expect(quectel?.physicalUid).toBe('pci-0000:00-usb-0:1'); + expect(quectel?.sysfsPath).toBe('/sys/devices/pci0000:00/usb1/1-1'); }); test('parses interface class triples from ID_USB_INTERFACES', () => { diff --git a/control/src/backend/usb-enumerator.ts b/control/src/backend/usb-enumerator.ts index 6ed7360..6e0f8b1 100644 --- a/control/src/backend/usb-enumerator.ts +++ b/control/src/backend/usb-enumerator.ts @@ -96,7 +96,10 @@ function interfaceIndex(devpath: string): number | undefined { return Number.isNaN(index) ? undefined : index; } -function buildSnapshot(env: ReadonlyMap): UsbDeviceSnapshot | undefined { +function buildSnapshot( + devpath: string, + env: ReadonlyMap, +): UsbDeviceSnapshot | undefined { const vendorId = env.get('ID_VENDOR_ID'); const productId = env.get('ID_MODEL_ID'); if (vendorId === undefined || productId === undefined) { @@ -112,6 +115,7 @@ function buildSnapshot(env: ReadonlyMap): UsbDeviceSnapshot | un return { vendorId, productId, + sysfsPath: `/sys${devpath}`, bDeviceClass: 0, interfaces: parseInterfaces(env.get('ID_USB_INTERFACES')), udevProperties: props, @@ -135,7 +139,7 @@ export function parseUdevDatabase(text: string): UsbDeviceSnapshot[] { if (record.env.get('DEVTYPE') !== 'usb_device') { continue; } - const snapshot = buildSnapshot(record.env); + const snapshot = buildSnapshot(record.devpath, record.env); if (snapshot !== undefined) { snapshots.set(record.devpath, snapshot); devices.set(record.devpath, [...snapshot.interfaces]); diff --git a/control/src/redact.test.ts b/control/src/redact.test.ts index f042dea..a98ba00 100644 --- a/control/src/redact.test.ts +++ b/control/src/redact.test.ts @@ -28,6 +28,45 @@ test('redacts every sensitive class at the top level, keeping non-secret sibling expect(out.username).toBe('operator-user'); }); +test('redacts modem equipment identifiers while preserving model, vendor, and SKU facts', () => { + // Given + const input = { + EquipmentIdentifier: '867978050016855', + equipmentIdentifier: '867978050016855', + equipment_identifier: '867978050016855', + imei: '867978050016855', + 'modem.generic.equipment-identifier': '867978050016855', + 'modem.3gpp.imei': '867978050016855', + model: 'RM530N-GL', + vendorId: '2c7c', + sku: { + vidPid: '2c7c:0801', + model: 'RM530N-GL', + firmwarePrefix: 'RM530NGLAAR05A01M4G', + }, + }; + + // When + const out = redact(input); + + // Then + expect(out).toEqual({ + EquipmentIdentifier: REDACTED, + equipmentIdentifier: REDACTED, + equipment_identifier: REDACTED, + imei: REDACTED, + 'modem.generic.equipment-identifier': REDACTED, + 'modem.3gpp.imei': REDACTED, + model: 'RM530N-GL', + vendorId: '2c7c', + sku: { + vidPid: '2c7c:0801', + model: 'RM530N-GL', + firmwarePrefix: 'RM530NGLAAR05A01M4G', + }, + }); +}); + test('redacts an APN password nested three levels deep', () => { const policy = { connection: { auth: { username: 'u', password: 'hunter2' } } }; const out = redact(policy) as { connection: { auth: { username: string; password: string } } }; diff --git a/control/src/redact.ts b/control/src/redact.ts index dc136b2..412417e 100644 --- a/control/src/redact.ts +++ b/control/src/redact.ts @@ -19,6 +19,10 @@ const SENSITIVE_KEYS: ReadonlySet = new Set([ 'iccid', 'imsi', 'eid', + 'imei', + 'equipmentidentifier', + 'equipment-identifier', + 'equipment_identifier', 'pin', 'pin2', 'newpin', @@ -166,7 +170,7 @@ function isPlainObject(value: unknown): value is Record { if (typeof value !== 'object' || value === null) { return false; } - const proto = Object.getPrototypeOf(value) as unknown; + const proto: unknown = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; } diff --git a/docs/BENCH.md b/docs/BENCH.md index 49455b9..34c917d 100644 --- a/docs/BENCH.md +++ b/docs/BENCH.md @@ -810,27 +810,28 @@ a SKU that is not already in the catalog (`cli/src/certify/transition-evidence.t Stage 2 is only reachable for a SKU whose stage-1 entry is already merged. Both stages feed the same reviewed ingestion path: [`CATALOG-INGESTION.md`](CATALOG-INGESTION.md). -### Blockers that make every RB-11…RB-15 row `[PARTIAL]` today +### Status ledger that keeps every RB-11…RB-15 row `[PARTIAL]` today Originally verified on `ceralive2` (192.168.78.132) on 2026-08-16 and **re-verified on the same board on 2026-08-18** by a non-mutating capture pass against the SIMCom SIM7600G-H and -the Fibocom FM350-GL. The re-run **cleared B1, downgraded B3, promoted B2 from a code read -to a hardware-proven failure, and found two new blockers (B5, B6).** None is "not run yet"; -each is a named obligation with a code or packaging fix behind it. +the Fibocom FM350-GL. That pass cleared B1, downgraded B3, proved B2 on hardware, and found +B5/B6. B2, B5, and B6 were fixed in software on 2026-08-20 with realistic RM530N fixtures; +the fixed build has **not** been rerun on the board, so this ledger does not claim hardware +closure. | # | Blocker | State | Verified how | Consequence | |---|---------|-------|--------------|-------------| | **B1** | `usbutils` absent from the board and its apt archive | **CLEARED (2026-08-18)** | live `command -v` sweep: `/usr/bin/lsusb`, `/usr/bin/usb-devices` both present | `certify` now completes its base capture; two real `CERTIFY OK … synthetic=false` bundles were captured on 2026-08-18 | -| **B2** | The production USB enumerator never populates `ifname` (`control/src/backend/usb-enumerator.ts`, `buildSnapshot`), but `certify` matches its target device **by** `ifname` (`cli/src/commands/certify.ts`) | **OPEN — now hardware-proven** | two live `certify` runs | the matched device is always `undefined`, so both real bundles came out with **no `sku` and empty `udevProperties`**; the ingestion seam correctly refuses them `sku-missing`, so **no bundle this pipeline produces can currently be promoted**. Pinned by `control/src/usb-mode/ingestion.hardware.test.ts` | +| **B2** | Historical defect: the enumerator discarded the USB-device sysfs path while `certify` tried to match a USB device by `ifname` | **SOFTWARE FIXED (2026-08-20); BOARD RE-CAPTURE PENDING** | the 2026-08-18 live failure plus `usb-enumerator.test.ts` and realistic `usb-device-match.test.ts` regressions | snapshots now retain the udev `P:` path under `/sys`, and `certify` correlates MM `Device`/`Physdev` to the most-specific USB parent without `ifname`. A new real bundle must still prove populated `sku` and `udevProperties` | | **B3** | No AT transport | **PARTIALLY CLEARED (2026-08-18)** | live: `socat` **is** present at `/usr/bin/socat`; a query-only AT session over `/dev/ttyUSB2` (SIMCom) and `/dev/ttyUSB12` (FM350) succeeded | a **manual** AT session is now possible on the bench. The CLI half is unchanged: `benchAtSender` still rejects every send, so `certify --transition` still cannot execute an AT command. `picocom` / `minicom` remain absent; ModemManager's `--command` passthrough remains unavailable (MM is not run with `--debug`, so `mmcli --command` answers `Operation only allowed in debug mode`) | | **B4** | The shipped `certified-catalog.json` holds exactly one entry, `CERALIVE-SYNTHETIC-TEST-SKU` | OPEN | repo read | stage 2 is unreachable for every real SKU until that SKU's stage-1 entry is merged | -| **B5** | The shared redactor does **not** mask `imei` / `equipment-identifier` / `device-identifier` (`control/src/redact.ts` `SENSITIVE_KEYS` covers ICCID / IMSI / EID / PIN / PUK / passwords only) | **OPEN — new** | inspected both real bundles | every real bundle's `modemManager` half carries the IMEI of **every** modem on the bench (`GetManagedObjects` is fleet-wide, not slot-scoped). A bundle therefore **must not be committed to this repo or pasted into a PR** as-is — which directly conflicts with the review workflow in [`CATALOG-INGESTION.md`](CATALOG-INGESTION.md). The `usb.lsusb` / `usb.usbDevices` halves are IMEI-free and safe to quote | -| **B6** | `skuOf` (`cli/src/certify/transform.ts`) derives `firmwarePrefix` from udev `ID_REVISION`, which is the USB **bcdDevice** — not the modem firmware revision | **OPEN — new** | compared udev against `mmcli` and AT `AT+CGMR` for both units | for the bench SIMCom, `ID_REVISION` is `0318` while the firmware is `LE20B04SIM7600G22`; for the FM350, `0001` vs `81600.0000.00.19.17.10`. A catalog entry keyed on `ID_REVISION` would **not** be firmware-keyed, so it could not distinguish two firmware builds of one SKU. Pinned by `ingestion.hardware.test.ts` | +| **B5** | Historical defect: the shared redactor omitted `imei` and `EquipmentIdentifier` spellings used by MM and `mmcli -K` | **SOFTWARE FIXED (2026-08-20); BOARD RE-CAPTURE PENDING** | the 2026-08-18 unsafe bundles plus shared-redactor and realistic bundle regressions | MM `EquipmentIdentifier`, bare/camel/separator IMEI keys, and dotted `equipment-identifier` / `imei` keyfile fields are masked while model/vendor/SKU facts remain visible. The destroyed pre-fix bundles remain unsafe and must not be restored | +| **B6** | Historical defect: `skuOf` derived `firmwarePrefix` from udev `ID_REVISION`, the USB **bcdDevice** | **SOFTWARE FIXED (2026-08-20); BOARD RE-CAPTURE PENDING** | the 2026-08-18 udev/MM comparison plus an RM530N regression (`0504` versus `RM530NGLAAR05A01M4G`) | `captureBase` now derives the discriminator from MM `Modem.Revision` in the same managed-object snapshot used for target correlation. A new real bundle must still prove the value on hardware | -**B2 and B6 gate stage 1. B3 and B4 additionally gate stage 2. B5 gates the review step for -every stage.** A run that reports a `CERTIFY OK` line with `synthetic=true`, or with an empty -`sku`, is **not** a passing RB-11…RB-15 — the gate is the `synthetic=false` line *and* a -bundle whose `sku` is populated. +**B2, B5, and B6 are no longer software blockers, but their post-fix board proof is still +outstanding. B3 and B4 gate stage 2.** A run that reports a `CERTIFY OK` line with +`synthetic=true`, with an empty `sku`, with a firmware prefix equal to USB `ID_REVISION`, or +with a raw IMEI is **not** a passing RB-11…RB-15. > **Query-only AT sessions are safe; SET forms are not.** The 2026-08-18 pass established > that a read-only AT survey needs no ModemManager inhibit on this bench: MM held only the @@ -883,7 +884,7 @@ schema-representable within-ModemManager mode switch. **Preconditions** -- The shared contract above, including blockers **B1–B4**. +- The shared status ledger above, including the outstanding post-fix stage-1 re-capture. - The Quectel enumerated and MM-managed. Live values on this bench (2026-08-16): USB `4-1.4.4`, VID:PID `2c7c:0801`, `ID_PATH=platform-xhci-hcd.0.auto-usb-0:1.4.4:1.4`, MM `/org/freedesktop/ModemManager1/Modem/2`, plugin `quectel`, drivers `qmi_wwan` + @@ -944,11 +945,11 @@ grep -Eq '^CERTIFY OK: sha256=[0-9a-f]{64} synthetic=false transition=none slot= && echo "RB-11 PASS" || echo "RB-11 FAIL" ``` -The `vidPid` clause is not decoration: it is what fails when blocker **B2** is still live, -because an unmatched device yields a bundle with no `sku` at all. +The `vidPid` clause guards the historical B2 failure: an unmatched device yields a bundle +with no `sku` at all. -**Status:** `[PARTIAL]` — no capture. Blockers B1 (no `usbutils`) and B2 (no `ifname` on -enumerated devices) both stop stage 1; B3 and B4 additionally stop stage 2. +**Status:** `[PARTIAL]` — the pre-fix real capture was unpromotable (no `sku`, empty udev +properties, raw IMEI). The fixed code has not been rerun on the board; B3 and B4 stop stage 2. **Evidence:** `test-results/modem-phase-b/08/quectel-rm530n-gl/{certify.txt,bundle.json,qmi-facts.txt,transition-candidate.txt}` @@ -1023,8 +1024,8 @@ grep -Eq '^CERTIFY OK: sha256=[0-9a-f]{64} synthetic=false transition=none slot= && echo "RB-12 PASS" || echo "RB-12 FAIL" ``` -**Status:** `[PARTIAL]` — **fully** unrun, and unrunnable: no EM75xx exists on this bench, on -top of blockers B1–B4. No capture directory is created; no value above was observed. +**Status:** `[PARTIAL]` — **fully** unrun and unrunnable because no EM75xx exists on this +bench. No capture directory is created; no value above was observed. **Evidence:** `test-results/modem-phase-b/08/sierra-em75xx/{certify.txt,bundle.json,fcc-locked-state.txt,fcc-policy.txt}` @@ -1105,9 +1106,9 @@ grep -Eq '^CERTIFY OK: sha256=[0-9a-f]{64} synthetic=false transition=none slot= The raw-IP clause asserts the flag was **captured**, not that it holds a particular value — `N` is a legitimate observation and a legitimate finding. Recording it is the gate. -**Status:** `[PARTIAL]` — no capture; blockers B1/B2 stop stage 1, and this unit has no SIM. -The raw-IP flag value (`Y`) and the transport facts above are RB-9-class live observations -already captured on 2026-08-16; they are **not** a certification bundle. +**Status:** `[PARTIAL]` — the pre-fix real capture was unpromotable and the fixed code has not +been rerun on the board; this unit also has no SIM. The raw-IP flag value (`Y`) and the +transport facts above are RB-9-class live observations, not a certification bundle. **Evidence:** `test-results/modem-phase-b/08/simcom-sim7600g-h/{certify.txt,bundle.json,raw-ip-state.txt,rndis-observation.txt}` diff --git a/docs/CATALOG-INGESTION.md b/docs/CATALOG-INGESTION.md index fe0e47b..07c0154 100644 --- a/docs/CATALOG-INGESTION.md +++ b/docs/CATALOG-INGESTION.md @@ -96,7 +96,7 @@ partial result. |---|---| | `sha256-malformed` | the sha is not 64 lowercase hex — checked before the bundle is read | | `bundle-malformed` | the bundle fails the ingestion view schema | -| `sku-missing` | the capture matched no USB device to the slot (see `BENCH.md` blocker **B2**) | +| `sku-missing` | the capture matched no USB device to the slot; the promotion gate remains strict even if capture wiring regresses | | `device-not-in-capture` | the SKU's VID:PID is absent from the `usb-devices` text | | `device-ambiguous` | two or more devices share that VID:PID — a fixture must name one physical device (this bench has an identical Huawei HiLink pair) | | `no-interfaces-captured` | the device parsed with zero interfaces; such a fixture classifies nothing | @@ -228,22 +228,22 @@ a comment — a silently-absent comment is indistinguishable from a forgotten ru --- -## Bench reality, 2026-08-18 — this path does not yet reach the catalog +## Bench reality — pre-fix captures remain unpromotable A non-mutating capture pass against the SIMCom SIM7600G-H and the carrier-mounted Fibocom FM350-GL ([`COMPOSITION-EVIDENCE.md`](COMPOSITION-EVIDENCE.md)) ran the real `modem-control certify` on real hardware. Both runs printed a valid -`CERTIFY OK … synthetic=false` line, and **neither bundle was promotable**. Three blockers -sit between `certify` and the seam above; all three are recorded in [`BENCH.md`](BENCH.md) -and the first two are pinned by -[`../control/src/usb-mode/ingestion.hardware.test.ts`](../control/src/usb-mode/ingestion.hardware.test.ts): +`CERTIFY OK … synthetic=false` line, and **neither bundle was promotable**. The three capture +defects below were fixed in software on 2026-08-20, but the fixed build has not been rerun on +the board. The historical bundles stay destroyed/unpromotable and prove no post-fix behavior. +[`BENCH.md`](BENCH.md) carries the hardware-status ledger. -| Blocker | Effect on this document's flow | +| Defect | Historical effect and current software behavior | |---|---| -| **B2** — `certify` matches its USB device by `ifname`, which `parseUdevDatabase` never populates | the bundle arrives with **no `sku`** and **empty `udevProperties`**, so `buildCatalogEntryCandidate` and `buildClassifierFixture` both refuse `sku-missing`. The refusal is correct; the capture is what is broken | -| **B5** — the shared redactor masks ICCID / IMSI / EID but **not** `imei` / `equipment-identifier` | a real bundle's `modemManager` half carries the IMEI of every modem on the bench, because `GetManagedObjects` is fleet-wide. **"Post `comment` on the PR" is unsafe for a real bundle today**, and a real bundle must not be committed as a fixture. The `usb.lsusb` / `usb.usbDevices` halves are IMEI-free and safe to quote | -| **B6** — `skuOf` reads `firmwarePrefix` from udev `ID_REVISION` (the USB `bcdDevice`) | a promoted entry would be keyed on `0318` / `0001` rather than on `LE20B04SIM7600G22` / `81600.0000.00.19.17.10`, so it could not distinguish two firmware builds of one SKU — defeating the point of a firmware-keyed catalog | +| **B2** — target USB parent was matched by an unpopulated `ifname` | Pre-fix bundles had no `sku` and empty `udevProperties`, and the seam correctly refused `sku-missing`. The enumerator now retains its `/sys` path and `certify` matches MM `Device`/`Physdev` to the most-specific USB parent | +| **B5** — `imei` / `EquipmentIdentifier` spellings were outside the shared redaction class | Pre-fix fleet-wide managed objects leaked every modem's IMEI. Shared-redactor and bundle regressions now mask MM property, bare/camel/separator, and dotted keyfile spellings while preserving model/vendor/SKU facts | +| **B6** — `firmwarePrefix` came from USB `ID_REVISION` / `bcdDevice` | Pre-fix entries would have been keyed on `0318` / `0001`. `captureBase` now derives the discriminator from MM `Modem.Revision`; the RM530N regression distinguishes `0504` from `RM530NGLAAR05A01M4G` | -None of these is a flaw in the seam itself: the refusals above are exactly the typed, -loud behaviour this document promises. They are defects in the **capture** step, and until -they are fixed no SKU can reach `certified-catalog.json` from bench evidence. +None was a flaw in the seam itself: its typed refusals were the correct response to broken +capture input. No SKU may reach `certified-catalog.json` from this evidence until a new +post-fix board capture passes the stage-1 checks and human review.