Reject unversioned party documents

This commit is contained in:
2026-09-02 11:57:51 +00:00
parent 2cfca710ec
commit 61095e69ce
29 changed files with 172 additions and 293 deletions

View File

@@ -62,8 +62,7 @@ effective pipeline YAML, with defaults and concrete expansion included but
composition and family declarations omitted. `config sources` prints a stable composition and family declarations omitted. `config sources` prints a stable
source projection instead of effective values: root/import/profile/default source projection instead of effective values: root/import/profile/default
ownership plus campaign/party and generated-family records. Canonical derived ownership plus campaign/party and generated-family records. Canonical derived
players trace to the party; a legacy configured players file is explicitly players trace to the party. The [CLI reference](cli.md#config-validate-config-show-config-sources-and-config-diff)
marked as a legacy player source. The [CLI reference](cli.md#config-validate-config-show-config-sources-and-config-diff)
owns command syntax and output conventions. owns command syntax and output conventions.
`config diff` compares normalized field values rather than YAML text or source `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 rosters to [canonical party input](integrations/party.md), remove a separate
`players_file`, then express character work as families. Inspect the result `players_file`, then express character work as families. Inspect the result
with `config validate`, `config show`, and `config sources`; use `config diff` with `config validate`, `config show`, and `config sources`; use `config diff`
to review profiles before running a session. Unversioned parties and their to review profiles before running a session. Unversioned party documents are
`players_file` remain a clearly bounded legacy compatibility path. rejected.
### Identity segments ### 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 `party_file`) and the optional `spell_catalog_file` resolve from session
overrides when provided, otherwise from campaign defaults. An empty or overrides when provided, otherwise from campaign defaults. An empty or
omitted session spell-catalog value inherits the campaign value. omitted session spell-catalog value inherits the campaign value.
- `party_file` is classified when pipeline and campaign configuration are - `party_file` is parsed when pipeline and campaign configuration are combined.
combined. A versioned [canonical party](integrations/party.md) is It must be a versioned [canonical party](integrations/party.md), is
campaign-owned, derives the players input internally, and forbids both a campaign-owned, derives the players input internally, and forbids both a
separate `players_file` and a session `party_file` override. An unversioned separate `players_file` and a session `party_file` override.
party remains a bounded legacy input and requires `players_file`; its normal
campaign/session overrides continue to apply.
- Exactly one audio mode must be configured in session input: - Exactly one audio mode must be configured in session input:
- local (`audio_dir` or `audio_files`), or - local (`audio_dir` or `audio_files`), or
- S3 (`audio_s3.prefix`). - S3 (`audio_s3.prefix`).
@@ -213,7 +210,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.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 `pipeline.scriptorium.artifact_families` declares a shared artifact template
for every canonical campaign character. Configuration resolution expands each for every canonical campaign character. Configuration resolution expands each
family into ordinary `pipeline.scriptorium.artifacts` entries before analyze 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.<name>`: For each `pipeline.scriptorium.artifact_families.<name>`:
@@ -524,7 +521,7 @@ integration.
| `inputs.speakers_file` | string | Yes | stable input default | | `inputs.speakers_file` | string | Yes | stable input default |
| `inputs.autocorrect_file` | string | Yes | stable input default | | `inputs.autocorrect_file` | string | Yes | stable input default |
| `inputs.glossary_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.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` | | `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.speakers_file` | string | No | overrides campaign stable input |
| `inputs.autocorrect_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.glossary_file` | string | No | overrides campaign stable input |
| `inputs.players_file` | string | No | legacy-party override; 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 | No | legacy-party override; forbidden for a canonical campaign 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.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_dir` | string | Conditional | local audio mode |
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode | | `inputs.audio_files[]` | list[string] | Conditional | local audio mode |

View File

@@ -1,8 +1,8 @@
# Canonical Party Input # Canonical Party Input
`party.yml` is a campaign-owned roster input. Narratio recognizes the `party.yml` is a campaign-owned roster input. It must be a single, versioned
versioned `narratio.party.v1` document below when it resolves a pipeline, `narratio.party.v1` document when Narratio resolves a pipeline, campaign, and
campaign, and session together. session together.
```yaml ```yaml
schema_version: narratio.party.v1 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 and aliases must be unique across the full roster under Unicode-aware
case-insensitive comparison; player names may repeat. case-insensitive comparison; player names may repeat.
The document has exactly one YAML document and accepts no unknown fields. A The document has exactly one YAML document and accepts no unknown fields.
wrong or malformed `schema_version` is an error. `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 Unversioned party inputs are rejected. Replace each legacy party document with
material while campaigns migrate. It requires a separate `players_file` and the versioned structure above, move each roster entry under `characters`, and
retains the existing session override behavior. It cannot be mixed with a remove separate player and session-party overrides from the selected campaign
canonical party: canonical campaigns must omit `players_file`, and sessions and session configuration. The configuration-key removal itself is staged
must not override their party or players inputs. 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 The configuration rules and source-relative path behavior are defined in the
source-relative path behavior are defined in the [Configuration Reference](../config.md). [Configuration Reference](../config.md).
## Derived players document ## Derived players document

View File

@@ -75,8 +75,7 @@ each contributor as root, import, profile, or centralized default. The
projection replaces generated concrete member paths with paired family and projection replaces generated concrete member paths with paired family and
canonical-party records, and does the same for generated publish rules. canonical-party records, and does the same for generated publish rules.
Campaign records identify campaign-owned fields and party inputs; canonical Campaign records identify campaign-owned fields and party inputs; canonical
derived players point to the party source, while legacy players retain a derived players point to the party source. The application command only joins these sorted
dedicated legacy-player role. The application command only joins these sorted
records with selection metadata and never reparses configuration files. records with selection metadata and never reparses configuration files.
`config diff` uses a paired profile loader that parses the root, imports, and `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 raw YAML fragments, ownership records, or secret material. A differing digest
with no projected difference is treated as an internal consistency error. with no projected difference is treated as an internal consistency error.
Campaign context construction also reads and classifies the campaign-owned Campaign context construction reads the campaign-owned party source through
party source through `ParseParty`. A canonical party retains its raw bytes and `ParseParty`. The required canonical party retains its raw bytes and normalized
normalized roster in runtime-only `ResolvedParty` provenance, while a legacy roster in runtime-only `ResolvedParty` provenance. Canonical resolution creates
party remains opaque. Canonical resolution creates a virtual a virtual `derived_from_party` players input and rejects competing campaign or
`derived_from_party` players input and rejects competing campaign or session session players files and session party overrides.
players files and session party overrides. The compact legacy compatibility
path resolves the effective campaign/session party and players files together.
## Canonical Party Domain ## Canonical Party Domain
`ParseParty` is the package-owned boundary for classifying a party source. `ParseParty` is the package-owned boundary for parsing a party source. It
When a top-level `schema_version` is present, it strictly validates the requires a top-level `schema_version` and strictly validates the
`narratio.party.v1` contract into ordered character domain values. The `narratio.party.v1` contract into ordered character domain values. The
canonical value retains a separate exact byte copy of its source so consumers canonical value retains a separate exact byte copy of its source so consumers
can materialize the authored party document without reserializing it. Its can materialize the authored party document without reserializing it. Its
`PlayersYAML` method deterministically derives the versioned players-only `PlayersYAML` method deterministically derives the versioned players-only
projection. projection.
An unversioned source is classified by the small legacy compatibility boundary An unversioned source is rejected before resolution, family expansion, or
in `party_legacy.go`; it deliberately exposes no parsed roster information. prepare-stage materialization.
That boundary exists solely to isolate removable compatibility behavior from
the canonical parser.
## Diagnostics And Runtime Metadata ## 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 `party_test.go` protects the versioned party schema, domain invariants, and
deterministic players projection without involving campaign or runtime wiring. deterministic players projection without involving campaign or runtime wiring.
`party_resolution_test.go` protects campaign-owned party loading, canonical `party_resolution_test.go` protects campaign-owned party loading, canonical
input restrictions, legacy overrides, source provenance, and virtual players input restrictions, source provenance, and virtual players input selection.
input selection.
## Artifact Family Resolution ## Artifact Family Resolution

View File

@@ -150,7 +150,7 @@ branches.
## Stage 2 — Reject Unversioned Party Documents ## Stage 2 — Reject Unversioned Party Documents
**Status: Pending** **Status: Completed**
### Goal ### Goal

View File

@@ -76,6 +76,27 @@ Safe fix:
Relevant reference: [Configuration](./config.md). 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 ## Unexpected imported or profile value
Symptom: Symptom:

View File

@@ -227,8 +227,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err) t.Fatalf("write pipeline.yml: %v", err)
@@ -292,8 +290,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err) t.Fatalf("write pipeline.yml: %v", err)
@@ -389,14 +385,13 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.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, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "players.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 stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -522,7 +517,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.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, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.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"), "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(campaignDir, "party.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
return pipelinePath, campaignPath, sessionPath return pipelinePath, campaignPath, sessionPath
@@ -568,13 +561,12 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
` `
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err) 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 return campaignPath
} }

View File

@@ -400,12 +400,12 @@ func TestConfigSourcesReportsStableOwnership(t *testing.T) {
"imports: none", "imports: none",
"profile: name=testing selection=cli overlay=", "profile: name=testing selection=cli overlay=",
"campaign: id=sample-campaign source=", "campaign: id=sample-campaign source=",
"party: mode=legacy source=", "party: mode=canonical source=",
"digest: ", "digest: ",
"path\trole\tsource\n", "path\trole\tsource\n",
"workspace.root\troot\t", "workspace.root\troot\t",
"whisperx.language\tprofile\t", "whisperx.language\tprofile\t",
"campaign.inputs.players_file\tlegacy_player\t", "derived.players\tparty\t",
"trim.enabled\tdefault\tdefault", "trim.enabled\tdefault\tdefault",
} { } {
if !strings.Contains(output, want) { if !strings.Contains(output, want) {

View File

@@ -210,7 +210,7 @@ func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) {
func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDownstream(t *testing.T) { func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDownstream(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true) 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 { if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
t.Fatalf("initial executeStages() error = %v", err) 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 { if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), before); err != nil {
t.Fatalf("Save(downstream success) error = %v", err) t.Fatalf("Save(downstream success) error = %v", err)
} }
if err := os.WriteFile(originalPaths[artifactpolicy.SourceInputParty], []byte("changed party bytes\n"), 0o644); err != nil { partyDocument, err := config.ParseParty([]byte(`schema_version: narratio.party.v1
t.Fatalf("WriteFile(party source) error = %v", err) 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) prepared := loadLifecycleManifest(t, cfg)
prepare, err := stage.Select("prepare") 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) cfg, env, runner := extractionLifecycleFixture(t, true)
configureLifecycleReferences(t, cfg) configureLifecycleReferences(t, cfg)
overridePath := filepath.Join(filepath.Dir(cfg.SessionPath), "session-party.yml") partyBytes := []byte(`schema_version: narratio.party.v1
if err := os.WriteFile(overridePath, []byte("session override party\n"), 0o644); err != nil { characters:
t.Fatalf("WriteFile(session override) error = %v", err) 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{ cfg.Party.Canonical = partyDocument.Canonical
Path: "./session-party.yml", ConfigPath: cfg.SessionPath, Source: "session_config",
}
cfg.Session.Inputs.PartyFile = "./session-party.yml"
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil { if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
@@ -294,8 +308,8 @@ func TestExtractLifecycleSessionOverrideBytesReachCanonicalReference(t *testing.
if err != nil { if err != nil {
t.Fatalf("ReadFile(prepared party) error = %v", err) t.Fatalf("ReadFile(prepared party) error = %v", err)
} }
if string(contents) != "session override party\n" || partyPath == overridePath { if string(contents) != string(partyBytes) || partyPath == cfg.Party.Source.Path {
t.Fatalf("prepared party path=%q contents=%q override=%q", partyPath, contents, overridePath) t.Fatalf("prepared party path=%q contents=%q source=%q", partyPath, contents, cfg.Party.Source.Path)
} }
} }

View File

@@ -113,7 +113,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
`), 0o644); err != nil { `), 0o644); err != nil {
t.Fatalf("write explicit campaign: %v", err) 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, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "players.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{} fake := &storage.FakeBackend{}
var storeInitCalls int var storeInitCalls int

View File

@@ -66,6 +66,10 @@ func inspectStableInputs(cfg *config.Config) []stableInputCheck {
} }
out := make([]stableInputCheck, 0, len(items)) out := make([]stableInputCheck, 0, len(items))
for _, item := range 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) == "" { if item.optional && strings.TrimSpace(item.in.Path) == "" {
continue continue
} }

View File

@@ -28,7 +28,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
` `
if err := os.WriteFile(campaignPath, []byte(campaign), 0o644); err != nil { if err := os.WriteFile(campaignPath, []byte(campaign), 0o644); err != nil {

View File

@@ -117,8 +117,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err) t.Fatalf("write pipeline.yml: %v", err)

View File

@@ -436,8 +436,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline config: %v", err) 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, "speakers.yml"), "alice: alice.flac\n")
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "glossary.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"), "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, "party.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
return pipelinePath, campaignPath, sessionPath return pipelinePath, campaignPath, sessionPath

View File

@@ -1316,14 +1316,17 @@ func testConfig(t *testing.T) *config.Config {
pipelinePath := filepath.Join(cfgDir, "pipeline.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") 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, 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, "speakers.yml"), "alice: alice.flac\n")
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
mustWriteFile(t, filepath.Join(cfgDir, "glossary.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"), "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, "party.yml"), "[]\n")
mustWriteFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "audio") 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{ return &config.Config{
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
@@ -1331,6 +1334,15 @@ func testConfig(t *testing.T) *config.Config {
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
SessionPath: sessionPath, 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{ StableInputs: config.ResolvedStableInputs{
SpeakersFile: config.ResolvedInputFile{ SpeakersFile: config.ResolvedInputFile{
Path: "./speakers.yml", Path: "./speakers.yml",
@@ -1347,11 +1359,7 @@ func testConfig(t *testing.T) *config.Config {
ConfigPath: campaignPath, ConfigPath: campaignPath,
Source: "campaign_config", Source: "campaign_config",
}, },
PlayersFile: config.ResolvedInputFile{ PlayersFile: config.ResolvedInputFile{Source: "derived_from_party"},
Path: "./players.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PartyFile: config.ResolvedInputFile{ PartyFile: config.ResolvedInputFile{
Path: "./party.yml", Path: "./party.yml",
ConfigPath: campaignPath, ConfigPath: campaignPath,
@@ -1366,8 +1374,6 @@ func testConfig(t *testing.T) *config.Config {
SpeakersFile: "./speakers.yml", SpeakersFile: "./speakers.yml",
AutocorrectFile: "./autocorrect.yml", AutocorrectFile: "./autocorrect.yml",
GlossaryFile: "./glossary.yml", GlossaryFile: "./glossary.yml",
PlayersFile: "./players.yml",
PartyFile: "./party.yml",
}, },
}, },
} }
@@ -1390,7 +1396,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
` `
sessionYAML := `session_id: 2026-05-03 sessionYAML := `session_id: 2026-05-03
@@ -1400,13 +1405,11 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
mustWriteFile(t, pipelinePath, pipelineYAML) mustWriteFile(t, pipelinePath, pipelineYAML)
mustWriteFile(t, campaignPath, campaignYAML) mustWriteFile(t, campaignPath, campaignYAML)
mustWriteFile(t, sessionPath, sessionYAML) 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) cfg, err := config.Load(pipelinePath, sessionPath)
if err != nil { if err != nil {

View File

@@ -22,8 +22,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil {
t.Fatalf("write session template: %v", err) t.Fatalf("write session template: %v", err)
@@ -77,8 +75,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) t.Fatalf("write session.yml: %v", err)

View File

@@ -123,7 +123,7 @@ func TestArtifactFamiliesRejectInvalidExpansionDeclarations(t *testing.T) {
families string families string
wantError 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 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: "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"}, {name: "repeated token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "meta.md", "{character_id}.md", 1), wantError: "must contain exactly one"},

View File

@@ -37,7 +37,7 @@ notification:
func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) { func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "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) { func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "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) { func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "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) { func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "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) { func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "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.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.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.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") assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config")
} }
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) { func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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\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", "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{}) 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.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config")
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.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.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./session-players.yml", sessionPath, "session_config") assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "", "", "derived_from_party")
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config") assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config")
} }
func TestCampaignSessionMergeSpellCatalog(t *testing.T) { func TestCampaignSessionMergeSpellCatalog(t *testing.T) {
@@ -162,7 +162,7 @@ func TestCampaignSessionMergeSpellCatalog(t *testing.T) {
sessionSpell = " spell_catalog_file: " + tt.sessionValue + "\n" sessionSpell = " spell_catalog_file: " + tt.sessionValue + "\n"
} }
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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"+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, "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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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, "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+tt.sessionLine,
) )
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) _, 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, 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", "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", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) if _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}); err != nil {
if err == nil { t.Fatalf("LoadWithSessionOptions() error = %v", err)
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())
} }
} }
func TestCampaignSessionMismatchFails(t *testing.T) { func TestCampaignSessionMismatchFails(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "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) { func TestLoadMissingCampaignFileFails(t *testing.T) {
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(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", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml") 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) t.Fatalf("write session.yml: %v", err)
} }
for _, name := range []string{"party.yml", "campaign-party.yml", "session-party.yml"} { 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) t.Fatalf("write %s: %v", name, err)
} }
} }

View File

@@ -327,7 +327,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
` `
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { 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 { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) 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) t.Fatalf("write party.yml: %v", err)
} }

View File

@@ -83,17 +83,12 @@ type PartyClass struct {
Level *int Level *int
} }
// ParseParty classifies and, when selected by schema_version, strictly parses // ParseParty strictly parses one supported party document.
// a party source. An unversioned source takes the deliberately opaque legacy
// compatibility path; see party_legacy.go.
func ParseParty(data []byte) (*PartyDocument, error) { func ParseParty(data []byte) (*PartyDocument, error) {
root, decoder, err := parsePartyFirstDocument(data) root, decoder, err := parsePartyFirstDocument(data)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !partyDocumentSelectsCanonical(root) {
return classifyLegacyPartyDocument(), nil
}
if err := requireNoTrailingPartyDocuments(decoder); err != nil { if err := requireNoTrailingPartyDocuments(decoder); err != nil {
return nil, err return nil, err
} }
@@ -119,25 +114,12 @@ func parsePartyFirstDocument(data []byte) (*yaml.Node, *yaml.Decoder, error) {
return document.Content[0], decoder, nil 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 { func requireNoTrailingPartyDocuments(decoder *yaml.Decoder) error {
var trailing yaml.Node var trailing yaml.Node
if err := decoder.Decode(&trailing); err == nil { 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 { } 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 return nil
} }
@@ -147,17 +129,21 @@ func parseCanonicalParty(root *yaml.Node, raw []byte) (*CanonicalParty, error) {
if err != nil { if err != nil {
return nil, err 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 { if err := partyKnownFields(fields, "party", "schema_version", "characters"); err != nil {
return nil, err 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"] charactersNode, ok := fields["characters"]
if !ok { if !ok {
return nil, fmt.Errorf("party.characters is required") return nil, fmt.Errorf("party.characters is required")

View File

@@ -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
}

View File

@@ -32,19 +32,15 @@ func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) {
if err != nil { if err != nil {
return ResolvedParty{}, fmt.Errorf("parse party input %q: %w", path, err) return ResolvedParty{}, fmt.Errorf("parse party input %q: %w", path, err)
} }
resolved := ResolvedParty{ return ResolvedParty{
Mode: document.Mode, Mode: PartyModeCanonical,
Source: PartySource{ Source: PartySource{
Path: path, Path: path,
ConfigPath: configPath, ConfigPath: configPath,
Source: input.Source, Source: input.Source,
}, },
Canonical: document.Canonical, Canonical: document.Canonical,
} }, nil
if document.IsCanonical() {
return resolved, nil
}
return resolveLegacyParty(resolved)
} }
func campaignPartyInput(campaignPath string, campaign *CampaignConfig) (ResolvedInputFile, error) { func campaignPartyInput(campaignPath string, campaign *CampaignConfig) (ResolvedInputFile, error) {

View File

@@ -85,44 +85,7 @@ func TestCanonicalPartyRejectsSeparatePlayersAndSessionPartyOverrides(t *testing
} }
} }
func TestLegacyPartyPreservesCampaignAndSessionInputOverrides(t *testing.T) { func TestUnversionedPartyIsRejectedBeforeResolution(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) {
dir := t.TempDir() dir := t.TempDir()
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: campaign\n") writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: campaign\n")
pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign
@@ -135,49 +98,10 @@ inputs:
campaign: campaign campaign: campaign
inputs: inputs:
audio_dir: audio 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) { if _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}); err == nil || !strings.Contains(err.Error(), "schema_version is required") {
tests := []struct { t.Fatalf("LoadWithSessionOptions() error = %v, want schema rejection before resolution", err)
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)
}
})
} }
} }

View File

@@ -210,13 +210,20 @@ characters:
} }
} }
func TestParsePartyClassifiesUnversionedDocumentAsLegacy(t *testing.T) { func TestParsePartyRejectsMissingOrUnknownSchemaVersion(t *testing.T) {
document, err := ParseParty([]byte("players:\n - not canonical\n---\nlegacy: remains opaque\n")) for _, test := range []struct {
if err != nil { name string
t.Fatalf("ParseParty() error = %v", err) yaml string
} want string
if document.Mode != PartyModeLegacy || document.Canonical != nil || document.IsCanonical() { }{
t.Fatalf("document = %#v, want opaque legacy classification", document) {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 { if _, err := party.PlayersYAML(); err == nil {
t.Fatal("PlayersYAML() error = nil, want canonical party requirement") 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")
}
} }

View File

@@ -429,8 +429,8 @@ func TestLoadWithSessionOptionsCarriesExplicitProfilePresence(t *testing.T) {
rootPath := writeProfilePipeline(t, dir, profileComposition("production", "production", "testing")) rootPath := writeProfilePipeline(t, dir, profileComposition("production", "production", "testing"))
writePipelineSource(t, dir, "production.yml", "workspace:\n root: /production\n") writePipelineSource(t, dir, "production.yml", "workspace:\n root: /production\n")
writePipelineSource(t, dir, "testing.yml", "workspace:\n root: /testing\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") 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", "legacy: party\n") writePipelineSource(t, dir, "party.yml", canonicalPartyFixture)
sessionPath := writePipelineSource(t, dir, "session.yml", "session_id: session\ncampaign: campaign\n") sessionPath := writePipelineSource(t, dir, "session.yml", "session_id: session\ncampaign: campaign\n")
selected := "testing" selected := "testing"
cfg, err := LoadWithSessionOptions(rootPath, campaignPath, sessionPath, SessionLoadOptions{Profile: &selected}) cfg, err := LoadWithSessionOptions(rootPath, campaignPath, sessionPath, SessionLoadOptions{Profile: &selected})

View File

@@ -627,6 +627,4 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `

View File

@@ -17,7 +17,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
@@ -42,7 +41,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
@@ -69,7 +67,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
@@ -99,7 +96,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
@@ -150,7 +146,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
@@ -176,7 +171,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {

View File

@@ -699,8 +699,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
}, },
{ {
@@ -713,8 +711,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantErr: "session.inputs.audio_s3.prefix must be a relative path", wantErr: "session.inputs.audio_s3.prefix must be a relative path",
}, },
@@ -728,8 +724,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantErr: "session.inputs.audio_s3.prefix must not contain path traversal", wantErr: "session.inputs.audio_s3.prefix must not contain path traversal",
}, },
@@ -744,8 +738,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantErr: "mutually exclusive", wantErr: "mutually exclusive",
}, },
@@ -793,8 +785,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML) pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML)
cfg, err := Load(pipelinePath, sessionPath) cfg, err := Load(pipelinePath, sessionPath)

View File

@@ -108,7 +108,7 @@ func buildPrepareSemanticConfig(env *Env) (prepareSemanticConfig, error) {
} }
partyMode := cfg.Party.Mode partyMode := cfg.Party.Mode
if partyMode == "" { if partyMode == "" {
partyMode = config.PartyModeLegacy return prepareSemanticConfig{}, fmt.Errorf("prepare semantic configuration requires a resolved canonical party")
} }
payload := prepareSemanticConfig{ payload := prepareSemanticConfig{
CampaignID: strings.TrimSpace(cfg.Session.Campaign), CampaignID: strings.TrimSpace(cfg.Session.Campaign),

View File

@@ -26,9 +26,6 @@ func TestPrepareSemanticConfigSensitivity(t *testing.T) {
{name: "stable input owner", mutate: func(env *Env) { {name: "stable input owner", mutate: func(env *Env) {
env.Config.StableInputs.PartyFile.Source = "campaign_config" 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 { assertSemanticFingerprintUnchanged(t, prepareStage{}, initialSemanticEnv(), []struct {
@@ -129,6 +126,7 @@ func initialSemanticEnv() *Env {
StableInputs: config.ResolvedStableInputs{ StableInputs: config.ResolvedStableInputs{
PartyFile: config.ResolvedInputFile{Path: "/campaign/party.yml", ConfigPath: "/campaign/campaign.yml", Source: "session_config"}, PartyFile: config.ResolvedInputFile{Path: "/campaign/party.yml", ConfigPath: "/campaign/campaign.yml", Source: "session_config"},
}, },
Party: config.ResolvedParty{Mode: config.PartyModeCanonical},
}} }}
} }