Skip to content

[CHIRP-3538]: BSSCI 1.1 interoperability, durability and protocol hardening#31

Open
wirelesstim wants to merge 25 commits into
mainfrom
CHIRP-3538-bssci-version-negotiation-eui64
Open

[CHIRP-3538]: BSSCI 1.1 interoperability, durability and protocol hardening#31
wirelesstim wants to merge 25 commits into
mainfrom
CHIRP-3538-bssci-version-negotiation-eui64

Conversation

@wirelesstim

@wirelesstim wirelesstim commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Closes the BSSCI 1.1 interoperability story reported by Oliver Haala (Fraunhofer IIS), whose Fraunhofer AVA base station (BSSCI v1.1, EUI CA-FE-CA-FE-CA-FE-CA-FE) could not connect to the service center. Both root causes are fixed, and the surrounding protocol/durability surface is hardened.

Jira: CHIRP-3538 (sub-tasks CHIRP-3539 … CHIRP-3545, CHIRP-3550)

The two reported defects

Version negotiation (CHIRP-3539). A base station requesting 1.1.0 in con was answered with error 71 (Unsupported minor version) and disconnected. The service center now offers its own supported version in the conRsp version field and lets the station accept via conCmp or decline via error, per BSSCI rev1 §4.2/§5.3.2.

Unsigned EUI-64 range (CHIRP-3540). 0xCAFECAFECAFECAFE was rejected. The cause was signed narrowing of uint64int64 during message decoding, plus outbound scEui corruption through JSON projection and precision loss above 2^53. The full unsigned range now survives end-to-end: wire decode and encode, persistence, gRPC/browser, certificates, configuration.

⚠️ Breaking change — migration required

EUIs move from signed BIGINT to BYTEA(8). Run migrations before upgrading; EUIs above INT64_MAX overflowed silently on the old schema.

What else is in here

  • Session resume safety (CHIRP-3541) — durable SC operation-ID discipline (allocation never rolled back, counter persisted before the pending record, frames written last); ambiguous frame writes close the transport and recover through resume with the original operation IDs. Semantic reconstruction happens before conRsp, so an operation that cannot be rebuilt rejects the resume with EAGAIN and preserves its row and downlink queue state rather than dropping recoverable work after activation.
  • Operation lifecycle and command direction (CHIRP-3542)errorAck dispositions are tracked per session; only an errorAck that replaces a known pending SC operation finalizes it.
  • Downlink dispatch (CHIRP-3543) — one owner of pending → reserved → queued with exact-match reservation, idempotent confirmation and dlDataQueRsp repair; dlRxStatQry pairs persist atomically.
  • Certificate identity (CHIRP-3544) — composite resolver (EUI CN → registered station, org CN → org resolver), SHA-256 fingerprint enforcement with race-safe backfill, CN/EUI binding in strict mode.
  • Storage boundaries and SOLID (CHIRP-3545) — protocol servers consume narrow consumer-side contracts instead of the storage facade; SCACI context threading; the SOLID checker was repaired and now actually executes its rules.
  • Gateway, lifecycle and gates (CHIRP-3550) — circuit-breaker correctness (client stream teardown is no longer an upstream failure; half-open admits only slot-accounted unary probes), fail-fast wiring validation on Start, reproducible quality gates from a standalone clone, release-manifest provenance, and a new export-tree gate.

Verification

  • Branch CI green at 13c2dca: lint, full KC-Core suite (no -short), and the KC-DB postgres integration suite.
  • Local gates green: make pre-commit, make pre-push, make scaci-verify, make bssci-verify, make verify-solid.
  • Framed regression matrix includes an exact replay of the Fraunhofer scenario, plus per-type malformed-resume rejection tests and a deterministic half-open breaker test.
  • Verified against live hardware: base station 70b3d59cd00009e6 connects and stays online (protocol log, basestation_online event, and is_online = t in the database).

Merge order

This PR first. The ECE submodule bump follows once this squash-merges, repinned to the resulting main SHA.

…ange

Both defects Oliver Haala (Fraunhofer IIS) reported when his Fraunhofer
AVA base station could not connect.

Version negotiation (CHIRP-3539): a station requesting 1.1.0 in con was
answered with error 71 and disconnected. The service center now selects
a compatible version and returns it in the conRsp version field, so the
station accepts with conCmp or declines with error (BSSCI rev1 4.2,
5.3.2). A different major version remains a protocol error.

Full unsigned EUI-64 (CHIRP-3540): 0xCAFECAFECAFECAFE was rejected
because decoding narrowed uint64 to int64, the outbound scEui was
corrupted through its JSON projection, and values above 2^53 lost
precision in browser transport. The complete range now survives wire
decode and encode, persistence, gRPC, certificates and configuration.

Includes the framed interop matrix that replays the Fraunhofer scenario
exactly, and the EUI-64 database matrix covering constraint saturation.
…d tooling

Follow-through for CHIRP-3540 after EUIs moved to BYTEA(8): rename
cascades and the statistics join were still assuming the old signed
column, the archive rebuild was destructive and could downgrade
high-bit EUIs, and blueprint handler literals were not resolving
through the error catalog.

Also repairs the web lint and vitest gates so the frontend checks run
end to end.
Session resume (CHIRP-3541): Session is split into a transport-free
ProtocolSessionState, resume becomes spec-conformant and tenant-scoped,
and the connect state machine is completed so activation is atomic - a
half-activated session can no longer be observed.

Operation lifecycle (CHIRP-3542): the command-direction map is
corrected and its generator made deterministic, service-center-initiated
commands arriving inbound are rejected, pending operations are persisted
before they are cached and before the frame is sent, an inbound error is
answered only with errorAck instead of a guessed completion, and
SC-initiated operations are finalized after completion instead of
leaking. Migration 000140 purges pending operations orphaned by session
ownership.
… hardening

Downlink reception status (CHIRP-3543): dlRxStat reports are correlated
by the base station they were expected from, and an expiry worker with
config-backed timing retires queries that never receive a report.

Certificate issuance (CHIRP-3544): issuance is hardened against
cross-tenant minting - a certificate can only be produced for a base
station the caller's tenant owns.

Adds the BSSCI timing keys to the shipped configuration.
Downlink dispatch (CHIRP-3543) converges on a single owner of the
pending -> reserved -> queued lifecycle: exact queue-ID reservation,
idempotent queued confirmation, batch pending-operation insert, and
dlDataQueRsp repair. A downlink is claimed by exactly one dispatcher
and is neither lost nor sent twice.

Operation durability (CHIRP-3542): service-center operation IDs are
allocated monotonically and never rolled back, the counter is persisted
before the pending record, and the frame is written last. An ambiguous
write closes the transport so recovery happens through resume with the
original IDs. Connect-stage error exchanges now terminate cleanly and a
NULL negotiated version stays empty rather than failing the resume.
CHIRP-3544. A composite resolver maps the TLS client certificate to an
identity: an EUI common name resolves against the registered base
station, and an org-<UUID> common name resolves through the deployment's
organization resolver. Connect-time enforcement checks the SHA-256
fingerprint with a race-safe backfill from the stored PEM, and strict
mode binds the certificate CN to the connecting EUI.
…tions

CHIRP-3545. The boundary checker was matching nothing for its scoped
rules and therefore reporting success regardless of the code. With the
checker made meaningful, the real violations it had been hiding are
fixed, the dead broadcast hook and normalization test spy are removed,
and BSSCI protocol tuning is exposed through configuration instead of
scattered literals.

Also integrates the preserved legacy archive, purges leaked status
polls, and repairs the full-stack probe gates so they exercise the
protocol rather than asserting on their own fixtures.
CHIRP-3545. The protocol servers stop consuming the shared storage
facade and instead depend on narrow, consumer-owned contracts satisfied
structurally by the repositories, so errors.Is keeps working without
adapters. StatusService owns all pending-operation persistence, session
counters unify on UpdateOperationIDs, and the attach transactions move
to a dedicated persistence collaborator. Both protocol servers are
constructor-injected, with ConfigureRuntime supplying the circular
dependencies before Start.

SCACI threads context through the subsystem, taking its SOLID
exemptions to zero. MQTT events are no longer published for an
unresolved organization.
The transparent proxy returns codes.Internal (failed proxying s2c) when
the CLIENT side of a stream goes away - which happens on every KC-Web
page navigation and realtime reconnect. The stream interceptor counted
those as upstream failures, so normal browsing opened the core circuit
breaker and every page failed with 'circuit breaker is open/recovering';
long-lived streams also pinned the half-open probe slots, sticking
recovery.

Streams now run outside the breaker's execution slots: an open breaker
still rejects them up front, a terminal error still feeds the counters
via the new UpstreamBreaker.RecordResult - but only when the client
context is still alive, so client-initiated teardown is never a failure.
Unary methods keep the original in-breaker execution and per-RPC
timeout.
…sume rows

The pending-operation cache is keyed by the ephemeral runtime session ID,
but connection teardown only cleaned it for terminal sessions - an active
session lost unexpectedly leaked its cached entries forever, and the
inconsistent-resume path swept under a freshly hydrated runtime ID that
matched nothing. Teardown now always evicts the dead session's cache via
the new StatusService.EvictCachedOperations while the persisted rows keep
their resume semantics.

The resume reissue loop also removes rows that fail reconstitution (they
could never be reissued yet resurfaced on every resume) and stops at the
first send failure instead of hammering a broken connection.
Streams run outside the breaker's execution slots, so the previous gate
(reject only when open) let a half-open breaker admit unlimited streams
alongside its slot-accounted unary probes, bypassing MaxRequests and
muddying the recovery verdict. Streams now require a closed breaker;
half-open returns the recovering message and recovery is proven by the
unary probe path alone.
…uards

Start now verifies every dependency the composition root wires
unconditionally, so a wiring regression fails at startup instead of as a
nil dereference under traffic; feature-controlled collaborators stay
optional. ConfigureRuntime rejects double configuration and any call
after Start. The SCACI constructor gains the same fail-fast checks for
its four previously unvalidated collaborators (org resolver, session
snapshot provider, propagation, error recorder).

Removes the dead Server.broadcaster field and its Dependencies entry:
SCACI broadcasting reaches consumers through the service bundle, the
server never read it.
…op dead create branch

Fresh sessions are persisted synchronously via PersistConnectSync before
operation logging, so the async path's create branch was unreachable -
and it wrote session.ID from the goroutine, a latent data race on the
live session. The renamed PersistResumeAsync handles only the resume
update, reads nothing but the immutable pre-goroutine snapshot, and
rejects misuse on a fresh session. Fresh-session persistence coverage
moves to PersistConnectSync tests.
The test job excluded pkg/bssci although its -short run finishes in
seconds, so the packages this branch changes most were never tested in
CI. The KC-DB postgres testcontainers suite (~15 min) gets its own job
in parallel with the image builds instead of a blanket exclusion.
…ence

The attach transaction intentionally returns repository errors unwrapped
so handlers can match sentinels via errors.Is; the pass-through
annotations document that at each return site.
… rebuilt

Semantic reconstruction moved from the post-conCmp reissue loop into the
pre-conRsp resume preparation, alongside the existing strict decode: a
persisted operation that cannot be rebuilt rejects the whole resume with
EAGAIN, preserving every row and its downlink queue state, instead of
deleting recoverable work after activation (which could orphan a
reserved queue row - there is no reclaim sweeper).

A reissue send failure now aborts connect completion: the handler
returns the error, an ambiguous write closes the transport, and status
polling never starts on a dead connection. Covered by per-type
malformed-row rejection tests, an aborted-reissue test, and a
production-statusService eviction test.
A stream admitted while the breaker was closed could finish during
half-open, and RecordResult's Execute would consume a probe slot and
close or reopen the breaker - recovery was not exclusively decided by
the unary probes. RecordResult is now a no-op outside the closed state,
with a deterministic old-stream-during-half-open test.
Start now validates the wiring before setting started, so a failed
validation leaves the server startable once the composition root
completes it; a second Start and any Start after Stop are rejected, and
Stop is idempotent. The dlRxStatQry expiry worker starts only after the
listener is actually up (immediate or deferred-certificate path).

Adds real Start/Stop lifecycle tests and covers the four trailing SCACI
constructor checks (orgResolver, sessionSnapshotProvider,
propagationSvc, errorRecorder).
… returns

The pass-through comments claimed handlers match these errors via
errors.Is; neither consumer does - the attach handler maps every
persistence failure to the database catalog error, and wrapping with %w
would preserve errors.Is regardless. Every return now wraps with the
corresponding catalog log-message prefix, proven by injected-sentinel
tests.
…res suite

KC-Core drops -short (the full suite runs in minutes and covers the
resume/lifecycle integration tests), lint/test/test-postgres run on
every branch push so pushed branches get check runs, and every image job
now needs test-postgres and publishes only from main/dev (or a manual
dispatch) - broken database code can no longer publish images while the
PostgreSQL suite is still running.
SetupEnvDBOrSkip fail-fasts under CI=true, so the postgres job now runs
a migrated postgres:17.5 service on 5433 alongside the testcontainers
suites - the combination the local gates already run.
@wirelesstim
wirelesstim force-pushed the CHIRP-3538-bssci-version-negotiation-eui64 branch from ba289f6 to 13c2dca Compare July 22, 2026 21:03
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