Skip to content

feat: unified modem control — companion package, capability modules, v1.1 published surface - #12

Merged
andrescera merged 12 commits into
mainfrom
feat/modem-phase-c
Aug 20, 2026
Merged

feat: unified modem control — companion package, capability modules, v1.1 published surface#12
andrescera merged 12 commits into
mainfrom
feat/modem-phase-c

Conversation

@andrescera

Copy link
Copy Markdown
Member

Affected repo & language: modem-stack — TypeScript (Bun) + Debian packaging + shell

What

Turns modem-stack from a bench-only CLI into the device's cellular control plane and
its first shipped package.

Three things land together:

  1. ceralive-modem-support — the first-party companion .deb (Architecture: all)
    carrying the udev rules, usb_modeswitch entries and the FCC-unlock reconciler the
    device runtime needs, with a chroot test that runs the real maintainer scripts.
  2. A capability feature-gate framework and support-claim taxonomy, plus seven gated
    capability modules on top of it: band locking, read-only SMS, 5G mode preference,
    opt-in FCC auto-unlock, GPS/location, USSD, and the SIM's own number (MSISDN).
  3. The v1.1 published surface — frozen domain contracts, an evidence-scored provider
    registry, typed providers for ModemManager (D-Bus), NetworkManager, Huawei HiLink,
    ZTE and UFI, an admission port with cross-process exclusive ownership, a
    descriptor-gated operations engine, and a path-parameterized journal — published as
    built ESM from dist/ across seven entry points instead of raw TypeScript.

Also: catalog transitions may now declare the command that COMMITS them; the CI moves
to the Node 26 baseline and the workflow canon; eSIM closes blocked (not NO-GO) on the
eUICC gap.

Why

CeraUI carried its own copy of identity derivation, ModemManager enum normalization,
USB-device classification, capability selection and the router telemetry parsers. Two
copies of the same rule diverge, and this effort's premise is that the device and the
platform agree about what a modem is. That only works if there is one implementation,
published, versioned, and testable without a rack — which is what the pure migration
layer and the ./testing contract-fakes entry point are for.

The companion package exists because Phase A shipped nothing to devices: the bench CLI
installed from CI artifacts, so the modem runtime the device actually needs lived
nowhere a device image could install from.

Two decisions worth reading rather than re-deriving:

  • Capability answers are three-valued. Supported / unsupported /
    unknown-for-this-modem. unknown is a first-class answer, so a consumer can render an
    honest third state instead of hiding a control that might work or offering one that
    cannot.
  • The companion's udev rule never reuses an image-owned /etc/udev/rules.d/
    basename.
    udev resolves by basename, so an /etc file silently shadows the packaged
    one in a way dpkg -S cannot detect. The legacy v1 basename is retained as a
    checksummed override list, and postinst/postrm reconcile against it.

How to verify

cd modem-stack
bun install --frozen-lockfile
bun run lint && bun run typecheck && bun test
bash packaging/ci/contract.sh                 # asset inventory vs expected-packages.txt

The companion's install/remove behaviour is exercised for real, not mocked:

bash packaging/ci/build-companion.sh
sudo bash packaging/ci/test-companion-chroot.sh   # runs the shipped maintainer scripts
bash packaging/ci/test-fcc-reconcile.sh

Provider matching is reproducible without hardware — the conformance matrix drives the
committed sanitized transcript corpus. See docs/PROVIDER-MATCHING.md and
docs/DOMAIN-CONTRACTS.md.

Risks

Hardware certification is NOT claimed by this PR. The bench drills that would certify
provider behaviour against real modem silicon are recorded BLOCKED-ON-OWNER — no bench
board was reachable. Everything here is proven against fixtures, transcripts and a
chroot; nothing asserts a hardware verdict it did not measure, and the ingestion seam
refuses synthetic-backed catalog entries by design rather than by convention.

Known open blockers, already recorded in docs/BENCH.md and unchanged by this PR:
B2 (certify matches its target by ifname, which the production USB enumerator
never populates, so no bundle this pipeline produces can currently be promoted) and
B6 (skuOf derives firmwarePrefix from udev ID_REVISION, the USB bcdDevice, not
the modem firmware revision). Both are software-fixable and gate stage 1 for every SKU.

Packaging risk is contained: the companion is a new package, so nothing existing changes
version or content. The CI Node flip is 24 → 26 per the workspace canon; this PR's run
is the first CI execution of the edited ci-bun.yml / release.yml.

Rollback: the companion is additive and can be withheld from the closure by publishing a
closure_version: 1 manifest.


Checklist

  • Docs updated if behavior or structure changed (Rule A: AGENTS.md, README, docs/, ARCHITECTURE.md, versions.yaml)
  • Started from updated main; branch rebased on latest canonical branch (Rule B)
  • Rule D local-scratch reference check passes for tracked files
  • Tests pass; QA evidence attached or linked

Non-mutating bench capture of the SIMCom SIM7600G-H (1e0e:9001) and the
M.2->USB carrier-mounted Fibocom FM350-GL (0e8d:7127): descriptors, driver
bindings, firmware revisions, and the read-back state of each vendor's
USB-mode command. Only bare-execute, READ (?) and TEST (=?) AT forms were
sent; no SET form, and both units' VID:PID were unchanged across the session.

Certifies nothing. The SIMCom's PID->composition mapping is unproven, so its
target modes stay uncertified and hidden; the FM350 gains no classifier entry
for its carrier id and FM350-DECISION.md is unchanged.

The pass also re-verified the RB-11..15 blocker table on real hardware: B1
(usbutils absent) is cleared, B3 is downgraded (socat present, so a manual
query-only AT session works), B2 is promoted from a code read to a
hardware-proven failure, and two new blockers are recorded -- the redactor
does not mask IMEI, and skuOf keys firmwarePrefix on the USB bcdDevice rather
than the modem firmware revision. B2 and B6 are pinned by a new test.
Phase A shipped nothing to devices: the bench CLI installed from CI artifacts, so
modem-stack had no .deb for the image to fetch. The modem runtime the device
actually needs — udev rules, usb_modeswitch entries, and the FCC-unlock
reconciler — lived nowhere a device image could install from.

Adds the first-party `ceralive-modem-support` companion, `Architecture: all`. It
is ONE immutable release asset with TWO index memberships: built exactly once and
indexed into both binary-arm64 and binary-amd64. The manifest records its BUILD
architecture (`all`) and the publisher DERIVES index membership from it. Building
it per-arch would put two byte-different files under a single package/version key
and break the publisher's immutable-key rule.

The udev rule deliberately does not reuse an image-owned
/etc/udev/rules.d/ basename. udev resolves by basename, so an /etc file silently
shadows the packaged one in a way `dpkg -S` cannot detect; the legacy v1 basename
is retained as a checksummed override list and postinst/postrm reconcile against
it rather than clobbering operator state.

packaging/ci carries the build, the expected-package inventory, the release
manifest generator, and a chroot install/remove test that runs the real
maintainer scripts. The apt-dispatch preflight workflow refuses to dispatch a
publish whose asset set does not match the inventory.
…nomy

Every capability this effort adds — band locking, SMS, 5G preference, FCC unlock,
GPS, USSD — is real on some modems and absent on others, and the failure mode that
matters to an operator is a control that looks available and then refuses. The
package had no shared way to answer "can THIS modem do this?", so each surface
would have invented its own guess.

`capability/detect.ts` derives a module's availability from evidence the modem
itself reported, never from a model-name heuristic. `capability/support-claim.ts`
gives that answer a taxonomy: a claim is supported, unsupported, or
unknown-for-this-modem, and unknown is a first-class answer rather than a
degenerate "no". A consumer can therefore render an honest third state instead of
hiding a control that might work or offering one that cannot.

Nothing consumes the framework yet; the modules land on top of it.
…S, USSD, MSISDN

Seven capability modules on top of the feature-gate framework. Each is gated by
evidence the modem reported, each is certified per modem where certification is
required, and each answers unknown-for-this-modem rather than pretending a refusal
is a "no".

- band locking, certified per modem, with the primary-surface contract
- a read-only SMS port, so CeraUI stops shelling out to raw mmcli-sms
- 5G mode preference, gated
- opt-in FCC auto-unlock through ModemManager dispatcher hooks, plus its coverage
  matrix and a reconcile test that runs the shipped script rather than a fake
- GPS/location, gated
- USSD, gated
- the SIM's own number (MSISDN), folded onto ModemIdentity.ownNumbers

ModemManager publishes the SIM's own number as Modem.OwnNumbers, and the
package read nothing from it — so a consumer that wants to show an operator
which SIM is in which slot had no source for it.

mapping.ts now folds the property onto ModemIdentity.ownNumbers, branded
SubscriberNumber for the same reason SubscriptionId is: it identifies a
subscriber. Absent, empty and whitespace-only lists all read as not-reported
rather than as an empty list, because most SIMs genuinely carry no MSISDN and
an empty array invites a consumer to render silence as a finding. A
multi-number SIM keeps every number in order.

redact.ts gains its own whole-key own-number class. It cannot join
SENSITIVE_KEYS, which matches a leaf name: 'number'/'numbers' there would blank
a slot index and a band count across the package. PolicyBindingKey enumerates
its fields, so the new identity field cannot reach a durable key.

Board evidence: Quectel RM530N-GL reports own: +573115422359.

eSIM, by contrast, closes blocked rather than shipping:

The 2026-08-13 eSIM deferral was reversed, and eSIM re-entered scope as a
hardware-gated adoption spike. It closes `blocked`: no modem on the bench
exposes an eUICC. The only SIM on the whole fleet reports no `eid` under
ModemManager 1.24.2 — positive evidence of a classic removable UICC, not
missing data — and RM530N-GL eUICC capability is unproven for that unit.

`blocked` is deliberately not a NO-GO. A NO-GO would be a judgment that lpac
cannot work here, and nothing measurable supports one: the spike's three
hardware steps could not start. No verdict exists and none may be inferred,
so none of the GO-path delivery ships — no ceralive-lpac deb, no closure row,
no manifest entry, no apt publication, no image pin. closure_version 2 and its
frozen matrix are untouched.

The licensing half of the spike is pure research, so it did run, re-verified
against upstream rather than carried over:

- lpac's program logic is AGPL-3.0-only, so it may only ever be spawned as an
  external binary over its CLI — never linked into modem-control, cerastream,
  or CeraUI.
- Redistribution obliges Corresponding Source from the same place (AGPL-3.0
  §6(d)). apt.ceralive.tv publishes binary indexes only, so a source channel
  would have to exist before any lpac upload. Recording it now keeps a future
  GO from discovering it late.
- §13 attaches to modified versions only, so the rule is ship unmodified or
  not at all — which is what POLICY.md's no-fork rule already requires.
- lpac is now in Debian main (2.3.0-1) but in testing/unstable only, absent
  from bookworm and trixie, so the preferred stock-distro shipping form is
  ruled out by suite rather than by licence.

Also records the unblock path: a removable eUICC card in the Quectel's SIM
slot would give the bench a real EID with no new modem, and lpac's default
pcsc backend reaches such a card outside ModemManager entirely.
Some modules' USB-mode switch command only writes non-volatile configuration: it
answers OK, reads back the new value, and leaves the device enumerated in the OLD
composition. Measured on the bench RM530N-GL, `AT+QCFG="usbnet",<n>` behaves
exactly that way in both directions.

Undeclared, such a SKU exhausts the port-drop budget and reports failure AFTER the
NV write already succeeded — then lands in the new composition at the next
unrelated reboot. That is a fail-OPEN outcome from a fail-closed design.

`applyCommand` is optional (a SKU that re-enumerates on its own declares none) and
`min(1)` when present, because an unsendable empty string is not the same fact as
"no commit command".

`applyCommand` is dispatched between `at-command` and `await-port-drop`, through
the SAME allowlisted `AtCommandLease` as the switch command — the allowlist now
unions both fields, so a commit command is as tightly constrained as the switch it
completes.

It is its own step (`apply-command`) rather than a retry of the first, so a
transcript distinguishes the two and a reviewer can see the order. A commit the AT
layer rejects fails the transaction degraded and never reactivates, which is the
point: a half-applied NV write must not be reported as a switch.

The tests drive a LOCAL fixture catalog, deliberately. What is under test is the
engine's behaviour when an entry declares a commit command; asserting it through
the shipped catalog would turn an engine test into a certification claim and couple
it to a review decision.
…r registry

The frozen v1.1 domain surface this package publishes: physical and subscriber
identity, generation, observation envelopes, and operation results, each with the
branded types that keep an identifier from being passed where a different
identifier is expected.

On top of it, a provider registry with an evidence-scored matcher. A provider is
selected by how well the evidence a device actually reported matches the
provider's declared discriminators, not by a first-match-wins ordering that makes
adding a provider a reordering problem. DOMAIN-CONTRACTS.md and
PROVIDER-MATCHING.md document both, and a conformance fixture gives every
provider the same shape of test.
@ceralive/modem-control shipped raw TypeScript through v1.0.0 - exports
pointed at ./src/index.ts and files was ["src"], with no build tool in the
repo at all. It now publishes built ESM plus declarations from dist/, across
seven entry points and no more: ., ./transport, ./domain, ./providers,
./capabilities, ./hardware, ./testing. Internal barrels stay reachable only
through the root entry, so reorganising them is not a breaking change.

./testing is the public contract-fakes surface. A consumer testing against
this package needs valid instances of the frozen v1.1 domain and provider
contracts, and hand-rolling them is how a consumer's fixtures come to
disagree with the package - a hand-written OperationResult literal quietly
stops matching what classifyOperationCompletion returns. Every fake is built
through the package's own constructors and classifiers, so it cannot express
a shape the domain refuses. control/test-support/ is deliberately NOT this:
it needs a private session bus and a stateful nmcli harness, and stays
unpublished.

dist/ is a 1:1 tsc emit rather than a bundle. Bun.build --splitting emitted
an entry whose export list named symbols the file never imported, which Bun's
loader accepts and Node rejects with "Export 'BigIntRequiredError' is not
defined in module" - the Node fixture is what caught it. Bundling without
splitting instead gives each subpath its own copy of the shared modules,
which breaks instanceof across two subpaths of one package. Because tsc never
rewrites a specifier and these sources are written for bundler resolution,
the build rewrites each emitted ./x into ./x.js or ./x/index.js, resolved
against the emit itself, and fails if one extensionless specifier survives.

The repo-root tsconfig now maps @ceralive/modem-control* back to control/src.
That is required, not a convenience: with exports pointing at dist, the
workspace cli resolved the package to dist while control/test-support
resolved the same modules relatively, and Brand's unique symbol turned every
branded value crossing that boundary into a hard type error. The mapping is
never published, so consumers still resolve to dist.

The built artifact is therefore proven by things that ignore that mapping.
The shape gate packs with bun pm pack and runs six rules over the extracted
tarball: no raw source, dist really carries .js and .d.ts, every declared
entry both exported and packed, no undeclared subpath, nothing pointing
outside ./dist/, and the library-only proof - no bin, no systemd unit, no
shebang, no listening-socket construct. Each detector has a non-vacuity test
that trips it with a synthetic artifact. Two standalone consumer projects
then install the real tarball and import all seven specifiers; the Node one
refuses any major but 26, so a green result cannot come from an older Node
on PATH.

Incidental but required: biome's "!test-results" only excluded a root-level
directory, so the new control/test-results/ made lint red; it is now
"!**/test-results". Both workflows move to Node 26, and ci-bun.yml gains the
consumer-fixture step.
Pins node-version to "26" across the workflows and brings them onto the shared CI
canon: concurrency blocks with the correct cancel-in-progress polarity, push
triggers constrained by branches so a PR-branch push does not duplicate the PR
run, top-level least-privilege permissions with per-job escalation, major-pinned
actions, and dependency caching.
The layer CeraUI migrates onto: portable physical identity, normalized
ModemManager enums, presentation helpers, shadow-divergence folding, USB
network-device classification, surfaceable-module selection, and the router
telemetry parsers.

Every function here is pure. It takes evidence and returns a verdict, with no
D-Bus handle, no process spawn, and no clock — which is what makes it testable
without hardware and what lets CeraUI become a thin consumer instead of carrying
a second copy of the same rules. docs/ records the boundary so a later change does
not quietly reintroduce I/O into it.
… engine

The impure half, behind ports.

Providers: a typed D-Bus ModemManager provider, a NetworkManager desired/applied
bearer adapter, evidence-selected Huawei HiLink profiles with readback,
firmware-scoped ZTE goform profiles plus the MF79U diagnosis harness, and a
read-only UFI provider with explicit Qualcomm prohibition fences. Each one is
matched by evidence and conformance-tested against the same fixture corpus, with
sanitized transcripts committed so the matrix is reproducible without the rack.

Underneath them: provenance-aware normalized observation envelopes, an admission
port with cross-process exclusive ownership so two processes cannot drive one
modem, a descriptor-gated operations engine that reconciles unknown outcomes
rather than guessing, and a path-parameterized journal engine and store so a
killed process replays instead of losing the transaction.

Radio truth is verbatim: the mode and band a modem reports are surfaced as the
modem said them, with signal and SIM evidence normalized around them.

Per-repo AGENTS/docs record the resulting behavior.
Overlaying the 1.1 candidate onto CeraUI's own tree proved `parseZteDetails`
could not replace the reader it exists to retire: it was narrower than that
reader and disagreed with it about a key name.

`band` and `network_band` are two different readings and are no longer folded
onto one key. `lte_band` is the serving cell's band; `wan_active_band` is the
band the WAN leg is active on, and the two disagree the moment carrier
aggregation is up, so publishing either under the other's name reports a band
the device never claimed for that leg.

The carrier composition and the dongle's own counters are now carried:
`lte_ca_{p,s}cell_*`, the monthly triple plus `date_month`, and the five
`realtime_*` counters. The `realtime_*` to `session_*` rename is deliberate —
three of those five are cumulative counters, and the vendor's prefix reads as
"live rate" for all five.

`stated()` drops every vendor placeholder rather than only the single dash;
`--`, `n/a` and `N/A` are unset markers on these firmwares too, and echoing one
puts a value on screen that reads like a reading. `parseUfiDetails` shares the
helper and inherits the widening.

Also recorded: `parseUfiDetails` remains narrower than CeraUI's UFI reader and
takes a different input shape. No consumer probes for it, so nothing is broken
today, but a cutover must reconcile it first.

Also removes a stale CeraUI-only symbol reference (`withCapabilityModuleMutation`)
from the carryover section of AGENTS.md: it names a symbol that lives in CeraUI,
not in this repo, so a reader following it finds nothing here.
@andrescera
andrescera merged commit 4b4b206 into main Aug 20, 2026
2 checks passed
@andrescera
andrescera deleted the feat/modem-phase-c branch August 20, 2026 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant