ADR 0002 — Extract a testable gate + per-opcode seam from handleIMOpcode¶
Provenance. Moved here from OpenCCU-Loom, where this decision was recorded as ADR 0031 while the Matter stack still lived in that repository as
internal/north/matter/. Paths have been rewritten to this module's root; the decision itself is unchanged.
- Status: accepted
- Date: 2026-06-15
- Related:
bridge/receive.go(handleIMOpcode),bridge/receive_test.go, the analysis item Area 7 [W4]/[P1] innotes/audits/architecture-analysis-2026-06-15.md, matter.js as the Matter gold standard
Context¶
handleIMOpcode (receive.go, ~390 lines, carrying
//nolint:gocognit,gocyclo,funlen) is the bridge's inbound
Interaction-Model entry point. It interleaves four concerns in one
function:
- Pre-dispatch gates — absorb a
StatusResponse(MRP-level ack, no dispatch); reject a non-request opcode; reject Read / Subscribe / Timed over a Secure Group session (Matter §8.5.7); drop silently when no dispatcher is attached. - TLV decode — per-opcode
im.Unmarshal*TLV. - Per-opcode handling — Read / Write / Invoke / Subscribe / Timed, each ~60–120 lines of fabric/subject context stamping, timed-gate checks, dispatch, reply encoding, ack discharge, and diagnostics.
The analysis (Area 7 [W4]) flagged that the gate + decode +
session-validation logic has no unit-testable seam: every path
requires a fully-wired *Bridge, so the rejection rules (which opcodes
are valid, which are group-session-legal) can only be exercised
end-to-end. Subscribe is already extracted to handleSubscribeRequest;
the timed gate is already checkTimedGate. The rest is inline.
This is gold-standard-sensitive code (the Matter side mirrors matter.js
HEAD). matter.js is not checked out in this environment, so this ADR
is deliberately a behaviour-preserving refactor only — it moves
existing code and carries the existing matter.js citations verbatim
(e.g. the group-session reject already cites
InteractionMessenger.ts for the InvalidAction rule). It introduces
no new cluster schema, wire shape, status code, or dispatch decision.
Decision¶
Two extractions, both behaviour-preserving:
1. A pure gate-decision function¶
// imGateAction is the pre-dispatch routing decision for an inbound IM
// message, computed from opcode + session type alone — no Bridge state,
// so it is table-testable in isolation.
type imGateAction int
const (
imGateProceed imGateAction = iota // decode + dispatch
imGateAbsorbStatusResp // StatusResponse: MRP ack, no dispatch
imGateRejectUnsupported // non-request opcode
imGateRejectGroupSession // Read/Subscribe/Timed over a group session (§8.5.7)
)
func classifyIMOpcode(opcode uint8, sessionType message.SessionType) imGateAction
classifyIMOpcode holds the exact decision tree currently inlined at the
top of handleIMOpcode. Being free of *Bridge, it is unit-tested with
a table over every opcode × {unicast, group} → expected action — the
testability win the analysis asked for. The matter.js citation for the
group-session rule moves onto this function.
2. Per-opcode dispatch methods¶
Each inline opcode body moves verbatim into a *Bridge method
mirroring the existing handleSubscribeRequest:
dispatchReadRequest / dispatchWriteRequest / dispatchInvokeRequest
/ dispatchTimedRequest. The router decodes (as today) and hands the
decoded request to the method. All logging, comments, matter.js
citations, fabric/subject stamping, timed-gate checks, and ack discharge
move unchanged.
handleIMOpcode then reads as a thin router:
switch classifyIMOpcode(proto.Opcode, requestHdr.SessionType) {
case imGateAbsorbStatusResp: return b.absorbStatusResponse(src, proto)
case imGateRejectUnsupported: return b.rejectUnsupportedOpcode(src, proto)
case imGateRejectGroupSession: return b.rejectGroupSession(src, requestHdr, proto)
}
dispatcher := b.Dispatcher()
if dispatcher == nil { /* drop silently */ return nil }
dec := tlv.NewDecoder(payload)
switch proto.Opcode {
case im.OpcodeReadRequest: /* decode */ return b.dispatchReadRequest(...)
case im.OpcodeWriteRequest: /* decode */ return b.dispatchWriteRequest(...)
case im.OpcodeInvokeRequest: /* decode */ return b.dispatchInvokeRequest(...)
case im.OpcodeSubscribeRequest: /* decode */ return b.handleSubscribeRequest(...)
case im.OpcodeTimedRequest: /* decode */ return b.dispatchTimedRequest(...)
}
return nil
The gocognit,gocyclo,funlen nolints come off handleIMOpcode; each
extracted method keeps only the nolints it individually needs.
Alternatives considered¶
- Gate function only, leave opcode bodies inline. Rejected: the
decode-and-route monolith (the bulk of the 390 lines) would keep its
funlen/gocyclosuppressions and stay un-decomposed. The gate function alone is a partial answer. - A generic decode seam (
decodeAndRoute). Rejected: each opcode decodes to a distinct typed request and dispatches to a distinct handler; a generic seam would need type erasure (any) and lose the compile-time typing the per-opcode methods keep. - Wait for a matter.js checkout to re-verify. Unnecessary: this is a pure code move with no semantic change, so the existing citations remain valid by construction. A behaviour change would require the read; a relocation does not.
Consequences¶
- The opcode-validity and group-session rules become unit-testable
without a
Bridge(classifyIMOpcodetable test). - Each opcode handler is independently testable and individually
legible;
handleIMOpcodeis a ~30-line router. - Behaviour is identical — locked by the existing end-to-end
receive_test.gocases (TestDispatch_IMReadRoutes,TestDispatch_IMSubscribeReplies,TestDispatch_IMInvalidOpcode,TestDispatch_IMResponseOpcodeIsUnsupported,TestDispatch_NoDispatcherDropsSilently) plus the new gate table test. - No new matter.js semantics are introduced; existing citations are preserved verbatim. A future Matter behaviour change in these paths still follows the matter.js-first workflow once the reference is available.