Skip to content

00c β€” Replica Sets

Scope: every rule this codebase implements in more than one place on purpose, so that changing one copy sends you to the others. A listing, not an argument β€” the reasoning lives at the anchor site.


Why this exists rather than a helper

The obvious fix for duplicated logic is to unify it. That is often wrong here: roughly half the divergence in this repo is deliberate β€” each copy feeds a different consumer, or derives its inputs differently, and forcing agreement would force it in cases where the copies should differ. The ladder is constant > derived > helper > inline exception, and the usual target is a helper β€” but "usual" is not "always".

So a replica set is coordination without unification. It does not merge the copies. It records that they exist, and makes the others findable from any one of them.

The failure it exists to prevent is specific and has already happened. A fix landed in two of three copies of the translation_key rescue; 4381 tests passed, because each copy carries its own passing tests and a green suite proves only that every copy is self-consistent with itself. The third was caught by renaming a live vacuum's entities to German β€” not by any gate.

A green suite cannot see a missing copy. That is the whole argument for this file.


How a set is recorded

An RN notation anchor (design/shipped/notation-anchors.md) is declared once at the set's natural primary β€” the shared artifact the copies revolve around, or the copy whose comment carries the reasoning β€” and referenced from every other member.

# anchor: RNxxxxxx  <what the rule is> β€” the replica set     ← exactly one
# REPLICA RNxxxxxx β€” <the twin lives at …>                   ← one per other copy

python scripts/doc_anchor.py --show RNxxxxxx lists every site. --check enforces that the declaration is unique.

⚠ The declaration must live in SOURCE, not here. doc_anchor.py scans custom_components/, scripts/, src/ and harness/ for declarations; documents can only reference. This file is therefore an index over anchors that live in code β€” the same relationship 00b has with its invariants.

Mint with python scripts/doc_anchor.py --mint RN.


The sets

RNF2RCXP β€” translation_key rescue Β· 3 copies

The rescue function is shared; the decision to call it is written out three times, and that decision is what must agree.

Copy Feeds
entity_resolve.resolve_declared_entities the adapter's declared entities map
capabilities._rescue_maintenance_source maintenance sources
capabilities.augment_candidates_from_device the roles detect_capabilities probes

Not unified because each takes its wanted-key from a different place and feeds a different consumer. Declared at adapters/entity_resolve.py::rescue_by_translation_key.

History: ef810519 fixed two; 35ce560f fixed the third, ten hours later.

RNZM4AYY β€” most-specific-declaration ownership test Β· 3 copies

One rule: a candidate belongs to the declaration that explains the most of its name. The longest declared suffix claims a sibling exclusively, so _cleaning_area cannot swallow _total_cleaning_area.

Copy Applies to "Most" means
entity_resolve.py (inside resolve_declared_entities) the declared entity map longest suffix
capabilities.py (inside augment_candidates_from_device) the probe candidate lists longest suffix
entity_resolve.py::tokens_owned_elsewhere the button token sets proper superset

The third copy is the one that shows why this is a replica set rather than a helper: it is the identical rule over a different vocabulary β€” set containment where the others use string containment β€” so no single function can serve all three, and the failure is not that they duplicate but that they can silently disagree.

If the first two disagree, a role resolves one way through the declared map and another through the probe, and the wrong one binds a lifetime counter where a per-run value belongs. On live hardware that is 2.9 mΒ² against 11,814 mΒ², and nothing throws. The third was missing entirely until issue #49: dry_mop's tokens are a subset of stop_dry_mop's button id, so it matched two siblings, abstained, and a user lost a working control.

Declared at adapters/entity_resolve.py (the copy whose comment carries the reasoning).

RNWQ82XZ β€” consult the save-refusal check before closing Β· 2 handlers

The room editor's Save is bound on two different roots, and each must ask _roomEditorSaveWasRejected before closing the modal.

Copy Root
src/bindings/room-editor.js::_bindRoomEditorSave the main shadow root
src/bindings/index.js::bindModalHostEvents the detached modal host

The check itself is already shared β€” this set is the OBLIGATION TO CALL IT, the same shape as RNF2RCXP, where the rescue is one function and the decision to invoke it is written out three times. A handler that forgets to ask is indistinguishable from one that asked and got a yes: the modal closes, and a backend refusal (invalid_access_graph) reverts silently on the next snapshot.

Already drifted once before it was marked β€” the modal-host copy never gained setSkipRefreshOnClose. That is what moved it from suspected to confirmed.

Declared at src/bindings/room-editor.js::_roomEditorSaveWasRejected.

RNXX8X11 β€” the room-fill palette SIZE Β· 2 copies (of 3 sites)

How many room-fill colours exist. Three places care; only two can drift.

Site Kind
src/cards/map-room-color.js::ROOM_FILL_N derived β€” ROOM_FILL_PALETTE.length, cannot be wrong
src/theme-tokens/map.js hand-enumerated --evcc-room-fill-N token list β€” the copy
src/state/theme.js derived: imports the palette and seeds in a loop β€” NOT a member

The third row is the instructive one: it faces the same obligation and is immune to it, because it imports rather than restates β€” which is what the second row could have done. Currently 12 and 12.

The failure is silent in both directions. A 13th colour renders on the map with no picker in the theme editor, so it can never be themed; a removed one leaves a swatch for a token nothing reads. Neither errors, and nothing compares the counts.

Declared at src/cards/map-room-color.js::ROOM_FILL_N (the source of truth).


RNJ9YQF7 β€” the run-plan display helpers Β· 2 copies Γ— 7 functions

Every module-level helper the run-plan/dashboard display path uses, defined twice and byte-identical in both.

Site Kind
planning/run_plan.py primary β€” carries the reasoning and the audit header block
core/manager.py the copy β€” same seven functions, verified identical 2026-08-18
maintenance/manager.py third copy of _display_label, missed on first recording

This entry was wrong when written, and the method caught it. It was minted by hand after diffing two files, and said "2 copies". _relation_hunt.py shape found _display_label in a THIRD β€” maintenance/manager.py β€” 1.6 s of machine time against an hour of careful reading. An under-recorded set is a wrong claim, not a partial one: someone fixing both listed members would believe they were done.

Disposition: SPLIT, and the split matters. Four members (_safe_int, _safe_float, _iso_now, _display_label) are leaf utilities β€” dissolvable safely, a shared util module adds nothing any subsystem lacks. Three (_profile_name_label, _settings_profile_display, _room_surface_labels) carry profile / water / carpet vocabulary β€” hoisting those into a shared module puts domain logic on a seam, which is manager-gravity by another route. Those are dissolvable but shouldn't.

_display_label, _iso_now, _profile_name_label, _safe_float, _safe_int, _settings_profile_display, _room_surface_labels. Neither file imports the other.

This set has already failed, and the failure is on record as a success. A5-PP-RP-8 β€” the water-off suppression comparing the literal "off" instead of the brand's no-water value β€” is live in both copies today, while the ledger recorded it closed by RP-025, a packet whose commits never touched either file. So the set demonstrates the exact RNF2RCXP shape and the mis-attribution shape at once.

A green suite cannot see the second copy. Both are exercised, each is self-consistent, and no test compares them. The verification that found this was a whitespace-normalised diff of the two files' shared function names β€” cheap, and worth repeating rather than trusting.

Unification candidate, deliberately not done here. Seven identical functions is the helper rung of constant > derived > helper > inline exception, not a deliberate divergence β€” there is no consumer-specific reason for two copies. Recording the set is coordination now; extracting a shared module is the real fix and is a code change, not a tagging one.

Declared at planning/run_plan.py::_profile_name_label (immediately above the block).


RNGSVFKN β€” clean_times is deliberately UNBOUNDED Β· 3 schemas

Site Kind
services/job_control.py (start_zone_clean) primary β€” carries the reasoning
mapping/mapping_services.py Γ—2 the copies β€” same Range(min=1), no max

All three are vol.All(vol.Coerce(int), vol.Range(min=1)). The absent upper bound is the decision, not an omission: a schema cannot see which vacuum the call targets, so the real per-brand ceiling is enforced at dispatch β€” dispatch/manager.py clamps to zone_passes_max on one branch and normalizes to 1 where the brand declares no zone-repeat support on the other.

The drift that matters is someone "fixing" one. Adding a max to a single schema would make that path refuse values the other two accept and dispatch would have clamped anyway β€” a per-entry-point difference in what the same field means. If one gains a bound, all three must, and the dispatch clamp becomes redundant rather than authoritative.

Declared at services/job_control.py's zone-clean schema.


RNSERK29 β€” the dock EVENT-TYPE keys Β· 3 sites, one file

last_mop_wash / last_dry_start / last_dust_empty, hand-written three times in dock/manager.py (the trigger-vocabulary block and two count-mapping dicts).

These are framework keys, not brand words β€” core owning them is correct ([[feedback_eufy_is_not_the_default]]: core owns KEYS, never a brand's WORDS), so "derive them from the adapter" would be the wrong fix and is worth stating because the original finding (A6-DIAG-8) proposed exactly that. The state STRINGS these keys look up are correctly adapter-derived via _vocab_set, with a documented no-brand-fallback.

What is actually wrong is the copying, and a module-level constant is the real fix β€” the constant rung, the top of the ladder. This records the set until then.

Declared at dock/manager.py's trigger-vocabulary block.


Finding sets: four ways to ask, and three axes to judge

Derived 2026-08-18 and tested before being written down β€” five mixed candidates run through it, three correctly rejected, one new set found, one rule refined by the run.

The question is not "where is the duplication"

It is how is this code related β€” and there is no single way to ask. Each mode is blind by construction, which is why no one of them is sufficient:

ask finds blind to
shape β€” structural fingerprint copies, the day they are made cross-language; absences; derived-vs-enumerated
history β€” git co-change cross-language and cross-artifact obligations copies never yet co-edited β€” the dormant ones
data β€” who writes the same durable key multi-writer state coupling anything not routed through the store
vocabulary β€” shared literals not built β€” recorded so the gap is visible β€”

shape and history are complementary in risk, not just coverage: a set shape finds and history misses has never been co-edited, so nobody has learned it is coupled, and the first edit is the one that breaks it silently. _display_label Γ—3 was exactly that.

It is not combinatorial β€” fingerprint-and-group is O(N). 1,261 functions in 1.6 s, 794,430 comparisons never performed. Loosening three steps moved cross-file candidates 16 β†’ 58, so over-finding is affordable at this repo's size. That is a fact about 218 files, not about the method.

.claude/notes/_relation_hunt.py runs the first three.

Membership: does changing one OBLIGE changing the others?

Already the register's one question. Two things discharge the obligation, and a discharged obligation means there is no set:

  1. A mechanism already guarantees it. The two built bundles co-change 192 times and are not a set β€” a build moves both. guide-translations.js says "GENERATED β€” do not hand-edit" and is not a set either. The palette's third site "imports rather than restates" and is explicitly not a member.
  2. The consumer genuinely absorbs the change. A tolerant reader is not obliged. But note the trap: a consumer that tolerates silently is the worst case, not the safe one β€” A6-PP-EST-LBL-1 .get()s a key its producer stopped emitting and yields None for every room, forever, with no test failing.

Discharge relocates the risk, it does not delete it. A generator turns a replica obligation into a staleness obligation β€” and check_generated_docs.py's own header is the record of that failing: "Nothing was wrong with the generator. Nothing ran it." Different hazard, different guard.

And obliged-together is not the same as bound-by-a-rule: remove() Γ—4 across the listeners are byte-identical and all bound by [[INT79PB7]], which obliges each to satisfy it independently. That is an IN relation, not an RN one.

Severity: observational or mutation?

Unchanged β€” see below. Mutation sets produce divergent STATE and no test sees it.

Disposition: load-bearing, or dissolvable?

New, and it is the axis that says what to DO. It also decides whether the entry is permanent or is scaffolding awaiting an extraction.

load-bearing cannot be dissolved β€” a runtime boundary (Python ↔ card), an absence (you cannot extract "no upper bound"), or copies that must be able to differ
dissolvable safely the destination is a leaf utility with no domain knowledge; extraction adds a dependency that travels with the subsystem
dissolvable but shouldn't extraction is possible and would put domain logic on a seam that must stay cuttable β€” duplication is the correct terminal state

That third row exists because the register's whole purpose is to make deliberate duplication affordable. Without it duplication is a silent hazard, so the only safe response is to unify β€” and unifying welds. An extraction that makes a subsystem depend on something it did not depend on before is a coupling decision, not a cleanup; it belongs to history/core-minimality.md, not to a tidy-up pass.

A set can split across dispositions. RNJ9YQF7's seven members are four leaf utilities and three functions carrying profile/water/carpet vocabulary β€” same set, opposite correct actions.

Classify after finding, never during

A disposition-blind census is the point. Classify while hunting and you skip candidates that look dissolvable β€” but a dissolvable set is a live hazard until someone actually extracts it, and "we could unify that someday" is precisely how _display_label sat in three files unrecorded.


RNRVXK51 β€” the path-block ACTION vocabulary + its normalizer Β· 2 copies

Site Kind
core/manager.py primary β€” carries the reasoning
jobs/active_job.py the copy β€” frozenset and _normalize_path_block_action, byte-identical

Two levels duplicated at once: the closed vocabulary and the function that validates against it. Nothing discharges it β€” no shared import, no generator, no constant in const.py.

MUTATION class. This decides what the system does when a path is blocked. Add a policy to one site and it is honoured there while the other silently maps it to "event_only" β€” divergent behaviour, no test failing, because each copy is self-consistent.

Disposition: dissolvable safely. The natural destination is a shared constant; both modules already sit inside core and neither gains a dependency it lacks. This is a leaf vocabulary, not domain logic on a cuttable seam.

Found by method, not by reading β€” _relation_hunt.py shape, 2026-08-18, in the same 1.6 s pass that showed RNJ9YQF7 was recorded one member short.

Declared at core/manager.py's _PATH_BLOCK_ACTIONS.


RNY1AHMD β€” the canonical clean-mode ALIAS TABLE Β· 2 copies, 2 languages

Site Kind
profiles/room_profiles.py::canonical_clean_mode primary
src/clean-mode.js the copy β€” alias for alias

Load-bearing. Backend and card are separate languages and cannot share code; the block says so and names its own guard: "pinned to each other by test instead: if you add an alias to one, add it to the other." Severity mutation β€” the fold decides which mode a room dispatches as. ISSUE #48 is what the halves disagreeing cost.


RN4T4MPV β€” run-profile STEP normalization Β· 3 sites, 2 languages

profiles/manager.py::normalize_run_profile_steps (primary), src/state/steps-order.js ::sanitizeStepsForSave, and steps-order.test.mjs which pins the card half.

The card sanitizes before save so the service receives already-clean data. Diverge and the card ships what the backend then silently rewrites β€” the user sees their edit change shape after saving, with nothing reporting a refusal.


RNC6DK2S β€” the queue-steps INTERLEAVE Β· 2 copies, 2 languages

core/manager.py::get_queue_steps (primary) and src/state/steps-queue-order.js.

"A break with after_index K sits after the K-th room." Rederived card-side so the editor previews without a round trip. Observational, but the preview is what the user commits.


RN538E27 β€” the live-pose OVERRIDE precedence Β· 2 copies, 2 languages

mapping/map_source.py::apply_live_pose_override (primary) and src/state/map.js.

The live pose OWNS current_room + path; the stale snapshot values must be cleared first, in that order. The card repeats the sequence. Diverge and the "stale in the kitchen" ghost returns β€” a live anchor in a catch-all cell leaving the previous room lit and a lagged trail drawn. That ghost is the feature's whole reason for existing.


RN9N6NVB β€” "is this run SEQUENCED, or a flat queue?" Β· 2 copies, 2 languages

profiles/manager.py::_enrich_saved_run_profile's has_stops gate (primary) and src/state/run-profiles.js::_deriveHasStops.

Both halves were wrong on the same day: a roomsβ†’zone profile reported itself as a flat queue in each. Two independent answers to one question, and they agreed β€” on the wrong answer. step_types.py carries the companion warning that the two vocabularies inside it must NOT be merged; this set is the backend↔card pair, not those.


RN9Y5N84 β€” the profile DISPLAY LABEL composition Β· 2 copies, 2 languages

learning/manager.py::_settings_profile_label (primary) and src/renderers/metrics.js::_localizedProfile, which recomposes it in the user's language and falls back to the English one.

Diverge and the two disagree only for non-English users β€” the half nobody testing in English ever sees. Related: RNJ9YQF7 covers the Python duplication of display logic, so this rule is expressed in three places across two languages.


RNHW3BKZ β€” the shipped OPTION LISTS Β· 2 copies, 2 languages

adapters/eufy/adapter.py's option lists (primary) and harness/fixtures/cards.js, which mirrors them verbatim β€” values and order.

A fixture agrees with the caller, not the callee. Let this drift and every harness shot renders chips no real install shows, in an order it does not use β€” and the shots are what the README and the docs site publish. Silent, and self-confirming.


RNCCB8J2 β€” the SEMANTIC DEFAULT palette Β· 2 copies, 2 languages

src/styles/foundation.js's --evcc-sem-* declarations (primary) and harness/cvd/report.mjs, which hard-copies the four hexes as RGB triples to run the colour-vision contrast floor against them.

The failure is a green result about a palette that does not ship. Change a default here and the CVD report still PASSES β€” it is measuring the old colours. An accessibility claim that cannot notice it is stale is worse than no claim.


RNH0W1RK β€” the theme ENVELOPE SPLIT Β· 2 copies, 2 languages

themes/manager.py's tokens / colors / alpha split (primary) and harness/fixtures/theme-library.mjs, which rebuilds it so the editor's swatch and opacity rail render populated rather than empty. A drifted fixture shows an editor state no real theme produces.


RNF6XB1P β€” the LOCALE LOAD PATH Β· 2 copies

harness/shoot-locales.mjs (primary) and harness/tests/i18n-rtl.spec.mjs, which repeats the sequence β€” parse the shipped nested JSON, flatten against the English manifest β€” because Playwright's loader treats a typeless .js as CJS and rejects the ESM modules.

Load-bearing: the spec cannot import the real path, so it must restate it. Change the load order and the spec validates a model nothing uses.


RNNPSKT7 β€” the composer's 2dp CORNER ROUNDING Β· 2 copies

src/state/map.js::composeToSegments (primary) and map-compose-and-viewport.test.mjs's own r2() helper.

A test that reimplements the arithmetic it is checking cannot fail when that arithmetic changes β€” it asserts against its own stale copy and stays green. The narrowest possible example of why a fixture agrees with the caller, not the callee.


RNTKY81M β€” the ENGINE-BLOCK validation Β· 3 sites, one file

adapters/registry.py validates a declared engine block once per engine family β€” mapping, job_segmenter, room_attribution β€” each resolving the engine and calling its validate_tuning.

A change to the validation contract obliges all three, and a new engine family adds a fourth. The three are adjacent in one function, which is why this reads as safe; it is the same shape as any other triplicate, minus the distance.

Declared at the first (mapping) check.


RNARRS0S β€” DEREGISTERED 2026-09-12 (kept as a tombstone, deliberately not an entry)

Reserved a dual_pad tier for a true ROTATING roller mop while twin-mount flat cloths stayed wash_station, stated in roborock_upkeep_guides.py's tier docstring (primary) and upkeep_catalog.py's model table. Roborock's port to REGIME β†’ KEY routing deleted the guide library and removed the tier column, leaving ONE site.

An RN token is a replica set by definition β€” one primary plus at least one REPLICA marker β€” so a one-site RN is malformed, and test_replica_sets_are_well_formed says so. The fix is not to prop the token up with a second mention; a mention invented to satisfy a gate is exactly the "declaration defended by a comment" this registry exists to catch.

The rule was good, the shape was not. As a reservation it was a decision with no code and unfalsifiable by construction. The distinction it protected is now a MEASURED field β€” twin flat cloths are the same job as one cloth and measure cloth/pad; roller means an assembly lifted from a compartment, which emits different steps. So it moved to:

  • adapters/upkeep_keys.py, the MOP branch table, where the distinction is code;
  • RUK-6, tests/adapters/roborock/test_roborock_upkeep_keys.py::test_twin_flat_cloths_are_not_a_roller, which names the five affected models and fails if one is typed roller.

The heading above is deliberately H4 and unbacktick'd so the registry parser does not read this tombstone as a live entry. Kept because a token that simply vanishes reads, on the next audit, exactly like one that was never registered.

Pattern worth reusing: when a replica's sites collapse to one, ask whether the duplicated statement has become a measurable fact. If it has, a test beats an anchor β€” an anchor only asks two prose copies to agree, a test asks the data to agree with reality.


RNJB6JXD β€” the job-OWNERSHIP predicate Β· 2 sites, one file

phase_runner.py's _still_ours poller predicate (primary) and the phase-advance gate, which restates it: a job is advanceable only while it is still the one we dispatched.

Mutation class. Diverge and a phase advances against a job the dispatcher no longer owns β€” which is the _cancel_in_flight hazard the surrounding block already documents from the other direction.


RN8Q25E5 β€” UNIT NORMALIZATION by the entity's own unit Β· 2 sites, one file

job_finalizer.py reads cleaning_time and cleaning_area from separate entities and normalizes each by that entity's own unit_of_measurement, with an adapter fallback for a bare-number sensor.

Both halves are needed because a brand can report one imperial and one metric β€” Eufy ships area in ftΒ², and an imperial HA install changes the time unit independently. Normalize one and not the other and the value silently lands in the wrong unit, poisoning every learning bucket built from it. Nothing raises; the numbers are just wrong.


RND4MHSR β€” map-id mismatch NORMALIZATION Β· 2 sites, one file

jobs/job_monitor.py::evaluate_job_lifecycle (primary β€” carries the reasoning) and build_start_blocker_from_lifecycle each derive map_mismatch independently: the first for the lifecycle / card-readiness state, the second for the START protection result. Both must run selected_map_id / active_map_id through _norm, so a sentinel active-map selector (Dreame's select.<id>_selected_map reads unavailable with multi_floor_map OFF, even on a one-map device) maps to "" and the guard is SKIPPED.

Already drifted once. The fix first landed in evaluate_job_lifecycle only; the sibling kept str(active_map_id or "").strip(), so "unavailable" stayed truthy and the START blocker re-fired the phantom mismatch β€” Start stayed disabled on a one-map Dreame while the lifecycle state had already cleared. A helper for just the normalize-and-compare is the eventual target (derived > helper); recorded as a set meanwhile because the two return different result shapes (lifecycle dict vs blocker dict) for different consumers.


RNGP3ZBE β€” the RESPONSE-CAPABLE service-call convention Β· 2 copies

src/actions/core.js::callService (primary) and src/cards/_shared.js, restated argument for argument: target undefined, notifyOnError false, returnResponse true, unwrap response, null on any failure, never throw into the render cycle.

Panel and standalone card must refuse alike. Diverge and one surface swallows a failure the other reports β€” or worse, one throws mid-render.

The unwrap response clause was FALSE of the primary from this entry's creation until 2026-09-13. hass.callService(..., returnResponse) resolves to {context, response}; the card twin unwrapped it, the panel primary returned the envelope. Nothing could see the divergence: callResponse had no test at all, and every fake at the hass seam returned the payload bare. The cost was not a shape mismatch but a silent refusal β€” the panel's central check read result.success on the envelope, so it never fired once, and actions/rooms.js had already deleted its own toast in favour of it (MZ-2). A second funnel, actions/theme.js::_callThemeService, was dead the same way. The clause is now true of both members and pinned by [CES-5] in src/actions/core-envelope-shape.test.mjs, which asserts the two members return the SAME shape for the same hass.

Refusal parity is narrower than "refuse alike" reads. The card member has no refusal inspection, deliberately: its only two consumers are read-only fetches (get_dashboard_snapshot, get_saved_run_profiles), which have no refusal shape to report. The shared clause is the argument list, the unwrap, and null-on-failure β€” not the toast. Widening the card to toast would need a refusal-shaped service on that surface first.

Lesson for the register: the unwrap response clause was added at PROMOTION from 00c-h-replica-harvest.md, where the harvested row lists only the three arguments. The promoted prose described the replica and was read forever after as describing both. When promoting, check each added clause against the primary, not the copy you were looking at.


RN60D6C4 β€” the per-room SWITCH FILTER Β· 2 copies

src/cards/_shared.js::roomSwitchesFor (primary) and card-suggestions.js, which restates it locally and says why: "kept local so this module stays dependency-free."

Load-bearing. Dissolving it adds exactly the dependency the copy exists to avoid β€” the clearest case in the register of duplication as the correct terminal state.


RN1HP37Y β€” fold clean-mode spellings BEFORE the Set Β· 2 copies

src/state/steps-manifest.js (primary) and src/renderers/run-profiles.js, which names it: "ISSUE #48, twin of the same line."

Skip the fold and two rooms in one mode stored differently read as a mixed group, so the mode chip vanishes. Note this rides on RNY1AHMD β€” canonicalCleanMode is itself the card half of a cross-language set, so the alias table and its two call sites form a three-deep chain.


RNZQ33ZP β€” the ESCAPED/RAW translator pairing Β· 2 pairs, one file

t/tRaw (primary) and tVocab/tVocabRaw in src/renderers/shared.js. The Raw half returns unescaped output for call sites that escape again later.

Change the escaping on one pair and not the other and a translated "l'eau" either double-escapes (renders its entities literally) or reaches an innerHTML sink raw. One is ugly, the other is trust model B failing open.


RNG7V2Y3 β€” the run-profiles CLASS NAMES Β· 3 sites

src/styles/run-profiles.js (primary), the .evcc-run-profiles-seq-* markup emitted by state/steps-manifest.js, and the standalone card, which carries the same rules again in its own shadow root so it styles on a cold dashboard.

Rename here and the manifest renders unstyled in whichever host was not updated β€” and the two hosts are exercised by different tests, so each stays self-consistently green.


RNHME6XA β€” the FONT ID SPACE Β· 2 copies

src/i18n/font-store.js::FONT_SUPPORT (primary) and src/styles/fonts.js's CSS-side font table. The ids must match, and TF-11 pins that.

Adding a font means both plus a font.<id> label key. One edit short and the picker offers a font that styles nothing, or a font styles that nobody can pick. The existing test is a detector, not a discharge.


RN1RX2AT β€” the SIDECOL PANEL token set Β· 2 copies

src/styles/run-profiles.js's panel tokens (primary) and src/styles/saved-zones.js, which mirrors them "so the two sidecol panels read as a set."

Observational, and the weakest set in the register on purpose. Nothing breaks β€” the panels just stop looking designed, which no test can see and no user reports as a bug. Worth recording precisely because it is the kind of drift that has no other detector.


RN6F7RW6 β€” the BRAND-SCOPED vocab resolution order Β· 1 primary + 2 renderer replicas + 4 call sites

src/cards/_shared.js::vocab (primary β€” carries the reasoning) takes a brand and, when present, tries vocab.<brand>.<field>.<value> in the catalog BEFORE the shared vocab.<field>.<value>, falling back to the adapter's declared label last.

Renderer replicas (must stay in lockstep): src/renderers/shared.js::tVocab and its RAW twin tVocabRaw re-implement the same order (they can't call the primary β€” they escape via t/tRaw and route through the RNZQ33ZP pair). Both read the brand from the shared _vocabBrand() helper in that file.

Call sites of the primary (delegate the order, not replicas): dashboard-card.js::_tVocab, profile-card.js, and BOTH classes in room-card.js all call vocab(..., brand).

The load-bearing subtlety is the BRAND SOURCE, and getting it wrong is the whole bug this set was born from. brand is adapter_id, but where it is read differs by render context:

  • A real card element (dashboard-card) has its own this._snapshot β†’ this._snapshot.adapter_id.
  • The renderer instance (VacuumCardRenderers, which runs the modal + queue-manifest) has only this.card, never this._snapshot β†’ the brand must come from this.card._state.dashboardSnapshot().adapter_id (what _vocabBrand() does). A first fix read this._snapshot?.adapter_id here; it is always undefined on the renderer, so the modal showed the shared "Turbo" while the dashboard-card accordion (a real element) showed "Max" β€” the exact "card correct, panel wrong" split. 1035 unit tests stayed green because none exercised brand resolution on the renderer instance; renderers/vocab-brand-scope.test.mjs [VB-1..5] now pins it.
  • The standalone cards (room-card, profile-card) have no service-layer snapshot at all β†’ the brand rides in on the room-switch attrs (room_entities.py serves adapter_id + supports_edge_mopping there), read as _targetSwitch().attrs.adapter_id.

Why the order is load-bearing. A brand OWNS its value's word (doc 20's f/eufy_is_not_the_default). Dreame emits the token turbo for its highest suction, which its app labels "Max"; the shared catalog carries vocab.fan_speed.turbo = "Turbo" (Eufy's term). Without the brand-first probe the shared entry silently overrode Dreame's declared word. Eufy-safe by construction: with no vocab.<brand>.* keys the lookup is byte-identical to the old path. Rides adjacent to RNZM4AYY (most-specific-declaration ownership) and RNZQ33ZP (the escaped/raw tVocab pairing in the same file β€” tVocabRaw being a co-replica here means a change touches BOTH sets at once).


RNQ433CB β€” card-facing CAPABILITY copy Β· 1 primary + 3 adapter config blocks

A supports_* capability the card reads is written twice: the adapter's capabilities config block declares it, and core/manager.py's dashboard snapshot copies it out by an explicit key list (extract + emit), not a spread. Miss the snapshot half and the flag never reaches the card.

Site Kind
core/manager.py dashboard-snapshot capability block primary β€” the explicit copy + reasoning
adapters/dreame/adapter.py capabilities block source copy
adapters/eufy/adapter.py capabilities block source copy
adapters/roborock/adapter.py capabilities block source copy

Load-bearing β€” the snapshot is deliberately an allowlist (the card must not receive the whole capability payload), so it cannot be dissolved into a spread. Observational β€” it decides which controls the card exposes β€” but silent both ways: a flag added to a config block and not forwarded renders no control with every test green, and a forwarded flag no config block sets is dead. supports_goto shipped exactly this way on 2026-08-30 β€” the button never appeared and all suites passed; supports_zone_clean is the in-tree member that proves the pattern. The config_schema.py capabilities decl is a THIRD site but is test-gated (schema conformance fails LOUD), so it is discharged, not a silent member.

Declared at core/manager.py's dashboard-snapshot capability block.

History: minted AT the fix that added supports_goto β€” the bug that convicted the set ([[feedback_anchor_replica_at_the_fix]]).


RN0Y49XS β€” the Dreame render PROJECTION ↔ go-to/zone correspondences Β· 2 copies

Site Kind
mapping/map_source_runtime.py::dreame_render_from_mapdata _n primary β€” carries the offset reasoning
mapping/map_source_runtime.py::dreame_correspondences_from_mapdata the copy β€” restates it corner for corner

Both add map_frame_offset_mm to the vacuum coord, project through the SAME _dreame_projector, and normalize by (width, height). _n produces the rendered rooms; the correspondences produce the (nx, ny, mmx, mmy) pairs the go-to / zone affine is fitted from β€” so the affine INVERTS _n. Load-bearing (runtime data path), MUTATION-adjacent: diverge and a tap lands ~offset from where the robot drives, silently β€” the affine still fits, just to the wrong frame. They differ in ONE deliberate way β€” the correspondences REJECT an out-of-grid corner where _n clamps for display; that is the exception, not drift.

This shipped broken and the test could not see it. The go-to path called the Roborock-shaped correspondences_from_mapdata on a Dreame MapData, which read .rooms (absent on a Dreame MapData) and returned []; the unit test mocked the projector, so it agreed with the caller and never exercised the real Dreame shape (2026-08-30, live "no live map / projection failed" on the first real tap β€” [[feedback_anchor_replica_at_the_fix]], [[feedback_test_discipline]]).

Declared at dreame_render_from_mapdata's _n.


Observational vs MUTATION replicas

A replica set that decides what to display or bind is bad when it diverges: someone sees a wrong answer. A replica set that decides what to write is worse: the copies produce divergent STATE, permanently, and no test sees it because each copy is self-consistent with its own.

Both sets recorded above are observational β€” they decide which entity a role binds to. The dangerous kind is under Candidates below, and it has already cost real data.

The one question the register has to answer

The families have useful names β€” twins, semi-twins, adopted family, cousins β€” but naming is vocabulary for talking, not a field to fill in. The tooling needs one distinction with consequences:

Does changing one member OBLIGE changing the others?

Twins, semi-twins and adopted family all answer yes; cousins answer no. "Estranged twins" is not a category β€” it is obliged and currently violated, which is a defect with an owner, not a classification.

Census first. Helpers later, and only if they earn it

When mapping a suspected family, agents describe behaviour and relationships and are forbidden from proposing unification. Asking twenty agents to "find places that want a helper" returns twenty helpers; the search designs the answer.

⚠ This was violated on 2026-08-16, in the run that produced resolve_action_entity. The mapping lane was told "propose the smallest seam, and do NOT propose a fourth copy" β€” which pre-loaded the conclusion. It happened to be right, and that is the problem: there was no way for that agent to tell me otherwise. The two lanes framed as open questions both returned findings that contradicted the brief, and one refuted the maintainer's hypothesis and mine together.

Roughly half the divergence in this repo is deliberate ([[feedback_centralize_question_not_vocabulary]]), so a syntactic duplicate-finder gets this almost exactly backwards: two 90%-identical functions are often cousins, while two that look nothing alike are twins because both enforce "change X while preserving Y".

The harvest β€” 71 unclassified candidates

python scripts/replica_census.py reads back the replica notices ALREADY WRITTEN in source comments β€” "its twin", "the same predicate written twice", "these two handlers already drifted apart once". 114 notices across 73 files, 71 of them carrying no RN anchor at all, 14 with a strong notice.

That is why populating this register was never really a bootstrap problem. The noticing had already happened, dozens of times, by whoever was standing there when a fix landed in one copy and not its twin β€” it was simply never indexed. The first census needed no new insight, only a harvest.

The pile lives in 00c-h β€” replica harvest, deliberately a separate file: this one is the REGISTER (confirmed sets, each anchored in source), that one is the working list it gets reduced from. A suspicion filed next to a ruling starts looking like one.

⚠ The tool finds RECORDED knowledge, not unrecorded structure. A family nobody ever remarked on is invisible to it and stays that way until a bug convicts it β€” which is the argument for recording one AT THE FIX, where the evidence is strongest and the mental model is already built. The three candidates below all came from fixing bugs, not from reading code.

Candidates β€” not yet recorded

Suspected replica sets. Each needs confirming as deliberate before it earns an anchor; an accidental duplicate wants a helper, not an entry here.

  • reserved_suffixes at the capability probe β€” both adapters pass ALL_SUFFIXES (CN2X0DN6 Eufy, CNXD5V8Q Roborock). Two anchors already, and no link between them: the sibling problem surviving inside the scheme meant to fix it.
  • Brand vocabulary tables β€” every adapter declares its own state sets. Almost certainly correct divergence rather than a replica set, but unverified.
  • πŸ”΄ MUTATION β€” "did this run cover these rooms?" Decided independently at job_finalizer._detect_cancel_likely_run, job_finalizer._write_incomplete_run_log and _update_trouble_rooms_log. They disagreed on 2026-08-16: a three-room run aborted from the vendor app archived as completed / used_for_learning: True, credited EVERY queued room with a fresh last_cleaned_at, and trained the learning store on a 30-second "clean". Divergent state, not a wrong display β€” and the strongest candidate here.
  • "does this item need attention?" β€” maintenance/manager.py counts {warning, replace_soon, replace_now} into attention_count; the card's _maintenanceItemNeedsAttention adds a remaining_percent <= 20 rule the backend does not have. Issue #51 showed both halves of the contradiction on one screen: "ATTENTION 0 / No upkeep items need attention" above a populated Needs Attention list.
  • "is the job_active signal real?" β€” is_job_active (state is on) vs completion_secondary_satisfied (which tested only that the KEY was declared, until #51). Same question, two answers, and the weaker one gated completion.
  • πŸ”΄ MUTATION β€” the consumed-id guard. Slug-led carry with id fallback, written twice: rooms/room_manager.py::build_managed_rooms and maps/map_manager.py's rebuild path. The source already says so β€” "mirrors build_managed_rooms' own consumed_ids guard exactly (same bug, independently written in both writers, same fix)". Both are WRITERS of the persisted room store, and the subtle half is identical in each: a slug match consumes the room's OLD numeric id so a renumbered neighbour cannot inherit its settings through the id fallback. Drop that in one copy and the settings transplant returns silently on whichever write path skipped it. Obliged-to-change looks near-certain; unverified. Rule: [[INMKEHPQ]].
  • πŸ”΄ MUTATION β€” _enabled_room_ids_validator. services/rooms.py::_enabled_room_ids_validator and services/setup.py::_SETUP_SET_PANEL_TITLE_SCHEMA, identical refusal messages today. Both gate the same destructive write β€” null and [] are rejected as loud schema errors rather than coerced to "select nothing", which would wipe every managed room. Two service surfaces, one rule, no shared symbol. Rule: [[INC63FDF]].
  • The sentinel vocabulary β€” what counts as "no reading". Five sites, and the memberships already disagree: adapters/eufy/lifecycle.py::_active_cleaning_target_cleared carries "null", adapters/roborock/vocabulary.py::NOT_ERROR_SENTINELS does not, core/error_tracker.py::_NOT_ERROR omits "none" (its own comment calls that a deliberate last-resort scope), listeners/path_blockers.py has its own set inline, and rooms/room_discovery.py::_ACTIVE_MAP_SENTINELS is a fifth. Probably NOT one set: the brand files are declaring brand vocabulary, which is correct divergence, while the core ones are answering "is this a reading?" and may be twins with each other. Needs splitting before it can be classified. Rule: [[INFJXSM4]].

Adding one is cheap. Leaving one here is also fine β€” a listed candidate is honest; an anchored set nobody verified is not.