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

495 lines
26 KiB
Markdown

# 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.
```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<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:
```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<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:
```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<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:
```rust
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.