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
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.<name>`:
@@ -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 |

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")

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 {
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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),

View File

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