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>
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 (
ContradictionClaimstruct). 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
DisclosureCandidatescompute 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:
- 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. - 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. - Zero schema changes was a false economy. One optional field on
EntityKnowledgeis 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:
ContentStoreResourceSimRngSimulationTime- 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: ReplaceKnowledgeGrantstruct withKnowledgeGrantenum (#[serde(untagged)]); adddisclosure_blocked: boolfield toEntityvariantserver/src/content/line_pool.rs: UpdateIndexedDialogueLine.knowledge_granttype; update index building to parse both variantsserver/src/knowledge/types.rs: Adddisclosure_blocked: booltoFactKnowledge(defaultfalse)- New file
server/src/knowledge/content_registry.rs:ContentEntityRegistryresource —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: AddKnowledgeEventType::KnowledgeGrantedvariant: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 inprocess_knowledge_eventsforKnowledgeGranted:- For
Fact:observer_kg.facts.insert(fact_id, FactKnowledge { confidence, source: ToldBy/DirectObservation, ... }) - For
Entity:observer_kg.entities.entry(target_sid).or_insert_with(...)withsource: 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
- For
server/src/knowledge/types.rs: AddTryFrom<&str> for KnowledgeConfidencefor parsing confidence stringsserver/src/simulation/dialogue.rs: Inprocess_talk_interaction, after line selection, ifselected_line.knowledge_grant.is_some(), pushKnowledgeGrantedevent
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: AddContradictionClaimstruct; addcontradicted_claim: Option<ContradictionClaim>toEntityKnowledge; addCONTRADICTION_WINDOW_TICKS: u64 = 600constantserver/src/knowledge/graph.rs: Inobserve_entity(), add pre-overwrite contradiction check (see algorithm above); pushContradictionDetectedevent when detectedserver/src/knowledge/events.rs: AddKnowledgeEventType::ContradictionDetected { observer: Entity, entity_sid: StableId }variant; add processing inprocess_knowledge_eventsthat fires relationship shift for theToldBysource entityserver/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: Systemtransfer_npc_knowledge- Queries
NpcConversationcomponents (identifies conversation pairs) - Reads speaker's
KnowledgeGraph+Relationshipsfor 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)viaKnowledgeConfidence::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-levelKnowledgeGrantedatSuspectsconfidence withKnowledgeSource::Heard { tick, range: Medium }
- Queries
server/src/simulation/conversation.rs: Registertransfer_npc_knowledge.after(run_npc_conversations)in system ordering- ECS constraint note:
transfer_npc_knowledgeusesget_many_mut([entity_a, entity_b])for dual-mutable KG access — this requires the system to own the query, not inline it inrun_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: AddOption<&KnowledgeGraph>toderive_tell_statequery- Replace the
Friendlytell's directrelationships.entriesread (lines 105-112) withkg.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: AddEntityRegistry+Query<&NpcName>to system signature; addresolve_name(source_id, registry, name_query)helper (~15 lines); add match arm forContradictionDetectedevents that selects contradiction monologue line with name substitutionserver/src/knowledge/events.rs: InContradictionDetectedprocessing, callkg.set_relationship(&told_by_source_sid, RelationshipState::PersonOfInterest)— shifts Sera to amber (D-033 downstream via existing pipeline)- Monologue pool (content): Add
trigger: contradiction_detectedline 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:DisclosureCandidatescomponent withcandidates: Vec<FactId>,entity_candidates: Vec<(StableId, String)>,computed_tick: u64DisclosureCooldowncomponent withper_fact_history: BTreeSet<FactId>,npc_cooldown_until: u64derive_disclosure_candidatessystem: range-query Active NPCs near player, filter NPC KG by trust tier + trait filters +disclosure_blocked, populate componentprocess_unprompted_disclosuresystem: 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.rsintegration: Layer 4 readsDisclosureCandidatesfor 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
KnowledgeGrantedevent 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
ContradictionClaimstruct). 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.