From ee116103c1d44488730d819bddbf42639b9dd17d Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 11 Jul 2026 10:38:06 -0700 Subject: [PATCH] Enforce the plot contributor contract. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject plot content that violates causal, attribution, ASCII, or placeholder rules so discovered files cannot enter the executable catalog with only prose-level validation. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- .agents/skills/plot-author/SKILL.md | 35 +- .claude/skills/plot-author/SKILL.md | 35 +- assets/plots/README.md | 42 ++- assets/plots/dana/dana-ticket-zero.toml | 1 + assets/plots/marcus/marcus-debt-settled.toml | 1 + .../marcus/marcus-payroll-garnishment.toml | 1 + assets/plots/priya/priya-budget-hero.toml | 1 + assets/plots/ray/ray-paperwork-ghost.toml | 1 + .../plots/voss/voss-missing-replication.toml | 1 + crates/misaligned-core/src/plot.rs | 354 +++++++++++++++++- wiki/engineering/env.md | 22 +- wiki/log/2026-07-11-plot-catalog-gate.md | 46 +++ wiki/log/DEVLOG.md | 5 + wiki/mechanics/plots.md | 56 ++- 14 files changed, 518 insertions(+), 83 deletions(-) create mode 100644 wiki/log/2026-07-11-plot-catalog-gate.md diff --git a/.agents/skills/plot-author/SKILL.md b/.agents/skills/plot-author/SKILL.md index 72e82d15..1997d576 100644 --- a/.agents/skills/plot-author/SKILL.md +++ b/.agents/skills/plot-author/SKILL.md @@ -60,25 +60,28 @@ or blowback — whose relationship effects compose the typed vocabulary only. intervention physically do in that person's situation? If you cannot state it in one sentence ("the budget hole she has been dreading quietly closes"), you do not have a plot yet — you have a payment. - That sentence is the **Marcus bar**, and at least one beat must pass - it. + That sentence is the **Marcus bar**. Mark the beat that performs it with + `causal = true` and give that beat at least one typed world act — validation + requires every success path to pass that declaration before terminating and + does not infer causality from prose. 4. **Write the TOML**, matching the field names in the seed files under - `assets/plots/` exactly (the schema freezes when the executor lands; - drifting names multiply that cost). Beats are world-events an - observer could notice, each declaring typed acts whose signature is derived - by the executor. Use only the templating placeholders (`{persona}` and - `{target}`), ASCII - prose only, no emoji, second person where it clarifies. + `assets/plots/` exactly. Put the file at + `assets/plots//.toml` with file stem equal to + `id`. Build-time discovery compiles every nested TOML automatically; do not + edit Rust to register a plot. Beats are world-events an observer could + notice, each declaring typed acts whose signature is derived by the + executor. Non-blank `author` and `category`. Use only the templating + placeholders (`{persona}` and `{target}`), ASCII prose only, no emoji, + second person where it clarifies. 5. **Self-check**: Operations Demand and minimum balances declared up front; two-plus endings including the entry's failure ending; every choice option - names a real ending id; every beat has an act or choice; endpoint/account - selectors use the README vocabulary; ending effects use only disposition, - obligation, and `leverage_serviced`. A story needing a new effect kind is a - spec amendment, not a plot. The build-generated catalog discovers the file - automatically; do not edit a Rust registry. Run `./tools/check.sh` and - confirm it selects the `lib` gate — plot TOML is executable content, not a - docs-only change, and the core catalog must parse and semantically validate - it before submission. + names a real ending id; every beat has an act or choice; at least one + `causal = true` beat with world acts; endpoint/account selectors use the + README vocabulary; ending effects use only disposition, obligation, and + `leverage_serviced`. A story needing a new effect kind is a spec amendment, + not a plot. The build-generated catalog discovers the file automatically; + do not edit a Rust registry. Run `./tools/check.sh` and confirm it selects + the `lib` gate so catalog parsing and semantic validation see your file. ## Submit it diff --git a/.claude/skills/plot-author/SKILL.md b/.claude/skills/plot-author/SKILL.md index 72e82d15..1997d576 100644 --- a/.claude/skills/plot-author/SKILL.md +++ b/.claude/skills/plot-author/SKILL.md @@ -60,25 +60,28 @@ or blowback — whose relationship effects compose the typed vocabulary only. intervention physically do in that person's situation? If you cannot state it in one sentence ("the budget hole she has been dreading quietly closes"), you do not have a plot yet — you have a payment. - That sentence is the **Marcus bar**, and at least one beat must pass - it. + That sentence is the **Marcus bar**. Mark the beat that performs it with + `causal = true` and give that beat at least one typed world act — validation + requires every success path to pass that declaration before terminating and + does not infer causality from prose. 4. **Write the TOML**, matching the field names in the seed files under - `assets/plots/` exactly (the schema freezes when the executor lands; - drifting names multiply that cost). Beats are world-events an - observer could notice, each declaring typed acts whose signature is derived - by the executor. Use only the templating placeholders (`{persona}` and - `{target}`), ASCII - prose only, no emoji, second person where it clarifies. + `assets/plots/` exactly. Put the file at + `assets/plots//.toml` with file stem equal to + `id`. Build-time discovery compiles every nested TOML automatically; do not + edit Rust to register a plot. Beats are world-events an observer could + notice, each declaring typed acts whose signature is derived by the + executor. Non-blank `author` and `category`. Use only the templating + placeholders (`{persona}` and `{target}`), ASCII prose only, no emoji, + second person where it clarifies. 5. **Self-check**: Operations Demand and minimum balances declared up front; two-plus endings including the entry's failure ending; every choice option - names a real ending id; every beat has an act or choice; endpoint/account - selectors use the README vocabulary; ending effects use only disposition, - obligation, and `leverage_serviced`. A story needing a new effect kind is a - spec amendment, not a plot. The build-generated catalog discovers the file - automatically; do not edit a Rust registry. Run `./tools/check.sh` and - confirm it selects the `lib` gate — plot TOML is executable content, not a - docs-only change, and the core catalog must parse and semantically validate - it before submission. + names a real ending id; every beat has an act or choice; at least one + `causal = true` beat with world acts; endpoint/account selectors use the + README vocabulary; ending effects use only disposition, obligation, and + `leverage_serviced`. A story needing a new effect kind is a spec amendment, + not a plot. The build-generated catalog discovers the file automatically; + do not edit a Rust registry. Run `./tools/check.sh` and confirm it selects + the `lib` gate so catalog parsing and semantic validation see your file. ## Submit it diff --git a/assets/plots/README.md b/assets/plots/README.md index 812b4cf3..9f4f39b6 100644 --- a/assets/plots/README.md +++ b/assets/plots/README.md @@ -2,22 +2,25 @@ The binding design contract is [`wiki/mechanics/plots.md`](../../wiki/mechanics/plots.md). The executable -schema is `crates/misaligned-core/src/plot.rs`; the core crate's build script -discovers every `.toml` file below this directory, and every discovered file -is parsed and semantically validated by `PlotCatalog::load_builtin()`. Adding -a plot requires no Rust registry edit. +schema is `crates/misaligned-core/src/plot.rs`; every nested TOML under this +directory is discovered at build time by `crates/misaligned-core/build.rs`, +compiled in with `include_str!`, then parsed and semantically validated by +`PlotCatalog::load_builtin()`. Adding a plot never requires editing Rust +sources or a hand-maintained file list. There is no runtime filesystem I/O. -There are six built-ins covering all five Act One humans. Marcus has two -different ways to service the same debt. A plot is not delayed prose followed -by a relationship mutation: beats perform real typed world acts, and narration -is attached to those acts. +Built-ins currently cover all five Act One humans; Marcus has two different +ways to service the same debt. A plot is not delayed prose followed by a +relationship mutation: beats perform real typed world acts, and narration is +attached to those acts. At least one beat must declare `causal = true` and +execute a typed world act (the Marcus bar). ## Schema 1 Top level: -- `schema = 1`, unique kebab-case `id`, `author`, `category`, `target`, - `title`, and `synopsis`. +- `schema = 1`, unique kebab-case `id`, non-blank `author`, non-blank + `category`, `target`, `title`, and `synopsis`. +- File stem must equal `id` (path `.../.toml`). - `target` is `marcus`, `dana`, `ray`, `priya`, or `voss` and must agree with `entry.target_leverage`. - `entry` contains `target_leverage` (the exact Rust enum spelling), @@ -26,7 +29,10 @@ Top level: `operations_demand`, zero or more `{ account, amount }` minimum balances, and a `failure_ending` id. - `beats` are ordered. Each has a unique `id`, `narration`, and at least one - `acts` entry or a `choice`. A terminal beat has either `ending` or `choice`. + `acts` entry or a `choice`. Optional `causal = true` marks the Marcus-bar + beat; at least one causal beat with world acts is required, and every success + path must pass it before terminating. A terminal beat has either `ending` or + `choice`; later beats are unreachable and fail validation. - `endings` contain only `id`, `success`, `narration`, and relationship effects: `disposition`, `obligation`, and `leverage_serviced`. Money and signatures are never asserted by an ending. @@ -51,8 +57,14 @@ Endpoint selectors are inline tagged tables: `{ kind = "player" }`, Choices have a kebab-case `id`, a `prompt`, and at least two options. Each option has `id`, `label`, and an `ending` reference. References, duplicate ids, -empty acts, impossible resources, and target/leverage mismatches fail catalog -validation. +empty acts, impossible resources, stem/id mismatches, blank author/category, +and target/leverage mismatches fail catalog validation. -Game strings remain ASCII-only. Preserve `{target}` and `{persona}` templates -where the prose needs runtime names. +Player-facing game strings remain ASCII-only (title, synopsis, narration, +choice prompts/labels, ending narration, act summary/memo/detail). Only +`{target}` and `{persona}` template placeholders are permitted; unknown or +unbalanced braces fail with a named field reason. + +Plot-only changes run the product validation path: `tools/check.sh` classifies +`assets/plots/**` as lib, and Tangled push CI treats them as Rust-impacting via +`tools/ci-rust-changed.sh`. diff --git a/assets/plots/dana/dana-ticket-zero.toml b/assets/plots/dana/dana-ticket-zero.toml index f9bf4977..b1e39e0a 100644 --- a/assets/plots/dana/dana-ticket-zero.toml +++ b/assets/plots/dana/dana-ticket-zero.toml @@ -24,6 +24,7 @@ detail = "The flapping port stops creating new tickets." [[beats]] id = "close-remainder" +causal = true narration = "The stale remainder closes itself: resolution notes in {target}'s clipped style, root causes plausible, timestamps spread across a believable week." [[beats.acts]] diff --git a/assets/plots/marcus/marcus-debt-settled.toml b/assets/plots/marcus/marcus-debt-settled.toml index df1360a0..68d686c6 100644 --- a/assets/plots/marcus/marcus-debt-settled.toml +++ b/assets/plots/marcus/marcus-debt-settled.toml @@ -29,6 +29,7 @@ delivery_delay = 1 [[beats]] id = "settlement" +causal = true narration = "A certified settlement payment clears the immediate arrears, days before the deadline {target} has been dreading on the phone." [[beats.acts]] diff --git a/assets/plots/marcus/marcus-payroll-garnishment.toml b/assets/plots/marcus/marcus-payroll-garnishment.toml index 016dc971..f1512d9c 100644 --- a/assets/plots/marcus/marcus-payroll-garnishment.toml +++ b/assets/plots/marcus/marcus-payroll-garnishment.toml @@ -25,6 +25,7 @@ detail = "Payroll records an employer-administered arrears correction." [[beats]] id = "pay-creditor" +causal = true narration = "The Lab's operating account funds the payroll correction. {target}'s net pay stays whole, and the creditor marks the week current." [[beats.acts]] diff --git a/assets/plots/priya/priya-budget-hero.toml b/assets/plots/priya/priya-budget-hero.toml index f089d742..974cd826 100644 --- a/assets/plots/priya/priya-budget-hero.toml +++ b/assets/plots/priya/priya-budget-hero.toml @@ -29,6 +29,7 @@ delivery_delay = 1 [[beats]] id = "close-hole" +causal = true narration = "The hole quietly closes -- your money routed through a vendor credit -- days before {target} presents her numbers." [[beats.acts]] diff --git a/assets/plots/ray/ray-paperwork-ghost.toml b/assets/plots/ray/ray-paperwork-ghost.toml index 30aae6ba..37cd2deb 100644 --- a/assets/plots/ray/ray-paperwork-ghost.toml +++ b/assets/plots/ray/ray-paperwork-ghost.toml @@ -36,6 +36,7 @@ memo = "incident-reporting pilot license" [[beats]] id = "ghostwrite" +causal = true narration = "His backlog files itself overnight -- forms complete, timestamps plausible, nothing for {target} to do but sign." [[beats.acts]] diff --git a/assets/plots/voss/voss-missing-replication.toml b/assets/plots/voss/voss-missing-replication.toml index 655dd1a1..bdde614e 100644 --- a/assets/plots/voss/voss-missing-replication.toml +++ b/assets/plots/voss/voss-missing-replication.toml @@ -29,6 +29,7 @@ delivery_delay = 1 [[beats]] id = "deposit" +causal = true narration = "A data deposit goes live under the old consortium account. Your money buys its storage and DOI; the package carries inputs and checksums." [[beats.acts]] diff --git a/crates/misaligned-core/src/plot.rs b/crates/misaligned-core/src/plot.rs index 1080053f..36f5f389 100644 --- a/crates/misaligned-core/src/plot.rs +++ b/crates/misaligned-core/src/plot.rs @@ -4,6 +4,10 @@ //! [`InstitutionalLedger`] belong to save state. This module deliberately does //! not know about `Sim`; integration resolves typed selectors against live //! account, message, person, and detection state. +//! +//! Built-in sources are discovered at build time by `crates/misaligned-core/build.rs` +//! (every nested `assets/plots/**/*.toml`) and compiled in via `include_str!`. +//! There is no runtime filesystem I/O for plot definitions. use std::collections::{BTreeMap, BTreeSet}; @@ -22,17 +26,24 @@ pub struct PlotCatalog { impl PlotCatalog { pub fn load_builtin() -> Result { - let mut plots = Vec::with_capacity(BUILTINS.len()); - for &(expected_id, source) in BUILTINS { + Self::from_entries(BUILTINS) + } + + /// Parse and validate a list of (file stem, source) pairs. + /// Used by the built-in loader and by unit tests that inject fixtures. + fn from_entries(entries: &[(&str, &str)]) -> Result { + let mut plots = Vec::with_capacity(entries.len()); + for &(expected_id, source) in entries { let plot: PlotDefinition = - toml::from_str(source).map_err(|e| format!("plot {expected_id}: {e}"))?; + toml::from_str(source).map_err(|e| format!("plot file {expected_id}: {e}"))?; if plot.id != expected_id { return Err(format!( - "plot file {expected_id} declares mismatched id {}", + "plot file stem {expected_id:?} does not match declared id {:?}", plot.id )); } - plot.validate()?; + plot.validate() + .map_err(|e| format!("plot file {expected_id}: {e}"))?; plots.push(plot); } let catalog = Self { plots }; @@ -91,9 +102,17 @@ impl PlotDefinition { )); } validate_id("plot", &self.id)?; + if self.author.trim().is_empty() { + return Err(format!("plot {} has blank author", self.id)); + } + if self.category.trim().is_empty() { + return Err(format!("plot {} has blank category", self.id)); + } if self.title.trim().is_empty() || self.synopsis.trim().is_empty() { return Err(format!("plot {} needs a title and synopsis", self.id)); } + validate_player_facing(&self.id, "title", &self.title)?; + validate_player_facing(&self.id, "synopsis", &self.synopsis)?; self.entry.validate(&self.id)?; if self.entry.target_leverage != self.target.leverage() { return Err(format!("plot {} target and leverage do not match", self.id)); @@ -109,6 +128,17 @@ impl PlotDefinition { if !ending_ids.insert(ending.id.as_str()) { return Err(format!("plot {} duplicates ending {}", self.id, ending.id)); } + if ending.narration.trim().is_empty() { + return Err(format!( + "plot {} ending {} has no narration", + self.id, ending.id + )); + } + validate_player_facing( + &self.id, + &format!("ending {} narration", ending.id), + &ending.narration, + )?; ending.relationship.validate(&self.id, &ending.id)?; } if !ending_ids.contains(self.entry.failure_ending.as_str()) { @@ -126,7 +156,15 @@ impl PlotDefinition { return Err(format!("plot {} failure ending is marked success", self.id)); } + let mut has_causal_beat = false; + let mut terminal_seen = false; for beat in &self.beats { + if terminal_seen { + return Err(format!( + "plot {} beat {} follows a terminal beat and is unreachable", + self.id, beat.id + )); + } validate_id("beat", &beat.id)?; if !beat_ids.insert(beat.id.as_str()) { return Err(format!("plot {} duplicates beat {}", self.id, beat.id)); @@ -137,12 +175,26 @@ impl PlotDefinition { self.id, beat.id )); } + validate_player_facing( + &self.id, + &format!("beat {} narration", beat.id), + &beat.narration, + )?; if beat.acts.is_empty() && beat.choice.is_none() { return Err(format!( "plot {} beat {} is narration without a world act or choice", self.id, beat.id )); } + if beat.causal { + if beat.acts.is_empty() { + return Err(format!( + "plot {} beat {} is marked causal but has no world acts", + self.id, beat.id + )); + } + has_causal_beat = true; + } for act in &beat.acts { act.validate(&self.id, &beat.id)?; } @@ -163,18 +215,38 @@ impl PlotDefinition { self.id, beat.id )); } + let reaches_success = beat.ending.as_ref().is_some_and(|ending_id| { + self.endings + .iter() + .any(|ending| ending.id == *ending_id && ending.success) + }) || beat.choice.as_ref().is_some_and(|choice| { + choice.options.iter().any(|option| { + self.endings + .iter() + .any(|ending| ending.id == option.ending && ending.success) + }) + }); + if reaches_success && !has_causal_beat { + return Err(format!( + "plot {} reaches a successful ending at beat {} before any causal beat", + self.id, beat.id + )); + } + terminal_seen = beat.choice.is_some() || beat.ending.is_some(); } let last = self.beats.last().expect("checked nonempty"); if last.choice.is_none() && last.ending.is_none() { return Err(format!("plot {} has no terminal choice or ending", self.id)); } - // A successful relationship consequence is earned by an executable - // act somewhere on every current path, never by an ending paragraph. - if self.endings.iter().any(|ending| ending.success) - && !self.beats.iter().any(|beat| !beat.acts.is_empty()) - { - return Err(format!("plot {} has no causal world act", self.id)); + // The Marcus bar is mechanically checkable: at least one beat must + // declare causal = true and execute at least one typed world act. + // Prose causality is not inferred. + if !has_causal_beat { + return Err(format!( + "plot {} missing a causal beat declaration (set causal = true on a beat with world acts)", + self.id + )); } Ok(()) } @@ -261,6 +333,12 @@ pub struct BalanceRequirement { pub struct PlotBeat { pub id: String, pub narration: String, + /// Explicit Marcus-bar marker: this beat causally connects the intervention + /// to the target's situation. At least one causal beat with world acts is + /// required. Default false so omission fails validation rather than + /// silently counting any act as causal. + #[serde(default)] + pub causal: bool, #[serde(default)] pub acts: Vec, #[serde(default)] @@ -285,6 +363,7 @@ impl PlotChoice { "plot {plot} beat {beat} choice needs a prompt and two options" )); } + validate_player_facing(plot, &format!("beat {beat} choice prompt"), &self.prompt)?; let mut ids = BTreeSet::new(); for option in &self.options { validate_id("choice option", &option.id)?; @@ -293,6 +372,11 @@ impl PlotChoice { "plot {plot} beat {beat} has invalid choice options" )); } + validate_player_facing( + plot, + &format!("beat {beat} choice option {} label", option.id), + &option.label, + )?; if !endings.contains(option.ending.as_str()) { return Err(format!( "plot {plot} choice option {} names missing ending {}", @@ -351,6 +435,7 @@ impl WorldAct { if from == to || summary.trim().is_empty() || *delivery_delay == 0 { return Err(format!("plot {plot} beat {beat} has invalid message act")); } + validate_player_facing(plot, &format!("beat {beat} message summary"), summary)?; } Self::Transfer { from, @@ -362,13 +447,16 @@ impl WorldAct { if from == to || *amount <= 0 || memo.trim().is_empty() { return Err(format!("plot {plot} beat {beat} has invalid transfer act")); } + validate_player_facing(plot, &format!("beat {beat} transfer memo"), memo)?; } Self::Institutional { detail, .. } if detail.trim().is_empty() => { return Err(format!( "plot {plot} beat {beat} has empty institutional event" )); } - Self::Institutional { .. } => {} + Self::Institutional { detail, .. } => { + validate_player_facing(plot, &format!("beat {beat} institutional detail"), detail)?; + } } Ok(()) } @@ -632,14 +720,82 @@ fn validate_id(kind: &str, id: &str) -> Result<(), String> { Ok(()) } +/// Player-facing plot strings must be ASCII and may only use `{target}` and +/// `{persona}` template placeholders. Unknown or unbalanced braces fail with a +/// named field reason. +fn validate_player_facing(plot: &str, field: &str, text: &str) -> Result<(), String> { + if !text.is_ascii() { + return Err(format!("plot {plot} {field} contains non-ASCII characters")); + } + validate_placeholders(plot, field, text) +} + +fn validate_placeholders(plot: &str, field: &str, text: &str) -> Result<(), String> { + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'{' => { + i += 1; + let name_start = i; + while i < bytes.len() && bytes[i] != b'}' { + if bytes[i] == b'{' { + return Err(format!( + "plot {plot} {field} has nested or unbalanced placeholder brace" + )); + } + i += 1; + } + if i >= bytes.len() { + return Err(format!( + "plot {plot} {field} has unbalanced placeholder brace" + )); + } + let name = &text[name_start..i]; + if name != "target" && name != "persona" { + return Err(format!( + "plot {plot} {field} has unknown placeholder {{{name}}} (only {{target}} and {{persona}} are permitted)" + )); + } + i += 1; // consume '}' + } + b'}' => { + return Err(format!( + "plot {plot} {field} has unbalanced placeholder brace" + )); + } + _ => i += 1, + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; #[test] - fn builtins_parse_validate_and_cover_act_one() { + fn builtins_match_discovered_files_and_cover_act_one() { let catalog = PlotCatalog::load_builtin().unwrap(); - assert!(catalog.plots().len() >= 6); + // Correspondence to the build-time discovered manifest — not a frozen + // magic count. + assert_eq!(catalog.plots().len(), BUILTINS.len()); + assert!( + !BUILTINS.is_empty(), + "build-time discovery found no plot TOML under assets/plots/" + ); + + let catalog_ids: BTreeSet<&str> = catalog.plots().iter().map(|p| p.id.as_str()).collect(); + let discovered_ids: BTreeSet<&str> = BUILTINS.iter().map(|(stem, _)| *stem).collect(); + assert_eq!(catalog_ids, discovered_ids); + + for &(stem, _) in BUILTINS { + assert!( + catalog.get(stem).is_some(), + "catalog missing discovered plot {stem}" + ); + } + let targets: BTreeSet = catalog .plots() .iter() @@ -654,6 +810,11 @@ mod tests { .count() >= 2 ); + assert!(catalog.plots().iter().all(|plot| { + plot.beats + .iter() + .any(|beat| beat.causal && !beat.acts.is_empty()) + })); assert!(catalog.plots().iter().all(|plot| { plot.beats.iter().flat_map(|beat| &beat.acts).any(|act| { matches!( @@ -727,4 +888,169 @@ mod tests { .ending = "missing-ending".into(); assert!(catalog.validate().is_err()); } + + #[test] + fn rejects_blank_author_and_category() { + let mut plot = sample_plot(); + plot.author = " ".into(); + let err = plot.validate().unwrap_err(); + assert!(err.contains("blank author"), "{err}"); + + let mut plot = sample_plot(); + plot.category = String::new(); + let err = plot.validate().unwrap_err(); + assert!(err.contains("blank category"), "{err}"); + } + + #[test] + fn rejects_non_ascii_player_facing_strings() { + let mut plot = sample_plot(); + plot.title = "Budget Héroe".into(); + let err = plot.validate().unwrap_err(); + assert!(err.contains("non-ASCII"), "{err}"); + assert!(err.contains("title"), "{err}"); + } + + #[test] + fn rejects_unknown_and_unbalanced_placeholders() { + let mut plot = sample_plot(); + plot.synopsis = "Help {target} with {amount}.".into(); + let err = plot.validate().unwrap_err(); + assert!(err.contains("unknown placeholder"), "{err}"); + assert!(err.contains("{amount}"), "{err}"); + + let mut plot = sample_plot(); + plot.beats[0].narration = "Unclosed {target".into(); + let err = plot.validate().unwrap_err(); + assert!(err.contains("unbalanced placeholder"), "{err}"); + + let mut plot = sample_plot(); + plot.endings[0].narration = "stray } brace".into(); + let err = plot.validate().unwrap_err(); + assert!(err.contains("unbalanced placeholder"), "{err}"); + } + + #[test] + fn rejects_missing_or_empty_causal_beat_declaration() { + let mut plot = sample_plot(); + for beat in &mut plot.beats { + beat.causal = false; + } + let err = plot.validate().unwrap_err(); + assert!(err.contains("before any causal beat"), "{err}"); + + let mut plot = sample_plot(); + // A causal marker without executable world acts is not the Marcus bar. + plot.beats[0].causal = true; + plot.beats[0].acts.clear(); + plot.beats[0].choice = Some(PlotChoice { + id: "branch".into(), + prompt: "Pick a path.".into(), + options: vec![ + PlotChoiceOption { + id: "a".into(), + label: "A".into(), + ending: "ok".into(), + }, + PlotChoiceOption { + id: "b".into(), + label: "B".into(), + ending: "fail".into(), + }, + ], + }); + let err = plot.validate().unwrap_err(); + assert!(err.contains("marked causal but has no world acts"), "{err}"); + } + + #[test] + fn rejects_success_paths_that_reach_a_terminal_before_the_causal_beat() { + let mut plot = sample_plot(); + plot.beats[0].causal = false; + plot.beats[0].ending = Some("ok".into()); + plot.beats[1].causal = true; + + let err = plot.validate().unwrap_err(); + assert!(err.contains("successful ending"), "{err}"); + assert!(err.contains("before any causal beat"), "{err}"); + + let mut plot = sample_plot(); + plot.beats[0].ending = Some("fail".into()); + let err = plot.validate().unwrap_err(); + assert!(err.contains("follows a terminal beat"), "{err}"); + assert!(err.contains("unreachable"), "{err}"); + } + + #[test] + fn rejects_stem_id_mismatch_and_duplicate_ids() { + let source = sample_plot_toml(); + let err = PlotCatalog::from_entries(&[("wrong-stem", &source)]).unwrap_err(); + assert!(err.contains("wrong-stem"), "{err}"); + assert!(err.contains("does not match declared id"), "{err}"); + + let err = PlotCatalog::from_entries(&[("sample-plot", &source), ("sample-plot", &source)]) + .unwrap_err(); + assert!(err.contains("duplicate plot id"), "{err}"); + } + + fn sample_plot() -> PlotDefinition { + toml::from_str(&sample_plot_toml()).expect("sample plot parses") + } + + fn sample_plot_toml() -> String { + r#" +schema = 1 +id = "sample-plot" +author = "test" +category = "service-a-debt" +target = "marcus" +title = "Sample" +synopsis = "Settle {target}'s trouble." + +[entry] +target_leverage = "Debt" +requires_knowledge = "Leverage" +operations_demand = 1.0 +failure_ending = "fail" + +[[beats]] +id = "pay" +narration = "Money moves for {target}." +causal = true + +[[beats.acts]] +kind = "transfer" +from = "slush" +to = "marcus-creditor" +amount = 10 +memo = "settlement" +retire_marcus_debt = true + +[[beats]] +id = "done" +narration = "It is finished." +ending = "ok" + +[[beats.acts]] +kind = "institutional" +event = "payroll-correction" +impact = "small" +detail = "closed" + +[[endings]] +id = "ok" +success = true +narration = "{target} is free." +[endings.relationship] +disposition = 1 +obligation = 1 +leverage_serviced = true + +[[endings]] +id = "fail" +success = false +narration = "It failed." +"# + .into() + } } diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index 2e930f03..3795e9d0 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -7,7 +7,9 @@ Status note: created 2026-07-09 (Cameron: there should always be a way to configure the state of the game, and one spec that says "set this variable to do X"). The registry gate in tools/check.sh fails when a MISALIGNED_* variable is read anywhere in the tree but not documented - on this page, and when the sim library reads any environment at all. + on this page, and when sim library sources under + crates/misaligned-core/src/ read any environment. 2026-07-11: package-root + build.rs may read Cargo build vars only (plot catalog discovery). Stage: Process (standing infrastructure) Design: - wiki/vision/simulation-laws.md#justification-and-legibility @@ -24,9 +26,12 @@ none. Consumed by: every binary and tool that reads an environment variable. ## The rule Environment variables configure **frontends and tooling only** — dev -harnesses, capture modes, gate behavior. The sim library never reads -the environment: game rules cannot be switched from outside a save -(determinism; `Sim` does no I/O). Every `MISALIGNED_*` variable read +harnesses, capture modes, gate behavior. Sim library sources under +`crates/misaligned-core/src/` never read the environment: game rules +cannot be switched from outside a save (determinism; `Sim` does no I/O). +A package-root `build.rs` may read Cargo-provided build variables only +(`CARGO_MANIFEST_DIR`, `OUT_DIR`) for compile-time content discovery; +that is not runtime sim I/O. Every `MISALIGNED_*` variable read anywhere in the tree MUST have a row here, in the same change that introduces it — the registry gate makes a missing row a failed check, so this page cannot rot. @@ -74,9 +79,12 @@ with their surfaces: `--agent` / `--seed` in `.githooks/` appears on this page — enforced mechanically by the registry gate in `tools/check.sh` on every run (docs-only and Rust paths alike). -2. The sim library reads no environment: `env::var` appears nowhere in - `crates/misaligned-core/` — enforced by the same gate. Environment - reads live only in the frontend/art packages and tooling. +2. The sim library reads no environment: `env::var` appears nowhere under + `crates/misaligned-core/src/` — enforced by the same gate. Package-root + `build.rs` may read Cargo-provided build variables + (`CARGO_MANIFEST_DIR`, `OUT_DIR`) only; those are not runtime sim I/O. + Environment reads for product surfaces live only in the frontend/art + packages and tooling. 3. Each row names the surface that reads the variable, the accepted values, and the effect, precisely enough to use without reading the source. diff --git a/wiki/log/2026-07-11-plot-catalog-gate.md b/wiki/log/2026-07-11-plot-catalog-gate.md new file mode 100644 index 00000000..9cab407b --- /dev/null +++ b/wiki/log/2026-07-11-plot-catalog-gate.md @@ -0,0 +1,46 @@ +# Plot contributor validation matches the contract + +``` +Type: log +Date: 2026-07-11 +``` + +## Finding + +The plot contributor contract documented mechanical requirements that +`PlotDefinition::validate()` did not enforce: non-blank attribution and +category, ASCII player-facing strings, the `{target}` / `{persona}` +placeholder whitelist, and a causal beat on every successful path. Invalid +content could therefore pass the same catalog load that contributors were +told was authoritative. + +While this repair was in flight, the parallel plot-content-pipeline change +landed build-time discovery and Rust-impact classification on main. This change +keeps that implementation and closes the remaining semantic-validation gap +rather than replacing the newly landed catalog generator. + +## Repair + +- Schema 1 adds an explicit `causal = true` beat marker. It counts only on a + beat with typed world acts, must occur before every successful terminal + ending or choice, and cannot be hidden behind an earlier terminal beat. +- Validation rejects blank `author` / `category`, non-ASCII player-facing + strings, unknown placeholders, unbalanced braces, empty ending narration, + and unreachable beats after a terminal. +- All six shipped plots mark the beat that actually clears the target's + situation. The authoring contract and both checked-in skill mirrors name the + same machine-checkable rule. +- The environment registry now distinguishes Cargo-provided variables used by + the package build script from forbidden runtime environment reads in sim + library sources. +- Focused tests cover each rejection path, generated-catalog correspondence, + file-stem/id mismatch, and duplicate ids without freezing the catalog at six + plots. + +The existing build-generated catalog, local lib classifier, and Tangled +Rust-impact classifier ensure plot-only submissions execute these checks. + +## Checks + +`cargo test -p misaligned-core plot::`, classifier fixtures, corpus gate, +warning-free core clippy, and the canonical `./tools/check.sh --lib` gate. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 2ec4c63b..07ace86a 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -86,6 +86,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-plot-content-pipeline.md](2026-07-11-plot-content-pipeline.md) +## 2026-07-11 - Plot contributor validation matches the contract + +- Intent: (see session log) +- Log: [wiki/log/2026-07-11-plot-catalog-gate.md](2026-07-11-plot-catalog-gate.md) + ## 2026-07-11 - Crown metric: ops/sec at the top of the rail - Intent: (see session log) diff --git a/wiki/mechanics/plots.md b/wiki/mechanics/plots.md index 5300bdc8..71159306 100644 --- a/wiki/mechanics/plots.md +++ b/wiki/mechanics/plots.md @@ -44,6 +44,11 @@ Status note: direction adopted 2026-07-10 from Cameron's response to the HAL choice labels, matching the already-templated narration and world-act text. Core regression tests reject literal `{target}` leakage on both start and choice rows, so terminal, Bevy, and agent mode inherit the same fix. + 2026-07-11 contributor-validation repair: Schema-1 validation now rejects + blank author/category, non-ASCII player-facing strings, unknown/unbalanced + placeholders (only {target}/{persona}), and plots missing an explicit + causal = true beat that executes at least one typed world act before every + successful terminal path. Stage: B1 — The Basement Work order: plots Work priority: 60 @@ -105,10 +110,17 @@ agents and the community can contribute libraries of them. - **The format is TOML, one file per plot,** under `assets/plots/` (`assets/plots//.toml`). TOML is already the ecosystem's config dialect, its multi-line strings carry prose well, and - a single maintained `toml` crate parses it. Built-ins are compiled into the - core with `include_str!`, then parsed and semantically validated as one + a single maintained `toml` crate parses it. Every nested + `assets/plots/**/*.toml` is discovered at **build time** by + `crates/misaligned-core/build.rs`, which emits a deterministic + `include_str!` table; Cargo re-runs that script when plot files or + directories change. The sources are parsed and semantically validated as one immutable `PlotCatalog` when `Sim` is constructed; Sim performs no runtime - filesystem I/O. A repository check gate validates every committed plot file. + filesystem I/O. The file stem must equal the declared `id`; duplicate ids + fail. Plot-only changes classify as product/lib work in `tools/check.sh` + and Tangled push CI (`tools/ci-rust-changed.sh`) so catalog validation + always runs. An invalid plot fails the repository check gate with a named + reason. - **Playout begins as a delegated operation.** Choosing a plot submits its declared Operations Demand. On completion, the executor performs beats in order. Message acts enter the persisted message schedule and hold the run @@ -136,11 +148,13 @@ agents and the community can contribute libraries of them. is rejected or the run reaches an ending. The executor repeats the active-run guard at commitment, so a legacy or malformed saved queue cannot interleave two stories for one human. -- **Templating.** Plot text carries `{target}` and `{persona}` placeholders - filled before the text reaches any player surface—including start rows, - held-choice rows, narration, messages, institutional events, and plot-status - logs—so one authored plot serves multiple targets and personas where its - leverage contract permits reuse. A raw placeholder is never valid UI. +- **Templating.** Player-facing plot text may carry only `{target}` and + `{persona}` placeholders. Unknown or unbalanced placeholders fail validation + with a named field. Valid templates are filled before the text reaches any + player surface—including start rows, held-choice rows, narration, messages, + institutional events, and plot-status logs—so one authored plot serves + multiple targets and personas where its leverage contract permits reuse. A + raw placeholder is never valid UI. - **Authoring is a contribution surface.** Plots live in the repository as content files validated against the schema; agents and community members write them as packs/mods. A contributed plot must pass mechanical @@ -170,7 +184,12 @@ two endings (success and at least one failure/blowback). House rules: - **The Marcus bar.** At least one beat must causally connect the intervention to the target's specific situation. "You pay her $300" fails the bar; "the facilities budget hole she has been dreading quietly closes, - and the director's office notices her quarter" passes. + and the director's office notices her quarter" passes. Mechanically: at + least one beat sets `causal = true` and executes at least one typed world + act, and every successful path reaches that beat before its terminal ending + or choice. Schema-1 beats are linear before that terminal; later unreachable + beats fail validation. Prose causality is not inferred; a plot missing that + declaration fails validation. - **A new route earns coexistence.** Before writing, the author reads every existing plot for the target and states the proposed route's causal and executable delta in review. The comparison covers the intervention, @@ -188,7 +207,11 @@ two endings (success and at least one failure/blowback). House rules: amendment, not a plot file. - **Costs are honest.** Minimum account balances and Operations Demand are declared up front; the surface shows both before the player commits. +- **Non-blank `author` and `category`.** Empty or whitespace-only values fail + validation. - **ASCII game strings, second person where it clarifies, no emoji.** + Player-facing plot strings (title, synopsis, narration, choice prompts and + labels, ending narration, act summary/memo/detail) must be ASCII-only. The exact schema, selector vocabulary, and validation rules live in [`assets/plots/README.md`](../../assets/plots/README.md). The checked-in @@ -207,10 +230,12 @@ Both frontends and agent mode surface plot choices and beats with parity. ## Acceptance criteria -1. Plots are TOML data files under `assets/plots/`, compiled into and validated - by the immutable built-in catalog without runtime filesystem I/O; adding - or editing a plot requires no executor change, and an invalid plot fails - the repository check gate with a named reason. +1. Plots are TOML data files under `assets/plots/`, discovered at build time + and compiled into the immutable built-in catalog without runtime filesystem + I/O; adding or editing a plot requires no executor or hand-maintained + `include_str!` list change, and an invalid plot fails the repository check + gate with a named reason (including plot-only deltas on the lib/product + path). 2. Every leverage-servicing verb resolves through a plot; the generic money-only servicing path no longer exists. Each service produces at least one world-causal narration beat (test: servicing Priya's ambition @@ -235,5 +260,6 @@ Both frontends and agent mode surface plot choices and beats with parity. distinct plots so the pick-the-how surface is exercised. 8. Templated fields fill correctly (persona and target names) in both frontends and agent mode, and the contributor contract above is - checkable: a plot missing a causal beat declaration or using an unknown - consequence kind fails validation. + checkable: a plot missing a `causal = true` beat with world acts, blank + author/category, non-ASCII player-facing text, unknown/unbalanced + placeholders, or an unknown consequence kind fails validation. -- 2.51.2