indigo-matter

BRIDGE_PROTOCOL.md — plugin ⇄ bridge-node local protocol

Version: 2 (protocolVersion: 2) Transport: WebSocket, JSON text frames, loopback only, default port 5581 (pref-configurable; the controller’s WS is 5580) Peers: the Indigo plugin (client) and the indigo-matter-bridge node (server). We author both ends and ship them together. Governing docs: PRD-indigo-matter-export.md §4.4, ADR-0006 (workspace-level).

Section references: a bare §N refers to this document; PRD sections are always written PRD §N.

This is the outbound twin of the controller protocol that protocol.py / matter_client.py speak. It reuses the same envelope grammar so the client machinery and test doubles generalise, but it is not matter-server’s protocol and has no rename firewall: a field rename here is a coordinated edit to both peers in one release. What protects us instead is the version handshake — launchd deliberately keeps an old bridge node running across plugin reloads (PM-B), so plugin/node version skew is the failure mode that will actually occur.

§0 — Measured probes (matter.js 0.17.8)

A handful of bridge-node/src/ comments cite §0 for a claim that is not derivable from the matter.js typings or docs alone — it was established by running the stack and reading back what it actually did. Recorded once here rather than re-explained at every call site.

1. Envelope grammar

Identical shapes to the controller protocol:

Kind Direction Shape
Request plugin → node {"message_id": "<str>", "command": "<str>", "args": {…}}
Success response node → plugin {"message_id": "<str>", "result": …}
Error response node → plugin {"message_id": "<str>", "error_code": "<str>", "details": "<str>"}
Event node → plugin {"event": "<str>", "data": {…}} (no message_id)

message_id is an opaque string chosen by the plugin, echoed verbatim. Every request gets exactly one response, including ones the plugin chooses not to await (§3.4). Frames on one connection are processed strictly in receipt order, so a set_state sent after an upsert_endpoint on the same socket is applied after it.

Unknown fields are ignored by both peers (forward compatibility inside a protocol version). Unknown commands get error_code: "unknown_command". Unknown events are logged and dropped by the plugin.

1.1 Error codes

The complete error_code domain for protocol version 2. Anything else is a bug in the node.

error_code Meaning
unknown_command Command name not in §3
malformed_args Args missing/mistyped for the command
version_mismatch attach carried a different protocolVersion; node refuses and closes the socket after responding
not_attached Any command other than attach before a successful attach on this connection
unknown_device indigoDeviceId has no live endpoint
unknown_role role not in the §4.2 enum
role_change upsert_endpoint tried to change an existing endpoint’s role, or to move it to a different publishedAs (§3.2/§4.1)
mass_removal_refused attach would remove every live endpoint without "intent": "replace_all" (§3.1)
endpoint_map_invalid Node is in the refuse-to-start state (PRD §7); only get_status, get_pairing and rebuild_endpoint_map are accepted
commissioning_window_failed Matter stack refused to open the enhanced commissioning window
internal Unexpected node-side failure; details carries the message

endpoint_map_invalid covers every refuse-to-start reason, not only a bad map: the code names the state (which commands are accepted), and details opens with the reason, which is what selects the remedy. The reasons are a closed set (RefuseReason in bridge-node/src/protocol.ts, mirrored as constants in bridge_protocol.py), and two of them need opposite advice:

details prefix Remedy
endpoint map is unreadable §3.11 rebuild_endpoint_map, user-confirmed — it adopts the live numbers and renumbers nothing
this bridge was commissioned but its Matter fabric storage is gone… Restore a backup, or accept the loss via §3.11
the bridge identity file is present but unreadable §3.11 will not help and the node refuses it. Restore or repair identity.json.unreadable-<stamp> and restart the bridge

A client that shows the map remedy for all three sends the identity case at the one door deliberately locked against it. Clients MUST branch on the reason.

2. Handshake

On every new connection, before anything else:

  1. Node sends a bare frame (not an event, mirroring server_info): {"protocolVersion": 2, "bridgeVersion": "<npm semver>", "matterJsVersion": "<semver>"}
  2. Plugin sends attach (§3.1). If protocolVersion differs from the plugin’s own, the plugin does not attach: it surfaces an error naming what it needs — the bridge-node release paired with this plugin, installed from Plugins ▸ Matter ▸ Install/update the Matter bridge (typically the plugin was updated while launchd kept the old node alive; restarting the agent relaunches that same old node, so it is deliberately not the remedy the message gives). Node-side, a mismatched attach gets error_code: "version_mismatch" and the socket is closed — skew fails closed from both directions.

The node accepts exactly one attached client at a time; a new successful attach supersedes the incumbent (the old socket is closed). This exists to recover from half-open sockets: if the plugin’s TCP connection dies silently (plugin crash, reload), the node would otherwise hold a dead “attached” client forever and refuse the reconnecting plugin. A connection that completes the handshake but has not attached within 10 seconds is closed by the node.

3. Commands (plugin → node)

3.1 attach

Declares the client and delivers the desired endpoint set in one shot.

{"command": "attach", "args": {
  "protocolVersion": 2,
  "pluginVersion": "2026.8.1",
  "endpoints": [ <EndpointSpec>, … ]
}}

Result: <StatusReport> (§4.3). The node reconciles its live endpoint set against endpoints — creating, updating and removing as needed — so a fresh connection is always a full reconcile (PRD §5.4 startup).

The live set is not empty at first attach. Since bridge-node 0.6.0 the node rebuilds its last-known endpoint set from endpoint-map.json before it goes online (§4.3), so the first attach of a session reconciles against restored endpoints rather than against nothing. Nothing about the command changes — the plugin remains the source of truth (§6.2), and an endpoint that is restored but absent from endpoints is removed, which is exactly right for a device un-exported while the plugin was away. Two consequences worth stating:

Mass-removal guard. If endpoints is empty (or would remove every live endpoint) while the node currently serves a non-empty set, the node refuses with error_code: "mass_removal_refused" unless the args carry "intent": "replace_all". Removing every exported accessory from every paired ecosystem must be deliberate (the §5.1 allow-list being emptied), never the side effect of a stale or buggy client attaching with a default state.

The guard counts DEPARTURES, not removals (issue #240). A removed publishedAs whose indigoDeviceId also appears among the identities being created is one device changing which accessory identity it publishes — a role-change supersession, or an ordinary retarget — and the device stays exported throughout, so it is not a departure and does not count towards emptying the live set. Without that carve-out, changing the role of the only exported device on a bridge would be refused as a mass removal. Note this is deliberately the BROAD “one removal plus one create for the same device” rule, not the narrow generation test supersededBy uses: both shapes keep the device exported, which is the only question the guard is asking.

Because restore arms that guard from the first attach, the plugin now carries the intent in two cases, not one: the un-export debt it already tracked (XAC7), and an allow-list that is non-empty but whose every entry the classifier skipped — a deleted Indigo device, a device that stopped being exportable, a role this build cannot bridge, a states_for that raised. That second case sends [] and is genuinely asking for an empty set; without the intent it would meet an armed guard on an ordinary restart and be refused, and mass_removal_refused halts the client permanently. It is announced in the Indigo log with every skipped device named. A genuinely empty allow-list still carries no intent — the guard must keep its teeth against exactly the stale client it was written for.

A driving-device change under a stable identity is a re-key, not a removal (issue #246, ADR-0011). If endpoints carries a spec whose publishedAs is already live, whose role is unchanged and which does not gain a battery, but whose indigoDeviceId names a DIFFERENT device from the one currently driving that identity, the node re-keys rather than removes-and-recreates: the live Endpoint — same Endpoint.id, same number, same clusters — simply starts being driven by the new device, and endpoint-map.json’s record of who drives it is updated on the next persist. No endpoint is added or removed: ConfigurationVersion is not bumped and PartsList does not change, so no paired ecosystem processes a removal or an addition — which is what room, name and scene survival actually rests on. Label, reachability and state DO land, as an ordinary subscribable attribute update (a value mismatch between the old and new device is visible at migrate time, the same as any other set_state) — only the identity’s continuity is invisible to every ecosystem, not every change the rekey carries. This is the mechanism behind the plugin’s “Migrate an exported accessory…” menu action — a live device replaced by another one, or a module moved to new hardware, while both Indigo devices exist at once — and it is why migrate can honestly promise room/name/scene survival: the identity never lapses, so no ecosystem ever processes a removal of it. A retarget combined with a role change or a battery gain cannot be represented this way — a live cluster set cannot be reshaped without being rebuilt — and falls back to the ordinary recreate outcome above, wire activity and all; the plugin refuses cross-role migrate at the dialog before it ever reaches here (issue #240 reasoning).

3.2 upsert_endpoint

{"command": "upsert_endpoint", "args": {"endpoint": <EndpointSpec>}}

Creates the endpoint if absent, updates label/reachable/state if present. Idempotent. Result: {"endpointNumber": <int>}.

Two things it refuses with error_code: "role_change", for one reason — neither can be done to a live endpoint in place:

A third refusal, the transpose of the second (issue #246, ADR-0011). If endpoint.indigoDeviceId is not itself live, but endpoint.publishedAs IS live — on a DIFFERENT device — the node refuses with error_code: "role_change" rather than creating a second endpoint under an Endpoint.id the aggregator already holds. Falling through to an ordinary create here is not a clean failure: matter.js raises an internal error on the duplicate id, not a protocol refusal the plugin can act on. A driving-device change under a stable identity is exactly the re-key outcome described in §3.1, and the only peer allowed to cause it is attach doing a full reconcile — this refusal’s message names that path (the plugin’s migrate flow) rather than leaving the caller to guess why an apparently ordinary upsert failed.

3.3 remove_endpoint

{"command": "remove_endpoint", "args": {"indigoDeviceId": 123456789}}

Removes the child endpoint (endpoint.close()). Idempotent — removing an absent endpoint succeeds with {"removed": false}; a live removal returns {"removed": true}. Bulk removals are paced ~100ms apart by the node.

Destroys the endpoint-map record along with the live endpoint (issue #274). No number kept, no orphaned entry — because ADR-0015 retired the premise that a departed accessory is worth holding open for a re-adopt that will never come, once the driving device itself is gone. Every caller of this command is confirming the driving device is not coming back: the plugin sends it from exactly three places — deviceDeleted (a live deletion), the export dialog’s “Remove” (a deliberate un-export), and a sweep in _on_attached that closes the one gap those two cannot — a device deleted from Indigo while the plugin was not running to hear about it (§3.1 above already re-classifies every allow-list entry on each attach; a device missing there is exactly this case, export_bridge.py’s _spec_for) — plus the plugin’s two-command role-change sequence (replace(), which sends this remove_endpoint and a paired upsert_endpoint for a new identity on the same device).

attach’s own reconcile never destroys. A full reconcile (§3.1) cannot tell “the plugin dropped this on purpose” from “this device exists but failed classification this one pass” — a dependent plugin not yet started, a role this build does not know — so any identity that merely falls out of an attach’s desired set keeps the pre-#274 soft/orphan treatment regardless. Destroying is only ever a remove_endpoint decision, made with the device’s existence already confirmed on the plugin side.

3.4 set_state

{"command": "set_state", "args": {"indigoDeviceId": 123456789, "states": {"onOff": true}}}

Pushes Indigo-originated state outward. states keys are role-specific (§4.2). The node applies them as local (offline-context) writes so they are not echoed back as command events. Result: {} on success, or a normal error response (unknown_device, malformed_args).

The plugin sends this without awaiting the result — it must never block Indigo’s device thread. The response still arrives; the client’s frame loop MUST log any error response it cannot match to a waiting future (rather than dropping it silently), because an unnoticed set_state failure looks exactly like “the ecosystem shows stale state”. Both matter_client.py and this bridge client get this behaviour from the SAME shared transport core (ws_json_client._log_unmatched): it warns on any unmatched error response and debug-logs unmatched results, for both peers.

3.5 set_reachable

{"command": "set_reachable", "args": {"indigoDeviceId": 123456789, "reachable": false}}

Split from set_state because it maps to Bridged Device Basic Information, not the functional cluster, and is driven by device enable/disable rather than state change. reachable is also present on EndpointSpec; both paths write the same attribute and last-write-wins — there is no precedence rule. Result: {}.

3.6 get_status

Result: <StatusReport> (§4.3) — the same shape attach returns. Used by the watchdog tick and the PRD §5.5 config readout.

3.7 get_pairing

Reports pairing state. A Matter commissioning passcode is not durable: once the first fabric commissions, the basic window closes and the original code stops working; each additional admin needs an enhanced commissioning window with a freshly derived code (§3.8).

Result:

{"commissioned": true,
 "windowOpen": false,
 "windowExpiresAt": null,
 "manualPairingCode": null,
 "qrPairingCode": null,
 "fabrics": [ <FabricInfo>, … ]}

manualPairingCode/qrPairingCode are non-null only while a window is open (always true for the never-commissioned initial state, whose codes are the persisted originals).

3.8 open_commissioning_window

{"command": "open_commissioning_window", "args": {"durationSeconds": 900}}

Opens an enhanced commissioning window so another ecosystem can be added. Result: {"manualPairingCode": "...", "qrPairingCode": "MT:...", "windowExpiresAt": "<ISO 8601 UTC>"}. Fails with commissioning_window_failed if the stack refuses (e.g. a window is already open in a conflicting state). Emits window_closed (§5) when it expires or a commissioner completes.

3.9 remove_fabric

{"command": "remove_fabric", "args": {"fabricIndex": 2}}

Result: {"removed": true, "remaining": 1}. Emits fabrics_changed (§5).

removed is the point of the result. An index with no fabric behind it answers {"removed": false, "remaining": N} rather than failing — the caller’s index comes from a get_pairing readout that is by definition a moment old, and the way it goes stale is the ecosystem unpairing itself, so “it is already gone” is the request being granted. It is not the same thing as removing a live fabric, though, and a caller that reports this to a user in the past tense must be able to tell them apart. remaining is the fabric count after the call, or null when server state could not be read (legitimate mid-reset).

fabrics_changed is emitted either way — with change: "unchanged" on the already-gone path. Nothing changed on the node, but a caller that asked to remove a fabric that is not there is demonstrably holding a stale list, and this is the only moment the node knows that.

Removing the last fabric is a factory reset the node did not ask for. matter.js watches its own commissioned state and, when the fabric set empties, factory-resets itself (doFactoryReset() → erase()). The node therefore clears its commissioning witness whenever the fabric count reaches zero — by this command, by §3.10, or by an ecosystem removing us from its side — and emits decommissioned. Without that, the next start would see “witness says paired, stack says no fabrics”, which is the fabricStorageLost refusal signature, and refuse to serve anything on the grounds of a storage loss that was actually a deliberate unpairing.

3.10 factory_reset

Wipes commissioning credentials and starts advertising fresh (PRD §6 “reset all pairings”). The endpoint-number map is preserved by default: it is persisted outside the matter.js storage context that the reset wipes, in the node’s own endpoint-map.json in the bridge storage dir.

{"command": "factory_reset", "args": {"preserveEndpointNumbers": true}}

What preserveEndpointNumbers: true preserves is the entries, not their trustworthiness. matter.js owns the allocation, keyed on Endpoint.id in the storage context erase() wipes — so the numbers themselves are gone either way, and the endpoints will be re-created at whatever matter.js hands out next. What survives is the map: role/label and every UniqueID stay, but the node also voids every entry’s number (issue #140, since bridge-node 0.8.0) rather than leaving the old one standing. A voided number is silently adopted — not reported as drift — the next time check (§4.3) sees that UniqueID live, because both the reset and the last-fabric self-reset are reached with an empty fabric set: matter.js just erased its own allocation along with every fabric, so there is no paired ecosystem left that could be holding the old numbers to disagree with, and the adoption is unobservable outside this node. This is a narrow, deliberate carve-out of §4.3’s “never auto-repaired” rule (below) for the one case where the renumbering is the reset’s own, not a genuine anomaly — an entry that was never voided still drifts exactly as before. Passing false discards the baseline too — the “explicit rebuild” of PRD §7, for the case where the map itself is what is corrupt.

The commissioning witness is cleared either way, and the node verifies it by reading identity.json back before reporting completion: a witness that survived the erase is the exact fabricStorageLost signature, so failing to clear it would make the next start refuse and blame lost storage for the reset the user asked for. A failure to clear it appears in StatusReport.warnings.

Emits decommissioned and fabrics_changed (§5) when it takes the fabric count to zero.

3.11 rebuild_endpoint_map

The recovery path out of the endpoint_map_invalid refuse-to-start state (PRD §7 “Endpoint map lost/corrupt”). Discards the persisted baseline and adopts the numbers the live endpoints currently have as the new one. It reallocates nothing — by the time a user is asked to confirm it, whatever duplication lost Matter storage implies has already happened in the ecosystems; what changes is that the node stops refusing and starts telling the truth about the numbers that now exist. It cannot itself duplicate accessories — but it discards the only surviving record of what the numbers used to be (quarantined aside, not deleted), which is why it is a separate, explicit command that the plugin only issues after the user confirms via a warning dialog (“Rebuild Matter Endpoint Map…”). Result: <StatusReport>.

Two refusals of its own:

A present-but-unusable map is copied to endpoint-map.json.corrupt-<stamp> before it is replaced — by the plain persist() path as well as by the rebuild, since a corrupt map on a bridge that has never been commissioned is served past rather than refused (§7) and would otherwise be overwritten with no copy at all. The name is timestamped for the same reason identity.json.unreadable-<stamp> is: a fixed name means the second quarantine silently destroys the first, and the first is the one nearest the original truth.

4. Shapes

4.1 EndpointSpec

{"indigoDeviceId": 123456789,
 "role": "onOffLight",
 "label": "Kitchen Lamp",
 "reachable": true,
 "states": {"onOff": true},
 "options": {}}

4.2 Roles: state keys and commands (v1)

Each role defines two vocabularies: the state keys the plugin pushes via set_state, and the commands the node emits as command events when an ecosystem acts. Both are enumerated here in full; there is no other source.

role Matter device type set_state keys command names (args)
onOffPlugInUnit On/Off Plug-in Unit onOff: bool onOff {"value": bool}
onOffLight On/Off Light onOff: bool onOff {"value": bool}
dimmableLight Dimmable Light onOff: bool, level: 0-100 onOff, setLevel {"level": 0-100}
colorTemperatureLight Color Temperature Light + colorTempMireds: 153-500 + setColorTemp {"colorTempMireds": int}
extendedColorLight Extended Color Light + hue: 0-360, saturation: 0-100 + setColor {"hue": 0-360, "saturation": 0-100}
windowCovering Window Covering position: 0-100 (100 = open, inbound convention; polarity in options) goToPosition {"position": 0-100}, stopMotion {}
doorLock Door Lock locked: bool lock {}, unlock {}
occupancySensor Occupancy Sensor occupied: bool — (sensors emit no commands)
contactSensor Contact Sensor contact: bool (true = closed) —
waterLeakDetector Water Leak Detector (0x0043) leak: bool (true = leak detected) —
waterFreezeDetector Water Freeze Detector (0x0041) freeze: bool (true = freeze detected) —
rainSensor Rain Sensor (0x0044) rain: bool (true = rain detected) —
smokeAlarm Smoke CO Alarm (0x0076), SmokeAlarm feature smoke: bool (true = alarming) —
coAlarm Smoke CO Alarm (0x0076), CoAlarm feature co: bool (true = alarming) —
temperatureSensor Temperature Sensor temperatureC: float —
humiditySensor Humidity Sensor humidityPct: float —
lightSensor Light Sensor lux: float —
pressureSensor Pressure Sensor pressureKPa: float —
flowSensor Flow Sensor flowM3h: float —
thermostat Thermostat localTemperatureC: float, heatingSetpointC: float, coolingSetpointC: float, systemMode: str setHeatingSetpoint {"valueC": float}, setCoolingSetpoint {"valueC": float}, setSystemMode {"mode": str}

4.3 StatusReport and FabricInfo

{"commissioned": true,
 "fabrics": [{"fabricIndex": 1, "label": "Apple Home", "vendorId": 4937}],
 "endpointCount": 12,
 "endpoints": [{"indigoDeviceId": 123456789, "endpointNumber": 2, "role": "onOffLight",
                "publishedAs": "indigo-123456789"}],
 "drift": [],
 "driftChecked": false,
 "warnings": [],
 "subscriptionChurn": {"checked": true, "active": false, "peers": []},
 "sessionHygiene": {"checked": true, "peers": [], "closed": {"superseded": 0, "dead": 0, "rotated": 0}}}

drift lists any UniqueID → endpointNumber mappings that changed since last persist (PRD §4.3 drift detection); non-empty drift is surfaced as a plugin error, never auto-repaired. One narrow, deliberate carve-out (issue #140, since bridge-node 0.8.0): a renumbering caused by §3.10’s preserving reset (or the last-fabric self-reset) is voided at the reset and then silently adopted, not reported here — see §3.10 for the safety argument. Voiding also drops driftChecked back to false: a baseline the node just declared void has not been verified against anything, and the flag comes back once the first post-reset reconcile checks it against live endpoints. Everything else about this rule is unchanged: an entry the reset did not touch still drifts and is still never repaired. Each entry is a DriftEntry:

{"uniqueId": "indigo-123456789", "expected": 2, "actual": 5}

endpoints[].role is one of the §4.2 enum; endpoints[].publishedAs is issues #219/#240’s accessory identity (§4.1) — informational here, tolerantly defaulted so a report from a pre-publishedAs node still parses; endpointCount is endpoints.length (it is sent explicitly so a client can log the size without walking the list).

endpoint-map.json — contents, and restore-on-start

The map is a sibling of identity.json in the bridge storage directory (outside matter.js’s own storage context, so §3.10’s reset cannot wipe it). Schema version 2 since bridge-node 0.6.0:

{"version": 2,
 "endpoints": {
   "indigo-123456789": {"number": 2, "role": "onOffLight", "label": "Kitchen Lamp"}
 }}

Version 1 files are read, migrated in place, and never treated as corrupt. A v1 entry is a bare number; it keeps that number, is simply not restorable until the next attach records a role and label, and the file is rewritten as v2 by the first drift check. A v2 entry whose role/label are malformed keeps its number the same way — the number is the part that cannot be re-derived.

Restore-on-start. At startup the node rebuilds every restorable entry — through the ordinary createEndpoint path, at the same Endpoint.id and therefore the same persisted number, with reachable: false and the role’s own default state — before server.start(). A bridge that came online with a childless aggregator and waited for the plugin told every paired ecosystem that every accessory had gone; Apple then re-added them as new accessories in the bridge’s own room, destroying the user’s room assignments on every restart (issue #141). The restore is not drift: these are the map’s own numbers being re-used, and driftChecked stays false until a real comparison runs.

A node in a refuse-to-start state (§1.1 endpoint_map_invalid) ends up serving nothing — it cannot trust its own records, and creating endpoints is precisely what the refusal exists to prevent — but how it gets there differs by reason, and one of the three is observable on the wire:

Refusal reason When it is known What a controller can see
identityUnreadable before the stack starts nothing is ever built; PartsList is empty from the first read
mapUnreadable before the stack starts as above — an unreadable map restores nothing in the first place
fabricStorageLost only after server.start(), because it needs the fabric table the restored set exists briefly and is then withdrawn

fabricStorageLost is therefore not silent. The node goes online carrying its restored endpoints, reads the fabric table, refuses, and takes them back out — so a reader would see a transient non-zero endpointCount, a ConfigurationVersion bump from the withdrawal, and the extra startup latency of building and closing the set. That is accepted rather than avoided: by definition this node has no fabrics, so there is no commissioned controller subscribed to see any of it, and the alternative (deferring the restore until after the refusal decision) reopens the online-and-empty window for every healthy start, which is the bug itself.

An attach reconcile’s un-export marks an entry orphaned, keeping its restoration half. When a reconcile takes an endpoint away because it simply falls out of the desired set, its entry is marked orphaned (see above) while role, label and number are all kept — a full reconcile can never tell “the plugin dropped this on purpose” from “this device exists but failed classification this one pass” (issue #274 constraint), so it must stay reversible. Without this, check’s add-and-refresh-only behaviour would leave an un-exported device restorable for ever: it would be rebuilt before every server.start() and removed again by the next attach — the same appear-then-vanish churn, aimed at devices the user had already removed — and each ghost would also cost a removal-pacing slot on every attach. Keeping the number is §3.3’s retained allocation, so a re-export returns the same accessory rather than a new one; keeping role/label costs nothing extra, since orphaned alone already keeps the entry out of restore-on-start. orphaned is cleared the moment the same UniqueID is live again, so a plain re-export is unaffected. Only a measured removal marks an entry: absence from the live set proves nothing (a node that never attached has an empty one), so a factory reset, a seed and an entry this build cannot rebuild all leave the map alone.

An explicit remove_endpoint destroys instead (issue #274, ADR-0015) — number, role, label and every other field, gone. Every remove_endpoint command (§3.3) is the plugin confirming the driving device is gone for good — deleted, or deliberately un-exported — so the node deletes the record from endpoints outright rather than marking it orphaned. Unlike every reconcile-driven mutation above, this is the one place a record’s number is not kept: the guarantee that a retired number is never reissued (ADR-0010, numberVoid’s and orphaned’s own comments above) still holds, because it was never this file that enforced it — matter.js’s own persisted allocation for the retired Endpoint.id, a separate store this file never touches, is what actually prevents reuse, and it is untouched by deleting this witness of it. What is lost is restorable(): a destroyed identity is gone from it the moment it is destroyed — there is nothing left to rebuild. attach’s own reconcile never destroys (see §3.1 and §3.3 above); only an explicit remove_endpoint does.

drift is populated by every operation that can change the live endpoint set — attach/reconcile, upsert_endpoint and remove_endpoint — and on the first such operation, not “at startup”: before one has run there are no live endpoints to compare, which is precisely what driftChecked: false says. A node that starts commissioned with no endpoint-map.json at all (every install that predates the map) bootstraps a baseline from matter.js’s own persisted allocation and serves normally; it does not refuse. Refusing would take working exports offline on upgrade, and would buy nothing — matter.js owns the numbers, the map only witnesses them, and a missing witness renumbers nothing. A map that is present and unreadable is a different matter and still refuses (§1.1).

5. Events (node → plugin)

Event data Meaning
command {"indigoDeviceId", "command", "args"} Ecosystem-originated action; names and args exactly as enumerated per role in §4.2
fabrics_changed {"fabrics": […], "change": "added"\|"deleted"\|"updated"} Pairing/unpairing activity, including the changes §3.9/§3.10 cause themselves. fabrics is the set after the change
commissioned / decommissioned {} The fabric count went zero → non-zero / non-zero → zero. Transitions, not repeats: a second ecosystem pairing raises fabrics_changed only
window_closed {"reason": "expired"\|"commissioned"} The enhanced commissioning window ended
drift_detected {"drift": […]} Endpoint-number drift found by a reconcile, upsert or remove

The plugin resolves indigoDeviceId through the allow-list before acting; a command for a device no longer exported gets no action and a warning (PRD §7 race row — the node responds to the ecosystem with failure when the endpoint is already gone).

6. Invariants

  1. Loopback only. The node binds 127.0.0.1; there is no auth on the socket because there is no remote surface. The mass-removal guard (§3.1) exists because “local process” still includes stale plugin instances.
  2. The plugin is the source of truth for the export set; the node is the source of truth for commissioning state and endpoint numbers. Neither peer caches the other’s domain across reconnects — attach reconciles.
  3. Identity flows one way: publishedAs → Endpoint.id → persisted endpoint number, and publishedAs defaults to indigo-<indigoDeviceId> (issues #219/#240) — a narrowing of the earlier indigoDeviceId → Endpoint.id → number statement, not a reversal: nothing is still ever keyed on list position or label, and every export that has never role-changed or been migrated publishes under exactly today’s derivation.
  4. State pushes are echo-guarded in the node (ctx.offline); the plugin never receives a command event for a change it pushed.
  5. Version skew fails closed: mismatched protocolVersion means no attach (both peers enforce it), an error in the Indigo log, and untouched pairings. Applied literally, not just stated, for publishedAs (issues #219/#240, PR5 design owner ruling 1): PROTOCOL_VERSION bumped 1 → 2 in the same commit that first sends it, rather than adding a permanent bridgeVersion capability gate around it — an old (pre-#219/#240) node cannot silently ignore a publishedAs it does not understand and publish a duplicate default-identity accessory, because it never gets past this handshake with a v2-speaking plugin at all.
  6. Destructive operations are explicit: emptying the endpoint set needs intent: "replace_all"; discarding endpoint identity needs preserveEndpointNumbers: false or rebuild_endpoint_map. Neither can happen as a default.

7. Testing contract

Golden frames for every command/response/event pair live as JSON fixture files under tests/fixtures/bridge_protocol/, consumed by both the Python suite and the TypeScript node’s protocol tests — a deliberate departure from test_golden_real.py’s inline-dict pattern, which a TS suite cannot import. A frame change that only updates one side fails that side’s suite.