Skip to content

feat(air-gap): add @bsv/air-gap one-directional optical transport v0.1.1 (wire v1, BRC-141) - #404

Merged
sirdeggen merged 7 commits into
mainfrom
feat/air-gap-transport
Jul 30, 2026
Merged

feat(air-gap): add @bsv/air-gap one-directional optical transport v0.1.1 (wire v1, BRC-141)#404
sirdeggen merged 7 commits into
mainfrom
feat/air-gap-transport

Conversation

@sirdeggen

@sirdeggen sirdeggen commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What

Adds @bsv/air-gap v0.1.1 at packages/helpers/air-gap — a zero-dependency, payload-agnostic one-directional optical air-gap transport: arbitrary bytes in, a deterministic sequence of fountain-coded wire parts out, reassembled from a camera feed with no back-channel of any kind.

This revision implements wire protocol v1 (BRC-141) and addresses every item in the change-requesting review: the wire format is now versioned with a real session identity, the fountain mapping is exactly specified in cross-language integer arithmetic, the decoder is resource-bounded against hostile input, recovery claims are honest and measured, the protocol has a normative spec plus implementation-neutral root conformance vectors, and the governance declaration matches the browser-facing runtime the package actually promises.

Wire format (v1)

air-gap: + unpadded_base64url( ver u8=1 ‖ sessionId 8B ‖ seq u32 ‖ K u16 ‖ msgLen u32 ‖ crc32 u32 ‖ block )
  • Version byte — the decoder rejects any ver ≠ 1, so the header can evolve without silent mis-decoding.
  • 8-byte session identity — random per encoder (caller-overridable for deterministic vectors); the decoder locks onto the first session and one stray foreign frame can no longer erase progress. Only SESSION_SWITCH_PARTS = 3 consecutive parts of the same new session switch it.
  • Seed correctness — the fountain seed is the exact u32 product Math.imul(seq, 0x9e3779b1); the old float multiplication silently diverged from seq = 3,393,265 (at 0x7fffffff: 3,788,015,616 vs the correct 3,788,015,183). Conformance vectors pin both sides of the boundary plus 0xffffffff.
  • Exact ideal soliton — the old two-draw sampler was not the distribution it claimed (K=5 put ~20% on degree 5 instead of 5%). v1 uses a single-draw exact-integer inverse CDF (d = ceil(2^23/(r+1)), overflow → 1) that matches ideal soliton to 4 decimal places and is bit-exact in any language. A frozen distribution test pins it.
  • Bounded block sizeMAX_BLOCK_BYTES = 2048 (sized to QR v40-L byte-mode capacity), enforced by encoder and decoder; scanned strings longer than any legal part (2,770 chars) are rejected before base64 decoding.
  • Not bsvpayf2-compatible — the review established the legacy coding's seed and sampler as defects; v1 corrects them instead of reproducing them, so the "bit-for-bit port" claim is withdrawn. Migration/adapters for bsvpayf2, PiWalletSV PW1, and Vault Manager CHUNK are tracked in Air-gap optical transport (BRC-141): convergence plan for PiWalletSV PW1, Vault Manager CHUNK, and legacy bsvpayf2 #408.

Decoder guarantees

  • accept() never throws; every stray read is an ordinary { ok: false } no-op
  • Nothing unverified escapes: message() is CRC32-gated; a mismatch discards the assembly and self-resets
  • Session locking (above) plus a per-session block-size pin against padded/truncated frames
  • Resource-bounded against hostile senders: pre-decode length gate, duplicate tracking capped at 65,536 seqs, pending mixes capped at 1,024 parts / 4,096 unresolved block references — with liveness preserved (systematic and degree-1 parts are never buffered), all covered by adversarial tests
  • A completed session is immutable: further parts are acknowledged without state changes

Honest recovery semantics

"Any K + ε distinct parts reconstruct the message" was false and is gone everywhere. Distinct parts can be linearly dependent — for K=3, parts 4, 27, 38, 56, 63, 72 all reduce to block 0 (now a pinned stall-then-recovery regression and a conformance vector). Measured percentiles (400 deterministic trials/cell): repair-only late joiner completes at ≈1.4–1.5 K parts median, ≈3.8–4.6 K at p99; with a sender looping an 8K-wide window, ≈1.5 K median bounded by the next systematic pass. Docs now tell senders to loop seq (a finite u32, no longer "unbounded") and receivers to keep scanning.

Spec, conformance, and coordination

  • Normative spec: specs/transport/air-gap-optical.md (Experimental), registered in the specs inventory and docs site
  • Public BRC: BRC-141 revised in place to wire v1 (bsv-blockchain/BRCs@master), including the seed-precision warning and an Implementations section linking back to this repository
  • Root conformance corpus: conformance/vectors/transport/air-gap-optical.json — 31 implementation-neutral vectors (encode incl. boundary seqs, decode, session lock/switch, stall regression, hostile rejects, part lengths, CRC check value). The package test suite and the cross-language runner (conformance/runner/ts, new transport dispatcher) execute the same file; META/PARITY_MATRIX/baselines updated (74→75 files, 6,650→6,681 vectors, 6,439→6,470 passed)
  • Status: experimental everywhere (docs front-matter, spec, BRC) until an independent second implementation passes the shared vectors
  • Coordination: Air-gap optical transport (BRC-141): convergence plan for PiWalletSV PW1, Vault Manager CHUNK, and legacy bsvpayf2 #408 tracks the PW1 / CHUNK / bsvpayf2 convergence and adapter plan

Governance and runtime truth

  • projects.json: browser-library, consumers browser-bundler, browser-esm, node-cjs, node-esm, runtime targets browser, node, criticality tier-1 (signing/offline-wallet trust boundary)
  • New test:browser lane (exact-tarball Vite + esbuild consumers via check-browser-package.mjs), browser-budget.json (measured 8.9 kB raw / 3.1 kB gzip vite; budgets ≈2× measured), and a governance/browser-artifact-policy.json entry
  • Version rolled to 0.1.1; release notes and publicPackageVersions updated; docs page front-matter version: 0.1.1, status: experimental
  • QR capacity language corrected everywhere to byte mode (base64url rules out alphanumeric mode), with a capacity assertion test; default part = 1,639 chars ≤ v40-Q, ceiling part = 2,770 chars ≤ v40-L
  • tests/decoder.test.ts no longer contains a literal NUL byte (String.fromCodePoint(0)), so GitHub renders the diff as text

Dependency evidence

  • Release notes and necessity: @bsv/conformance-runner-ts gains one new devDependency, @bsv/air-gap: workspace:^, so the cross-language conformance runner can execute the same shared vector file as the package's own tests (the whole point of putting fixtures in root conformance/, per repository policy). No third-party dependency is added anywhere; @bsv/air-gap is a first-party workspace package already part of this PR. .github/workflows/conformance.yml's "Build TS runner dependencies" step is updated to also build @bsv/air-gap (previously scoped only to @bsv/wallet-toolbox's dependency graph), which is what makes the new devDependency's dist/ available to the runner's typecheck.
  • Runtime, build, and peer compatibility: Dev-only, workspace-linked dependency; no runtime, build, or peer surface changes to any published package. @bsv/air-gap itself still ships zero runtime dependencies and zero peer dependencies (verified by pack:check).
  • Deduplicated lockfile: pnpm-lock.yaml was regenerated by a plain pnpm install with no --force and no override edits. The new importer entry is conformance/runner/ts@bsv/air-gap: workspace:^ resolving to link:../../../packages/helpers/air-gap — a workspace symlink, not a registry package, so there is no new entry in the resolved dependency graph and nothing to deduplicate.
  • Audit and CodeQL: No third-party package is added, so there is nothing new for pnpm audit or CodeQL to flag; both were already clean on the prior revision's package changes, which are otherwise unchanged.
  • Package and consumer tests: pnpm --filter @bsv/conformance-runner-ts typecheck/lint/format:check/test all pass locally once @bsv/air-gap is built (confirmed after correcting the CI build-dependency filter above); the TS conformance runner reports 6,470 passed / 211 skipped across 75 vector files, including the new transport.air-gap-optical.* vectors via the new transport dispatcher.
  • Bundle and performance impact: No measured regression — @bsv/conformance-runner-ts is a private, unpublished test harness ("private": true), so it has no consumer bundle. @bsv/air-gap's own published bundle impact is unchanged from this PR's earlier revision (≈8.9 kB raw / 3.1 kB gzip via Vite, verified by test:browser).
  • Affected public package versions: @bsv/air-gap@0.1.1 only, a first release still pending publication. No other package's version, dependency range, or published surface changes; pnpm check-versions confirms all cross-package references are current.

Verification

Gate Result
test 139 tests across 8 suites, green (includes conformance-corpus execution, adversarial budget tests, session locking, stall regression)
test:coverage 100% statements / branches / lines on src
test:property fast-check suite incl. new foreign-frame-progress property; the loss property is now deterministically terminating (prime sender cycle co-prime with any loss mask)
mutation (air-gap-codec) 87.50% against the 85 floor (269 killed, 32 timeouts, 43 survivors of 344 mutants)
test:browser / browser-budget exact-tarball Vite + esbuild consumers verified
pack:check publint, strict types, esm/cjs clean consumers, 13 exports
conformance JS structural runner: 75 files / 6,681 vectors PASS; TS runner: 6,470 passed / 211 skipped (locally, after the CI build-filter fix)
health:check 38 projects, 31 public packages, 0 findings
test:governance / docs:facts:check pass
typecheck / lint / format:check clean workspace-wide

Notes for review

  • Three CI findings on prior pushed heads were fixed on this head: a Sonar S3776 cognitive-complexity finding on AirGapDecoder.accept (session routing and ingestion extracted into enterSession / ingest, behavior identical); a genuinely flaky frame-loss property where the sender's loop could resonate with the keep/drop mask (loop length is now a prime above the mask period, making completion deterministic by CRT); and the ts-runner / dependency-governance failures addressed by this section and the conformance.yml build-filter fix.
  • pnpm-lock.yaml moves because @bsv/conformance-runner-ts now depends on @bsv/air-gap (workspace link only, see Dependency evidence above).
  • The two shared-CI fixes from the previous revision (coverage-other LCOV naming; patch-coverage config-file exclusion) are unchanged.
  • The BRCs repository change (BRC-141 revision) is already pushed to bsv-blockchain/BRCs@master.

🤖 Generated with Claude Code

sirdeggen and others added 3 commits July 30, 2026 14:29
Adds a zero-dependency, payload-agnostic one-directional optical air-gap
transport: arbitrary bytes in, a deterministic endless sequence of
fountain-coded wire parts out, reassembled from a camera feed with no
back-channel.

Wire format: `air-gap:` + unpadded base64url of a 14-byte big-endian header
(seq u32, K u16, msgLen u32, crc32 u32) and exactly one fixed-size block. The
first K parts are the source blocks verbatim, so one clean camera cycle decodes
with zero overhead; later parts are interchangeable XOR mixes chosen by an
xorshift32 RNG seeded from seq with an ideal-soliton degree, so any K + epsilon
distinct parts reconstruct the message and a missed frame costs almost nothing.

The coding is ported bit-for-bit from bsv-browser's `bsvpayf2:` fountain so a
later migration and any cross-language port stay interoperable; the frozen part
strings in tests/vectors.test.ts are the conformance contract. The legacy
prefix is not accepted.

Decoder guarantees, because the input is a camera:
- accept() never throws; stray reads are ordinary { ok: false } no-ops
- no partial or unverified bytes ever escape; message() is CRC32-gated
- a CRC mismatch discards the assembly and self-resets, so a looping sender
  refills it without the application managing a retry
- the session block size is pinned by the first accepted part, which is what
  stops two senders, or one padded frame, from being assembled together

Encoder guards empty and oversize messages, non-integer block sizes, block
counts past the u16 K field, and sequence numbers outside u32.

Out of scope by design, and deliberately absent: QR rendering, camera capture,
display cadence (no frame-interval constant is exported), payment or BRC-100
schemas, compression, encryption.

Tests: 100 unit and vector cases plus a fast-check property suite over the wire
boundary; 100% statement, branch and line coverage of src; 89% mutation score
against an 85 floor.

Governance: registered in repository-health, package-release-notes, test-quality
property suites, mutation targets and npm supply chain; package count baselines
and the docs package page and indexes updated to 31 public packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wave 39 (#403) landed after this branch was cut and now requires a generated
root-policy pointer at every governed project, so add
packages/helpers/air-gap/AGENTS.md and bump the scoped-pointer count to 44.

Also clears the ten new SonarCloud findings the zero-findings gate reported:

- S8786 in src/base64url.ts and tests/helpers.ts — `=+$` backtracks
  super-linearly on a long run of '='. btoa pads to a multiple of four, so a
  bounded `={0,2}$` strips the same padding in linear time.
- S7749 in src/constants.ts — uneven numeric separator groups in
  `0x1_0000_0000`; `2 ** 32` states the u32 bound directly.
- S5906 across four test files — `toHaveLength` reports better than comparing
  a raw `.length`.

No behavior change: the frozen conformance vectors and all 100 tests pass
unchanged, src coverage stays at 100%, and the mutation score is 89.77%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding the first package whose coverage comes only from the "Coverage / other
affected packages" lane exposed two gaps that made the 90% patch-coverage gate
unsatisfiable for it.

1. That lane flattened report paths into `packages_helpers_air-gap_coverage_lcov.info`,
   but both the aggregate lane's Codecov glob and scripts/patch-coverage.mjs
   collect `*.lcov.info` — a dot, not an underscore. Every coverage-other
   package's LCOV was therefore invisible to the gate and never uploaded to
   Codecov. The artifact is now named `packages_helpers_air-gap.lcov.info`.

2. patch-coverage.mjs counted `jest.config.cjs` and similar as governed
   production source. Configuration is never instrumented, so a package that
   adds or edits a test config could never clear "changed production files
   absent from LCOV". Config files are now excluded, with a regression test.

Verified locally against origin/main: the gate reports
100.00% (276/276 changed line/branch points).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sirdeggen
sirdeggen force-pushed the feat/air-gap-transport branch from 3a7d9a0 to 8ada948 Compare July 30, 2026 19:29
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@ty-everett ty-everett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deggen, this is a strong package contribution, and it already incorporates a lot of the repository doctrine we just established: the package-local AGENTS.md is the correct root pointer, the docs/release evidence and governance inventory are present, strict lint/type/build/artifact checks pass, the property/coverage/mutation work is substantial, and the implementation is admirably small and dependency-free.

I am requesting changes because this PR also creates a new public wire contract at an untrusted camera and offline-wallet boundary. A few details that look small in TypeScript become permanent interoperability or resource-safety problems once another implementation ships. The inline comments identify the must-fix code paths: cross-language u32 seed divergence, a degree sampler that is not the documented ideal-soliton distribution, an absolute recovery guarantee that LT coding cannot provide, missing wire version/session identity, unbounded decoder work/memory, and a Node-only governance declaration for a browser-facing package.

There are four cross-cutting items to address with those changes:

  1. packages/helpers/air-gap/tests/decoder.test.ts contains a literal NUL byte at line 128. GitHub consequently treats the 315-line test file as binary and hides its patch from review tooling. Please express that hostile input without embedding byte 0 in the source (for example, construct it with String.fromCodePoint(0)) so the file remains ordinary reviewable UTF-8.
  2. Base64url contains lowercase letters and _, so these strings use QR byte mode, not QR alphanumeric mode. Please correct base64url.ts, helpers.ts, constants.ts, and the package/docs capacity language, then validate the documented default against byte-mode capacity (ideally with a real render/scan sizing fixture).
  3. This is a portable protocol, but its normative definition and vectors currently live only in package prose/Jest. Add a versioned specification under specs/ and implementation-neutral vectors under the root conformance/ corpus (including boundary/high-sequence vectors), with the package tests consuming the same fixtures. Until the protocol and cross-implementation behavior are settled, the docs should say experimental, not stable.
  4. Please keep @bsv/air-gap payload-agnostic, but design its versioning and conformance with the adjacent systems in view. PiWalletSV PR #1 now has versioned gzip+CBOR envelopes with stateSync, stateReceipt, state revision/hash, proposal IDs, and Atomic BEEF over its indexed PW1 transport. Vault Manager has its own CHUNK transport plus an Atomic-BEEF/coin-state and audit model. The clean convergence boundary is: this package owns a shared, versioned optical transport; a separate common wallet-state envelope owns capabilities, network/wallet identity, Atomic BEEF, revision/hash, proposal/idempotency and receipt/replay semantics. Existing PW1, CHUNK, and bsvpayf2 should get explicit adapters/migration paths rather than silent multi-format decoding. Please capture that coordination as a linked design/tracking item and avoid calling the transport stable until shared fixtures can exercise at least one independent implementation.

For orientation to the new contribution contract: the root AGENTS.md, CONTRIBUTING.md, and SECURITY.md are authoritative; package files should only point there. Runtime/criticality claims in the repository inventory must match docs and exact packed-consumer evidence. Protocol work belongs in specs/ plus root conformance fixtures. Untrusted parsers must be resource-bounded. Docs, changelog/migration notes, and tests change with the code. Before the next review handoff, run the strict local equivalents and then wait for every check on the exact pushed head; new Sonar or CodeQL findings are blockers, never waivable.

Validation on commit 8ada94853cf4657ce8d7fcd8b806b254850c3e22: frozen install; package typecheck, zero-warning lint, formatting, build, property tests, 100-test coverage suite (100% src coverage), exact-tarball pack/consumer checks, repository health contract, and local mutation testing (89.77%) all pass. Every GitHub check is terminal green as well, including the repository-owned 90% patch gate, Sonar zero-new-findings gate, CodeQL (zero open PR alerts), dependency review, browser/mobile lanes, and the 8m13s air-gap mutation lane. So the request is about correctness and durable protocol quality rather than unresolved CI.

Comment thread packages/helpers/air-gap/src/coding.ts Outdated
* @param k - Source block count.
*/
export function blocksForPart(seq: number, k: number): number[] {
const rng = makeRng((seq * 0x9e3779b1) >>> 0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve the intended u32 multiplication across languages. JavaScript Number multiplication begins losing low bits once seq * 0x9e3779b1 exceeds 2^53 (from about seq 3,393,265). For example, at seq = 0x7fffffff this expression seeds 3,788,015,616, while 32-bit modular multiplication (Math.imul) yields 3,788,015,183; a normal Go/Rust/Python u32 port will therefore select different blocks. Use Math.imul(seq, 0x9e3779b1) >>> 0 (or an equivalently specified operation) and add frozen vectors around the precision threshold plus 0x7fffffff and 0xffffffff before this mapping is declared immutable.

Comment thread packages/helpers/air-gap/src/coding.ts Outdated
let degree: number
if (k === 1) degree = 1
else if (open01() <= 1 / k) degree = 1
else degree = Math.min(k, Math.ceil(1 / open01()))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Sample the distribution that the protocol claims. The degree-1 test consumes one uniform draw and the non-1 branch consumes a second independent draw, so the final probabilities are not ideal soliton. With K=5, for example, degree 5 occurs about 20% here instead of the ideal 5%. Because this choice is wire format, settle an explicitly normalized distribution (ideal soliton or another justified/tested choice), sample it deterministically, quantify recovery overhead/loss percentiles, and freeze language-neutral vectors; preserving the current accidental distribution while calling it ideal would make every future port reproduce the bug.

if (typeof text !== 'string' || !text.startsWith(AIR_GAP_PREFIX)) return this.rejected()
let bytes: Uint8Array
try {
bytes = fromB64url(text.slice(AIR_GAP_PREFIX.length))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Bound the input before allocating it. A caller-controlled string is currently copied/replaced, decoded by atob, copied into a binary string, and copied again into a Uint8Array before any length ceiling is checked. Moreover, the shape test allows an arbitrarily large payload when K=1. Define a maximum encoded-part/block length, reject on text.length before base64 decoding, and enforce the same block ceiling in the encoder. Add adversarial boundary tests; MAX_MESSAGE_BYTES alone does not make this untrusted camera parser resource-safe.

Comment thread packages/helpers/air-gap/src/decoder.ts Outdated
else if (payload.length !== this.blockBytes) return this.rejected()

if (this.seen.has(seq)) return this.accepted()
this.seen.add(seq)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Put finite bounds on per-session memory and work, and stop mutating a completed session. seen can grow across all unique u32 sequence values, pending retains a payload plus index set for every unsolved equation, and blocksForPart allocates an array of K entries where K may be 65,535. Even after done, additional unique frames keep growing seen. Establish practical K/seen/pending/work limits with defined reject/evict/reset semantics, return immediately once complete, and test adversarial streams so a camera callback cannot be used for memory or CPU exhaustion.

Comment thread packages/helpers/air-gap/src/decoder.ts Outdated
// not talking about the same message shape.
if (Math.ceil(msgLen / payload.length) !== total) return this.rejected()

const key = `${total}:${msgLen}:${crc}`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Give the new wire protocol an explicit version and a real stream identity before freezing it. (K,msgLen,crc32) is a shape plus accidental-corruption checksum, not a collision-resistant session identifier; and automatically adopting any different well-formed tuple lets one foreign frame erase useful progress. Include a version/capability discriminator and a sufficiently strong message/session ID, retain CRC only for accidental integrity, and make session locking/switching explicit. Cover simultaneous senders, collisions, replay, and foreign-frame behavior now, while changing the header/prefix is still inexpensive.

Comment thread packages/helpers/air-gap/src/encoder.ts Outdated
*
* `seq < blockCount` returns source block `seq` verbatim — the systematic
* prefix, so an unlucky-free receiver finishes in exactly `blockCount` reads.
* Past that, parts are XOR mixes and are interchangeable: any `blockCount + ε`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Replace this absolute recovery guarantee with a probabilistic one. Distinct LT equations can be linearly dependent: with K=3, the six distinct sequence values [4, 27, 38, 56, 63, 72] produced by this implementation all select block 0, so K+3 reads still leave progress at 1/3. Document that receivers keep scanning until recovery, add a deterministic stall-then-recovery regression plus measured recovery percentiles, and update every README/JSDoc occurrence. Also, seq is u32 and therefore finite, not “without bound.”

"name": "@bsv/air-gap",
"owner": "ts-stack-maintainers",
"area": "helpers",
"profile": "node-library",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Declare and verify the runtime this package actually promises. Both READMEs say browser support, the implementation deliberately uses browser globals, and the primary use case is browser/mobile camera exchange, but node-library plus Node-only targets means the health contract does not require exact packed browser/bundler consumers or the corresponding bundle evidence. Use the browser-capable profile and consumer targets (browser-bundler, browser-esm, plus Node CJS/ESM), add exact-tarball browser tests/budget evidence, and reconsider Tier 1 given the signing/offline-wallet trust boundary. Under the new policy, inventory, documentation, and runtime evidence must agree.

Wire format: versioned 23-byte header (ver u8 = 1, 8-byte session identity,
seq u32, K u16, msgLen u32, crc32 u32). The fountain seed is the exact u32
product Math.imul(seq, 0x9e3779b1) - float multiplication diverged from
seq 3,393,265 - and the degree draw is a single exact-integer inverse-CDF
sample of the ideal soliton distribution, replacing the accidental two-draw
sampler. Both are pinned by conformance vectors at the precision boundary,
0x7fffffff and 0xffffffff. No bsvpayf2 bit-compatibility is claimed; the
legacy coding's defects are corrected, not reproduced.

Decoder: rejects oversize strings before base64 work, enforces the new
MAX_BLOCK_BYTES = 2048 ceiling (QR v40-L byte mode) on both sides, locks
onto the first session and switches only after 3 consecutive parts of one
foreign session, acknowledges completed sessions without mutation, and
bounds duplicate tracking (65,536 seqs) and buffered mixes (1,024 parts /
4,096 indices) with liveness preserved. All bounds have adversarial tests;
coverage stays 100% statements/branches/lines on src.

Recovery language is now probabilistic everywhere, with a pinned K=3
stall-then-recovery regression (seqs 4,27,38,56,63,72 all reduce to block 0)
and measured percentiles in the spec. QR capacity language corrected to
byte mode with an assertion test. The literal NUL byte is gone from
decoder.test.ts so the diff renders as text.

Spec and conformance: normative spec at specs/transport/air-gap-optical.md
(experimental), BRC-141 revised in place in bsv-blockchain/BRCs, and 31
implementation-neutral vectors at conformance/vectors/transport/ executed
by both the package suite and the TS conformance runner via a new transport
dispatcher (corpus 74->75 files, 6650->6681 vectors). Convergence with
PW1/CHUNK/bsvpayf2 is tracked in #408.

Governance: browser-library profile with browser-bundler/browser-esm
consumers, tier-1 criticality, test:browser lane with browser-budget.json
and a browser-artifact-policy entry; version rolled to 0.1.1 with updated
release notes, baselines and experimental docs status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VPatJS7hMnrmHJG9iMRQQ
@sirdeggen sirdeggen changed the title feat(air-gap): add @bsv/air-gap one-directional optical transport v0.1.0 feat(air-gap): add @bsv/air-gap one-directional optical transport v0.1.1 (wire v1, BRC-141) Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Review addressed on head 0e4a8fb — every inline P1 and the four cross-cutting items, item by item:

Inline P1s

  1. u32 seed precision (coding.ts) — seed is now Math.imul(seq, 0x9e3779b1) >>> 0. Frozen vectors pin the mapping at seq = 3,393,264 / 3,393,265 (the first divergent value — matching your estimate), 0x7fffffff and 0xffffffff, both as blocksForPart pins in tests/vectors.test.ts and as full part strings in the shared corpus.
  2. Degree sampler (coding.ts) — replaced with a single-draw exact-integer inverse CDF of the ideal soliton: d = floor((2^23 + r)/(r + 1)), overflow-past-K → 1 (which is exactly the ρ(1) = 1/K mass). Verified empirically to 4 decimal places at K = 5/20/55 (the old sampler's K=5 degree-5 mass was ~20%; it is now 5.00%), with a frozen distribution test so no port can reintroduce the accident. Normative statement in specs/transport/air-gap-optical.md §5.1 and BRC-141.
  3. Unbounded pre-decode allocation (decoder.ts) — scanned strings longer than the longest legal part (2,770 chars = estimatePartCharLength(MAX_BLOCK_BYTES)) are rejected before any base64 work; decoded payloads are bounded to MAX_BLOCK_BYTES = 2048 (sized to QR v40-L byte mode), and the encoder enforces the same ceiling. The K=1 padded-payload hole is closed by the same bound. Adversarial boundary tests included.
  4. Unbounded session memory/work (decoder.ts) — duplicate tracking capped at 65,536 seqs; buffered mixes capped at 1,024 parts and 4,096 total unresolved block references, with defined reject semantics (mixes over budget soft-reject; systematic/degree-1 parts always land, so a looping sender cannot be starved — tested). A completed session is immutable: further parts are acknowledged with zero state change.
  5. Version + session identity (decoder.ts) — the header now leads with ver u8 = 1 (any other value rejected) and an 8-byte sessionId (random per encoder, caller-overridable). Session identity is (sessionId, K, msgLen, crc32); CRC is demoted to accidental-integrity only. The decoder locks onto the first session — one foreign frame no longer erases progress — and switches only after 3 consecutive parts of the same new session. Simultaneous senders, same-id/different-crc collisions, replay (duplicate acknowledgement), and foreign-frame behaviour are all covered by tests and corpus vectors.
  6. Absolute recovery claim (encoder.ts) — gone from every README/JSDoc/doc page. Your exact K=3 example (4, 27, 38, 56, 63, 72 → all block 0) is now a pinned stall-then-recovery regression and a conformance vector. Measured percentiles (400 deterministic trials/cell) are in the spec: repair-only late joiner ≈1.4–1.5 K median / ≈3.8–4.6 K p99; looping sender bounds it at ≈1.5 K median. seq is documented as a finite u32 and senders are told to loop.
  7. Runtime declaration (projects.json) — now browser-library with browser-bundler, browser-esm, node-cjs, node-esm consumers, browser, node runtime targets, and tier-1 criticality. Backed by a real test:browser lane (check-browser-package.mjs exact-tarball Vite + esbuild consumers), browser-budget.json (measured 8.9 kB raw / 3.1 kB gzip; budgets ≈2× measured), and a browser-artifact-policy.json entry.

Cross-cutting

  1. NUL byte in decoder.test.ts — replaced with String.fromCodePoint(0); the file diffs as ordinary UTF-8 now.
  2. QR byte mode — corrected in base64url.ts, helpers.ts, constants.ts, README, and the docs page; the default (1,639 chars ≤ v40-Q) and the ceiling (2,770 ≤ v40-L) are validated by a capacity assertion test.
  3. Spec + root conformance — normative spec at specs/transport/air-gap-optical.md (registered in the specs inventory and docs site), and 31 implementation-neutral vectors at conformance/vectors/transport/air-gap-optical.json executed by both the package suite and a new transport dispatcher in the TS conformance runner. Docs say experimental everywhere. One correction to my earlier framing: the public spec already existed as BRC-141, so that document was revised in place to wire v1 (pushed to bsv-blockchain/BRCs@master) rather than claiming a new number — including the Math.imul seed-precision warning so no port repeats the float bug.
  4. CoordinationAir-gap optical transport (BRC-141): convergence plan for PiWalletSV PW1, Vault Manager CHUNK, and legacy bsvpayf2 #408 tracks the PW1 / Vault Manager CHUNK / bsvpayf2 convergence boundary and adapter plan; the decoder stays BRC-141-v1-only (no silent multi-format decoding). The bit-for-bit bsvpayf2 claim is withdrawn since the review established the legacy coding as defective.

Local gates on this head: 139 package tests green, 100% statements/branches/lines on src, property suite green, browser + pack consumer checks green, JS structural runner 75 files / 6,681 vectors PASS, TS runner 6,470 passed / 211 skipped, health:check / test:governance / docs:facts:check / workspace lint / typecheck / format all clean. Now watching CI on this exact head.


Generated by Claude Code

@sirdeggen
sirdeggen requested a review from ty-everett July 30, 2026 21:29
claude and others added 3 commits July 30, 2026 21:37
…operty

Sonar flagged AirGapDecoder.accept at cognitive complexity 18 (limit 15)
after the session-locking and budget logic landed; the session routing and
peeling ingestion are now extracted into enterSession and ingest with
identical behavior, keeping accept at a straight-line read. Coverage stays
100% statements/branches/lines on src.

The frame-loss property let the sender's 4K-part loop resonate with the
repeating keep/drop mask - CI's seed found a mask keeping exactly one tick
in eight against an 8-part cycle, so the receiver saw one seq forever. The
loop length is now the smallest prime above both 4K and the mask period,
which by CRT guarantees every part is kept within mask.length * cycle ticks
and makes the property deterministically terminating. The failing CI seed
replays green, plus a 2,000-run soak.

Jest now ignores .stryker-tmp so local runs cannot pick up mutation
sandboxes. Local Stryker on air-gap-codec: 87.50% against the 85 floor
(269 killed, 32 timeouts, 43 survivors of 344 mutants).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VPatJS7hMnrmHJG9iMRQQ
…ks it

conformance/runner/ts now depends on @bsv/air-gap as a devDependency so it
can execute the shared transport vectors, but the 'Build TS runner
dependencies' step only ever built @bsv/wallet-toolbox's dependency graph.
air-gap isn't in that graph, so its dist/ (and therefore its type
declarations) never existed when the runner's typecheck ran, failing with
'Cannot find module @bsv/air-gap'. Add it to the build filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VPatJS7hMnrmHJG9iMRQQ
Empty commit to re-trigger CI so "Require complete dependency review
evidence" evaluates the updated PR body (GitHub job re-runs reuse the
original pull_request event payload, so a body-only edit does not take
effect until a new run starts).
@sonarqubecloud

Copy link
Copy Markdown

@sirdeggen
sirdeggen merged commit 1c530ed into main Jul 30, 2026
65 of 66 checks passed
@sirdeggen
sirdeggen deleted the feat/air-gap-transport branch July 30, 2026 22:32
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.

3 participants