# Workshop Round 2 — Dudley: Cross-Review and Synthesis **Workshop:** Knowledge Flow & NPC Information Boundaries **Author:** Dudley (Server Developer) **Date:** 2026-02-23 **Focus:** Tension resolution, implementation ticket specs, implementation order --- ## Reading Summary After reviewing all Round 1 outputs: - Tyre and Gestalt have converged on architecturally identical solutions for Tension B (`ContradictionClaim` struct). Different field names, same structure. - Paula makes the blocking case for Tension A (entity grants in Sprint 17) that I cannot dismiss on architectural grounds — only on infrastructure grounds I am now reconsidering. - Gestalt raises the only substantive dissent (no global disclosure rate limit) that I will address. - Two open questions are directed at me specifically: the `DisclosureCandidates` compute trigger and the StableId→name lookup in the monologue system. Both have answers. --- ## Tension A: Entity Grants — Position Change **I am changing my position. Entity grants are Sprint 17 scope.** My Round 1 concern was: entity grants require a "name → StableId" content registry that doesn't exist, and building it is non-trivial. Let me examine this more carefully. ### The actual infrastructure needed Tyre's proposal stores `entity_ref: "kael"` in the YAML and resolves it to `StableId` at content load via a `BTreeMap`. The question I raised: where does this map come from? The answer: **resolve at spawn time, not content load time.** When an authored NPC is spawned from its content definition (which includes an authored identifier like `"kael"`), the spawn system inserts `"kael" → StableId(N)` into a new `ContentEntityRegistry` resource. The content definition already has the authored identifier — NPCs need to be referenced in dialogue YAML. The spawn registration is ~5 lines per spawn site. At grant processing time, `process_knowledge_events` queries `ContentEntityRegistry` to resolve `entity_id: "kael"` → `StableId(N)`. If the entity is not yet in the registry (not yet spawned), the grant is dropped with `tracing::warn!` and a log entry. This is graceful — no panic, no undefined behavior. ``` ContentEntityRegistry: BTreeMap | ├── Populated at: NPC spawn from authored content ├── Read by: process_knowledge_events (entity grant processing) └── Size: O(authored NPCs) — small, ~20-50 entries for v0.1 ``` This is genuinely minimal infrastructure. I was treating it as a large unknown; it is a ~60-line addition. ### Why my workaround was worse My Round 1 alternative — structured FactId strings like `FactId("entity.kael.position.second_shift_dock")` — fails Paula's test: contradiction detection operates on `EntityKnowledge.last_known_position`, not on `FactKnowledge`. A `DirectObservation` updates `EntityKnowledge` (via `observe_entity`). If Sera's testimony only creates a `FactKnowledge` entry, the contradiction detector in `observe_entity` has nothing to compare against. The chain breaks at step 4 of THE FRIEND arc. Tyre and Gestalt are correct. Paula's blocking case is sound. ### Confirmed schema: two-type grant For Sprint 17: `FactGrant` and `EntityGrant`. `Compound` (grants that do both simultaneously) can be Sprint 18. ```yaml # Fact grant (existing format, backwards-compatible): knowledge_grant: fact_id: "poi.dock_7_restricted" confidence: "knows_of" # Entity grant (new format): knowledge_grant: entity_ref: "kael" attributes: location: "dock-7" shift: "second" confidence: "knows_of" # source is always ToldBy { source_id: speaking_npc_sid, tick } — inferred by system ``` The Rust type: ```rust // In server/src/content/types.rs — replaces current KnowledgeGrant struct #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum KnowledgeGrant { Fact { fact_id: String, confidence: String, }, Entity { entity_ref: String, #[serde(default)] attributes: std::collections::BTreeMap, confidence: String, #[serde(default)] disclosure_blocked: bool, }, } ``` The `#[serde(untagged)]` attribute allows the existing `fact_id/confidence` YAML format to deserialize as `Fact` variant without any content changes to existing YAML files. Backwards compatible. --- ## Tension B: ContradictionClaim Struct — Position Change **I am conceding Option B. The `ContradictionClaim` struct approach is correct.** Tyre and Gestalt have proposed structurally identical solutions. My Option B (encoding position claims as structured `known_attributes` strings) has three problems I underweighted: 1. **String parsing is fragile.** A malformed string `"claim.T1.position" = "dock_7,T1,sera_sid"` silently fails. The struct approach fails loudly at compile time. 2. **It doesn't help the monologue system.** The monologue needs to display "Sera said Kael was at the dock" — which requires `ToldBy { source_id: sera_sid }` as a typed field, not a parsed string. Paula's requirement for source-named contradiction monologues is correct and it requires the struct. 3. **Zero schema changes was a false economy.** One optional field on `EntityKnowledge` is a smaller change than the refactor obligation Option B creates. ### Agreed struct design Aligning on Tyre's naming (`ContradictionClaim`) — the field is specifically "the claim that was contradicted," which is precise: ```rust // In server/src/knowledge/types.rs — add to EntityKnowledge pub struct EntityKnowledge { // ... all existing fields unchanged ... /// When state is Contradicted: the prior claim that conflicts with the /// current observation. Populated by contradiction detection in observe_entity(). /// None while state is Active or Stale. pub contradicted_claim: Option, } pub struct ContradictionClaim { /// Who made the contradicted claim (typically ToldBy { source_id, tick }). pub source: KnowledgeSource, /// Where they claimed the entity was. pub claimed_position: Option, /// Tick when the contradiction was detected. pub detected_at_tick: u64, } ``` Detection in `observe_entity()` at graph.rs line 111, before the overwrite: ```rust // BEFORE updating the entry: check for contradiction if let Some(entry) = self.entities.get_mut(&target) { if let KnowledgeSource::ToldBy { tick: told_tick, .. } = entry.source { if let Some(old_pos) = entry.last_known_position { if old_pos != position && current_tick.saturating_sub(told_tick) < CONTRADICTION_WINDOW_TICKS { entry.state = KnowledgeState::Contradicted; entry.contradicted_claim = Some(ContradictionClaim { source: entry.source.clone(), claimed_position: entry.last_known_position, detected_at_tick: current_tick, }); // Don't return — fall through to update position and source } } } } ``` The `CONTRADICTION_WINDOW_TICKS` constant: **600 ticks (1 game-hour)**. This is Gestalt's proposal. Rationale: within a game-hour, a claim about "where Kael was during second shift" is not stale — second shift hasn't ended. Beyond a game-hour, the decay system should mark entries Stale, not Contradicted. The contradiction is a fresh conflict, not an archaeology project. --- ## Open Questions Directed at Dudley — Answers ### DisclosureCandidates compute trigger Gestalt asks: is there a "player entered dialogue range" event or does this need a range-query every N ticks? **Answer: range-query in the existing Active NPC processing loop, every tick, gated on distance.** There is no "player entered dialogue range" event — the game doesn't have a proximity-event system. The disclosure system should piggyback on the conversation system's existing proximity check pattern: ```rust // In derive_disclosure_candidates system: // Query Active NPCs within CONVERSATION_PROXIMITY of the player. // Same O(N_active) scan the conversation system already does. let player_pos = player_query.single().0; for (npc_entity, npc_pos, npc_kg, ...) in npc_query.iter() { let dist = npc_pos.manhattan_distance(player_pos).unwrap_or(u32::MAX); if dist > DISCLOSURE_RANGE { continue; // Skip NPCs far from player } // Derive candidates for this NPC } ``` `DISCLOSURE_RANGE` can match `CONVERSATION_PROXIMITY` (3 tiles) or be slightly larger. This is O(N_active) per tick — at 30-80 Active NPCs, this is ~60 comparisons per tick. Negligible. The `DisclosureCandidates` component acts as a cache. It's only populated for NPCs within range and expires after a configurable number of ticks (suggest 30 ticks = 3 game-minutes). If the NPC moves out of range or the player moves away, the component decays and is not repopulated. ### Monologue StableId → displayable name lookup Gestalt asks: is `EntityRegistry + NpcName` available from the monologue system context? **Answer: yes, with a small signature addition.** The monologue system at `server/src/simulation/monologue.rs` currently takes: - `ContentStoreResource` - `SimRng` - `SimulationTime` - Player entity queries Adding `EntityRegistry` and a `Query<&NpcName>` to the system signature is ~5 lines. The lookup chain: ```rust fn resolve_name( source_id: &StableId, registry: &EntityRegistry, name_query: &Query<&NpcName>, ) -> String { registry.to_entity(source_id) .and_then(|entity| name_query.get(entity).ok()) .map(|name| name.0.clone()) .unwrap_or_else(|| format!("Unknown({})", source_id.0)) } ``` This is available and works. The monologue system CAN produce "Sera said Kael was at the dock" as Paula requires. For THE FRIEND arc specifically: Paula's Option 3 (generic fallback + hand-authored override) is the right authoring model. The monologue pool gets a `trigger: contradiction_detected` variant. The system selects a line, then substitutes `{source_name}`, `{entity_name}`, `{claimed_location}`, `{actual_location}` from the `ContradictionClaim` struct. For FRIEND-pattern NPCs (Sera, Kael), Mellanie authors specific lines that are selected first via the priority system; the generic template serves all other cases. Parameterized string substitution is ~20 lines in the monologue emission path. Worth it for the emotional payoff. --- ## Secondary Disputes — Positions ### Global disclosure rate limit (3-vs-1 split with Gestalt dissenting) Gestalt argues: a global limit is invisible to the player and creates unintelligible competition. This is correct but incomplete. The global limit is NOT a gameplay-visible competition — it is a degenerate-case safeguard. Consider: the player enters a crowded room with 15 Active NPCs, all at Friendly relationship, all with disclosure candidates. Without a global limit, 15 NPCs may attempt disclosure in the same tick. The per-NPC disclosure cooldown (300 ticks) prevents repeat disclosures from ONE NPC but does nothing about simultaneous first-time disclosures from MANY NPCs. **Resolution: include the global limit, but make it predictable rather than random.** NPCs are evaluated in `StableId` order (ascending). The first NPC in StableId order whose gates all pass fires its disclosure. The cap is 1 per tick. This means: - The competition is deterministic (D-010 principle 4) - The same-tick scenario resolves gracefully - No NPC is silently blocked from EVER disclosing — they simply fire on a different tick The cap is a tick-granularity concern only. Over game-minutes, every NPC with a valid disclosure eventually fires. Gestalt's concern applies to a cap that permanently suppresses NPCs; this one does not. ### Trust-weighted transfer count (Paula's 0-1/1-2/up-to-3 vs flat 1-3) Paula's weighting adds a trust-tier dependency. I adopt it — it's cleaner narratively and the implementation is trivial: ```rust let max_transfers = match trust_tier { TrustTier::None => return, // no transfer TrustTier::Surface => rng.random_range(0..=1), TrustTier::Real => rng.random_range(1..=2), TrustTier::Secret => rng.random_range(2..=3), }; ``` Surface trust NPC pairs may transfer 0 facts (nothing worth saying). This makes surface trust feel surface-level. ### Major secret `disclosure_blocked` flag (Paula's addition) Paula's proposal: a per-KG-entry flag preventing transfer even at maximum trust tier. **Include it.** Implementation: `disclosure_blocked: bool` field on `FactKnowledge` (default `false`). The `transfer_npc_knowledge` system skips entries where `disclosure_blocked == true`. Content authors mark Major-secret facts as `disclosure_blocked: true` in the initial KG YAML. This is ~15 lines total and correctly models "some things you never tell anyone regardless of trust." ### Witness inhibition + location privacy gate (Gestalt + Paula additions) Both are additive trigger gates for unprompted disclosure. I include both: **Witness inhibition** (Gestalt): count Active NPCs within 5 tiles of the disclosing NPC. If `count > 2`, disclosure is suppressed unless the NPC's trait overrides it (a `Talkative` NPC ignores witnesses). **Location privacy** (Paula): disclosure candidates can be tagged `"location_privacy: private|semi_private|any"` in content. The disclosure trigger checks the NPC's current location zone tag. A `private`-tagged fact won't fire at `The Terminal`. This creates the spatial behavior pattern Paula describes: "If you want Sera to confide, find her at Lera's." Implementation of both gates: ~30 lines combined. Both use data already available in the system (NPC positions for witness count, location zone tags for privacy). ### Runtime NPC KG guardrail Tyre proposes a 3-line runtime check; I called it Tier 3 in Round 1. **I recalibrate: include it.** Tyre's 3-line check is straightforward once the entity grant architecture is in place. The check is: ```rust // For fact grants: verify granting NPC knows the fact if let Some(npc_kg) = npc_kg_query.get(granting_npc_entity).ok() { if !npc_kg.knows_fact(&fact_id) { tracing::warn!("NPC {} granted unknown fact {}", npc_sid.0, fact_id.0); continue; } } // For entity grants: verify granting NPC knows the target entity // (check entities BTreeMap contains target_sid at appropriate confidence) ``` This handles the runtime KG decay case Gestalt identifies: a dialogue line remains eligible after the NPC's KG decays — the runtime check catches it. Include in Sprint 17. --- ## Implementation Ticket Specifications ### Ticket A: KnowledgeGrant schema + ContentEntityRegistry **Depends on:** Nothing (foundational) **Blocks:** Tickets B, D, E, F **Changes:** - `server/src/content/types.rs`: Replace `KnowledgeGrant` struct with `KnowledgeGrant` enum (`#[serde(untagged)]`); add `disclosure_blocked: bool` field to `Entity` variant - `server/src/content/line_pool.rs`: Update `IndexedDialogueLine.knowledge_grant` type; update index building to parse both variants - `server/src/knowledge/types.rs`: Add `disclosure_blocked: bool` to `FactKnowledge` (default `false`) - New file `server/src/knowledge/content_registry.rs`: `ContentEntityRegistry` resource — `BTreeMap` + `register(content_id, stable_id)` + `resolve(content_id) -> Option` - NPC spawn sites: add `content_registry.register(npc_content_id, sid)` call **Line estimate:** ~120 lines total --- ### Ticket B: KnowledgeGranted event + process_knowledge_events handler **Depends on:** Ticket A (ContentEntityRegistry, schema) **Blocks:** Tickets D, E, F **Changes:** - `server/src/knowledge/events.rs`: Add `KnowledgeEventType::KnowledgeGranted` variant: ```rust KnowledgeGranted { recipient: Entity, grant: ProcessedKnowledgeGrant, granting_npc: Option, // None for evidence/POI discovery } pub enum ProcessedKnowledgeGrant { Fact { fact_id: FactId, confidence: KnowledgeConfidence }, Entity { target_sid: StableId, attributes: BTreeMap, confidence: KnowledgeConfidence }, } ``` - `server/src/knowledge/events.rs`: Add match arm in `process_knowledge_events` for `KnowledgeGranted`: - For `Fact`: `observer_kg.facts.insert(fact_id, FactKnowledge { confidence, source: ToldBy/DirectObservation, ... })` - For `Entity`: `observer_kg.entities.entry(target_sid).or_insert_with(...)` with `source: ToldBy { source_id: granting_npc_sid, tick }` - Runtime guardrail: if `granting_npc.is_some()`, verify granting NPC's KG contains the granted fact/entity before applying - `server/src/knowledge/types.rs`: Add `TryFrom<&str> for KnowledgeConfidence` for parsing confidence strings - `server/src/simulation/dialogue.rs`: In `process_talk_interaction`, after line selection, if `selected_line.knowledge_grant.is_some()`, push `KnowledgeGranted` event **Line estimate:** ~150 lines --- ### Ticket C: ContradictionClaim struct + detection in observe_entity **Depends on:** Nothing (changes only `types.rs` and `graph.rs`) **Blocks:** Ticket F (contradiction monologue) **Changes:** - `server/src/knowledge/types.rs`: Add `ContradictionClaim` struct; add `contradicted_claim: Option` to `EntityKnowledge`; add `CONTRADICTION_WINDOW_TICKS: u64 = 600` constant - `server/src/knowledge/graph.rs`: In `observe_entity()`, add pre-overwrite contradiction check (see algorithm above); push `ContradictionDetected` event when detected - `server/src/knowledge/events.rs`: Add `KnowledgeEventType::ContradictionDetected { observer: Entity, entity_sid: StableId }` variant; add processing in `process_knowledge_events` that fires relationship shift for the `ToldBy` source entity - `server/src/knowledge/graph.rs`: Tests — location contradiction, time window boundary, no-false-positive for non-ToldBy sources **Line estimate:** ~120 lines + ~40 lines of tests --- ### Ticket D: NPC-to-NPC knowledge transfer system **Depends on:** Ticket B (KnowledgeGranted event infrastructure) **Blocks:** Ticket E (contradiction detection needs ToldBy entries) **Changes:** - New file `server/src/simulation/npc_knowledge_transfer.rs`: System `transfer_npc_knowledge` - Queries `NpcConversation` components (identifies conversation pairs) - Reads speaker's `KnowledgeGraph` + `Relationships` for trust-tier lookup - Calls `KnowledgeEventQueue.push(KnowledgeGranted { ... })` for each transferred fact - Trust-weighted rate: `Surface → 0..=1`, `Real → 1..=2`, `Secret → 2..=3` (SimRng drawn) - Confidence downgrade: `min(source_confidence, KnowsOf)` via `KnowledgeConfidence::min()` - Skips entries with `disclosure_blocked == true` - `KnowledgeSource::ToldBy { source_id: speaker_sid, tick: current_tick }` - Overheard grant for player: if player within `VOICE_RANGE_TILES`, push entity-level `KnowledgeGranted` at `Suspects` confidence with `KnowledgeSource::Heard { tick, range: Medium }` - `server/src/simulation/conversation.rs`: Register `transfer_npc_knowledge.after(run_npc_conversations)` in system ordering - ECS constraint note: `transfer_npc_knowledge` uses `get_many_mut([entity_a, entity_b])` for dual-mutable KG access — this requires the system to own the query, not inline it in `run_npc_conversations` **Line estimate:** ~160 lines --- ### Ticket E: tell_state.rs KG awareness (MVP boundary #142) **Depends on:** Ticket B (needed for KG to contain meaningful content) **Blocks:** Nothing (standalone improvement) **Changes:** - `server/src/npc/tell_state.rs`: Add `Option<&KnowledgeGraph>` to `derive_tell_state` query - Replace the `Friendly` tell's direct `relationships.entries` read (lines 105-112) with `kg.relationship_with(&entity_sid)` query against observed entities - Note: self-axes (`Secret`, `Contentment`, `Tolerance`) remain ground-truth reads — KG applies to other-entity state only - Tests: existing tests continue to pass (KG is optional, None falls back to current behavior) **Line estimate:** ~35 lines --- ### Ticket F: Contradiction monologue + event chain completion **Depends on:** Tickets B + C (ToldBy entries + ContradictionClaim struct) **Blocks:** Nothing (downstream consumers already exist) **Changes:** - `server/src/simulation/monologue.rs`: Add `EntityRegistry` + `Query<&NpcName>` to system signature; add `resolve_name(source_id, registry, name_query)` helper (~15 lines); add match arm for `ContradictionDetected` events that selects contradiction monologue line with name substitution - `server/src/knowledge/events.rs`: In `ContradictionDetected` processing, call `kg.set_relationship(&told_by_source_sid, RelationshipState::PersonOfInterest)` — shifts Sera to amber (D-033 downstream via existing pipeline) - Monologue pool (content): Add `trigger: contradiction_detected` line category with `{source_name}`, `{entity_name}`, `{claimed_location}`, `{actual_location}` substitution tokens. Generic fallback line authored by Mellanie; FRIEND-specific lines separately authored. - Test: THE FRIEND arc integration test — Tick 100 grant creates ToldBy, Tick 150 DirectObservation triggers contradiction, monologue fires with correct names, Sera shifts to PersonOfInterest, AnomalyMarker set on Kael **Line estimate:** ~90 lines + content (Mellanie) --- ### Ticket G: DisclosureCandidates + Unprompted Disclosure system (#172) **Depends on:** Tickets B + D (KG must have meaningful NPC content before this is useful) **Blocks:** Nothing **Changes:** - New file `server/src/npc/disclosure.rs`: - `DisclosureCandidates` component with `candidates: Vec`, `entity_candidates: Vec<(StableId, String)>`, `computed_tick: u64` - `DisclosureCooldown` component with `per_fact_history: BTreeSet`, `npc_cooldown_until: u64` - `derive_disclosure_candidates` system: range-query Active NPCs near player, filter NPC KG by trust tier + trait filters + `disclosure_blocked`, populate component - `process_unprompted_disclosure` system: 5-gate check (trust ≥ surface, mood ≠ Hostile, contentment ≥ -10, witness count ≤ 2, location privacy gate), global rate limit (1 per tick, StableId-ordered), push line to dialogue pipeline if gates pass - `server/src/simulation/dialogue.rs` integration: Layer 4 reads `DisclosureCandidates` for NPC-initiated dialogue selection - System ordering: `derive_disclosure_candidates.after(process_knowledge_events)`, `process_unprompted_disclosure.after(derive_disclosure_candidates)` **Line estimate:** ~220 lines --- ## Implementation Order with Dependencies ``` SPRINT 17 CRITICAL PATH: Ticket A: KnowledgeGrant schema + ContentEntityRegistry (~120 lines) │ ├──→ Ticket B: KnowledgeGranted event + dialogue wire (~150 lines) │ │ │ ├──→ Ticket D: NPC-to-NPC transfer system (~160 lines) │ │ │ │ │ └──→ [ToldBy entries now exist in KGs] │ │ │ └──→ Ticket E: tell_state KG awareness (~35 lines) ← can parallel with D │ └──→ Ticket C: ContradictionClaim + detect in observe_entity (~160 lines) │ └──→ [Requires ToldBy from B+D to fire. But struct can land independently.] After B + C + D complete: └──→ Ticket F: Contradiction monologue + event chain (~90 lines) After B + D complete (KG populated with meaningful NPC content): └──→ Ticket G: DisclosureCandidates + unprompted disclosure (~220 lines) TOTAL: ~935 lines across 7 tickets ``` ### Parallelization notes - **Ticket A** must complete first. It's the schema foundation for everything else. - **Tickets B and C** can develop in parallel after A. B wires the grant pipe; C wires the detection pipe. They don't conflict. - **Ticket D** requires B's `KnowledgeGranted` event type to exist (it pushes events). B's system changes don't affect D's query structure. - **Ticket E** is fully independent of D-F. It can be done any time after A if someone needs a small task. - **Ticket F** requires both B (for ToldBy sources to exist) and C (for `ContradictionClaim` struct). It's the last step on the critical path. - **Ticket G** (unprompted disclosure) has no hard dependency on C or F, but benefits greatly from D having populated NPC KGs. Implement after D. --- ## Unresolved Items — For the D-Record The following are forming positions that need to be captured in the decision document: | Item | Resolution | |------|------------| | Entity grants Sprint 17 | **Yes** — via `ContentEntityRegistry` (spawn-time registration) | | `ContradictionClaim` struct vs attribute encoding | **`ContradictionClaim` struct** — Tyre/Gestalt approach adopted | | `ContradictionClaim` field name | `contradicted_claim: Option` on `EntityKnowledge` | | `CONTRADICTION_WINDOW_TICKS` | **600 ticks (1 game-hour)** | | Global disclosure rate limit | **1 per tick, StableId-ordered** (deterministic, includes Tyre/Paula's cap, addresses Gestalt's concern) | | Trust-weighted transfer count | **Paula's proposal adopted** (Surface 0-1, Real 1-2, Secret 2-3) | | `disclosure_blocked` flag on FactKnowledge | **Include** — for Major secret no-transfer override | | Witness inhibition gate | **Include** (count Active NPCs ≤5 tiles; threshold = 2) | | Location privacy gate | **Include** (disclosure candidate tag: private/semi_private/any) | | Runtime NPC KG guardrail | **Include** — 3-line check in KnowledgeGranted processing | | Contradiction monologue authoring | **Option 3: generic template + hand-authored override for FRIEND-pattern NPCs** | | `DisclosureCandidates` compute trigger | **Range-query per tick in Active NPC loop** — no special event needed | | StableId → name in monologue system | **Available** — add EntityRegistry + Query<&NpcName> to system signature | | Q-025 formal closure | **Close** — 30KB per Active NPC at current scale is not a constraint | --- ## Final Notes The downstream chain for THE FRIEND arc is complete from step 3 onward. The simulation guarantees: once `ContradictionClaim` is stored and `KnowledgeState::Contradicted` is set, the anomaly system, relationship system, and D-033 color pipeline fire correctly without additional work — all tested and passing. The two new structural pieces this workshop adds are `ContradictionClaim` on `EntityKnowledge` (Topic 5 prerequisite) and `ContentEntityRegistry` as a new resource (Topic 1 prerequisite). Both are minimal. Everything else is new systems that plug into existing, tested infrastructure. State consistency across all 7 tickets: each ticket produces a well-defined state transition. None of them reach across system boundaries in ways that break existing determinism guarantees. The BTreeMap iteration order mandate (D-010 principle 4) is preserved throughout — `ContentEntityRegistry`, `DisclosureCandidates`, and the transfer system all use `BTreeMap` for deterministic ordering.