Files
settled-reach/docs/workshops/knowledge-flow-npc-boundaries/dudley-round2.md
T
jpmschweitzerandClaude Sonnet 4.6 f685cb7324 chore(meta): register D-079–D-083, close Q-024/Q-025/Q-026, ticket Sprint 17 knowledge flow work
Workshop outputs from Knowledge Flow & NPC Information Boundaries workshop (2026-02-24):

Decisions registered in decisions/perception.md:
- D-079: Knowledge Grant Architecture (unified KnowledgeGranted event, Fact+Entity enum, ContentEntityRegistry)
- D-080: NPC-to-NPC Knowledge Propagation (transfer_npc_knowledge system, trust-tier gating, ToldBy source)
- D-081: Unprompted Disclosure Design (DisclosureCandidates component, two-stage trait filter, trigger gates)
- D-082: NPC Information Boundaries MVP Scope (tell_state + disclosure; pathfinding KG integration explicitly not planned)
- D-083: Contradiction Detection Pipeline (event-driven at observe_entity, ContradictionClaim struct, downstream chain)

Questions closed in decisions/questions.md:
- Q-024: closed by D-080 (immediate during conversation, not queued)
- Q-025: confirmed closed (gossip propagation ships Sprint 17; ~12MB peak, no cap needed before v0.3)
- Q-026: closed by D-083 (event-driven at KG write time, ContradictionClaim struct)

Tickets created (#545–#551, Sprint 17, team server):
- #545 KnowledgeGrant schema + ContentEntityRegistry (critical, blocks all downstream)
- #546 KnowledgeGranted event + process_knowledge_events handler
- #547 ContradictionClaim struct + detection in observe_entity
- #548 NPC-to-NPC knowledge transfer system
- #549 tell_state.rs KG awareness — MVP information boundary
- #550 Contradiction monologue + event chain completion
- #551 DisclosureCandidates + unprompted disclosure

Existing tickets updated: #141, #142 (sprint 17 + team assigned, descriptions updated); #172, #173 (full mechanical spec from D-081).

Workshop source files committed: all round docs + workshop-outcomes.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 01:59:58 +01:00

26 KiB

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<String, StableId>. 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<String, StableId>
  |
  ├── 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.

# 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:

// 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<String, String>,
        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:

// 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<ContradictionClaim>,
}

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<TilePosition>,
    /// Tick when the contradiction was detected.
    pub detected_at_tick: u64,
}

Detection in observe_entity() at graph.rs line 111, before the overwrite:

// 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:

// 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:

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:

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:

// 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<String, StableId> + register(content_id, stable_id) + resolve(content_id) -> Option<StableId>
  • 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:
    KnowledgeGranted {
        recipient: Entity,
        grant: ProcessedKnowledgeGrant,
        granting_npc: Option<Entity>,   // None for evidence/POI discovery
    }
    
    pub enum ProcessedKnowledgeGrant {
        Fact { fact_id: FactId, confidence: KnowledgeConfidence },
        Entity { target_sid: StableId, attributes: BTreeMap<String, String>, 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<ContradictionClaim> 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<FactId>, entity_candidates: Vec<(StableId, String)>, computed_tick: u64
    • DisclosureCooldown component with per_fact_history: BTreeSet<FactId>, 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<ContradictionClaim> 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.