From 61095e69ce893df3a18ad7d2ff7fc97f94d2a096 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 2 Sep 2026 11:57:51 +0000 Subject: [PATCH] Reject unversioned party documents --- docs/config.md | 25 +++--- docs/integrations/party.md | 27 +++--- docs/internal/configuration.md | 28 +++---- docs/roadmap/implementation.md | 2 +- docs/troubleshooting.md | 21 +++++ internal/app/commands_test.go | 14 +--- internal/app/config_commands_test.go | 4 +- internal/app/extract_lifecycle_test.go | 40 ++++++--- internal/app/operator_helpers_test.go | 3 +- internal/app/operator_inspection.go | 4 + internal/app/operator_session_init_test.go | 1 - internal/app/plan_test.go | 2 - internal/app/restore_test.go | 5 +- internal/app/runner_test.go | 31 +++---- internal/app/session_cli_test.go | 4 - internal/config/artifact_families_test.go | 2 +- internal/config/campaign_config_test.go | 40 ++++----- internal/config/load_validate_test.go | 3 +- internal/config/party.go | 44 ++++------ internal/config/party_legacy.go | 17 ---- internal/config/party_resolution.go | 10 +-- internal/config/party_resolution_test.go | 82 +------------------ internal/config/party_test.go | 28 +++---- internal/config/pipeline_profiles_test.go | 4 +- internal/config/scriptorium_test.go | 2 - internal/config/session_template_test.go | 6 -- internal/config/storage_publish_test.go | 10 --- internal/stage/semantic_contracts_initial.go | 2 +- .../stage/semantic_contracts_initial_test.go | 4 +- 29 files changed, 172 insertions(+), 293 deletions(-) delete mode 100644 internal/config/party_legacy.go diff --git a/docs/config.md b/docs/config.md index e0d20eb..17a58b3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -62,8 +62,7 @@ effective pipeline YAML, with defaults and concrete expansion included but composition and family declarations omitted. `config sources` prints a stable source projection instead of effective values: root/import/profile/default ownership plus campaign/party and generated-family records. Canonical derived -players trace to the party; a legacy configured players file is explicitly -marked as a legacy player source. The [CLI reference](cli.md#config-validate-config-show-config-sources-and-config-diff) +players trace to the party. The [CLI reference](cli.md#config-validate-config-show-config-sources-and-config-diff) owns command syntax and output conventions. `config diff` compares normalized field values rather than YAML text or source @@ -83,8 +82,8 @@ selected overlay, and retain a production `default_profile`. Convert campaign rosters to [canonical party input](integrations/party.md), remove a separate `players_file`, then express character work as families. Inspect the result with `config validate`, `config show`, and `config sources`; use `config diff` -to review profiles before running a session. Unversioned parties and their -`players_file` remain a clearly bounded legacy compatibility path. +to review profiles before running a session. Unversioned party documents are +rejected. ### Identity segments @@ -116,12 +115,10 @@ remote state with an unsafe legacy identity must be migrated before use. `party_file`) and the optional `spell_catalog_file` resolve from session overrides when provided, otherwise from campaign defaults. An empty or omitted session spell-catalog value inherits the campaign value. -- `party_file` is classified when pipeline and campaign configuration are - combined. A versioned [canonical party](integrations/party.md) is +- `party_file` is parsed when pipeline and campaign configuration are combined. + It must be a versioned [canonical party](integrations/party.md), is campaign-owned, derives the players input internally, and forbids both a - separate `players_file` and a session `party_file` override. An unversioned - party remains a bounded legacy input and requires `players_file`; its normal - campaign/session overrides continue to apply. + separate `players_file` and a session `party_file` override. - Exactly one audio mode must be configured in session input: - local (`audio_dir` or `audio_files`), or - S3 (`audio_s3.prefix`). @@ -213,7 +210,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ``` @@ -476,7 +472,8 @@ does not provide adapter-specific input passthrough fields. `pipeline.scriptorium.artifact_families` declares a shared artifact template for every canonical campaign character. Configuration resolution expands each family into ordinary `pipeline.scriptorium.artifacts` entries before analyze -planning or Scriptorium invocation. A legacy party cannot be used for a family. +planning or Scriptorium invocation. Party parsing rejects unsupported document +schemas before family expansion. For each `pipeline.scriptorium.artifact_families.`: @@ -524,7 +521,7 @@ integration. | `inputs.speakers_file` | string | Yes | stable input default | | `inputs.autocorrect_file` | string | Yes | stable input default | | `inputs.glossary_file` | string | Yes | stable input default | -| `inputs.players_file` | string | Conditional | required only with an unversioned legacy `party_file`; forbidden for a canonical party | +| `inputs.players_file` | string | No | retained temporarily for configuration compatibility; forbidden with the required canonical party | | `inputs.party_file` | string | Yes | stable campaign party source; relative paths resolve from `campaign.yml` | | `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` | @@ -540,8 +537,8 @@ integration. | `inputs.speakers_file` | string | No | overrides campaign stable input | | `inputs.autocorrect_file` | string | No | overrides campaign stable input | | `inputs.glossary_file` | string | No | overrides campaign stable input | -| `inputs.players_file` | string | No | legacy-party override; forbidden for a canonical party | -| `inputs.party_file` | string | No | legacy-party override; forbidden for a canonical campaign party | +| `inputs.players_file` | string | No | retained temporarily for configuration compatibility; forbidden with the required canonical party | +| `inputs.party_file` | string | No | retained temporarily for configuration compatibility; forbidden for a canonical campaign party | | `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value | | `inputs.audio_dir` | string | Conditional | local audio mode | | `inputs.audio_files[]` | list[string] | Conditional | local audio mode | diff --git a/docs/integrations/party.md b/docs/integrations/party.md index 3758e6b..6ad79e0 100644 --- a/docs/integrations/party.md +++ b/docs/integrations/party.md @@ -1,8 +1,8 @@ # Canonical Party Input -`party.yml` is a campaign-owned roster input. Narratio recognizes the -versioned `narratio.party.v1` document below when it resolves a pipeline, -campaign, and session together. +`party.yml` is a campaign-owned roster input. It must be a single, versioned +`narratio.party.v1` document when Narratio resolves a pipeline, campaign, and +session together. ```yaml schema_version: narratio.party.v1 @@ -34,19 +34,20 @@ non-empty, trimmed display strings without control characters. Character names and aliases must be unique across the full roster under Unicode-aware case-insensitive comparison; player names may repeat. -The document has exactly one YAML document and accepts no unknown fields. A -wrong or malformed `schema_version` is an error. +The document has exactly one YAML document and accepts no unknown fields. +`schema_version` is required: a missing, wrong, or malformed value is an error. -## Legacy migration boundary +## Migration -An unversioned party input remains supported only as opaque legacy reference -material while campaigns migrate. It requires a separate `players_file` and -retains the existing session override behavior. It cannot be mixed with a -canonical party: canonical campaigns must omit `players_file`, and sessions -must not override their party or players inputs. +Unversioned party inputs are rejected. Replace each legacy party document with +the versioned structure above, move each roster entry under `characters`, and +remove separate player and session-party overrides from the selected campaign +and session configuration. The configuration-key removal itself is staged +separately; current configurations must already omit those competing values +when using a canonical party. -Use the canonical document for new campaigns. The configuration rules and -source-relative path behavior are defined in the [Configuration Reference](../config.md). +The configuration rules and source-relative path behavior are defined in the +[Configuration Reference](../config.md). ## Derived players document diff --git a/docs/internal/configuration.md b/docs/internal/configuration.md index 51a7265..8346bbf 100644 --- a/docs/internal/configuration.md +++ b/docs/internal/configuration.md @@ -75,8 +75,7 @@ each contributor as root, import, profile, or centralized default. The projection replaces generated concrete member paths with paired family and canonical-party records, and does the same for generated publish rules. Campaign records identify campaign-owned fields and party inputs; canonical -derived players point to the party source, while legacy players retain a -dedicated legacy-player role. The application command only joins these sorted +derived players point to the party source. The application command only joins these sorted records with selection metadata and never reparses configuration files. `config diff` uses a paired profile loader that parses the root, imports, and @@ -90,28 +89,24 @@ Values are compact deterministic JSON representations for command output, not raw YAML fragments, ownership records, or secret material. A differing digest with no projected difference is treated as an internal consistency error. -Campaign context construction also reads and classifies the campaign-owned -party source through `ParseParty`. A canonical party retains its raw bytes and -normalized roster in runtime-only `ResolvedParty` provenance, while a legacy -party remains opaque. Canonical resolution creates a virtual -`derived_from_party` players input and rejects competing campaign or session -players files and session party overrides. The compact legacy compatibility -path resolves the effective campaign/session party and players files together. +Campaign context construction reads the campaign-owned party source through +`ParseParty`. The required canonical party retains its raw bytes and normalized +roster in runtime-only `ResolvedParty` provenance. Canonical resolution creates +a virtual `derived_from_party` players input and rejects competing campaign or +session players files and session party overrides. ## Canonical Party Domain -`ParseParty` is the package-owned boundary for classifying a party source. -When a top-level `schema_version` is present, it strictly validates the +`ParseParty` is the package-owned boundary for parsing a party source. It +requires a top-level `schema_version` and strictly validates the `narratio.party.v1` contract into ordered character domain values. The canonical value retains a separate exact byte copy of its source so consumers can materialize the authored party document without reserializing it. Its `PlayersYAML` method deterministically derives the versioned players-only projection. -An unversioned source is classified by the small legacy compatibility boundary -in `party_legacy.go`; it deliberately exposes no parsed roster information. -That boundary exists solely to isolate removable compatibility behavior from -the canonical parser. +An unversioned source is rejected before resolution, family expansion, or +prepare-stage materialization. ## Diagnostics And Runtime Metadata @@ -153,8 +148,7 @@ configuration tests continue to protect defaults and validation after assembly. `party_test.go` protects the versioned party schema, domain invariants, and deterministic players projection without involving campaign or runtime wiring. `party_resolution_test.go` protects campaign-owned party loading, canonical -input restrictions, legacy overrides, source provenance, and virtual players -input selection. +input restrictions, source provenance, and virtual players input selection. ## Artifact Family Resolution diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b56808b..9c00fe7 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -150,7 +150,7 @@ branches. ## Stage 2 — Reject Unversioned Party Documents -**Status: Pending** +**Status: Completed** ### Goal diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 85bf6dc..82e1617 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -76,6 +76,27 @@ Safe fix: Relevant reference: [Configuration](./config.md). +## Party schema version rejected + +Symptom: + +- configuration loading reports that `party.schema_version` is required or + unsupported. + +Likely cause: + +- the campaign `party.yml` is an unversioned legacy document, or names a + schema other than `narratio.party.v1`. + +Safe fix: + +- replace the file with one exact, single-document + [`narratio.party.v1` party](./integrations/party.md); do not add a version + marker to an otherwise incompatible legacy structure. Remove competing + player and session-party settings while migrating. + +Relevant reference: [Canonical Party Input](./integrations/party.md). + ## Unexpected imported or profile value Symptom: diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index f719b0a..8720715 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -227,8 +227,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) @@ -292,8 +290,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) @@ -389,14 +385,13 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml `) mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(otherDir, "players.yml"), "[]\n") - mustWriteTestFile(t, filepath.Join(otherDir, "party.yml"), "[]\n") + mustWriteTestFile(t, filepath.Join(otherDir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n") var stdout bytes.Buffer var stderr bytes.Buffer @@ -522,7 +517,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ` @@ -542,8 +536,7 @@ inputs: mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n") - mustWriteTestFile(t, filepath.Join(campaignDir, "players.yml"), "[]\n") - mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), "[]\n") + mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") return pipelinePath, campaignPath, sessionPath @@ -568,13 +561,12 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { t.Fatalf("write campaign.yml: %v", err) } - mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n") + mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n") return campaignPath } diff --git a/internal/app/config_commands_test.go b/internal/app/config_commands_test.go index b7376bd..ca9c39e 100644 --- a/internal/app/config_commands_test.go +++ b/internal/app/config_commands_test.go @@ -400,12 +400,12 @@ func TestConfigSourcesReportsStableOwnership(t *testing.T) { "imports: none", "profile: name=testing selection=cli overlay=", "campaign: id=sample-campaign source=", - "party: mode=legacy source=", + "party: mode=canonical source=", "digest: ", "path\trole\tsource\n", "workspace.root\troot\t", "whisperx.language\tprofile\t", - "campaign.inputs.players_file\tlegacy_player\t", + "derived.players\tparty\t", "trim.enabled\tdefault\tdefault", } { if !strings.Contains(output, want) { diff --git a/internal/app/extract_lifecycle_test.go b/internal/app/extract_lifecycle_test.go index 3a56a76..606162c 100644 --- a/internal/app/extract_lifecycle_test.go +++ b/internal/app/extract_lifecycle_test.go @@ -210,7 +210,7 @@ func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) { func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDownstream(t *testing.T) { cfg, env, runner := extractionLifecycleFixture(t, true) - originalPaths := configureLifecycleReferences(t, cfg) + configureLifecycleReferences(t, cfg) if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil { t.Fatalf("initial executeStages() error = %v", err) } @@ -222,9 +222,18 @@ func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDo if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), before); err != nil { t.Fatalf("Save(downstream success) error = %v", err) } - if err := os.WriteFile(originalPaths[artifactpolicy.SourceInputParty], []byte("changed party bytes\n"), 0o644); err != nil { - t.Fatalf("WriteFile(party source) error = %v", err) + partyDocument, err := config.ParseParty([]byte(`schema_version: narratio.party.v1 +characters: + changed: + player: {name: Eric} + character: + name: Changed + classes: [{name: wizard}] +`)) + if err != nil { + t.Fatalf("ParseParty(changed party) error = %v", err) } + cfg.Party.Canonical = partyDocument.Canonical prepared := loadLifecycleManifest(t, cfg) prepare, err := stage.Select("prepare") @@ -266,17 +275,22 @@ func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDo } } -func TestExtractLifecycleSessionOverrideBytesReachCanonicalReference(t *testing.T) { +func TestExtractLifecycleCanonicalPartyBytesReachCanonicalReference(t *testing.T) { cfg, env, runner := extractionLifecycleFixture(t, true) configureLifecycleReferences(t, cfg) - overridePath := filepath.Join(filepath.Dir(cfg.SessionPath), "session-party.yml") - if err := os.WriteFile(overridePath, []byte("session override party\n"), 0o644); err != nil { - t.Fatalf("WriteFile(session override) error = %v", err) + partyBytes := []byte(`schema_version: narratio.party.v1 +characters: + session: + player: {name: Eric} + character: + name: Session + classes: [{name: wizard}] +`) + partyDocument, err := config.ParseParty(partyBytes) + if err != nil { + t.Fatalf("ParseParty(canonical party) error = %v", err) } - cfg.StableInputs.PartyFile = config.ResolvedInputFile{ - Path: "./session-party.yml", ConfigPath: cfg.SessionPath, Source: "session_config", - } - cfg.Session.Inputs.PartyFile = "./session-party.yml" + cfg.Party.Canonical = partyDocument.Canonical if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil { t.Fatalf("executeStages() error = %v", err) @@ -294,8 +308,8 @@ func TestExtractLifecycleSessionOverrideBytesReachCanonicalReference(t *testing. if err != nil { t.Fatalf("ReadFile(prepared party) error = %v", err) } - if string(contents) != "session override party\n" || partyPath == overridePath { - t.Fatalf("prepared party path=%q contents=%q override=%q", partyPath, contents, overridePath) + if string(contents) != string(partyBytes) || partyPath == cfg.Party.Source.Path { + t.Fatalf("prepared party path=%q contents=%q source=%q", partyPath, contents, cfg.Party.Source.Path) } } diff --git a/internal/app/operator_helpers_test.go b/internal/app/operator_helpers_test.go index 4499a7e..d237df1 100644 --- a/internal/app/operator_helpers_test.go +++ b/internal/app/operator_helpers_test.go @@ -113,7 +113,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml `), 0o644); err != nil { t.Fatalf("write explicit campaign: %v", err) @@ -122,7 +121,7 @@ inputs: mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(explicitDir, "players.yml"), "[]\n") - mustWriteTestFile(t, filepath.Join(explicitDir, "party.yml"), "[]\n") + mustWriteTestFile(t, filepath.Join(explicitDir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n") fake := &storage.FakeBackend{} var storeInitCalls int diff --git a/internal/app/operator_inspection.go b/internal/app/operator_inspection.go index 60a4266..4d31154 100644 --- a/internal/app/operator_inspection.go +++ b/internal/app/operator_inspection.go @@ -66,6 +66,10 @@ func inspectStableInputs(cfg *config.Config) []stableInputCheck { } out := make([]stableInputCheck, 0, len(items)) for _, item := range items { + if item.name == "players" && item.in.Source == "derived_from_party" { + out = append(out, stableInputCheck{Name: item.name, Path: "derived from canonical party"}) + continue + } if item.optional && strings.TrimSpace(item.in.Path) == "" { continue } diff --git a/internal/app/operator_session_init_test.go b/internal/app/operator_session_init_test.go index 645ab59..6f51b7e 100644 --- a/internal/app/operator_session_init_test.go +++ b/internal/app/operator_session_init_test.go @@ -28,7 +28,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(campaignPath, []byte(campaign), 0o644); err != nil { diff --git a/internal/app/plan_test.go b/internal/app/plan_test.go index cdfe576..f82b7bf 100644 --- a/internal/app/plan_test.go +++ b/internal/app/plan_test.go @@ -117,8 +117,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) diff --git a/internal/app/restore_test.go b/internal/app/restore_test.go index ecc371b..2f53f7c 100644 --- a/internal/app/restore_test.go +++ b/internal/app/restore_test.go @@ -436,8 +436,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline config: %v", err) @@ -448,8 +446,7 @@ inputs: mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n") mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") - mustWriteTestFile(t, filepath.Join(dir, "players.yml"), "[]\n") - mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "[]\n") + mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") return pipelinePath, campaignPath, sessionPath diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 6a3082f..b6e1cd9 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -1316,14 +1316,17 @@ func testConfig(t *testing.T) *config.Config { pipelinePath := filepath.Join(cfgDir, "pipeline.yml") mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") - mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n") + mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n") mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n") mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") - mustWriteFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n") - mustWriteFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n") + mustWriteFile(t, filepath.Join(cfgDir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n") mustWriteFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "audio") + partyDocument, err := config.ParseParty([]byte("schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n")) + if err != nil { + t.Fatalf("parse canonical party: %v", err) + } return &config.Config{ Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, @@ -1331,6 +1334,15 @@ func testConfig(t *testing.T) *config.Config { PipelinePath: pipelinePath, CampaignPath: campaignPath, SessionPath: sessionPath, + Party: config.ResolvedParty{ + Mode: config.PartyModeCanonical, + Canonical: partyDocument.Canonical, + Source: config.PartySource{ + Path: filepath.Join(cfgDir, "party.yml"), + ConfigPath: campaignPath, + Source: "campaign_config", + }, + }, StableInputs: config.ResolvedStableInputs{ SpeakersFile: config.ResolvedInputFile{ Path: "./speakers.yml", @@ -1347,11 +1359,7 @@ func testConfig(t *testing.T) *config.Config { ConfigPath: campaignPath, Source: "campaign_config", }, - PlayersFile: config.ResolvedInputFile{ - Path: "./players.yml", - ConfigPath: campaignPath, - Source: "campaign_config", - }, + PlayersFile: config.ResolvedInputFile{Source: "derived_from_party"}, PartyFile: config.ResolvedInputFile{ Path: "./party.yml", ConfigPath: campaignPath, @@ -1366,8 +1374,6 @@ func testConfig(t *testing.T) *config.Config { SpeakersFile: "./speakers.yml", AutocorrectFile: "./autocorrect.yml", GlossaryFile: "./glossary.yml", - PlayersFile: "./players.yml", - PartyFile: "./party.yml", }, }, } @@ -1390,7 +1396,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ` sessionYAML := `session_id: 2026-05-03 @@ -1400,13 +1405,11 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml ` mustWriteFile(t, pipelinePath, pipelineYAML) mustWriteFile(t, campaignPath, campaignYAML) mustWriteFile(t, sessionPath, sessionYAML) - mustWriteFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n") + mustWriteFile(t, filepath.Join(dir, "party.yml"), "schema_version: narratio.party.v1\ncharacters:\n arannis:\n player: {name: Eric}\n character:\n name: Arannis\n classes: [{name: wizard}]\n") cfg, err := config.Load(pipelinePath, sessionPath) if err != nil { diff --git a/internal/app/session_cli_test.go b/internal/app/session_cli_test.go index 6f5b06f..ce9fe55 100644 --- a/internal/app/session_cli_test.go +++ b/internal/app/session_cli_test.go @@ -22,8 +22,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil { t.Fatalf("write session template: %v", err) @@ -77,8 +75,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) diff --git a/internal/config/artifact_families_test.go b/internal/config/artifact_families_test.go index faf471d..4edacab 100644 --- a/internal/config/artifact_families_test.go +++ b/internal/config/artifact_families_test.go @@ -123,7 +123,7 @@ func TestArtifactFamiliesRejectInvalidExpansionDeclarations(t *testing.T) { families string wantError string }{ - {name: "legacy party", party: "legacy: party\n", families: validFamilyYAML, wantError: "requires a canonical campaign party"}, + {name: "unversioned party", party: "characters: {}\n", families: validFamilyYAML, wantError: "schema_version is required"}, {name: "invalid iteration", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "party.characters", "party.players", 1), wantError: ".for_each must be"}, {name: "invalid token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "{character_id}", "{character_name}", 1), wantError: "must contain exactly one"}, {name: "repeated token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "meta.md", "{character_id}.md", 1), wantError: "must contain exactly one"}, diff --git a/internal/config/campaign_config_test.go b/internal/config/campaign_config_test.go index 8a164b1..563e42e 100644 --- a/internal/config/campaign_config_test.go +++ b/internal/config/campaign_config_test.go @@ -37,7 +37,7 @@ notification: func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, - "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", + "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) @@ -52,7 +52,7 @@ func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) { func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, - "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", + "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) @@ -67,7 +67,7 @@ func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) { func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, - "campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", + "campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) @@ -82,7 +82,7 @@ func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) { func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, - "campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", + "campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) @@ -97,7 +97,7 @@ func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) { func TestCampaignSessionMergeFillsStableInputs(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, - "campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n", + "campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n party_file: ./campaign-party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) @@ -114,14 +114,14 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) { assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config") - assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./campaign-players.yml", campaignPath, "campaign_config") + assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "", "", "derived_from_party") assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config") } func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, - "campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n", - "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n players_file: ./session-players.yml\n party_file: ./session-party.yml\n", + "campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n party_file: ./campaign-party.yml\n", + "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n", ) cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) @@ -134,8 +134,8 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) { assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config") assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config") - assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./session-players.yml", sessionPath, "session_config") - assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config") + assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "", "", "derived_from_party") + assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config") } func TestCampaignSessionMergeSpellCatalog(t *testing.T) { @@ -162,7 +162,7 @@ func TestCampaignSessionMergeSpellCatalog(t *testing.T) { sessionSpell = " spell_catalog_file: " + tt.sessionValue + "\n" } pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, - "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n"+campaignSpell, + "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n"+campaignSpell, "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+sessionSpell, ) @@ -195,7 +195,7 @@ func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, - "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n"+tt.campaignLine, + "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n"+tt.campaignLine, "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+tt.sessionLine, ) _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) @@ -206,24 +206,20 @@ func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) { } } -func TestLegacyCampaignRequiresPlayersInput(t *testing.T) { +func TestCampaignWithCanonicalPartyDoesNotRequirePlayersInput(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) - _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) - if err == nil { - t.Fatal("expected load error, got nil") - } - if !strings.Contains(err.Error(), "players_file is required with a legacy party") { - t.Fatalf("error = %q, want players_file required", err.Error()) + if _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}); err != nil { + t.Fatalf("LoadWithSessionOptions() error = %v", err) } } func TestCampaignSessionMismatchFails(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, - "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", + "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n", ) @@ -238,7 +234,7 @@ func TestCampaignSessionMismatchFails(t *testing.T) { func TestLoadMissingCampaignFileFails(t *testing.T) { pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t, - "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", + "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml") @@ -271,7 +267,7 @@ func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string t.Fatalf("write session.yml: %v", err) } for _, name := range []string{"party.yml", "campaign-party.yml", "session-party.yml"} { - if err := os.WriteFile(filepath.Join(dir, name), []byte("legacy: party\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, name), []byte(canonicalPartyFixture), 0o644); err != nil { t.Fatalf("write %s: %v", name, err) } } diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 2ad464b..7a258d9 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -327,7 +327,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { @@ -336,7 +335,7 @@ inputs: if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) } - if err := os.WriteFile(filepath.Join(dir, "party.yml"), []byte("legacy: party\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "party.yml"), []byte(canonicalPartyFixture), 0o644); err != nil { t.Fatalf("write party.yml: %v", err) } diff --git a/internal/config/party.go b/internal/config/party.go index a23531e..a9dc090 100644 --- a/internal/config/party.go +++ b/internal/config/party.go @@ -83,17 +83,12 @@ type PartyClass struct { Level *int } -// ParseParty classifies and, when selected by schema_version, strictly parses -// a party source. An unversioned source takes the deliberately opaque legacy -// compatibility path; see party_legacy.go. +// ParseParty strictly parses one supported party document. func ParseParty(data []byte) (*PartyDocument, error) { root, decoder, err := parsePartyFirstDocument(data) if err != nil { return nil, err } - if !partyDocumentSelectsCanonical(root) { - return classifyLegacyPartyDocument(), nil - } if err := requireNoTrailingPartyDocuments(decoder); err != nil { return nil, err } @@ -119,25 +114,12 @@ func parsePartyFirstDocument(data []byte) (*yaml.Node, *yaml.Decoder, error) { return document.Content[0], decoder, nil } -func partyDocumentSelectsCanonical(root *yaml.Node) bool { - if root == nil || root.Kind != yaml.MappingNode { - return false - } - for index := 0; index+1 < len(root.Content); index += 2 { - key := root.Content[index] - if key.Kind == yaml.ScalarNode && key.Tag == "!!str" && key.Value == "schema_version" { - return true - } - } - return false -} - func requireNoTrailingPartyDocuments(decoder *yaml.Decoder) error { var trailing yaml.Node if err := decoder.Decode(&trailing); err == nil { - return fmt.Errorf("canonical party must contain exactly one YAML document") + return fmt.Errorf("party must contain exactly one YAML document") } else if err != io.EOF { - return fmt.Errorf("decode trailing canonical party YAML: %w", err) + return fmt.Errorf("decode trailing party YAML: %w", err) } return nil } @@ -147,17 +129,21 @@ func parseCanonicalParty(root *yaml.Node, raw []byte) (*CanonicalParty, error) { if err != nil { return nil, err } + + version, ok := fields["schema_version"] + if !ok { + return nil, fmt.Errorf("party.schema_version is required; supported schema_version is %q", PartySchemaVersion) + } + schemaVersion, err := partyRequiredString(map[string]*yaml.Node{"schema_version": version}, "schema_version", "party") + if err != nil { + return nil, fmt.Errorf("party.schema_version must be a non-empty string; supported schema_version is %q", PartySchemaVersion) + } + if schemaVersion != PartySchemaVersion { + return nil, fmt.Errorf("party.schema_version %q is unsupported; supported schema_version is %q", schemaVersion, PartySchemaVersion) + } if err := partyKnownFields(fields, "party", "schema_version", "characters"); err != nil { return nil, err } - - version, err := partyRequiredString(fields, "schema_version", "party") - if err != nil { - return nil, err - } - if version != PartySchemaVersion { - return nil, fmt.Errorf("party.schema_version %q is unsupported", version) - } charactersNode, ok := fields["characters"] if !ok { return nil, fmt.Errorf("party.characters is required") diff --git a/internal/config/party_legacy.go b/internal/config/party_legacy.go deleted file mode 100644 index 80543c1..0000000 --- a/internal/config/party_legacy.go +++ /dev/null @@ -1,17 +0,0 @@ -package config - -// classifyLegacyPartyDocument is the bounded compatibility path for an -// unversioned party source. It intentionally retains no parsed roster data and -// must be removed once legacy campaign inputs are no longer supported. -func classifyLegacyPartyDocument() *PartyDocument { - return &PartyDocument{Mode: PartyModeLegacy} -} - -// resolveLegacyParty preserves the deliberately opaque legacy party mode. -// This small compatibility surface is intended for removal once legacy -// campaign inputs are no longer supported. -func resolveLegacyParty(resolved ResolvedParty) (ResolvedParty, error) { - resolved.Mode = PartyModeLegacy - resolved.Canonical = nil - return resolved, nil -} diff --git a/internal/config/party_resolution.go b/internal/config/party_resolution.go index 108c410..60772cf 100644 --- a/internal/config/party_resolution.go +++ b/internal/config/party_resolution.go @@ -32,19 +32,15 @@ func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) { if err != nil { return ResolvedParty{}, fmt.Errorf("parse party input %q: %w", path, err) } - resolved := ResolvedParty{ - Mode: document.Mode, + return ResolvedParty{ + Mode: PartyModeCanonical, Source: PartySource{ Path: path, ConfigPath: configPath, Source: input.Source, }, Canonical: document.Canonical, - } - if document.IsCanonical() { - return resolved, nil - } - return resolveLegacyParty(resolved) + }, nil } func campaignPartyInput(campaignPath string, campaign *CampaignConfig) (ResolvedInputFile, error) { diff --git a/internal/config/party_resolution_test.go b/internal/config/party_resolution_test.go index bdc0b68..9b966ad 100644 --- a/internal/config/party_resolution_test.go +++ b/internal/config/party_resolution_test.go @@ -85,44 +85,7 @@ func TestCanonicalPartyRejectsSeparatePlayersAndSessionPartyOverrides(t *testing } } -func TestLegacyPartyPreservesCampaignAndSessionInputOverrides(t *testing.T) { - dir := t.TempDir() - writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: campaign\n") - writePartyResolutionFile(t, filepath.Join(dir, "session-party.yml"), "legacy: session\n") - pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign -inputs: - speakers_file: speakers.yml - autocorrect_file: autocorrect.yml - glossary_file: glossary.yml - players_file: campaign-players.yml - party_file: party.yml -`, `session_id: session -campaign: campaign -inputs: - audio_dir: audio - players_file: session-players.yml - party_file: session-party.yml -`) - - cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) - if err != nil { - t.Fatalf("LoadWithSessionOptions() error = %v", err) - } - if err := Validate(cfg); err != nil { - t.Fatalf("Validate() error = %v", err) - } - if cfg.Party.Mode != PartyModeLegacy || cfg.Party.Canonical != nil { - t.Fatalf("party = %#v, want opaque legacy party", cfg.Party) - } - if cfg.Party.Source.Source != "session_config" || filepath.Base(cfg.Party.Source.Path) != "session-party.yml" { - t.Fatalf("party source = %#v, want session override", cfg.Party.Source) - } - if got := cfg.StableInputs.PlayersFile; got.Path != "session-players.yml" || got.Source != "session_config" { - t.Fatalf("players input = %#v, want session override", got) - } -} - -func TestLegacyPartyAcceptsAnEffectiveSessionPlayersOverride(t *testing.T) { +func TestUnversionedPartyIsRejectedBeforeResolution(t *testing.T) { dir := t.TempDir() writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: campaign\n") pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign @@ -135,49 +98,10 @@ inputs: campaign: campaign inputs: audio_dir: audio - players_file: session-players.yml `) - cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) - if err != nil { - t.Fatalf("LoadWithSessionOptions() error = %v", err) - } - if got := cfg.StableInputs.PlayersFile; got.Path != "session-players.yml" || got.Source != "session_config" { - t.Fatalf("players input = %#v, want effective session override", got) - } -} -func TestLegacyPartyRequiresPlayersAndPartyMustBeReadableRegularFile(t *testing.T) { - tests := []struct { - name string - partySetup func(t *testing.T, dir string) - partyFile string - playersFile string - want string - }{ - {name: "missing players", partySetup: func(t *testing.T, dir string) { - writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n") - }, partyFile: "party.yml", want: "players_file is required with a legacy party"}, - {name: "missing party file", partySetup: func(t *testing.T, dir string) {}, partyFile: "missing.yml", playersFile: "players.yml", want: "read party input"}, - {name: "non-regular party file", partySetup: func(t *testing.T, dir string) { - if err := os.Mkdir(filepath.Join(dir, "party-dir"), 0o755); err != nil { - t.Fatal(err) - } - }, partyFile: "party-dir", playersFile: "players.yml", want: "read party input"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - dir := t.TempDir() - test.partySetup(t, dir) - players := "" - if test.playersFile != "" { - players = " players_file: " + test.playersFile + "\n" - } - pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n"+players+" party_file: "+test.partyFile+"\n", "session_id: session\ncampaign: campaign\ninputs:\n audio_dir: audio\n") - _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) - if err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("LoadWithSessionOptions() error = %v, want %q", err, test.want) - } - }) + if _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}); err == nil || !strings.Contains(err.Error(), "schema_version is required") { + t.Fatalf("LoadWithSessionOptions() error = %v, want schema rejection before resolution", err) } } diff --git a/internal/config/party_test.go b/internal/config/party_test.go index 24e835d..13bd53c 100644 --- a/internal/config/party_test.go +++ b/internal/config/party_test.go @@ -210,13 +210,20 @@ characters: } } -func TestParsePartyClassifiesUnversionedDocumentAsLegacy(t *testing.T) { - document, err := ParseParty([]byte("players:\n - not canonical\n---\nlegacy: remains opaque\n")) - if err != nil { - t.Fatalf("ParseParty() error = %v", err) - } - if document.Mode != PartyModeLegacy || document.Canonical != nil || document.IsCanonical() { - t.Fatalf("document = %#v, want opaque legacy classification", document) +func TestParsePartyRejectsMissingOrUnknownSchemaVersion(t *testing.T) { + for _, test := range []struct { + name string + yaml string + want string + }{ + {name: "missing", yaml: "characters: {}\n", want: "schema_version is required"}, + {name: "unknown", yaml: "schema_version: narratio.party.v2\ncharacters: {}\n", want: "supported schema_version is \"narratio.party.v1\""}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := ParseParty([]byte(test.yaml)); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ParseParty() error = %v, want %q", err, test.want) + } + }) } } @@ -276,11 +283,4 @@ func TestCanonicalPartyPlayersYAMLRequiresCanonicalParty(t *testing.T) { if _, err := party.PlayersYAML(); err == nil { t.Fatal("PlayersYAML() error = nil, want canonical party requirement") } - legacy, err := ParseParty([]byte("legacy: party\n")) - if err != nil { - t.Fatalf("ParseParty() legacy error = %v", err) - } - if _, err := legacy.PlayersYAML(); err == nil { - t.Fatal("legacy PlayersYAML() error = nil, want canonical party requirement") - } } diff --git a/internal/config/pipeline_profiles_test.go b/internal/config/pipeline_profiles_test.go index 8910c66..15236e5 100644 --- a/internal/config/pipeline_profiles_test.go +++ b/internal/config/pipeline_profiles_test.go @@ -429,8 +429,8 @@ func TestLoadWithSessionOptionsCarriesExplicitProfilePresence(t *testing.T) { rootPath := writeProfilePipeline(t, dir, profileComposition("production", "production", "testing")) writePipelineSource(t, dir, "production.yml", "workspace:\n root: /production\n") writePipelineSource(t, dir, "testing.yml", "workspace:\n root: /testing\n") - campaignPath := writePipelineSource(t, dir, "campaign.yml", "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n players_file: players.yml\n party_file: party.yml\n") - writePipelineSource(t, dir, "party.yml", "legacy: party\n") + campaignPath := writePipelineSource(t, dir, "campaign.yml", "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n party_file: party.yml\n") + writePipelineSource(t, dir, "party.yml", canonicalPartyFixture) sessionPath := writePipelineSource(t, dir, "session.yml", "session_id: session\ncampaign: campaign\n") selected := "testing" cfg, err := LoadWithSessionOptions(rootPath, campaignPath, sessionPath, SessionLoadOptions{Profile: &selected}) diff --git a/internal/config/scriptorium_test.go b/internal/config/scriptorium_test.go index 6c8e3ec..6cd4e3a 100644 --- a/internal/config/scriptorium_test.go +++ b/internal/config/scriptorium_test.go @@ -627,6 +627,4 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml ` diff --git a/internal/config/session_template_test.go b/internal/config/session_template_test.go index 7bf6bf3..3530367 100644 --- a/internal/config/session_template_test.go +++ b/internal/config/session_template_test.go @@ -17,7 +17,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { @@ -42,7 +41,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { @@ -69,7 +67,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { @@ -99,7 +96,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { @@ -150,7 +146,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { @@ -176,7 +171,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { diff --git a/internal/config/storage_publish_test.go b/internal/config/storage_publish_test.go index 4e0aef7..5f3a873 100644 --- a/internal/config/storage_publish_test.go +++ b/internal/config/storage_publish_test.go @@ -699,8 +699,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml `, }, { @@ -713,8 +711,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml `, wantErr: "session.inputs.audio_s3.prefix must be a relative path", }, @@ -728,8 +724,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml `, wantErr: "session.inputs.audio_s3.prefix must not contain path traversal", }, @@ -744,8 +738,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml `, wantErr: "mutually exclusive", }, @@ -793,8 +785,6 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml ` pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML) cfg, err := Load(pipelinePath, sessionPath) diff --git a/internal/stage/semantic_contracts_initial.go b/internal/stage/semantic_contracts_initial.go index ccb91de..f254feb 100644 --- a/internal/stage/semantic_contracts_initial.go +++ b/internal/stage/semantic_contracts_initial.go @@ -108,7 +108,7 @@ func buildPrepareSemanticConfig(env *Env) (prepareSemanticConfig, error) { } partyMode := cfg.Party.Mode if partyMode == "" { - partyMode = config.PartyModeLegacy + return prepareSemanticConfig{}, fmt.Errorf("prepare semantic configuration requires a resolved canonical party") } payload := prepareSemanticConfig{ CampaignID: strings.TrimSpace(cfg.Session.Campaign), diff --git a/internal/stage/semantic_contracts_initial_test.go b/internal/stage/semantic_contracts_initial_test.go index 21312e3..273aba6 100644 --- a/internal/stage/semantic_contracts_initial_test.go +++ b/internal/stage/semantic_contracts_initial_test.go @@ -26,9 +26,6 @@ func TestPrepareSemanticConfigSensitivity(t *testing.T) { {name: "stable input owner", mutate: func(env *Env) { env.Config.StableInputs.PartyFile.Source = "campaign_config" }}, - {name: "party mode", mutate: func(env *Env) { - env.Config.Party.Mode = config.PartyModeCanonical - }}, }) assertSemanticFingerprintUnchanged(t, prepareStage{}, initialSemanticEnv(), []struct { @@ -129,6 +126,7 @@ func initialSemanticEnv() *Env { StableInputs: config.ResolvedStableInputs{ PartyFile: config.ResolvedInputFile{Path: "/campaign/party.yml", ConfigPath: "/campaign/campaign.yml", Source: "session_config"}, }, + Party: config.ResolvedParty{Mode: config.PartyModeCanonical}, }} }