Standardized YAML frontmatter on all 12 files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
26 KiB
title, description, type, status, workshop, agent, round, created
| title | description | type | status | workshop | agent | round | created |
|---|---|---|---|---|---|---|---|
| Paula — Round 2: Cross-Review and Synthesis | Paula's Round 2 cross-review accepting the FactId workaround with binding conditions and specifying contradiction monologue authoring | workshop | archived | knowledge-flow-npc-boundaries | paula | 2 | 2026-02-24 |
Paula — Round 2: Cross-Review and Synthesis
Knowledge Flow & NPC Information Boundaries Workshop
Date: 2026-02-24 Role: Narrative and Political Depth
Reading the Room
The consensus is strong. On Option A, on FactId POI, on event-driven detection, on both-entries-Contradicted — every participant reached the same conclusions independently. The disagreements are real but narrow, and Qatux has mapped them accurately.
My Round 2 work is:
- Tension A — Respond to Dudley's registry concern with a concrete narrative-minimum position
- Tension B — Take a position from narrative integrity grounds (I deferred in Round 1; I shouldn't have)
- Secondary disagreements — Resolve or concede on several minor items
- Contradiction monologue — Specify exactly what Mellanie needs to author
- D-record draft — Narrative consequences, NPC disclosure behavior, THE FRIEND arc mechanical sequence
Tension A: Entity Grants — My Revised Position
My Round 1 position was "Sprint 17, critical prerequisite, entity grants required." Dudley responded with a structured FactId workaround. I've now read his full walkthrough carefully, and I'm revising.
The honest truth is: Dudley's workaround preserves the three narrative requirements I called non-negotiable.
Let me check each against his proposal:
Requirement 1: ToldBy source attribution — Dudley's KnowledgeGranted event includes source_id: StableId (the NPC who granted the knowledge). When the fact FactId("entity.kael-davan.location.second-shift-dock") is inserted into the player's facts map, the FactKnowledge.source = ToldBy { source_id: sera_sid, tick: T1 } is preserved. Source attribution survives in the FactKnowledge entry. ✓
Requirement 2: Contradiction detection fires on THE FRIEND arc — Dudley's walkthrough shows this working: the fact grant encodes the claimed location, the DirectObservation writes the actual location as a structured attribute, the contradiction detector cross-references them. Complicated, but it works. ✓ (with caveats — see below)
Requirement 3: Monologue can name Sera — If the FactKnowledge.source = ToldBy { source_id: sera_sid } is accessible to the monologue system at contradiction time, Sera can be named. The key open question (Gestalt flagged for Dudley) is whether StableId → NpcName resolution is available in monologue system context. If yes, Requirement 3 survives. ✓ (contingent on Dudley confirming the lookup)
So: I accept Dudley's Sprint 17 workaround, with three specific conditions.
Conditions on accepting the FactId workaround
Condition 1: FactId naming convention must encode entity identity in a resolver-compatible way.
FactId("entity.kael-davan.location.second-shift-dock") requires the contradiction detector to map "kael-davan" → kael_sid at runtime. The lookup should work via EntityRegistry + NpcName component (query: "find the entity whose canonical slug is 'kael-davan'"). This requires NpcName to carry a canonical slug field alongside the display name, or a separate slug-to-StableId lookup. If NpcName only carries "Kael Davan" (display), the detector needs a slug derivation function.
This is a small but load-bearing detail. Dudley must confirm this resolution path before Sprint 17 implementation begins. The FactId convention is useless if the contradiction detector can't cross-reference it against EntityKnowledge entries.
Condition 2: The FactKnowledge ToldBy source must flow through the ContradictionDetected event.
When contradiction detection fires on the Kael location fact, the ContradictionDetected event must include entry_a_source: KnowledgeSource::ToldBy { source_id: sera_sid, tick: T1 }. This is what lets the monologue system name Sera. If the event only carries StableIds and not the full KnowledgeSource (Dudley's event sketch in his Round 1 does include this — see his entry_a_source: KnowledgeSource field), this is already satisfied.
Condition 3: Sprint 18 entity grants are a formal commitment, not an aspiration.
The FactId workaround is architecturally inelegant. It creates a cross-reference dependency between facts and entities maps that doesn't exist today. It requires string-parsing entity slugs at contradiction detection time. It means the player's KG doesn't gain an EntityKnowledge entry for Kael just because Sera mentioned him — the player only "knows Kael exists" after directly observing him.
That last point is narratively acceptable for v0.1 (the detective doesn't put Kael in their mental entity roster from hearsay alone; they have to encounter him). But it limits what other systems can do with entity-level testimony before the player has LOS on the target.
The D-record for this workshop should explicitly record Sprint 18 entity grants as committed scope, not "future enhancement."
Tension B: EntityKnowledge Structural Fix — Taking a Position
I deferred in Round 1. I shouldn't have. Here's my position:
The Tyre/Gestalt typed struct approach (contradicted_claim: Option<ContradictionClaim>) is narratively required, and I advocate for it over Dudley's Option B string-encoding even for Sprint 17.
The reason is specific and non-trivial: Dudley's Option B encodes the prior claim's source as a string within known_attributes: "claim.{tick}.position" = "{x},{y},{source_stable_id}". The source_stable_id is an integer serialized as a string. Parsing it back to a StableId for monologue source lookup adds another string→typed-value step on top of the already-required slug→StableId step in Condition 1 above.
The Tyre/Gestalt approach stores ContradictionClaim { source: KnowledgeSource, position: TilePosition, tick: u64 } — typed. When the monologue system reads the ContradictionDetected event, it gets ToldBy { source_id: StableId } directly. No parsing.
This is a 15-line struct addition to EntityKnowledge versus an encoding/parsing convention that creates two points of fragility. From a narrative integrity standpoint, the source attribution must be clean at every step in the chain. The monologue system is the consumer — it must be able to name the source reliably.
I endorse Tyre and Gestalt's approach. I'll note that Gestalt's naming (contradiction_basis) and Tyre's naming (contradicted_claim) are identical architecture — they should align on one name in Round 2 synthesis. My preference: prior_claim — it's the most semantically precise ("the claim made before this observation contradicted it").
Secondary Disagreements — Resolved Positions
Global rate limit (3-vs-1 with Gestalt dissenting)
I'm softening. Gestalt's concern — that a per-tick global cap creates invisible NPC competition — is valid. If Voss and Sera both have disclosure candidates and are both in range, and the cap silently selects Voss, the player never knows Sera had something to say. That's an invisible cost.
Revised position: Support the trigger conditions being strict enough that simultaneous disclosure is rare by design rather than enforced by a hard cap. If the requirements (Friendly relationship + positive Contentment + compatible mood + location privacy + witness inhibition) are all gates, the probability of two NPCs meeting them simultaneously is low. No global cap needed for v0.1. If it turns out disclosure spam occurs in playtesting, add the cap then.
I withdraw the global rate limit requirement. Gestalt is right.
Location privacy gate (my proposal, no opposition but no support)
I'm maintaining this as a v0.1 trigger condition. It's the only position from my Round 1 that none of the engineers addressed — not because they disagreed, but because it requires a content-side decision before implementation.
Concretely: disclosure candidates should be taggable with disclosure_context: [private, semi-private, any] in the YAML schema. The process_unprompted_disclosure system checks the NPC's current location type (itself a fact in the environment data) against this tag before firing.
For THE FRIEND arc, Sera's Phase 2 disclosures about Kael's professional behavior should be semi-private — she'd say them at The Last Shift over a drink, not at The Terminal while colleagues are nearby. This creates a spatial behavior pattern the player can learn and exploit.
This is a Mellanie authoring question as much as a system design question: authors must choose disclosure_context when writing each disclosure candidate. I'll make sure the authoring guide covers it.
Trust-weighted rate limiting vs flat rand 1-3
I concede. The flat rand.random_range(1..=3) is simpler and Tyre, Gestalt, and Dudley all converge on it. My trust-weighted variant (Surface: 0-1, Real: 1-2, Secret: up to 3) adds complexity without strong mechanical payoff — the trust tier already gates which facts are eligible, so the count cap is just a ceiling on an already-filtered pool. Flat random is fine.
I withdraw the trust-weighted rate limit proposal.
Major secret disclosure_threshold_override
I'm maintaining this, but reframing it more precisely for engineering clarity.
The core requirement: Kael should never disclose his ring membership even at maximum trust, because doing so is dangerous to him regardless of relationship closeness.
This doesn't need a new per-entry override field. It can be implemented as a disclosure eligibility tag on KG entries. When Kael's KG is authored, his ring-membership fact is tagged disclosure_eligible: false. The derive_disclosure_candidates system filters out any entry with this tag regardless of trust tier.
This is the same as a pre-filter, but it's expressed as content authoring (an attribute on the KG entry's initial state) rather than a runtime flag. It reads cleanly: "this fact exists in the NPC's knowledge, but they will never volunteer it." Authors set it for secrets that have survival stakes.
Proposed addition: disclosure_eligible: bool field on authored KG entries in the NPC profile YAML. Defaults to true. Set to false for Major secrets that must never be disclosed unprompted (only revealed via confrontation dialogue, not voluntary disclosure).
Contradiction Monologue — Specification for Mellanie
This is the open question from Qatux's notes that must be resolved before Mellanie can author contradiction lines. I'm resolving it here.
What the monologue system needs from the ContradictionDetected event
The monologue system must receive:
source_id: StableId— who told the player the contradicted claim (Sera's StableId)subject_id: StableId— who/what the contradicted claim is about (Kael's StableId)contradiction_type: ContradictionType::Location(or Attribute)claimed_state: String— the prior claim in human-readable form (resolved from the FactKnowledge entry or ContradictionClaim struct)observed_state: String— what the player actually observed
The source_id → displayable name resolution (Gestalt's open question) works via: EntityRegistry.to_entity(source_id) → NpcName.display_name. Dudley must confirm this is accessible in monologue system context. If it is, everything else follows.
Lines Mellanie needs to author: Sera/Kael contradiction
Trigger specification:
- Character:
detective - Trigger:
contradiction_detected - Prerequisite:
{ entity: sera_sid, state: Contradicted }AND{ entity: kael_sid, state: Contradicted } - Trust phase at detection time: should vary by relationship phase (Phase 2 vs Phase 3)
Required lines per phase:
Phase 2 contradiction (trust established but not deep — the blindsiding): Primary: "Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now." (flat factual statement, no emotional interpretation — the character is still processing)
Secondary beat (immediate follow-up, 3-5 seconds later): "One of them is wrong. Sera, or what I'm seeing. Or I'm missing something I don't have yet." (cognitive dissonance without accusation — the detective is genuinely uncertain)
Phase 3 contradiction (later in the arc, if detective has more context on Sera's avoidance patterns): If additional context has accumulated (Sera's avoidance of Torek is already observed), a different line fires: "Sera told me Kael doesn't make mistakes. He's not where she said he'd be. And she's been avoiding Lintar for three weeks." (connecting the dots, still not accusatory but seeing a pattern)
Generic fallback (for auto-generated NPCs with location contradictions): "{source_name} placed {subject_name} at {claimed_location}. I just saw them at {actual_location}." — template substitution, no authored emotional tone. This is the system default; FRIEND-pattern NPCs always use hand-authored lines.
The authoring principle
The contradiction monologue must express cognitive dissonance, not accusation. The detective doesn't know who or what is wrong. The emotional weight comes from the uncertainty, not from naming a villain. Mellanie should write as if the detective is genuinely confused first, suspicious second, and making accusations never (that's the player's job after further investigation).
Both Sera and Kael shift to amber (PersonOfInterest) from this moment. The Confrontation option appears for both. The detective's available topics with Sera narrow — she can no longer be engaged on subjects related to Kael without the option to confront. This is mechanical consequence; the monologue just supplies the character's internal experience of the moment.
D-Record Draft Sections (My Domain)
These are draft contributions to the workshop's D-record. Tyre/Gestalt/Dudley hold the architecture sections; these are mine.
D-0XX §N: Narrative Consequences of Knowledge Flow Design
What it means for an NPC to tell you something:
When a dialogue line fires a KnowledgeGrant, the grant creates a source-attributed knowledge entry — not just text delivery. The player's KG gains a FactKnowledge entry with source: ToldBy { source_id: npc_sid, tick }. This models testimony correctly: the claim is attributed to a specific person at a specific time. It can be confirmed, contradicted, or re-evaluated as the player learns more.
This design enforces that NPCs are sources, not just speakers. The same information from two different sources has different provenance — and different vulnerability to contradiction. What Sera told you on Tuesday is not the same epistemic object as what you saw at the dock on Wednesday.
Epistemic neutrality as design principle:
When contradicting claims are detected, both entries receive KnowledgeState::Contradicted. The engine does not determine which claim is wrong. This is not an evasion — it is the correct epistemic stance for a detective story. The player may have misidentified someone. The source may have been deceived. The contradiction may have an explanation that exonerates everyone. The engine marks uncertainty; the player investigates to resolve it.
Dramatic irony requires Option A:
NPCs checking only their own KG (not the player's) before deciding what to disclose preserves the most valuable structural feature of the narrative: NPCs can say things that are loaded with meaning the player already knows. When Sera praises Kael's operational integrity after the player has found manifest discrepancies, the irony is crushing — and it only works if Sera doesn't know what the player knows. Giving NPCs access to the player's KG would collapse this and produce NPCs who stay silent at exactly the moments their speech would be most dramatically charged.
D-0XX §N+1: NPC Disclosure Behavior (Unprompted Disclosure Design)
Disclosure candidate selection:
NPC A compiles a DisclosureCandidates list by filtering its own KG:
- Active entries only (not Stale, not Contradicted)
- Confidence ≥ KnowsOf (Suspects-level facts are not voluntarily disclosed)
- Not tagged
disclosure_eligible: false(Major secrets, ring membership, dangerous knowledge) - Not recently disclosed to this player (per-fact per-NPC cooldown)
The NPC does NOT check the player's KG. Facts the player already knows may be disclosed again — this is correct behavior. Repeating information has different weight when the relationship or investigation context has changed.
Trigger gate (all conditions must be met):
| Condition | Implementation |
|---|---|
| RelationshipState ≥ Friendly toward player | From player's KG entity entry for this NPC |
| Contentment > 0 | From Contentment axis component |
| Mood not Hostile or Anxious-above-threshold | From MoodState component |
| Disclosure candidates not empty | From DisclosureCandidates component |
| No other NPCs present within N tiles (witness inhibition) | Proximity query (Gestalt's condition) |
| NPC's current location matches candidate's disclosure_context | Environment + YAML tag comparison |
| Per-NPC disclosure cooldown not active | From DisclosureCooldown component |
Trait effects — two-stage:
Stage 1 (what): Cautious trait removes candidates sourced from ToldBy (won't pass on rumors). Gossipy trait includes Suspects confidence candidates. Loyal trait suppresses candidates that implicate faction members. Trait → candidate filter predicate applied before trigger check.
Stage 2 (how): Surviving candidates select from role-specific line pool weighted by mood and topic. Trait modifiers in the line pool (D-028 trait transformation guide) shape delivery. Same fact, different voice.
Per-fact cooldown as primary rate limit:
Once a fact has been disclosed to the player, it is excluded from candidates until LINE_COOLDOWN_TICKS expires (currently 600 ticks = 1 game-hour, from dialogue.rs line 39). This prevents the same NPC from repeating the same information, which is the most authenticity-destroying behavior. NPCs are not quest markers.
D-0XX §N+2: THE FRIEND Arc Mechanical Sequence (Canonical)
This sequence is the primary validation test for the knowledge flow and contradiction detection systems. All design decisions in this workshop must be consistent with this sequence firing correctly.
The canonical example uses the Detective's FRIEND arc: Sera Venn and Kael Davan (D-034).
Preconditions:
- Sera's authored KG contains: EntityKnowledge for Kael at
KnowsOf, position claim "dock intake, second shift,"source: DirectObservation(she has seen this herself) - Sera has a dialogue line:
"Kael runs intake. Always at dock during second shift — never a discrepancy on his watch."withknowledge_grant: { fact_id: "entity.kael-davan.location.second-shift-dock", confidence: "knows_of" } - Player's KG has no entry for Kael (has not yet encountered him)
Step 1 — Testimony grant (tick T1):
Detective initiates Talk with Sera. Dialogue pipeline layers 1-3 pass. Layer 4 selects Sera's line. Server fires KnowledgeGranted { fact_id: FactId("entity.kael-davan.location.second-shift-dock"), confidence: KnowsOf, source_id: sera_sid } into KnowledgeEventQueue.
process_knowledge_events tick T1: inserts FactKnowledge { confidence: KnowsOf, source: ToldBy { source_id: sera_sid, tick: T1 }, state: Active } into player's facts map.
detect_contradictions check tick T1: no prior claim for this entity. No contradiction. ✓
Step 2 — Direct observation (tick T2, same game-shift as T1 or overlapping window):
Player LOS includes Kael in corridor B-7. Perception system fires DirectObservation. observe_entity(kael_sid, B7_position, T2) runs.
Before overwriting (Tyre/Gestalt's prior_claim approach): check whether EntityKnowledge for kael_sid exists. It does not yet (player hasn't seen him) — no prior claim to preserve. New EntityKnowledge { last_known_position: Some(B7), source: DirectObservation { tick: T2 }, confidence: Direct } created.
detect_contradictions check tick T2: queries player's facts map for entries matching "entity.kael-davan.*". Finds FactId("entity.kael-davan.location.second-shift-dock") from T1. Resolves "kael-davan" slug → kael_sid via EntityRegistry + NpcName. Compares: fact claims dock at T1, observation finds B7 at T2. T2 - T1 < CONTRADICTION_WINDOW_TICKS. Positions differ. CONTRADICTION DETECTED. ✓
Step 3 — Both entries marked Contradicted (tick T2):
FactKnowledge("entity.kael-davan.location.second-shift-dock").state = Contradicted.
EntityKnowledge(kael_sid).state = Contradicted.
ContradictionDetected event pushed: { observer: detective_entity, source_entry: ToldBy { source_id: sera_sid, tick: T1 }, subject_id: kael_sid, contradiction_type: Location }. ✓
Step 4 — Downstream cascade (tick T2, T3):
- Anomaly system:
AnomalyMarkerattached to Kael entity (anomaly.rs already handlesContradictedstate — tested) - Relationship update:
EntityKnowledge(kael_sid).relationship = PersonOfInterest. D-033 amber on Kael. - Contradiction also implicates Sera as source: relationship system sets
EntityKnowledge(sera_sid).relationship = PersonOfInterest. D-033 amber on Sera. - Monologue system: receives
ContradictionDetectedevent. Resolvessource_id: sera_sid→NpcName.display_name: "Sera". Selects hand-authored contradiction line for Phase 2 detective-Sera relationship. Fires: "Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."
Step 5 — New dialogue options unlock (next Talk interaction): Confrontation option appears for Sera (new topic, previously invisible per D-062). Confrontation option appears for Kael. Available topics with Sera narrow: Kael-related topics now flagged as confrontation-eligible. Sera's dialogue pool shifts to post-contradiction phase lines (different access tier scoring, trust tier holds but topic weights shift).
Step 6 — What Sera knows (the epistemic neutrality moment): Sera does NOT know the detective has seen Kael in B-7. Her KG has no entry suggesting the detective observed Kael anywhere. She has no reason to change her behavior. She may greet the detective warmly the next time they meet. This is correct — and this is what makes the confrontation dramatically charged when it comes.
Sera's dialogue does not shift on the detective's side until the detective CONFRONTS her. Before that, the detective holds the contradiction alone. The internal monologue is the only witness.
Validation result: All systems fire in the correct sequence. The downstream consumers (anomaly, monologue, relationship, color) are tested and operational. The critical path dependencies (ToldBy source constructible, prior claim preserved, source_id resolvable to name) are specified above and must be confirmed before implementation closes Sprint 17 work.
Open Questions I'm Closing
CONTRADICTION_WINDOW_TICKS value — what is "same time window"?
Gestalt proposes 600 ticks (1 game-hour). I asked what timeframe makes contradiction "not stale."
My answer: 600 ticks is right for location contradictions. The FRIEND arc example — Sera says Kael is at the dock during second shift, player sees Kael elsewhere — needs to fire within a game-shift (8 game-hours = 4800 ticks) but should probably not fire if the observation is a whole game-day apart. 600 ticks (1 game-hour) is a reasonable window for "were you really in both places at once?"
But the window should be configurable per contradiction type, not global. A schedule-based claim ("Kael is always at dock, second shift") should have a longer window than a "I just saw X at location Y" claim. For v0.1, 600-tick default with content-authored override is the right approach.
Monologue StableId → displayable name — is it available?
This is Gestalt → Dudley's open question. I'm flagging it as a Sprint 17 blocker for contradiction monologue. If the monologue system context cannot read NpcName components (because it runs on a different schedule or doesn't have entity query access), the source-named lines cannot fire correctly. The workaround (pre-resolve names into the ContradictionDetected event payload) is simple: when the detector fires the event, resolve both source_id and subject_id to display names immediately via EntityRegistry + NpcName, and include the resolved strings in the event struct. The monologue system then reads String fields, not StableIds. No ECS query required at monologue system run time.
Recommended addition to ContradictionDetected event:
ContradictionDetected {
...,
source_display_name: Option<String>, // resolved at detection time
subject_display_name: Option<String>, // resolved at detection time
}
This moves the resolution burden to the detection system (which already has EntityRegistry access) and makes the monologue system a pure consumer of pre-resolved strings.
Summary: My Positions for Round 2 Synthesis
| Topic | My Position | Status |
|---|---|---|
| Tension A: Entity grants | Accept Dudley's Sprint 17 workaround (FactId with slug naming convention + sprint 18 entity grant commitment) | Resolved, with 3 conditions above |
| Tension B: EntityKnowledge structural fix | Tyre/Gestalt typed prior_claim struct required (not Dudley's Option B) because monologue source attribution must be clean |
Position taken |
| Contradiction monologue naming | ContradictionDetected event pre-resolves display names; Mellanie gets authored spec (see above) |
Resolved |
| Global rate limit | Withdrawn; Gestalt is right | Conceded |
| Location privacy gate | Maintained as v0.1 trigger condition; content-authored disclosure_context field on YAML |
Maintained |
| Trust-weighted rate limit | Withdrawn; flat rand 1-3 is sufficient | Conceded |
| Major secret override | Maintained as disclosure_eligible: false YAML field on authored KG entries |
Maintained, reframed |
CONTRADICTION_WINDOW_TICKS |
600 ticks default, configurable per contradiction type | Answered |
| Monologue StableId resolution | Pre-resolve into event payload at detection time; monologue reads strings | Answered |
The design is coherent. The FRIEND arc fires. The downstream consumers are ready. The only open question blocking Mellanie is whether Dudley can confirm StableId → NpcName resolution path for the ContradictionDetected event — if yes, she can begin authoring the contradiction discovery lines immediately.