Skip to content

feat(export): E5 — endpoint-map drift detection, refuse-to-start, replace-all persistence - #126

Merged
simons-plugins merged 4 commits into
mainfrom
feat/e5-persistence-hardening
Aug 5, 2026
Merged

feat(export): E5 — endpoint-map drift detection, refuse-to-start, replace-all persistence#126
simons-plugins merged 4 commits into
mainfrom
feat/e5-persistence-hardening

Conversation

@simons-plugins

@simons-plugins simons-plugins commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

Milestone E5 (PRD's highest-risk correctness requirement) + the late-#124-review carry-overs.

Node: endpoint-map.json beside identity (outside matter.js storage — survives factory_reset), drift detector on every reconcile/upsert (report-only per §4.3, driftChecked now real), refuse-to-start as a protocol state (get_pairing answers; attach refused; rebuild_endpoint_map exits it), §3.9–3.11 implemented (ServerNode.erase() with both preserve flavours), all 5 remaining fixtures graduated — pending is empty and both suites assert it.

Plugin: pendingReplaceAll persisted + discharged on reconnect (fixes orphaned-accessories-forever, XAC7), diff against last-pushed snapshot (kills unbounded ramp drift), on_command on a single-worker executor, bounded re-subscribe watchdog, start() gated during un-export, fabric_backup archives the bridge dir (restore deliberately manual until E7's stop seam), whiteLevel or 100 falsy-zero fixed.

Notable: matter.js erase() leaves a ref'd timer close() never clears — without the ordered-shutdown fix a clean stop after factory reset exits 1 and launchd reads success as a crash.

Verification

Python 1978, node 291/0 skipped, pylint 9.38, drift/diff mutations verified failing. PluginVersion 2026.7.30, bridge-node 0.4.0.

Full XAC5 jarvis script (reboot + forced-drift + refuse-to-start + storage-loss sequence) in the PR discussion after review.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S

Summary by CodeRabbit

  • New Features

    • Endpoint assignments now persist across restarts, with drift detection and recovery tools.
    • Added endpoint-map rebuilding, factory-reset, fabric-removal, and pairing-reset controls.
    • Bridge storage is included in backups, with restore guidance.
    • Export synchronization now tracks failed un-exports and replacement work.
    • Added retries for inactive device-change subscriptions.
  • Bug Fixes

    • Improved handling of unreadable identities, endpoint-map recovery, shutdown, command processing, and failed operations.
    • Preserved zero white-channel levels and skipped unsupported color-temperature devices.
    • Status now surfaces persistence warnings, drift, and recovery state.

…lace-all persistence

Endpoint identity must survive everything, and its loss must be loud (XAC5,
PRD §4.3/§7).

Bridge node:
- endpoint-map.json, a persisted UniqueID → endpoint-number map kept OUTSIDE
  matter.js's storage context so §3.10's factory reset cannot wipe the witness
  of the very event it exists to survive. Written atomically, checked on every
  reconcile/upsert, report-only: drift populates StatusReport.drift, emits §5
  drift_detected, and never moves the baseline.
- driftChecked is now real — true once the detector has run against a
  persisted map, false before.
- Refuse-to-start (§1.1 endpoint_map_invalid): fabrics with no usable map, a
  commissioning witness with no fabrics (PRD §7 "storage missing but
  previously commissioned"), or an unusable identity.json. The Matter stack
  still starts so get_pairing answers; attach is refused, so nothing is ever
  created. rebuild_endpoint_map (§3.11) is the way out.
- identity.json gains commissionedAt, stamped on the first fabric observed.
- §3.9 remove_fabric via FabricManager (the cluster command asserts a remote
  actor), §3.10 factory_reset via ServerNode.erase() with both flavours.
- main.ts exits 0 after an ordered shutdown: erase() leaves a ref'd timer that
  close() does not clear, so a clean stop after a reset exited 1.
- All five remaining pending golden frames graduated; pending is now empty and
  both suites assert it.

Plugin:
- device_updated diffs against the last state PUSHED, not against orig_dev — a
  ±1° hue tolerance against the previous Indigo reading let a 1°/step ramp
  push nothing at all, forever.
- pendingReplaceAll persisted in prefs (XAC7): an un-export whose attach never
  landed reconnects on its own and is discharged by the next successful one.
- §5 commands dispatch through a single-worker executor, off the shared loop.
- start() is gated while an un-export is in flight.
- subscribeToChanges gets a bounded re-issue watchdog.
- Fabric backup covers the bridge-node storage dir; restore reports and skips
  it until E7 provides a stop seam.
- _set_color_temp no longer turns a whiteLevel of 0 into 100.

PluginVersion 2026.7.30, bridge-node 0.4.0. 1978 Python / 291 TS tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds persisted endpoint maps, drift detection, refusal and recovery flows in bridge-node. It also updates the Indigo plugin to persist un-export debt, track pushed state, serialize commands, retry subscriptions, and include bridge storage in backups.

Changes

Bridge-node endpoint-map recovery

Layer / File(s) Summary
Persistence and protocol contracts
bridge-node/src/endpoint-map.ts, bridge-node/src/storage.ts, bridge-node/src/protocol.ts
Adds endpoint-map validation, persistence, rebuild and discard behavior. Adds identity quarantine, commissioning witnesses, refusal reasons, recovery commands, drift status, and persistence warnings.
Node lifecycle and recovery flow
bridge-node/src/main.ts, bridge-node/src/node.ts, bridge-node/src/reconcile.ts, bridge-node/src/ws-server.ts
Startup checks identity and endpoint-map state. Node operations detect drift and support fabric removal, factory reset, endpoint-map rebuilding, refusal gating, and lifecycle events.
Bridge-node validation and support
bridge-node/test/*, tests/fixtures/bridge_protocol/frames.json, bridge-node/package.json, docs/BRIDGE_PROTOCOL.md, docs/HANDOVER.md
Tests cover persistence, restart, refusal, recovery, drift, identity handling, fixtures, and shutdown. Documentation and package metadata describe the new behavior.

Plugin export and backup hardening

Layer / File(s) Summary
Export state and attach recovery
.../bridge_client.py, .../export_bridge.py, .../export_handlers.py
Attach operations now carry replacement intent from persisted removal debt. ExportBridge tracks pushed snapshots, persists incomplete un-exports, serializes Indigo commands, and preserves color-temperature values.
Plugin watchdog and bridge backups
.../plugin.py, .../fabric_backup.py, .../MenuItems.xml, .../bridge_protocol.py
Adds subscription retries, status warning and drift reporting, recovery menus, bridge-node storage backups, and explicit non-restoring handling for bridge archive members.
Plugin validation and release support
tests/*, indigo-matter.indigoPlugin/Contents/Info.plist, CLAUDE.md
Adds test infrastructure and coverage for export, command, watchdog, recovery-menu, status, and backup behavior. Updates documentation and plugin metadata.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Process as bridge-node main
  participant Storage as identity and map storage
  participant Node as BridgeNode
  participant Client as WebSocket client
  Process->>Storage: validate identity and load endpoint map
  Process->>Node: construct with refusal state
  Client->>Node: attach or recovery command
  Node->>Storage: persist or rebuild endpoint baseline
  Storage-->>Node: status and warnings
  Node-->>Client: status, drift, or refusal response
Loading
sequenceDiagram
  participant Plugin
  participant ExportBridge
  participant BridgeClient
  participant BridgeNode
  Plugin->>ExportBridge: start or process export change
  ExportBridge->>BridgeClient: provide replacement debt
  BridgeClient->>BridgeNode: attach with replacement intent
  BridgeNode-->>BridgeClient: status report
  ExportBridge->>ExportBridge: compare against pushed snapshot
  ExportBridge->>BridgeNode: dispatch serialized command
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 49.02% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main endpoint-map, refuse-to-start, and replace-all persistence changes in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/e5-persistence-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 13

🧹 Nitpick comments (13)
tests/test_bridge_client.py (1)

1083-1102: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider asserting the fail-closed warning.

_replace_all logs a warning when the provider raises. That log line is the only signal an operator gets that the pending un-export flag was unreadable. Asserting it locks in the diagnostic, not just the safe outcome.

♻️ Proposed addition
             assert bridge_protocol.ARG_INTENT not in sent(fake, bridge_protocol.CMD_ATTACH)["args"]
+            assert any("pending un-export flag" in str(call.args[0])
+                       for call in mock_logger.warning.call_args_list)
             await client.close()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_bridge_client.py` around lines 1083 - 1102, Update
test_a_raising_provider_attaches_without_the_intent to assert that mock_logger
records the warning emitted by _replace_all when replace_all_provider raises,
while preserving the existing assertion that the attach command omits
bridge_protocol.ARG_INTENT.
tests/test_export_wiring.py (1)

547-572: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Derive the tick counts from the plugin constants.

The literals 4 and 3 duplicate RESUBSCRIBE_TICKS and MAX_RESUBSCRIBE_ATTEMPTS in plugin.py. If either constant changes, these tests fail for a reason unrelated to the behaviour they cover. Referencing the constants keeps the intent explicit.

♻️ Proposed tweak
     def test_it_re_issues_after_a_minute_with_no_device_updates_at_all(
-            self, plug, mock_indigo_base):
+            self, plug, mock_indigo_base, plugin_mod):
         subscribe = self._exporting(plug, mock_indigo_base)
-        for _ in range(4):
+        for _ in range(plugin_mod.RESUBSCRIBE_TICKS):
             plug._resubscribe_tick()
         subscribe.assert_called_once_with()

Apply the same substitution in test_it_gives_up_rather_than_nagging_forever, using plugin_mod.MAX_RESUBSCRIBE_ATTEMPTS for the expected call count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_export_wiring.py` around lines 547 - 572, Update the
resubscription tests around _resubscribe_tick to derive retry counts from
plugin_mod.RESUBSCRIBE_TICKS instead of the literal 4, and assert the final call
count with plugin_mod.MAX_RESUBSCRIBE_ATTEMPTS instead of 3. Preserve the
existing test behavior and loop structure.
tests/fakes.py (1)

177-182: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a join timeout so a stuck nested coroutine fails instead of wedging the suite.

thread.join() waits without a bound. If a future nested coroutine ever awaits something only the outer loop can complete, the test run hangs with no output rather than failing. A timeout plus an explicit error keeps the failure diagnosable.

♻️ Proposed tweak
     thread = threading.Thread(target=_worker, name="fake-runtime-nested")
     thread.start()
-    thread.join()
+    thread.join(timeout=10)
+    if thread.is_alive():
+        raise RuntimeError("nested coroutine did not finish; it is awaiting the outer loop")
     if "error" in outcome:
         raise outcome["error"]
     return outcome["value"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/fakes.py` around lines 177 - 182, Update the nested coroutine execution
flow around the thread.join call to use a bounded timeout, then detect whether
the thread is still alive and raise a clear diagnostic error if it failed to
finish. Preserve the existing outcome error propagation and value return for
threads that complete normally.
bridge-node/test/protocol.test.ts (1)

909-921: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer as DriftEntry[] over as never at line 915.

as never is assignable to every parameter type, so it removes the type check on the emitDrift argument. GoldenFrames types drift_detected.data.drift as unknown[], so a cast is needed, but as DriftEntry[] keeps the shape contract and still compiles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/test/protocol.test.ts` around lines 909 - 921, Update the
emitDrift call in the “drift_detected event (§5)” test to cast
golden.drift_detected.data.drift as DriftEntry[] instead of never, preserving
compile-time validation of the drift payload shape.
bridge-node/test/fixture-shapes.ts (1)

222-224: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a satisfies clause to the two empty-result fixtures.

Every other fixture in this file carries one, including rebuiltStatus and endpointMapInvalid below. Without it, these two constants assert no shape. If a future §3.9 or §3.10 result gains a field, the fixture stays silently empty.

♻️ Proposed change
-export const removeFabricResult = {};
-export const factoryResetResult = {};
+export const removeFabricResult = {} satisfies Record<string, never>;
+export const factoryResetResult = {} satisfies Record<string, never>;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/test/fixture-shapes.ts` around lines 222 - 224, Add explicit
`satisfies` clauses to the `removeFabricResult` and `factoryResetResult` empty
fixtures, using the response shape types expected for §3.9 and §3.10. Match the
established fixture typing pattern used by `rebuiltStatus` and
`endpointMapInvalid` so future shape changes are validated.
bridge-node/src/endpoint-map.ts (1)

70-78: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Return RefuseReasonValue instead of string.

protocol.ts line 174 defines RefuseReasonValue for exactly this purpose. The wider string return type lets any caller pass an arbitrary reason through endpointMapInvalidDetails, so the union adds no compile-time protection today.

♻️ Proposed narrowing
-import { describeError, type DriftEntry, RefuseReason } from "./protocol.js";
+import { describeError, type DriftEntry, RefuseReason, type RefuseReasonValue } from "./protocol.js";
-export function refuseReasonFor(state: EndpointIdentityState): string | undefined {
+export function refuseReasonFor(state: EndpointIdentityState): RefuseReasonValue | undefined {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/src/endpoint-map.ts` around lines 70 - 78, Update refuseReasonFor
to return RefuseReasonValue | undefined instead of string | undefined, importing
or referencing the existing RefuseReasonValue type from protocol.ts. Preserve
the current branching and returned RefuseReason values so
endpointMapInvalidDetails receives the narrowed union and rejects arbitrary
reason strings at compile time.
bridge-node/package.json (1)

18-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document why --test-force-exit is present.

--test-force-exit exits after tests finish before the event loop drains and can hide unclosed WebSocket/server handles. Add a short comment explaining the failure mode that required it, or close the leaking handle if a test is keeping a server/socket open.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/package.json` at line 18, Add a brief explanation near the test
script documenting which unclosed WebSocket or server handle necessitates
--test-force-exit and the failure mode it prevents; alternatively, identify and
close that leaking handle so the flag can be removed while preserving reliable
test completion.
bridge-node/src/main.ts (1)

70-70: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The phase label duplicates the wrapper's own prefix.

phase logs Startup failed — ${name}, so this reads "Startup failed — identity check failed" on a throw. The other call sites pass a noun phrase, for example "mDNS interface check". Use "identity check" and "identity load" for consistency.

♻️ Proposed wording fix
-    const identityFault = await phase("identity check failed", () => identityProblem(config.storagePath));
+    const identityFault = await phase("identity check", () => identityProblem(config.storagePath));
-    const identity = await phase("identity load failed", () => loadOrCreateIdentity(config.storagePath, log));
+    const identity = await phase("identity load", () => loadOrCreateIdentity(config.storagePath, log));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/src/main.ts` at line 70, Update the phase label in the identity
check call around identityProblem to use the noun phrase "identity check"
instead of "identity check failed", and use "identity load" for the
corresponding identity load phase call. Keep the phase wrapper responsible for
adding failure wording.
bridge-node/src/node.ts (1)

529-534: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the live-endpoint projection shared with checkDrift.

Lines 530-533 repeat lines 349-352 exactly. Both build the same LiveEndpointNumber[] from #registry?.summaries(). A change to the projection — a new field on LiveEndpointNumber, or a filter for endpoints that have no number yet — has to be made in both places, and a rebuild that disagrees with the detector writes a baseline the detector then reports as drift.

♻️ Proposed refactor
+    /** The live `UniqueID → endpoint number` set, as both §4.3 and §3.11 see it. */
+    private liveEndpointNumbers(): LiveEndpointNumber[] {
+        return (this.#registry?.summaries() ?? []).map(summary => ({
+            uniqueId: uniqueIdFor(summary.indigoDeviceId),
+            endpointNumber: summary.endpointNumber,
+        }));
+    }
+
     async rebuildEndpointMap(): Promise<StatusReport> {
-        const live: LiveEndpointNumber[] = (this.#registry?.summaries() ?? []).map(summary => ({
-            uniqueId: uniqueIdFor(summary.indigoDeviceId),
-            endpointNumber: summary.endpointNumber,
-        }));
+        const live = this.liveEndpointNumbers();
         this.#endpointMap.rebuild(live);

Apply the same substitution in checkDrift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/src/node.ts` around lines 529 - 534, Extract the duplicated
`LiveEndpointNumber[]` projection from `rebuildEndpointMap` and `checkDrift`
into a shared helper, then use that helper in both methods before rebuilding or
comparing the endpoint map. Preserve the existing `#registry?.summaries()`
fallback and projection behavior exactly.
bridge-node/src/storage.ts (2)

207-214: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The documented precondition is not enforced by the code.

The comment states that the replace branch is safe only because main.ts calls identityProblem first. loadOrCreateIdentity is exported, and the guard lives in another module. A second caller that omits the check re-mints installId, which changes serialNumber and uniqueId and un-pairs every ecosystem — the exact loss this milestone prevents.

Consider making the precondition structural rather than documentary. One option is a required option, for example loadOrCreateIdentity(storagePath, log, { replaceUnusable: true }), so a caller has to state that it accepts the loss. Another is to return the problem from a single combined function and let the caller branch, which also removes the duplicated parse in identityProblem.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/src/storage.ts` around lines 207 - 214, Make the replacement
behavior in loadOrCreateIdentity structurally opt-in instead of relying on
main.ts calling identityProblem first. Add a required option or equivalent
explicit authorization that callers must provide before replacing an unreadable
or invalid identity file, and ensure callers without that authorization preserve
the existing identity-problem handling rather than re-minting values.

103-125: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Make writeJsonAtomic durable across power loss.

writeFileSync plus renameSync does not guarantee the temp file contents are on stable storage before the rename completes; a crash can leave endpoint-map.json empty, missing, or still the old corrupt file. Flush the temp file before renameSync, and flush the parent directory afterward before claiming durability. Platform support for directory fsyncSync is implementation-defined, so the directory flush must handle unsupported cases such as Windows or network filesystems.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/src/storage.ts` around lines 103 - 125, Update writeJsonAtomic to
flush the temporary file’s contents with fsyncSync before renameSync, then open
and fsyncSync the parent directory after the rename to persist the directory
entry. Handle unsupported directory fsync cases, including Windows or network
filesystems, without failing the successful atomic write; preserve cleanup and
error propagation for write, flush, or rename failures.
bridge-node/test/persistence.test.ts (1)

158-183: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

A mid-test failure leaks a live ServerNode and can cascade into later tests.

Only the test at line 317 wraps its session in try/finally. Every other test calls close() on the success path only. The file header explains that matter.js takes an exclusive lock per storage path and that the restart tests rebuild a ServerNode on the same path. So a failed assertion between boot and close leaves the node holding that lock and the after hook then removes the directory underneath it. The first genuine failure in this file is likely to be followed by unrelated failures that hide it.

Apply the same try/finally treatment used at line 320, or give boot a helper that owns the lifetime.

💚 Proposed refactor
+/** Run `body` against a booted session and always close it. */
+async function withSession<T>(
+    storagePath: string,
+    body: (session: Session) => Promise<T>,
+    commissionedAt?: string,
+): Promise<T> {
+    const session = await boot(storagePath, commissionedAt);
+    try {
+        return await body(session);
+    } finally {
+        await session.close();
+    }
+}

Several tests read state after close()readMap at line 205, for example — so each conversion needs the assertion order checked rather than a mechanical wrap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/test/persistence.test.ts` around lines 158 - 183, Ensure every
test that creates a ServerNode through boot, including the restart test, closes
it in a finally block so assertion failures cannot leak live nodes or storage
locks. Apply the existing try/finally pattern used by the test around line 320,
preserving operations that intentionally read state after close by placing only
the appropriate assertions before or after cleanup.
bridge-node/test/endpoint-map.test.ts (1)

165-179: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This assertion can pass without proving anything.

Line 178 filters the log for the prefix "Endpoint map recorded". That prefix is produced by persist as Endpoint map ${why}, and why is chosen inside EndpointMapStore.check. The test does not control that string. If the wording changes to anything else, the filter becomes empty for every input and the test passes whether or not the file was rewritten — including for the regression it exists to catch.

Assert on the observable effect instead of the log wording.

💚 Proposed fix
-        quiet.check([{ uniqueId: "indigo-1", endpointNumber: 2 }]);
-
-        // `get_status`/attach run this on every reconnect; a steady state that
-        // wrote to disk each time would be a needless write per watchdog cycle.
-        assert.deepEqual(logged.filter(line => line.startsWith("Endpoint map recorded")), []);
+        const before = statSync(join(dir, ENDPOINT_MAP_FILE)).mtimeMs;
+
+        quiet.check([{ uniqueId: "indigo-1", endpointNumber: 2 }]);
+
+        // `get_status`/attach run this on every reconnect; a steady state that
+        // wrote to disk each time would be a needless write per watchdog cycle.
+        assert.equal(statSync(join(dir, ENDPOINT_MAP_FILE)).mtimeMs, before, "no write in a steady state");
+        assert.deepEqual(logged, [], "and nothing to say about it");

mtimeMs resolution can be coarse on some filesystems. If that proves flaky, assert logged is empty and count writes through an injected writer instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/test/endpoint-map.test.ts` around lines 165 - 179, Update the
“does not rewrite the file when nothing was added” test to verify the observable
no-write behavior rather than filtering for the “Endpoint map recorded” log
prefix. Capture the loaded map file’s modification state or, preferably,
inject/count the writer used by EndpointMapStore.persist and assert that
quiet.check performs no write; use logged only if needed as a secondary check
and avoid relying on message wording.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bridge-node/src/main.ts`:
- Around line 131-141: Update the clean shutdown path after the “Shutdown
complete” log to flush stdout before terminating: await one stdout drain step if
needed, or allow the event loop to finish naturally after clearing the escape
hatch. Preserve process.exit(0) for paths where shutdown does not complete.

In `@bridge-node/src/node.ts`:
- Around line 457-469: Update removeFabric to clear the commissioning witness
after fabric.leave() when no fabrics remain, matching the witness cleanup
performed by factoryReset. Preserve the existing behavior for remaining fabrics
and ensure the cleanup occurs only after the last fabric is removed.
- Around line 235-257: Update assertEndpointIdentity so
noteCommissioningWitness() runs whenever the server is commissioned and the
endpoint registry/map state is available, before evaluating the refusal reason.
Keep refusal decision and fabricStorageLost logging unchanged, and allow
independent witness-storage failures to remain non-fatal.

In `@bridge-node/src/storage.ts`:
- Around line 207-214: Make loadOrCreateIdentity in
bridge-node/src/storage.ts:207-214 require an explicit replacement decision or
return the validation problem, eliminating duplicated identityProblem parsing
and preventing silent replacement by default. In bridge-node/src/main.ts:70-83,
when identityFault is present, rename the existing identity.json to an
identity.json.unusable-<timestamp> backup before loadOrCreateIdentity mints a
replacement, and log the backup path so recoverable installId data is preserved.

In `@bridge-node/src/ws-server.ts`:
- Around line 247-272: Update the refusal-state recovery handling in the
WebSocket command flow to require protocolVersion === PROTOCOL_VERSION for
recovery frames. Preserve pre-attach access for rebuild_endpoint_map, while
allowing attach to perform its existing version validation and rejecting other
recovery commands when the version is missing or mismatched.

In `@bridge-node/test/endpoint-map.test.ts`:
- Around line 266-273: Update the test case for “serves an uncommissioned bridge
even with an unreadable map” to pass mapPresent: true while retaining
commissioned: false and mapProblem: "corrupt", so refuseReasonFor exercises the
unreadable-map branch and still returns undefined.
- Around line 181-199: Update the write-failure test around
EndpointMapStore.check to avoid relying on chmod-based permission denial, which
is bypassed when running as root. Prefer adding or using an injectable
map-writer dependency in EndpointMapStore so the test can deterministically
simulate a write failure while preserving the expected empty drift result and
warning assertion.

In `@bridge-node/test/persistence.test.ts`:
- Around line 290-313: Update the test named “clears the commissioning
witness...” so factory_reset is the operation being tested: remove the preceding
rebuild_endpoint_map request, since factory_reset is admitted while the node
refuses. Preserve the initial refusal assertion, then verify after factory_reset
that identity.json no longer contains commissionedAt and that restart has no
refusal.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/bridge_client.py:
- Around line 229-233: Update the recovery flow in start() to retain the pending
un-export count from PREF_PENDING_REPLACE_ALL rather than only the boolean from
_owes_replace_all(). Carry that count through the provider into _replace_all(),
and when the empty-handshake attach enables replace_all, pass an explicit
deadline sized for the pending removals to _attach(). Preserve the existing
timeout behavior for non-replace-all attaches.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/export_bridge.py:
- Around line 341-348: Update the `_un_exporting` handling in the coroutine
scheduling flow around `_fire` so a failed schedule cannot leave the flag set
permanently; either assign it only after scheduling succeeds or clear it when
`_fire` reports failure. Apply the same correction to the related path around
lines 366–371, preserving the existing cleanup behavior when scheduling
succeeds.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/fabric_backup.py:
- Around line 161-165: Update the backup logging branch around bridge_members to
distinguish a supplied but missing or empty bridge_storage_path from a
controller-only backup. When bridge_storage_path is configured but no bridge
members are written, emit a clear log message stating that bridge storage was
omitted; retain the existing messages for backups containing bridge members and
for configurations without bridge storage.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/plugin.py:
- Around line 371-390: Update the watchdog guard in the health-tick method
around _issue_device_subscription so an initial subscribeToChanges failure can
enter the bounded retry path even when _subscribed_to_devices is false. Preserve
the existing early return for cases with no exported devices, and keep the
current retry counter, tick threshold, and MAX_RESUBSCRIBE_ATTEMPTS limit
unchanged.

In `@tests/test_fabric_backup.py`:
- Around line 519-523: Update the assertions in the backup test to iterate over
the archive-derived names rather than the hardcoded path list. Verify that every
non-bridge member in names lacks fabric_backup.BRIDGE_MEMBER_PREFIX, while also
asserting the controller members exist under their plain paths, including config
and certificates/root.pem.

---

Nitpick comments:
In `@bridge-node/package.json`:
- Line 18: Add a brief explanation near the test script documenting which
unclosed WebSocket or server handle necessitates --test-force-exit and the
failure mode it prevents; alternatively, identify and close that leaking handle
so the flag can be removed while preserving reliable test completion.

In `@bridge-node/src/endpoint-map.ts`:
- Around line 70-78: Update refuseReasonFor to return RefuseReasonValue |
undefined instead of string | undefined, importing or referencing the existing
RefuseReasonValue type from protocol.ts. Preserve the current branching and
returned RefuseReason values so endpointMapInvalidDetails receives the narrowed
union and rejects arbitrary reason strings at compile time.

In `@bridge-node/src/main.ts`:
- Line 70: Update the phase label in the identity check call around
identityProblem to use the noun phrase "identity check" instead of "identity
check failed", and use "identity load" for the corresponding identity load phase
call. Keep the phase wrapper responsible for adding failure wording.

In `@bridge-node/src/node.ts`:
- Around line 529-534: Extract the duplicated `LiveEndpointNumber[]` projection
from `rebuildEndpointMap` and `checkDrift` into a shared helper, then use that
helper in both methods before rebuilding or comparing the endpoint map. Preserve
the existing `#registry?.summaries()` fallback and projection behavior exactly.

In `@bridge-node/src/storage.ts`:
- Around line 207-214: Make the replacement behavior in loadOrCreateIdentity
structurally opt-in instead of relying on main.ts calling identityProblem first.
Add a required option or equivalent explicit authorization that callers must
provide before replacing an unreadable or invalid identity file, and ensure
callers without that authorization preserve the existing identity-problem
handling rather than re-minting values.
- Around line 103-125: Update writeJsonAtomic to flush the temporary file’s
contents with fsyncSync before renameSync, then open and fsyncSync the parent
directory after the rename to persist the directory entry. Handle unsupported
directory fsync cases, including Windows or network filesystems, without failing
the successful atomic write; preserve cleanup and error propagation for write,
flush, or rename failures.

In `@bridge-node/test/endpoint-map.test.ts`:
- Around line 165-179: Update the “does not rewrite the file when nothing was
added” test to verify the observable no-write behavior rather than filtering for
the “Endpoint map recorded” log prefix. Capture the loaded map file’s
modification state or, preferably, inject/count the writer used by
EndpointMapStore.persist and assert that quiet.check performs no write; use
logged only if needed as a secondary check and avoid relying on message wording.

In `@bridge-node/test/fixture-shapes.ts`:
- Around line 222-224: Add explicit `satisfies` clauses to the
`removeFabricResult` and `factoryResetResult` empty fixtures, using the response
shape types expected for §3.9 and §3.10. Match the established fixture typing
pattern used by `rebuiltStatus` and `endpointMapInvalid` so future shape changes
are validated.

In `@bridge-node/test/persistence.test.ts`:
- Around line 158-183: Ensure every test that creates a ServerNode through boot,
including the restart test, closes it in a finally block so assertion failures
cannot leak live nodes or storage locks. Apply the existing try/finally pattern
used by the test around line 320, preserving operations that intentionally read
state after close by placing only the appropriate assertions before or after
cleanup.

In `@bridge-node/test/protocol.test.ts`:
- Around line 909-921: Update the emitDrift call in the “drift_detected event
(§5)” test to cast golden.drift_detected.data.drift as DriftEntry[] instead of
never, preserving compile-time validation of the drift payload shape.

In `@tests/fakes.py`:
- Around line 177-182: Update the nested coroutine execution flow around the
thread.join call to use a bounded timeout, then detect whether the thread is
still alive and raise a clear diagnostic error if it failed to finish. Preserve
the existing outcome error propagation and value return for threads that
complete normally.

In `@tests/test_bridge_client.py`:
- Around line 1083-1102: Update
test_a_raising_provider_attaches_without_the_intent to assert that mock_logger
records the warning emitted by _replace_all when replace_all_provider raises,
while preserving the existing assertion that the attach command omits
bridge_protocol.ARG_INTENT.

In `@tests/test_export_wiring.py`:
- Around line 547-572: Update the resubscription tests around _resubscribe_tick
to derive retry counts from plugin_mod.RESUBSCRIBE_TICKS instead of the literal
4, and assert the final call count with plugin_mod.MAX_RESUBSCRIBE_ATTEMPTS
instead of 3. Preserve the existing test behavior and loop structure.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e1145b9a-15ae-44b3-8950-3462d0116aa2

📥 Commits

Reviewing files that changed from the base of the PR and between 243f21f and 35c8cf4.

⛔ Files ignored due to path filters (1)
  • bridge-node/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (31)
  • bridge-node/package.json
  • bridge-node/src/endpoint-map.ts
  • bridge-node/src/main.ts
  • bridge-node/src/node.ts
  • bridge-node/src/protocol.ts
  • bridge-node/src/reconcile.ts
  • bridge-node/src/storage.ts
  • bridge-node/src/ws-server.ts
  • bridge-node/test/client.ts
  • bridge-node/test/endpoint-map.test.ts
  • bridge-node/test/fixture-shapes.ts
  • bridge-node/test/fixtures.test.ts
  • bridge-node/test/integration.test.ts
  • bridge-node/test/persistence.test.ts
  • bridge-node/test/protocol.test.ts
  • bridge-node/test/storage.test.ts
  • bridge-node/test/stub-bridge.ts
  • docs/HANDOVER.md
  • indigo-matter.indigoPlugin/Contents/Info.plist
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/export_handlers.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/fabric_backup.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py
  • tests/fakes.py
  • tests/fixtures/bridge_protocol/frames.json
  • tests/test_bridge_client.py
  • tests/test_export_bridge.py
  • tests/test_export_handlers.py
  • tests/test_export_wiring.py
  • tests/test_fabric_backup.py

Comment thread bridge-node/src/main.ts
Comment on lines +131 to +141
clearTimeout(escapeHatch);
log("Shutdown complete");
// Both closes returned, in order, so everything that is ours is
// down — and what is left on the loop is not ours to wait for.
// matter.js 0.17.8's `ServerNode.erase()` (§3.10) leaves a ref'd
// timer behind that `close()` does not clear, measured at 0.17.8:
// without this, a perfectly clean shutdown *after a factory reset*
// would sit until the escape hatch fired and then exit 1, telling
// launchd a successful stop was a crash. Exit 0 here; the escape
// hatch still owns every path where a close does NOT return.
process.exit(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Node.js process.exit stdout pipe not flushed truncated output macOS

💡 Result:

The truncation of stdout when using process.exit occurs because process.exit terminates the Node.js process immediately, forcing it to stop even if there are asynchronous I/O operations pending [1][2]. In Node.js, writes to stdout and stderr are often asynchronous; when process.exit is called, these operations are interrupted before the data can be fully flushed to the underlying stream or pipe [1][2]. This issue is particularly noticeable on macOS and other Unix-like systems when piping output, as the buffering behavior differs from TTY output, increasing the likelihood that data remains in the buffer when the process is killed [3][4][5]. The recommended approach to avoid this truncation is to avoid using process.exit entirely [1][2]. Instead, you should: 1. Set the process.exitCode property to your desired exit code [1][2]. 2. Allow the process to exit naturally by ensuring no further asynchronous work is scheduled [1][2]. The Node.js event loop will then terminate the process gracefully once all remaining operations, including stdout/stderr flushes, are completed [1][2]. If you must terminate due to an error, throwing an uncaught error is generally safer than calling process.exit, as it allows the process to handle the termination more predictably [1][2]. While some developers have used workarounds such as setting stdout._handle.setBlocking(true) or manually draining streams with a promise before calling process.exit, these are not officially supported or guaranteed methods and may lead to other issues [3][6][7]. Top results: [1], [2], [8], [5]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Target file context"
wc -l bridge-node/src/main.ts
sed -n '1,190p' bridge-node/src/main.ts

echo
echo "## Runtime/package Node config"
if [ -f bridge-node/package.json ]; then
  cat bridge-node/package.json
fi
if [ -f package.json ]; then
  grep -n '"node":' package.json || true
fi
if [ -f .nvmrc ]; then cat .nvmrc; fi
if [ -f bridge-node/.nvmrc ]; then cat bridge-node/.nvmrc; fi
node -p "process.version" 2>/dev/null || true

echo
echo "## stdout drain behavioral probe"
node - <<'JS'
const { spawn } = require('child_process');
const script = `
process.exitCode = 0;
let n = 0;
function writeUntilFull() {
  if (++n > 10000) return;
  if (process.stdout.write('x'.repeat(4096))) {
    writeUntilFull();
  } else {
    process.stdout.once('drain', function drain() {
      process.stdout.once('drain', function drain2() {
        process.stdout.write('exit now');
        process.exit(0);
      });
    });
  }
}
writeUntilFull();
`;
const child = spawn(process.execPath, ['-e', script], { stdio: ['ignore', 'pipe', 'pipe'] });
const out = [];
child.stdout.on('data', (d) => out.push(d.toString()));
child.stderr.on('data', (d) => process.stderr.write(d));
child.on('close', (code) => {
  const text = out.join('');
  console.log(JSON.stringify({
    code,
    stdoutLength: text.length,
    endsWithExitNow: text.endsWith('exit now'),
    expectedMinLength: 40960
  }));
});
JS

Repository: simons-plugins/indigo-matter

Length of output: 7856


Flush stdout before calling process.exit(0).

The clean shutdown path now prints Shutdown complete and then calls process.exit(0). When launchd redirects stdout to a pipe, process.exit can terminate before the buffered writes flush, so the final log can be cut off. Await one drain step before exiting, or avoid process.exit on this clean path if the loop is allowed to complete after the escapes are cleared.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/src/main.ts` around lines 131 - 141, Update the clean shutdown
path after the “Shutdown complete” log to flush stdout before terminating: await
one stdout drain step if needed, or allow the event loop to finish naturally
after clearing the escape hatch. Preserve process.exit(0) for paths where
shutdown does not complete.

Comment thread bridge-node/src/node.ts
Comment on lines +235 to +257
private assertEndpointIdentity(): void {
if (this.#refusal !== undefined) {
this.refuse(this.#refusal);
return;
}
const reason = refuseReasonFor({
commissioned: this.server.lifecycle.isCommissioned,
mapPresent: this.#endpointMap.present,
mapProblem: this.#endpointMap.problem,
commissionedAt: this.identity.commissionedAt,
});
if (reason === undefined) {
this.noteCommissioningWitness();
return;
}
if (reason === RefuseReason.fabricStorageLost) {
this.log(
`Identity records commissioning at ${this.identity.commissionedAt}, but the ` +
"Matter stack reports no fabrics",
);
}
this.refuse(reason);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

matter.js 0.17.8 ServerNode events commissioning fabricsChanged emitted on start for existing fabrics

💡 Result:

In matter.js (including version 0.17.8), the server.events.commissioning.fabricsChanged event is designed to notify you of changes to commissioned fabrics (addition, removal, or update) that occur during the runtime of the server node [1][2]. It is not standard behavior for the fabricsChanged event to be emitted automatically upon the initial startup of a ServerNode for existing fabrics [1][2]. The fabricsChanged event is reactive, triggered by specific actions performed on the node, such as a fabric being added, removed, or updated via a controller interaction [1][2]. If you need to retrieve or inspect the list of existing fabrics when the server node starts, you should access the server.state.commissioning.fabrics object directly during or after the server's initialization (for example, in a server.lifecycle.online event listener) [2][3]. The fabrics property is dynamically maintained and will contain the current state of commissioned fabrics upon startup [4][3]. For identifying when a node is generally ready or has completed its initial state load, it is recommended to use the server.lifecycle.online event, which ensures the node is up and the state is available [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate bridge-node files =="
git ls-files 'bridge-node/**/*.{ts,tsx,js,jsx}' | sed -n '1,120p'

echo "== outline node.ts =="
ast-grep outline bridge-node/src/node.ts || true

echo "== relevant node.ts ranges =="
sed -n '1,90p' bridge-node/src/node.ts
sed -n '160,275p' bridge-node/src/node.ts
sed -n '500,550p' bridge-node/src/node.ts

echo "== package versions =="
for f in package.json bridge-node/package.json; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    cat "$f" | sed -n '1,220p'
  fi
done

echo "== refs to fabricsChanged and noteCommissioningWitness =="
rg -n "fabricsChanged|noteCommissioningWitness|commissionedAt|isCommissioned|commissioning.fabrics" bridge-node/src -S

Repository: simons-plugins/indigo-matter

Length of output: 15232


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== bridge-node package lock / material locations =="
git ls-files | rg '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|node_modules|\.test-build|package\.json|src/node\.ts|src/endpoint-map\.ts|src/storage\.ts|tests/fixtures/bridge_protocol|package\.lock)' | sed -n '1,200p'

echo "== lockfile material for matter 0.17.8 =="
for f in package-lock.json bridge-node/package-lock.json pnpm-lock.yaml yarn.lock bun.lockb; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n '"`@matter/`(main|nodejs)"|`@matter/`(main|nodejs)|0\.17\.8' "$f" -C 2 -S | sed -n '1,200p'
  fi
done

echo "== endpoint-map and storage relevant implementations =="
sed -n '35,90p' bridge-node/src/endpoint-map.ts
sed -n '155,205p' bridge-node/src/storage.ts
sed -n '270,295p' bridge-node/src/node.ts

Repository: simons-plugins/indigo-matter

Length of output: 9407


Record the commissioning witness independently of the refusal decision.

noteCommissioningWitness() currently runs only after refuseReasonFor() returns undefined. If a commissioned bridge has a present but unreadable endpoint-map.json, the node refuses without setting commissionedAt, and rebuildEndpointMap() skips both clearing and setting the witness because isCommissioned is still true.

Record the witness when the server state and endpoint registry are available, then decide whether to refuse. Independent storage failures can still be tolerated, but placing the write inside the successful path leaves the §7 detection disabled for commissioned installs with map incidents.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/src/node.ts` around lines 235 - 257, Update
assertEndpointIdentity so noteCommissioningWitness() runs whenever the server is
commissioned and the endpoint registry/map state is available, before evaluating
the refusal reason. Keep refusal decision and fabricStorageLost logging
unchanged, and allow independent witness-storage failures to remain non-fatal.

Comment thread bridge-node/src/node.ts
Comment thread bridge-node/src/storage.ts Outdated
Comment on lines +247 to 272
// The refuse-to-start gate comes before everything, including the
// attach gate. In this state there is nothing to attach *to* — the node
// will not create an endpoint until its map is rebuilt — so answering
// `not_attached` would send the plugin round a reconnect loop that can
// never succeed, and hide the one error that names the remedy. The
// three §1.1 recovery commands are exempt from the attach requirement
// for the same reason: the client holding this socket open never got to
// attach, and `rebuild_endpoint_map` is the only way it ever will.
const refusal = this.options.bridge.endpointMapRefusal();
if (refusal !== undefined) {
if (!RECOVERY_COMMANDS.has(command)) {
this.sendError(
socket,
messageId,
ErrorCode.endpointMapInvalid,
endpointMapInvalidDetails(refusal),
);
return;
}
} else if (command !== "attach" && !state.attached) {
// Gating: before `attach` the node has said nothing about which
// commands it knows, so `not_attached` is the honest answer even for
// a name it would otherwise reject (§1.1).
this.sendError(socket, messageId, ErrorCode.notAttached, `${command} requires a successful attach first`);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Resolve RECOVERY_COMMANDS membership and every protocolVersion check.
set -euo pipefail

echo "== RECOVERY_COMMANDS definition =="
rg -n -A 12 'RECOVERY_COMMANDS' bridge-node/src/protocol.ts

echo "== every protocolVersion check in the node =="
rg -n --type=ts -C 3 'protocolVersion' bridge-node/src

echo "== tests exercising recovery commands before attach =="
rg -n --type=ts -C 5 'rebuild_endpoint_map|factory_reset|remove_fabric' bridge-node/test

Repository: simons-plugins/indigo-matter

Length of output: 23085


Add a protocol-version check to rebuild_endpoint_map in the refusal state.

RECOVERY_COMMANDS contains get_status, get_pairing, and rebuild_endpoint_map. The refusal branch admits all three without the normal protocolVersion comparison, and it skips setting state.attached, so handleAttach is the only version check on that socket. Require protocolVersion === PROTOCOL_VERSION for recovery frames, and still allow rebuild_endpoint_map before attach; only the attach flow itself cannot omit the version.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/src/ws-server.ts` around lines 247 - 272, Update the
refusal-state recovery handling in the WebSocket command flow to require
protocolVersion === PROTOCOL_VERSION for recovery frames. Preserve pre-attach
access for rebuild_endpoint_map, while allowing attach to perform its existing
version validation and rejecting other recovery commands when the version is
missing or mismatched.

Comment thread indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py Outdated
Comment thread indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py
Comment on lines +161 to +165
if bridge_members:
log.info("Fabric backup written: %s (%d member(s), including %d from the Matter "
"export bridge node)", archive_path, members_written, bridge_members)
else:
log.info("Fabric backup written: %s (%d member(s))", archive_path, members_written)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Report when the bridge storage is omitted.

If bridge_storage_path is supplied but missing or empty, bridge_members remains zero and Line 165 logs the same message as a controller-only backup. Plugin._bridge_storage_path() documents that this case produces a log line, but no branch emits one. The user can then assume the bridge identity was backed up.

Proposed fix
     if bridge_members:
         log.info("Fabric backup written: %s (%d member(s), including %d from the Matter "
                  "export bridge node)", archive_path, members_written, bridge_members)
+    elif bridge_storage_path:
+        log.info("Fabric backup written: %s (%d member(s)); bridge-node storage was not included from %s",
+                 archive_path, members_written, bridge_storage_path)
     else:
         log.info("Fabric backup written: %s (%d member(s))", archive_path, members_written)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if bridge_members:
log.info("Fabric backup written: %s (%d member(s), including %d from the Matter "
"export bridge node)", archive_path, members_written, bridge_members)
else:
log.info("Fabric backup written: %s (%d member(s))", archive_path, members_written)
if bridge_members:
log.info("Fabric backup written: %s (%d member(s), including %d from the Matter "
"export bridge node)", archive_path, members_written, bridge_members)
elif bridge_storage_path:
log.info("Fabric backup written: %s (%d member(s)); bridge-node storage was not included from %s",
archive_path, members_written, bridge_storage_path)
else:
log.info("Fabric backup written: %s (%d member(s))", archive_path, members_written)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/fabric_backup.py around
lines 161 - 165, Update the backup logging branch around bridge_members to
distinguish a supplied but missing or empty bridge_storage_path from a
controller-only backup. When bridge_storage_path is configured but no bridge
members are written, emit a clear log message stating that bridge storage was
omitted; retain the existing messages for backups containing bridge members and
for configurations without bridge storage.

Comment on lines +371 to +390
if not self._exported_ids or not self._subscribed_to_devices:
return
if self._device_updates_seen or self._resubscribe_attempts >= MAX_RESUBSCRIBE_ATTEMPTS:
return
self._no_update_ticks += 1
if self._no_update_ticks < RESUBSCRIBE_TICKS:
return
self._no_update_ticks = 0
self._resubscribe_attempts += 1
# **Bounded, because "no updates" is also what a quiet house looks
# like.** A device that nobody touches genuinely produces no callback,
# so this cannot be a permanent retry loop without being permanent
# noise. A handful of re-issues covers the case it is for — a
# subscription that never registered — and after that the evidence is
# indistinguishable from nothing having happened.
self.logger.debug(
"Matter export: no device updates since subscribing; re-issuing "
"subscribeToChanges (attempt %d of %d)",
self._resubscribe_attempts, MAX_RESUBSCRIBE_ATTEMPTS)
self._issue_device_subscription()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Retry a failed initial subscription.

If subscribeToChanges() raises at Lines 347-353, _subscribed_to_devices remains False. Line 371 then returns on every health tick. Exported device state remains stale until an allow-list change or plugin restart retries the call.

Allow the bounded watchdog path to run after an initial failure.

Proposed fix
-        if not self._exported_ids or not self._subscribed_to_devices:
+        if not self._exported_ids:
             return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not self._exported_ids or not self._subscribed_to_devices:
return
if self._device_updates_seen or self._resubscribe_attempts >= MAX_RESUBSCRIBE_ATTEMPTS:
return
self._no_update_ticks += 1
if self._no_update_ticks < RESUBSCRIBE_TICKS:
return
self._no_update_ticks = 0
self._resubscribe_attempts += 1
# **Bounded, because "no updates" is also what a quiet house looks
# like.** A device that nobody touches genuinely produces no callback,
# so this cannot be a permanent retry loop without being permanent
# noise. A handful of re-issues covers the case it is for — a
# subscription that never registered — and after that the evidence is
# indistinguishable from nothing having happened.
self.logger.debug(
"Matter export: no device updates since subscribing; re-issuing "
"subscribeToChanges (attempt %d of %d)",
self._resubscribe_attempts, MAX_RESUBSCRIBE_ATTEMPTS)
self._issue_device_subscription()
if not self._exported_ids:
return
if self._device_updates_seen or self._resubscribe_attempts >= MAX_RESUBSCRIBE_ATTEMPTS:
return
self._no_update_ticks += 1
if self._no_update_ticks < RESUBSCRIBE_TICKS:
return
self._no_update_ticks = 0
self._resubscribe_attempts += 1
# **Bounded, because "no updates" is also what a quiet house looks
# like.** A device that nobody touches genuinely produces no callback,
# so this cannot be a permanent retry loop without being permanent
# noise. A handful of re-issues covers the case it is for — a
# subscription that never registered — and after that the evidence is
# indistinguishable from nothing having happened.
self.logger.debug(
"Matter export: no device updates since subscribing; re-issuing "
"subscribeToChanges (attempt %d of %d)",
self._resubscribe_attempts, MAX_RESUBSCRIBE_ATTEMPTS)
self._issue_device_subscription()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/plugin.py around lines 371
- 390, Update the watchdog guard in the health-tick method around
_issue_device_subscription so an initial subscribeToChanges failure can enter
the bounded retry path even when _subscribed_to_devices is false. Preserve the
existing early return for cases with no exported devices, and keep the current
retry counter, tick threshold, and MAX_RESUBSCRIBE_ATTEMPTS limit unchanged.

Comment on lines +519 to +523
# The prefix is what keeps two sibling directories apart in one archive —
# and what lets an old, controller-only backup still restore cleanly.
assert "config" in names
assert not any(name.startswith(fabric_backup.BRIDGE_MEMBER_PREFIX)
for name in ["config", "certificates/root.pem"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

This assertion cannot fail — assert against the archive names.

The comprehension iterates the literal list ["config", "certificates/root.pem"], not names. It checks that two hardcoded strings do not start with the prefix, which is true independently of the archive. Assert that every non-bridge member of names is unprefixed, and that the controller members are present under their plain paths.

🐛 Proposed fix
         assert "config" in names
-        assert not any(name.startswith(fabric_backup.BRIDGE_MEMBER_PREFIX)
-                       for name in ["config", "certificates/root.pem"])
+        assert "certificates/root.pem" in names
+        # Controller members keep their plain, storage-root-relative paths.
+        assert {name for name in names
+                if not name.startswith(fabric_backup.BRIDGE_MEMBER_PREFIX)} >= {
+            "config", "certificates/root.pem"}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# The prefix is what keeps two sibling directories apart in one archive —
# and what lets an old, controller-only backup still restore cleanly.
assert "config" in names
assert not any(name.startswith(fabric_backup.BRIDGE_MEMBER_PREFIX)
for name in ["config", "certificates/root.pem"])
# The prefix is what keeps two sibling directories apart in one archive —
# and what lets an old, controller-only backup still restore cleanly.
assert "config" in names
assert "certificates/root.pem" in names
# Controller members keep their plain, storage-root-relative paths.
assert {name for name in names
if not name.startswith(fabric_backup.BRIDGE_MEMBER_PREFIX)} >= {
"config", "certificates/root.pem"}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_fabric_backup.py` around lines 519 - 523, Update the assertions in
the backup test to iterate over the archive-derived names rather than the
hardcoded path list. Verify that every non-bridge member in names lacks
fabric_backup.BRIDGE_MEMBER_PREFIX, while also asserting the controller members
exist under their plain paths, including config and certificates/root.pem.

simons-plugins and others added 2 commits August 5, 2026 18:48
Two independent expert reviews plus a test-coverage review: 5 Criticals, ~10
Highs, 5 Mediums, 9 coverage gaps. The deploy blocker first.

A1 — a commissioned bridge with NO endpoint-map.json refused to serve, and
that is the state of every install commissioned before E5, because the file
did not exist yet. jarvis today: 2 fabrics, 4 endpoints, no map — deploying
E5 as written would have refused the attach and taken all four accessories
offline. The refusal bought nothing either: matter.js OWNS the numbers, keyed
on Endpoint.id in its own store, and this map is only the witness, so a
missing witness renumbers precisely nothing. Absent-on-commissioned is now a
BOOTSTRAP — seed the baseline from matter.js's persisted allocation (read
through ServerNodeStore, guarded, falling back to the first attach's live
set), log it as a migration, serve. Only a present-but-unreadable map refuses.

A2 — rebuild_endpoint_map had no caller anywhere in the plugin while three
user-facing strings told the user to confirm the rebuild "in the plugin".
Ships the exits: "Rebuild Matter Endpoint Map…" (§3.11, confirm + duplication
warning) and "Reset Matter Export Pairings…" (§3.10 preserve=true, two
confirms). Both gate on connected, not attached — §1.1 holds the socket open
un-attached and that is the only state a rebuild is ever needed in.
remove_fabric still has no UI; it needs E6's fabric readout to pick from.

A3 — main.ts called identityProblem() then loadOrCreateIdentity()
unconditionally, and the mint writes through rename: an unreadable
identity.json was destroyed one line BEFORE the refusal that exists to
protect it, taking the SerialNumber and UniqueID every paired ecosystem knows
us by. Now moved aside to identity.json.unreadable-<stamp>, minted in memory
only, nothing written. rebuildEndpointMap refuses to clear an
identityUnreadable refusal — different loss, different remedy.

A4 — pending debt plus a disjoint re-add was a permanent halt.
_owes_replace_all was ANDed with an empty allow-list, so emptying the list
while the node was down and then exporting a different device sent an attach
with no intent; the node's guard saw N removals against zero survivors,
answered mass_removal_refused — which HALTS — and blamed an allow-list that
was never the problem. The debt now answers independently of store emptiness,
and mass_removal_refused retries once with the intent while a debt is
recorded instead of halting.

A5 — remove_fabric on the last fabric: matter.js self-factory-resets when the
set empties, and our witness was left set, so the next boot refused with
fabricStorageLost and blamed lost storage for a deliberate unpairing. Handled
in noteFabrics/noteLastFabricGone, so an ecosystem unpairing US is covered too.

Visibility of persistence failures — the milestone's whole point:

- StatusReport gains warnings[] (§4.3). The node's only other channel is a
  stdout nobody is watching while it is hand-started. persist(),
  markCommissioned() and clearCommissioned() report whether the write landed;
  rebuild_endpoint_map FAILS rather than saying "serving normally again" over
  a map that never reached disk; factory_reset re-reads identity.json to
  verify the witness is gone before reporting completion.
- driftChecked no longer asserts an all-clear over a RAM-only baseline
  (#dirty), and check() retries the failed write even when it added nothing.
- The §5 command executor gets a 30s deadline naming command+device,
  submitted/completed counters, a health_tick queue-depth warning and a
  dropped-count line at stop().
- fabric_backup WARNS, naming the path, when the bridge storage dir is
  missing or empty — and names it on success too.
- _set_color_temp returns the E4 tri-state reason instead of a bare None, so
  a no-op stops being reported as success.
- The node stops closing an un-attached socket while it is refusing; the
  discharge attach is sized from the debt count, not len([]); the debt is
  discharged on what the attach CARRIED, not on live state; and
  fabrics_changed/commissioned/decommissioned are finally emitted.

Medium: factory_reset re-checks drift and says what preservation bought
(erase() wipes matter.js's own allocation, so it preserves the ability to
NOTICE, not the numbers); _on_drift_detected is latched per drift-set; an
unusable map is copied to .corrupt before a rebuild; removeEndpoint runs the
detector; _indigo_device narrows to KeyError/IndexError and reports anything
else distinctly; stop() shuts the executor down with cancel_futures and
latches so a queued coroutine cannot rebuild it; _un_exporting cannot latch
forever on a submit failure; the plugin parses and surfaces
driftChecked/drift/warnings in the export dialog.

Docs: §3.9/§3.10/§3.11/§4.3/§5 corrected — what preserveEndpointNumbers
actually preserves, what §3.11 actually does, warnings[], when drift is
checked, and the RECOVERY_COMMANDS design call (factory_reset stays gated;
§3.11 is the non-destructive exit and already covers every refusal state).
HANDOVER carries the corrected refuse-to-start table, the real test counts,
the process-scoped stopped-keys caveat, and the three paths that need live
hardware. CLAUDE.md gains the bridge-node module table.

PluginVersion 2026.7.31. 2025 Python / 316 TS tests, pylint 9.39.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S
Verified against a copy of jarvis's real pre-E5 bridge storage (2 fabrics,
4 exported endpoints, no endpoint-map.json): the bootstrap correctly SERVES
rather than refuses, but seeded 0 numbers while logging that it had adopted
them from matter.js — a false success in a migration path.

The reader walked root->parts->aggregator->parts and called contexts(),
which enumerates only subcontexts already materialised in memory; at
bootstrap none are. The numbers live in flat on-disk keys whose layout is
matter.js-internal. Deleted rather than re-derived: the fallback records the
live set on the first reconcile, and those numbers ARE matter.js's persisted
ones (it restores each Endpoint.id's number as the endpoint is created), so
the witness is identical for everything we export. An endpoint we do not
export gains nothing from an entry — its number is decided by its id
whenever it is created.

Removes the coupling to matter.js's internal storage-context names that the
review flagged, and the log now states what actually happens.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tests/fakes.py (1)

146-182: 🩺 Stability & Availability | 🟡 Minor

Handle loop-bound work in nested RecordingRuntime.submit.

_run_to_completion offloads to threading.Thread from an event loop path, then joins that thread synchronously. If the submitted coroutine awaits objects bound to the caller’s loop, execution can deadlock or raise a non-deterministic cross-loop error. Run loop-bound submissions on the owning loop, or reject them for fake tests and add coverage for asyncio.Future, create_future, and transport callbacks.

[low_effort和high_reward]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/fakes.py` around lines 146 - 182, The nested path in _run_to_completion
must not execute coroutines on a new worker loop when they await objects owned
by the caller’s event loop. Update RecordingRuntime.submit and its completion
flow to run loop-bound submissions on the owning loop, or explicitly reject
unsupported loop-bound work in the fake; add coverage for asyncio.Future,
create_future, and transport-callback cases.
bridge-node/test/endpoint-map.test.ts (1)

351-379: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

These two tests assert the same call, so one of the two named cases is uncovered.

Line 358 and Line 376 pass identical arguments — { commissioned: true, commissionedAt: "2026-08-01T00:00:00.000Z" } — and assert the same undefined. The names claim two different states: "no map at all" and "a usable map". refuseReasonFor cannot tell them apart, because mapProblem is now its only map input and both states leave it undefined.

The distinction is real, and it lives one level up: assertEndpointIdentity in bridge-node/src/node.ts reads this.#endpointMap.present at Line 270 and calls bootstrapEndpointMap() only for the no-map case. Merge these two tests into one, and keep the no-map-versus-usable-map distinction where a branch actually depends on it. The bootstrap tests at Lines 382-421 already cover the store side of it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/test/endpoint-map.test.ts` around lines 351 - 379, Remove the
redundant “serves a commissioned bridge with a usable map” test from the
refuseReasonFor cases and retain the commissioned no-map upgrade-path assertion.
Preserve the distinction between missing and usable endpoint maps in
assertEndpointIdentity, where endpointMap.present controls whether
bootstrapEndpointMap() is called; do not represent that distinction through
identical refuseReasonFor inputs.
♻️ Duplicate comments (1)
bridge-node/src/node.ts (1)

258-283: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

The commissioning witness is still recorded only on the non-refusing path.

noteCommissioningWitness() at Line 273 runs only when reason === undefined. A commissioned bridge that refuses for mapUnreadable therefore never records commissionedAt on that boot. rebuildEndpointMap does not record it either: Line 850 calls clearCommissioned only when isCommissioned is false, and nothing calls markCommissioned. The witness stays absent until the next restart or the next fabricsChanged observation.

During that window a loss of matter.js storage is undetectable: the next start sees no witness and no fabrics, so refuseReasonFor returns undefined and the node serves and re-creates every accessory. The precondition is narrow — the map must become unusable before the witness was ever written, which is the shape of a pre-E5 upgrade whose first map write is corrupted — but the remedy is one line.

Record the witness before the refusal decision. The write is already non-fatal and already reports its own failure through applyWitness.

🛡️ Proposed fix
         const commissioned = this.server.lifecycle.isCommissioned;
+        // Before the decision, not after it. The witness records "endpoint
+        // numbers now matter to somebody", which is true whether or not the map
+        // is readable — and a refusal that skips it leaves the §7 storage-loss
+        // detection disabled until the next restart.
+        this.noteCommissioningWitness();
         const reason = refuseReasonFor({
             commissioned,
             mapProblem: this.#endpointMap.problem,
             commissionedAt: this.identity.commissionedAt,
         });
         if (reason === undefined) {
             if (commissioned && !this.#endpointMap.present) {
                 this.bootstrapEndpointMap();
             }
-            this.noteCommissioningWitness();
             return;
         }

refuseReasonFor reads commissionedAt only on the uncommissioned branch, so recording it first does not change any refusal outcome.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/src/node.ts` around lines 258 - 283, Move
noteCommissioningWitness() in assertEndpointIdentity() to execute after the
initial refusal check but before refuseReasonFor evaluates the endpoint state,
so commissioned bridges record commissionedAt even when refusing for
mapUnreadable. Preserve the existing bootstrap behavior and refusal handling,
including the early return for this.#refusal.
🧹 Nitpick comments (1)
indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py (1)

1185-1213: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider latching the read-failure warning.

The narrow/broad split is right. The broad-catch warning is not deduplicated, though. endpoint_specs calls _device_getter once per allow-list entry on every reconnect, so a broken IPC connection writes one warning per exported device per attach. Every other persistent condition in this module is latched for that reason (_skipped, _update_failed, _node_warnings, _drift_reported).

The module-level function has no state to latch on. One option is to move the reporting into a small ExportBridge helper that owns the latch and clears it on the first successful read.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/export_bridge.py around
lines 1185 - 1213, Latch broad-catch read-failure warnings instead of logging
once per allow-list entry on each reconnect. Move the warning responsibility
from _indigo_device into an ExportBridge-owned helper/state, suppress repeated
warnings while reads continue failing, and clear the latch after the first
successful device read; preserve None for genuinely absent device IDs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bridge-node/test/endpoint-map.test.ts`:
- Around line 259-301: Add an injectable writer seam to EndpointMapStore and use
it in the persistence path exercised by check and rebuild, allowing tests to
deterministically simulate write failure without relying on directory
permissions or process privileges. Update all three write-failure tests to
inject a failing writer, then retain the assertions that check leaves checked
false and rebuild returns false with a warning before verifying successful
retries.

In `@bridge-node/test/persistence.test.ts`:
- Around line 375-396: Update the test setup around rebuild_endpoint_map so it
explicitly verifies commissionedAt is present before factory_reset runs. Create
a witness-present, non-refusing state, such as commissioning a fabric so
rebuildEndpointMap does not invoke clearCommissioned, then assert the identity
witness remains before checking the factory-reset warnings and removal.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/export_bridge.py:
- Around line 329-346: Update indigo-matter.indigoPlugin/Contents/Server
Plugin/export_bridge.py:329-346 so _owes_replace_all returns the integer from
_pending_replace_all() and is annotated as int; exports_changed requires no
direct change. Leave indigo-matter.indigoPlugin/Contents/Server
Plugin/bridge_client.py:478-485 unchanged, as it will consume the corrected debt
count. Update tests/test_bridge_client.py:1049 to provide a count such as 1
instead of True, and update tests/test_export_bridge.py:1057-1065 to assert the
expected integer count for both _owes_replace_all() and the provider.

---

Outside diff comments:
In `@bridge-node/test/endpoint-map.test.ts`:
- Around line 351-379: Remove the redundant “serves a commissioned bridge with a
usable map” test from the refuseReasonFor cases and retain the commissioned
no-map upgrade-path assertion. Preserve the distinction between missing and
usable endpoint maps in assertEndpointIdentity, where endpointMap.present
controls whether bootstrapEndpointMap() is called; do not represent that
distinction through identical refuseReasonFor inputs.

In `@tests/fakes.py`:
- Around line 146-182: The nested path in _run_to_completion must not execute
coroutines on a new worker loop when they await objects owned by the caller’s
event loop. Update RecordingRuntime.submit and its completion flow to run
loop-bound submissions on the owning loop, or explicitly reject unsupported
loop-bound work in the fake; add coverage for asyncio.Future, create_future, and
transport-callback cases.

---

Duplicate comments:
In `@bridge-node/src/node.ts`:
- Around line 258-283: Move noteCommissioningWitness() in
assertEndpointIdentity() to execute after the initial refusal check but before
refuseReasonFor evaluates the endpoint state, so commissioned bridges record
commissionedAt even when refusing for mapUnreadable. Preserve the existing
bootstrap behavior and refusal handling, including the early return for
this.#refusal.

---

Nitpick comments:
In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/export_bridge.py:
- Around line 1185-1213: Latch broad-catch read-failure warnings instead of
logging once per allow-list entry on each reconnect. Move the warning
responsibility from _indigo_device into an ExportBridge-owned helper/state,
suppress repeated warnings while reads continue failing, and clear the latch
after the first successful device read; preserve None for genuinely absent
device IDs.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ae383cb-313f-4fb4-9d52-1ab78c1ff43f

📥 Commits

Reviewing files that changed from the base of the PR and between 35c8cf4 and 6208a30.

📒 Files selected for processing (34)
  • CLAUDE.md
  • bridge-node/package.json
  • bridge-node/src/endpoint-map.ts
  • bridge-node/src/main.ts
  • bridge-node/src/node.ts
  • bridge-node/src/protocol.ts
  • bridge-node/src/storage.ts
  • bridge-node/src/ws-server.ts
  • bridge-node/test/endpoint-map.test.ts
  • bridge-node/test/fixture-shapes.ts
  • bridge-node/test/main.test.ts
  • bridge-node/test/persistence.test.ts
  • bridge-node/test/protocol.test.ts
  • bridge-node/test/storage.test.ts
  • bridge-node/test/stub-bridge.ts
  • docs/BRIDGE_PROTOCOL.md
  • docs/HANDOVER.md
  • indigo-matter.indigoPlugin/Contents/Info.plist
  • indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_protocol.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/export_handlers.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/fabric_backup.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py
  • tests/fakes.py
  • tests/fixtures/bridge_protocol/frames.json
  • tests/test_bridge_client.py
  • tests/test_bridge_protocol_frames.py
  • tests/test_export_bridge.py
  • tests/test_export_handlers.py
  • tests/test_export_menu.py
  • tests/test_export_wiring.py
  • tests/test_fabric_backup.py
🚧 Files skipped from review as they are similar to previous changes (13)
  • indigo-matter.indigoPlugin/Contents/Info.plist
  • bridge-node/package.json
  • bridge-node/test/storage.test.ts
  • bridge-node/src/main.ts
  • bridge-node/test/fixture-shapes.ts
  • bridge-node/src/ws-server.ts
  • tests/fixtures/bridge_protocol/frames.json
  • docs/HANDOVER.md
  • bridge-node/test/stub-bridge.ts
  • bridge-node/src/protocol.ts
  • tests/test_export_handlers.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/fabric_backup.py
  • tests/test_fabric_backup.py

Comment on lines +259 to +301
describe("a write that did not land (E5 B1/B2)", () => {
it("never claims driftChecked over a RAM-only baseline", () => {
// ⊗ `#checked` used to be set unconditionally. A `driftChecked: true`
// over a map that never reached disk asserts an all-clear about a
// baseline the next restart cannot find — so that device's reallocated
// number gets recorded as the truth and the real renumbering is never
// reported by anybody, ever.
const dir = storage();
const store = new EndpointMapStore(dir);
store.load();
chmodSync(dir, 0o500);
try {
assert.deepEqual(store.check([{ uniqueId: "indigo-1", endpointNumber: 2 }]), []);
assert.equal(store.checked, false, "the write failed, so nothing durable was checked");
} finally {
chmodSync(dir, 0o700);
}

// And the retry: the next check re-attempts the write even though it
// adds nothing, because the map still owes the disk what it holds.
assert.deepEqual(store.check([{ uniqueId: "indigo-1", endpointNumber: 2 }]), []);
assert.equal(store.checked, true);
assert.deepEqual(mapFileIn(dir).endpoints, { "indigo-1": 2 });
});

it("tells the caller a rebuild did not persist, and warns", () => {
const dir = storage();
const store = new EndpointMapStore(dir);
store.load();
chmodSync(dir, 0o500);
try {
assert.equal(store.rebuild([{ uniqueId: "indigo-1", endpointNumber: 2 }]), false);
// §4.3 `warnings`: the node's log is a stdout nobody is watching in
// this milestone, so a failed write has to reach get_status.
assert.equal(store.warnings.length, 1);
assert.match(store.warnings[0]!, /Could not write the endpoint map/);
} finally {
chmodSync(dir, 0o700);
}
assert.equal(store.rebuild([{ uniqueId: "indigo-1", endpointNumber: 2 }]), true);
assert.deepEqual(store.warnings, [], "a warning is current, not historical");
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Both new write-failure tests break when the suite runs as root.

chmodSync(dir, 0o500) at Lines 269 and 288 removes write permission for ordinary users only. Root bypasses the mode bits, so the write succeeds. Then store.checked is true and the assertion at Line 272 fails, and store.rebuild(...) returns true and the assertion at Line 290 fails. Container CI images commonly run as root, so these are false failures rather than silently passing tests.

This is the same mechanism raised on the earlier check write-failure test. One injected writer seam on EndpointMapStore would fix all three deterministically. A uid skip is the smaller change but proves nothing on the machine where it matters.

💚 Minimal guard, if the seam is deferred
-describe("a write that did not land (E5 B1/B2)", () => {
-    it("never claims driftChecked over a RAM-only baseline", () => {
+// Root bypasses the mode bits below, so the read-only directory is not a write
+// failure for that user and every assertion here would run against a write that
+// actually succeeded.
+const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
+
+describe("a write that did not land (E5 B1/B2)", { skip: asRoot ? "file modes do not restrict root" : false }, () => {
+    it("never claims driftChecked over a RAM-only baseline", () => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/test/endpoint-map.test.ts` around lines 259 - 301, Add an
injectable writer seam to EndpointMapStore and use it in the persistence path
exercised by check and rebuild, allowing tests to deterministically simulate
write failure without relying on directory permissions or process privileges.
Update all three write-failure tests to inject a failing writer, then retain the
assertions that check leaves checked false and rebuild returns false with a
warning before verifying successful retries.

Comment thread bridge-node/test/persistence.test.ts
Comment thread indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py Outdated
Three whole-PR expert reviews (code correctness, silent failures, test
coverage via 136 mutations — 37 survived). The survivors share one shape,
worth naming because it will recur: the helper is covered, the real caller
is not. `fabric_backup` has a thorough suite and its only call site was
unpinned; `_resubscribe_tick` had four tests and nothing called it from
`_health_tick`; the queue counters were set by hand and nothing drove them
through a dispatch. Deleting the wiring left the suite green every time.

The five that would have hurt a real user:

* The confirm gates were inert IN THE TESTS, live in production. Deleting
  either recovery menu's confirm check kept the suite green, and with a
  live client the destructive action really ran unticked — the tests never
  set `export_bridge`, so the connection check below filled the same errors
  key. Now asserted against the client, which is the only real question.
* `replace_all_provider` was a bool where a count is required. The client
  declares `Callable[[], int]` and sizes the discharge attach's deadline
  from it; `int(True) == 1` gave every discharge the 8s floor, so an
  80-accessory un-export timed out mid-reconcile and retried forever — the
  regression the formula exists to prevent. The wrapper is gone.
* The §4.3 warnings channel had no reader. `get_status` had zero
  production callers while four docstrings and the protocol said it was
  polled, so the three faults that happen after the attach reached the
  user as nothing. `health_tick` now polls it.
* The identity refusal lasted exactly one restart: the next start read the
  quarantined-aside file as a first run and minted a new SerialNumber.
  `identityProblem` now also refuses on a leftover quarantine marker, and
  `identityUnreadable` gets its own remedy text — a rebuild cannot fix it.
* `rebuild_endpoint_map` reported the opposite of what happened. The node
  writes the map and stops refusing before answering, so a failed
  re-attach was reported as "unchanged and still refusing", left recovery
  set, and invited the user to repeat an irreversible operation.

Also: the un-export debt accumulates and is only cleared by a watched
discharge (an add-then-remove cycle could erase it); the resubscribe
watchdog says so when it gives up; `noteFabrics` no longer swallows three
failures at once; ignored `seed`/`discard`/witness returns are checked;
`driftChecked` no longer goes true off an empty comparison; corrupt-map
quarantine covers `persist()` and is timestamped; the rebuild menu
requires a refusal to be in force.

Docs: PRD §7 split into the four real cases and §4.3's drift wording
corrected; BRIDGE_PROTOCOL §1.1 gains the reason→remedy table and §4.3
makes polling mandatory. Version 2026.8.0 (two user-facing menu items plus
a persistence subsystem).

Suites: 2056 Python (from 2025), 344 TS (from 316). pylint 9.40.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S
@simons-plugins
simons-plugins merged commit 57647c5 into main Aug 5, 2026
2 of 3 checks passed
@simons-plugins
simons-plugins deleted the feat/e5-persistence-hardening branch August 5, 2026 19:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
bridge-node/src/endpoint-map.ts (1)

404-417: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Set #quarantined only after the copy succeeds.

Line 408 marks the file quarantined before copyFileSync runs. If the copy fails, the flag stays true, so no later attempt is made. The source file still exists at that moment, and persist retries on the next check while #dirty is set — that retry is the one remaining chance to preserve the bytes. Moving the assignment into the success path keeps the duplicate-suppression intent and restores the retry.

🛡️ Proposed fix
-        this.#quarantined = true;
         const file = join(this.storagePath, ENDPOINT_MAP_FILE);
         const target = `${file}.corrupt-${this.now().toISOString().replace(/[:.]/g, "-")}`;
         try {
             copyFileSync(file, target);
+            this.#quarantined = true;
             this.log(`Unusable endpoint map copied to ${target} before it is overwritten`);
         } catch (error) {
             this.log(`Could not copy the unusable endpoint map aside: ${describeError(error)}`);
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/src/endpoint-map.ts` around lines 404 - 417, Update
quarantineUnusable so `#quarantined` is assigned true only after copyFileSync
succeeds. Keep the early-return duplicate suppression and existing success
logging unchanged; leave the flag unset when copying fails so later
persist/check retries can attempt preservation again.
♻️ Duplicate comments (1)
bridge-node/src/node.ts (1)

261-286: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

A commissioned bridge that refuses never records the commissioning witness, and the rebuild does not repair it.

noteCommissioningWitness() runs only when reason === undefined (Line 276). On a commissioned bridge with an unusable endpoint-map.json, refuseReasonFor answers mapUnreadable, so the witness is never stamped. rebuildEndpointMap then clears the refusal, but its witness branch at Line 886 runs only when !isCommissioned, so it neither sets nor clears commissionedAt. The bridge ends up serving while paired with no witness on disk.

That disarms the §7 check the witness exists for: on a later boot where matter.js storage is gone, refuseReasonFor sees commissioned: false and commissionedAt: undefined, returns undefined, and the node serves — re-creating every accessory in every paired ecosystem without refusing.

fabricsChanged at Line 209 does not close the gap. It fires on a fabric change, not for fabrics that already exist when the node starts, so a bridge that simply stays paired never reaches it.

Record the witness once the fabric table is readable, before the refusal decision, and keep the write failure non-fatal.

🛡️ Proposed fix
     private assertEndpointIdentity(): void {
         if (this.#refusal !== undefined) {
             this.refuse(this.#refusal);
             return;
         }
         const commissioned = this.server.lifecycle.isCommissioned;
+        // Before the decision: the witness records "endpoint numbers now
+        // matter to somebody", which is true whether or not we go on to
+        // refuse — and a refusal that suppresses it disarms the §7 check for
+        // every later boot.
+        this.noteCommissioningWitness();
         const reason = refuseReasonFor({
             commissioned,
             mapProblem: this.#endpointMap.problem,
             commissionedAt: this.identity.commissionedAt,
         });
         if (reason === undefined) {
             if (commissioned && !this.#endpointMap.present) {
                 this.bootstrapEndpointMap();
             }
-            this.noteCommissioningWitness();
             return;
         }

Note that refuseReasonFor reads commissionedAt only on the un-commissioned branch, so stamping it first does not change this boot's decision.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/src/node.ts` around lines 261 - 286, Update
assertEndpointIdentity to call noteCommissioningWitness once the fabric table is
readable, before evaluating refuseReasonFor, while keeping witness-write
failures non-fatal. Ensure this also occurs when the endpoint map is unusable,
and remove or adjust the rebuildEndpointMap witness logic so a commissioned
bridge preserves the recorded commissionedAt witness when refusal is cleared.
🧹 Nitpick comments (5)
tests/test_plugin_behaviour.py (1)

562-563: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a Pylint-compliant test name.

Line [562] contains uppercase BRIDGE in a function identifier. Rename it to test_menu_export_fabric_backup_archives_the_bridge_storage_too, unless the repository Pylint configuration explicitly permits uppercase acronyms. Otherwise, Pylint can report invalid-name.

As per coding guidelines, Python files must follow the project's Pylint standards.

Proposed rename
-def test_menu_export_fabric_backup_archives_the_BRIDGE_storage_too(
+def test_menu_export_fabric_backup_archives_the_bridge_storage_too(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_plugin_behaviour.py` around lines 562 - 563, Rename the test
function test_menu_export_fabric_backup_archives_the_BRIDGE_storage_too to the
Pylint-compliant lowercase form
test_menu_export_fabric_backup_archives_the_bridge_storage_too, unless the
repository configuration explicitly allows uppercase acronyms.

Source: Coding guidelines

tests/test_bridge_client.py (2)

787-790: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Discard the unused fake binding.

Ruff reports RUF059 here because fake is never read in this test. If RUF059 is enforced in CI, this fails lint.

♻️ Proposed fix
-            fake, client = self._recovering(mock_logger, overrides={
+            _, client = self._recovering(mock_logger, overrides={
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_bridge_client.py` around lines 787 - 790, Remove the unused fake
binding from the _recovering call in this test, retaining only the client value
that is read and preserving the existing overrides configuration.

Source: Linters/SAST tools


1352-1372: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the module's run helper for consistency.

Every other test in this file drives its coroutine through run(...). This helper calls asyncio.run directly. If run performs any shared setup or cleanup, this test does not get it.

♻️ Proposed fix
-        asyncio.run(scenario())
+        run(scenario())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_bridge_client.py` around lines 1352 - 1372, Update the _sizes test
helper to execute scenario through the module’s run helper instead of calling
asyncio.run directly, preserving the existing coroutine setup and assertions.
tests/test_export_wiring.py (1)

631-660: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drive the tick counts from the plugin constants.

Both tests hard-code 4 and 4 * 10, while _resubscribe_tick reads RESUBSCRIBE_TICKS and MAX_RESUBSCRIBE_ATTEMPTS. If RESUBSCRIBE_TICKS changes, test_the_re_issues_are_a_minute_apart_not_back_to_back runs fewer ticks than one window and passes without ever reaching the re-issue it exists to pin.

♻️ Proposed refactor
         subscribe = self._exporting(plug, mock_indigo_base)
-        for _ in range(4):
+        for _ in range(plugin_mod.RESUBSCRIBE_TICKS):
             plug._resubscribe_tick()
         assert subscribe.call_count == 1
         plug._resubscribe_tick()          # one tick later — must NOT re-issue
         assert subscribe.call_count == 1

Add plugin_mod to the test signature, and apply the same substitution to the give-up test's loop bound.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_export_wiring.py` around lines 631 - 660, Update both tests,
test_the_re_issues_are_a_minute_apart_not_back_to_back and
test_giving_up_says_so_once_and_names_the_consequence, to accept plugin_mod and
derive loop counts from plugin_mod.RESUBSCRIBE_TICKS and
plugin_mod.MAX_RESUBSCRIBE_ATTEMPTS instead of hard-coded 4 and 4 * 10 values.
Preserve the existing assertions and timing semantics while ensuring the tests
reach the configured reissue and give-up thresholds.
tests/test_export_bridge.py (1)

1415-1479: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use monkeypatch.setattr for the handler patches.

Both tests replace handler.dispatch by hand and restore it in a finally. handler is a module-level singleton from export_handlers.HANDLERS, so a failure between the assignment and the try leaks the patched dispatch into every later test in the session. monkeypatch restores the attribute even then, and it removes the real_dispatch/try/finally boilerplate.

The same tests also use a local import export_handlers, while the sibling test at line 906 reaches the module through bridge_mod.export_handlers. Pick one form for both.

♻️ Proposed refactor for lines 1430-1444
-        # The command half-applies: dispatch raises, but the relay really moved.
-        import export_handlers
-        handler = export_handlers.handler_for("onOffLight")
-        real_dispatch = handler.dispatch
-
-        def boom(command, args, device, options):
-            device.onState = True                # the device DID move
-            raise RuntimeError("driver blew up after switching")
-
-        handler.dispatch = boom
-        try:
-            h.bridge.on_command(
-                bridge_protocol.parse_command(FRAMES["command_on_off"]["data"]))
-        finally:
-            handler.dispatch = real_dispatch
+        # The command half-applies: dispatch raises, but the relay really moved.
+        handler = bridge_mod.export_handlers.handler_for("onOffLight")
+
+        def boom(command, args, device, options):
+            device.onState = True                # the device DID move
+            raise RuntimeError("driver blew up after switching")
+
+        monkeypatch.setattr(handler, "dispatch", boom)
+        h.bridge.on_command(
+            bridge_protocol.parse_command(FRAMES["command_on_off"]["data"]))

Add monkeypatch to both test signatures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_export_bridge.py` around lines 1415 - 1479, Update both tests,
test_a_CORRECTION_folds_into_the_snapshot_it_just_pushed and
test_a_correction_is_gated_on_a_LIVE_client_like_every_other_push, to accept
pytest’s monkeypatch fixture and use monkeypatch.setattr on the shared
handler.dispatch instead of manual save/restore with try/finally. Use the same
export_handlers access style in both tests, matching the neighboring
bridge_mod.export_handlers usage, and remove the real_dispatch and restoration
boilerplate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bridge-node/test/endpoint-map.test.ts`:
- Around line 330-336: Guard both chmod-based write-failure tests in
bridge-node/test/endpoint-map.test.ts:330-336 and
bridge-node/test/endpoint-map.test.ts:352-358 by accepting the test context
argument in “clears the persistence warnings a successful discard has settled”
and “drops a stale warning rather than carrying it across a re-read,” then
skipping when process.getuid?.() === 0 before each chmodSync call.

In `@docs/HANDOVER.md`:
- Around line 101-104: Update the M11 documentation to replace “retained
endpoint-number allocations” with “retained endpoint-map entries,” clarifying
that rebuilding the witness can discard non-live map entries but does not affect
matter.js endpoint-number allocations or endpoint restoration after re-addition.

In `@tests/test_bridge_client.py`:
- Around line 840-876: Extend rebuild_endpoint_map to handle
asyncio.TimeoutError from the re-attach request the same way as a
BridgeProtocolError after a successful rebuild: clear client.recovery, preserve
and return the rebuild status, and log the re-attach refusal warning instead of
propagating the exception. Update the test scenario around rebuild_endpoint_map
to simulate a timed-out attach response and verify these outcomes.

---

Outside diff comments:
In `@bridge-node/src/endpoint-map.ts`:
- Around line 404-417: Update quarantineUnusable so `#quarantined` is assigned
true only after copyFileSync succeeds. Keep the early-return duplicate
suppression and existing success logging unchanged; leave the flag unset when
copying fails so later persist/check retries can attempt preservation again.

---

Duplicate comments:
In `@bridge-node/src/node.ts`:
- Around line 261-286: Update assertEndpointIdentity to call
noteCommissioningWitness once the fabric table is readable, before evaluating
refuseReasonFor, while keeping witness-write failures non-fatal. Ensure this
also occurs when the endpoint map is unusable, and remove or adjust the
rebuildEndpointMap witness logic so a commissioned bridge preserves the recorded
commissionedAt witness when refusal is cleared.

---

Nitpick comments:
In `@tests/test_bridge_client.py`:
- Around line 787-790: Remove the unused fake binding from the _recovering call
in this test, retaining only the client value that is read and preserving the
existing overrides configuration.
- Around line 1352-1372: Update the _sizes test helper to execute scenario
through the module’s run helper instead of calling asyncio.run directly,
preserving the existing coroutine setup and assertions.

In `@tests/test_export_bridge.py`:
- Around line 1415-1479: Update both tests,
test_a_CORRECTION_folds_into_the_snapshot_it_just_pushed and
test_a_correction_is_gated_on_a_LIVE_client_like_every_other_push, to accept
pytest’s monkeypatch fixture and use monkeypatch.setattr on the shared
handler.dispatch instead of manual save/restore with try/finally. Use the same
export_handlers access style in both tests, matching the neighboring
bridge_mod.export_handlers usage, and remove the real_dispatch and restoration
boilerplate.

In `@tests/test_export_wiring.py`:
- Around line 631-660: Update both tests,
test_the_re_issues_are_a_minute_apart_not_back_to_back and
test_giving_up_says_so_once_and_names_the_consequence, to accept plugin_mod and
derive loop counts from plugin_mod.RESUBSCRIBE_TICKS and
plugin_mod.MAX_RESUBSCRIBE_ATTEMPTS instead of hard-coded 4 and 4 * 10 values.
Preserve the existing assertions and timing semantics while ensuring the tests
reach the configured reissue and give-up thresholds.

In `@tests/test_plugin_behaviour.py`:
- Around line 562-563: Rename the test function
test_menu_export_fabric_backup_archives_the_BRIDGE_storage_too to the
Pylint-compliant lowercase form
test_menu_export_fabric_backup_archives_the_bridge_storage_too, unless the
repository configuration explicitly allows uppercase acronyms.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5d9fc004-d662-45a7-9353-18b7c990b7a4

📥 Commits

Reviewing files that changed from the base of the PR and between 6208a30 and eded0ab.

📒 Files selected for processing (24)
  • bridge-node/src/endpoint-map.ts
  • bridge-node/src/node.ts
  • bridge-node/src/storage.ts
  • bridge-node/src/ws-server.ts
  • bridge-node/test/endpoint-map.test.ts
  • bridge-node/test/main.test.ts
  • bridge-node/test/persistence.test.ts
  • bridge-node/test/protocol.test.ts
  • bridge-node/test/storage.test.ts
  • docs/BRIDGE_PROTOCOL.md
  • docs/HANDOVER.md
  • docs/PRD-indigo-matter-export.md
  • indigo-matter.indigoPlugin/Contents/Info.plist
  • indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_protocol.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py
  • tests/fakes.py
  • tests/test_bridge_client.py
  • tests/test_export_bridge.py
  • tests/test_export_menu.py
  • tests/test_export_wiring.py
  • tests/test_plugin_behaviour.py
🚧 Files skipped from review as they are similar to previous changes (9)
  • indigo-matter.indigoPlugin/Contents/Info.plist
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_protocol.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml
  • bridge-node/test/protocol.test.ts
  • tests/fakes.py
  • bridge-node/src/ws-server.ts
  • docs/BRIDGE_PROTOCOL.md
  • tests/test_export_menu.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py

Comment on lines +330 to +336
chmodSync(dir, 0o500);
try {
store.check([{ uniqueId: "indigo-1", endpointNumber: 2 }]);
assert.equal(store.warnings.length, 1, "the failed write must warn first");
} finally {
chmodSync(dir, 0o700);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Two chmod-based write-failure tests have no effective-uid guard. Both tests use chmodSync(dir, 0o500) to force a write failure. Root bypasses the mode bits, so the write succeeds, no warning is recorded, and the "must warn first" assertion fails. bridge-node/test/persistence.test.ts already guards the equivalent tests with process.getuid?.() === 0.

  • bridge-node/test/endpoint-map.test.ts#L330-L336: take the test context argument in "clears the persistence warnings a successful discard has settled" and skip when process.getuid?.() === 0, before the chmodSync at Line 330.
  • bridge-node/test/endpoint-map.test.ts#L352-L358: apply the same skip in "drops a stale warning rather than carrying it across a re-read", before the chmodSync at Line 352.
📍 Affects 1 file
  • bridge-node/test/endpoint-map.test.ts#L330-L336 (this comment)
  • bridge-node/test/endpoint-map.test.ts#L352-L358
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge-node/test/endpoint-map.test.ts` around lines 330 - 336, Guard both
chmod-based write-failure tests in bridge-node/test/endpoint-map.test.ts:330-336
and bridge-node/test/endpoint-map.test.ts:352-358 by accepting the test context
argument in “clears the persistence warnings a successful discard has settled”
and “drops a stale warning rather than carrying it across a re-read,” then
skipping when process.getuid?.() === 0 before each chmodSync call.

Comment thread docs/HANDOVER.md
Comment on lines +101 to +104
- **M11:** the rebuild menu gated only on `client.connected`. Run against a
healthy node it silently discarded the retained endpoint-number allocations of
every non-live export — the ones that make re-adding a device restore the same
accessory. It now requires a refusal to be in force, and the dialog says so.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Clarify which component preserves endpoint numbers.

matter.js owns persisted endpoint-number allocation. endpoint-map.json is an independent report-only witness. Rebuilding the witness can discard non-live map entries, but it does not discard the allocations that preserve a device's endpoint after re-addition.

Replace “retained endpoint-number allocations” with “retained endpoint-map entries” or name the matter.js allocation store. This prevents operators from treating rebuild_endpoint_map as an endpoint-allocation repair.

Based on the report-only endpoint-map rule and the architecture contract in docs/PRD-indigo-matter-export.md, Lines 108-116 and 219-221.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/HANDOVER.md` around lines 101 - 104, Update the M11 documentation to
replace “retained endpoint-number allocations” with “retained endpoint-map
entries,” clarifying that rebuilding the witness can discard non-live map
entries but does not affect matter.js endpoint-number allocations or endpoint
restoration after re-addition.

Source: Coding guidelines

Comment on lines +840 to +876
def test_a_rebuild_whose_re_attach_FAILS_is_still_a_completed_rebuild(self, mock_logger):
"""⊗ The node writes the map and stops refusing BEFORE it answers us.

Letting a re-attach failure after that propagate made the menu report
"the bridge node is unchanged and still refusing to export" — both
clauses false — leave `recovery` set so every state push went on being
dropped, and invite the user to repeat an operation that duplicates
accessories in every paired ecosystem.
"""
async def scenario():
answers = {bridge_protocol.CMD_ATTACH: error_response(
bridge_protocol.ERR_ENDPOINT_MAP_INVALID, "map unreadable")}

def responder(frame):
command = frame["command"]
if command == bridge_protocol.CMD_REBUILD_ENDPOINT_MAP:
# The rebuild worked; the re-attach after it does not.
answers[bridge_protocol.CMD_ATTACH] = error_response(
bridge_protocol.ERR_INTERNAL, "node is busy")
body = {**RESPONSES, **answers}.get(command)
return [{**body, "message_id": frame["message_id"]}]

fake = _fake(responder=responder)
client = _client(mock_logger, fake)
task = asyncio.create_task(client.run())
await settle(lambda: client.recovery)

status = await client.rebuild_endpoint_map()

assert status is not None, "the rebuild's own report, not an exception"
assert not client.recovery, (
"the node has stopped refusing; leaving this set drops every push")
assert not client.attached
assert "re-attaching was refused" in logged(mock_logger, "warning")
await client.close()
task.cancel()
run(scenario())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the exception type raised on a request timeout and what rebuild_endpoint_map catches.
rg -n -C10 -P '\basync def _request_frame\s*\(' --glob '*.py'
rg -n -C6 -P 'TimeoutError|_triage_rebuilt_reattach' --glob '*.py'

Repository: simons-plugins/indigo-matter

Length of output: 166


🏁 Script executed:

#!/bin/bash
set -u

echo "Files:"
git ls-files | rg '(^|/)(tests/test_bridge_client|bridge_client|fakes)\.py$|py$' | sed -n '1,120p'

echo
echo "Search relevant symbols:"
rg -n -C4 'rebuild_endpoint_map|attach\(\)|_triage_rebuilt_reattach|BridgeProtocolError|_request_frame|CMD_ATTACH|ERR_INTERNAL' . --glob '*.py' | sed -n '1,240p'

Repository: simons-plugins/indigo-matter

Length of output: 26770


🏁 Script executed:

#!/bin/bash
set -u

echo "bridge_client.py outline:"
ast-grep outline 'indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py' --view compact | sed -n '1,220p'

echo
echo "bridge_client.py relevant sections:"
sed -n '460,550p' 'indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py' | cat -n
sed -n '130,175p' 'indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py' | cat -n
sed -n '260,290p' 'indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py' | cat -n

echo
echo "ws_json_client.py request timeout handling:"
sed -n '440,510p' 'indigo-matter.indigoPlugin/Contents/Server Plugin/ws_json_client.py' | cat -n

Repository: simons-plugins/indigo-matter

Length of output: 13956


Cover re-attach timeout in rebuild_endpoint_map.

_request_frame raises asyncio.TimeoutError when an attach answer does not arrive; this test only covers BridgeProtocolError from an error-frame response. A timeout after a successful rebuild should still clear recovery and report the rebuild, not surface as an exception that makes menuRebuildEndpointMap say “the bridge node is unchanged and still refusing.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_bridge_client.py` around lines 840 - 876, Extend
rebuild_endpoint_map to handle asyncio.TimeoutError from the re-attach request
the same way as a BridgeProtocolError after a successful rebuild: clear
client.recovery, preserve and return the rebuild status, and log the re-attach
refusal warning instead of propagating the exception. Update the test scenario
around rebuild_endpoint_map to simulate a timed-out attach response and verify
these outcomes.

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