Adopt ConfigKit overlays for pipeline selection

This commit is contained in:
2026-09-25 15:24:03 +00:00
parent e27c49e946
commit 83eaa960ec
38 changed files with 394 additions and 320 deletions

View File

@@ -44,7 +44,7 @@ Most session-aware commands accept:
- `--session <session.yml>` - `--session <session.yml>`
- `--session-id <session_id>` - `--session-id <session_id>`
- `--previous-session-id <session_id>` - `--previous-session-id <session_id>`
- `--profile <name>` - `--overlay <name>`
- `--secrets-dir <directory>` - `--secrets-dir <directory>`
Rules: Rules:
@@ -54,9 +54,9 @@ Rules:
- if both positional `<session_id>` and `--session-id` are provided, values must match. - if both positional `<session_id>` and `--session-id` are provided, values must match.
- `--previous-session-id` is a strict expectation: the selected session file - `--previous-session-id` is a strict expectation: the selected session file
must contain the same `previous_session_id`. must contain the same `previous_session_id`.
- `--profile` selects `profiles/<name>.yml` relative to the pipeline root. It - `--overlay` selects `overlays/<name>.yml` relative to the pipeline root. It
may be supplied once; omitted, empty, and the exact name `default` select the may be supplied once; omitted, empty, and the exact name `default` select the
base configuration, while an unknown named profile fails. The same selection base configuration, while an unknown named overlay fails. The same selection
applies to all common-flag commands, including `regenerate-artifacts`. applies to all common-flag commands, including `regenerate-artifacts`.
- `--secrets-dir` selects ConfigKit credential-file bootstrap explicitly. A - `--secrets-dir` selects ConfigKit credential-file bootstrap explicitly. A
relative value uses caller CWD. Without the flag, a present `secrets/` relative value uses caller CWD. Without the flag, a present `secrets/`
@@ -85,14 +85,14 @@ Commands with additional positionals keep their command-specific order:
### `config validate`, `config show`, `config sources`, `config artifacts`, and `config diff` ### `config validate`, `config show`, `config sources`, `config artifacts`, and `config diff`
```bash ```bash
narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>] narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>] narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>] narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
narratio config artifacts [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>] narratio config artifacts [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <directory>] narratio config diff <left-overlay> <right-overlay> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <directory>]
``` ```
These commands resolve the selected profile, defaults, ordinary paths, and—if These commands resolve the selected overlay, defaults, ordinary paths, and—if
a campaign is selected—the campaign-owned party. They neither discover or load a campaign is selected—the campaign-owned party. They neither discover or load
a session nor create a workspace, manifest, run, lock, adapter, or remote a session nor create a workspace, manifest, run, lock, adapter, or remote
connection. Like every pipeline-loading command, they bootstrap the selected or connection. Like every pipeline-loading command, they bootstrap the selected or
@@ -107,7 +107,7 @@ configured default campaign so Narratio can expand its concrete artifacts and
publish rules. `--campaign` and `--campaign-file` remain mutually exclusive. publish rules. `--campaign` and `--campaign-file` remain mutually exclusive.
Session, range, force, and artifact-execution flags are not accepted. Session, range, force, and artifact-execution flags are not accepted.
`config validate` writes a concise root-path, selected-profile (or `none`), and `config validate` writes a concise root-path, selected-overlay (or `none`), and
effective-digest summary after successful complete validation. `config show` effective-digest summary after successful complete validation. `config show`
writes one deterministic, secret-free YAML document containing defaulted and writes one deterministic, secret-free YAML document containing defaulted and
expanded concrete configuration. It omits ConfigKit directives, artifact expanded concrete configuration. It omits ConfigKit directives, artifact
@@ -115,9 +115,9 @@ family declarations, and runtime provenance.
`config sources` reports the same fully validated resolution without printing `config sources` reports the same fully validated resolution without printing
effective values. Its header identifies the root, ordered imports, selected effective values. Its header identifies the root, ordered imports, selected
profile and overlay, selected campaign, party source, and digest. The overlay and its file, selected campaign, party source, and digest. The
remaining tab-separated records are sorted as `path`, `role`, and `source`. remaining tab-separated records are sorted as `path`, `role`, and `source`.
Roles distinguish root, import, profile, centralized default, campaign, party, Roles distinguish root, import, overlay, centralized default, campaign, party,
and generated family ownership. A generated party member has and generated family ownership. A generated party member has
one family record and one party record at the same logical path. The output one family record and one party record at the same logical path. The output
never reads or prints secret values. never reads or prints secret values.
@@ -133,14 +133,14 @@ party-driven families retain the campaign requirement. See
[Scriptorium Artifact Entries](./config.md#scriptorium-artifact-entries) for the [Scriptorium Artifact Entries](./config.md#scriptorium-artifact-entries) for the
configuration layout. configuration layout.
`config diff` resolves both supplied profile names independently and compares `config diff` resolves both supplied overlay names independently and compares
their fully resolved, secret-free effective mappings. It does their fully resolved, secret-free effective mappings. It does
not accept `--profile`; the two positional names must be distinct, declared not accept `--overlay`; the two positional names must be distinct, valid
profiles. When party-driven families are present, both profiles must resolve to overlays. When party-driven families are present, both overlays must resolve to
the same selected campaign and party. Use `--campaign-file` if profile-specific the same selected campaign and party. Use `--campaign-file` if overlay-specific
campaign configuration would otherwise select different files. campaign configuration would otherwise select different files.
Equal profiles print `no differences`. Otherwise, sorted tab-separated records Equal overlays print `no differences`. Otherwise, sorted tab-separated records
use one of these forms, with compact JSON values: use one of these forms, with compact JSON values:
```text ```text
@@ -151,7 +151,7 @@ changed <path> <left-value> <right-value>
Mappings are flattened to their logical field paths; lists remain one atomic Mappings are flattened to their logical field paths; lists remain one atomic
value. The command compares defaulted concrete artifacts and publish rules, not value. The command compares defaulted concrete artifacts and publish rules, not
profile names, source-file layout, or formatting. It succeeds when differences overlay names, source-file layout, or formatting. It succeeds when differences
are found, making it suitable for review and migration checks. are found, making it suitable for review and migration checks.
### `version` ### `version`
@@ -189,7 +189,7 @@ Behavior:
- writes the session manifest as the sole durable progress ledger. Each - writes the session manifest as the sole durable progress ledger. Each
invocation also creates its run-local directory and returns an immediate invocation also creates its run-local directory and returns an immediate
command summary; neither creates another durable progress record. command summary; neither creates another durable progress record.
- reports the resolved profile (or `none`) and effective configuration digest. - reports the resolved overlay (or `none`) and effective configuration digest.
When `--artifacts` is present, the selected range must contain `analyze` or When `--artifacts` is present, the selected range must contain `analyze` or
`publish`. Either consumer is sufficient, including a one-stage range. `publish`. Either consumer is sufficient, including a one-stage range.
@@ -304,7 +304,7 @@ Uses the same inclusive bounds, endpoint validation, force scope, and artifact
selection contract as `run`. It validates config and prints run/skip decisions selection contract as `run`. It validates config and prints run/skip decisions
for selected stages only without creating the local workdir or changing the for selected stages only without creating the local workdir or changing the
manifest. Resume-capable selected stages are checked against durable evidence. manifest. Resume-capable selected stages are checked against durable evidence.
The output includes the resolved profile (or `none`) and effective configuration The output includes the resolved overlay (or `none`) and effective configuration
digest without writing provenance or any manifest state. digest without writing provenance or any manifest state.
For `analyze`, the preview also lists explicit targets, prerequisite-only work, For `analyze`, the preview also lists explicit targets, prerequisite-only work,
execution order, and reusable current artifacts with concise reasons. These execution order, and reusable current artifacts with concise reasons. These

View File

@@ -47,24 +47,24 @@ the durable copied session input.
`narratio config validate`, `narratio config show`, `narratio config sources`, `narratio config validate`, `narratio config show`, `narratio config sources`,
and `narratio config artifacts` use the same `--config`, `--campaign`, and `narratio config artifacts` use the same `--config`, `--campaign`,
`--campaign-file`, and `--campaign-file`, and
`--profile` selection rules as pipeline commands, but do not select, discover, `--overlay` selection rules as pipeline commands, but do not select, discover,
or load a session. They resolve configured typed credential references as part or load a session. They resolve configured typed credential references as part
of the ordinary ConfigKit load but expose only their environment-variable names; of the ordinary ConfigKit load but expose only their environment-variable names;
they do not create runtime state. they do not create runtime state.
`narratio config diff <left-profile> <right-profile>` uses the same pipeline and `narratio config diff <left-overlay> <right-overlay>` uses the same pipeline and
campaign selectors, resolves each named profile with an independent ConfigKit campaign selectors, resolves each named overlay with an independent ConfigKit
load, and does not accept a separate `--profile` flag. load, and does not accept a separate `--overlay` flag.
Campaign selection is optional only when the resolved pipeline has no Campaign selection is optional only when the resolved pipeline has no
`scriptorium.artifact_families`. When families are declared, Narratio selects a `scriptorium.artifact_families`. When families are declared, Narratio selects a
campaign through an explicit flag or `pipeline.campaigns.default_campaign_id`, campaign through an explicit flag or `pipeline.campaigns.default_campaign_id`,
then parses the campaign-owned party and expands concrete artifacts and any then parses the campaign-owned party and expands concrete artifacts and any
family publish rules before validation. `config validate` prints the resulting family publish rules before validation. `config validate` prints the resulting
root, profile, and effective digest. `config show` emits the normalized root, overlay, and effective digest. `config show` emits the normalized
effective pipeline YAML, with defaults and concrete expansion included but effective pipeline YAML, with defaults and concrete expansion included but
ConfigKit directives and family declarations omitted. This review projection is ConfigKit directives and family declarations omitted. This review projection is
not promised to be a reloadable authoring document. `config sources` prints a stable not promised to be a reloadable authoring document. `config sources` prints a stable
source projection instead of effective values: root/import/profile/default source projection instead of effective values: root/import/overlay/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. `config artifacts` lists the concrete artifact map, players trace to the party. `config artifacts` lists the concrete artifact map,
effective dependencies, and defining sources. The [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff) effective dependencies, and defining sources. The [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
@@ -83,7 +83,7 @@ difference.
Use the [production/testing bundle](../examples/production-testing/pipeline.yml) Use the [production/testing bundle](../examples/production-testing/pipeline.yml)
as the complete copyable migration reference. Keep production settings in the as the complete copyable migration reference. Keep production settings in the
base, split them into explicit additive imports, and place testing differences base, split them into explicit additive imports, and place testing differences
in the fixed `profiles/testing.yml` overlay. Put ordinary artifacts and families in the fixed `overlays/testing.yml` overlay. Put ordinary artifacts and families
in the recommended one-definition files shown by that bundle. Convert campaign in the recommended one-definition files shown by that bundle. Convert campaign
rosters to [canonical party input](integrations/party.md), then express character rosters to [canonical party input](integrations/party.md), then express character
work as families. Inspect the result with `config validate`, `config show`, work as families. Inspect the result with `config validate`, `config show`,
@@ -108,11 +108,14 @@ remote state with an unsafe legacy identity must be migrated before use.
root-only `configkit.imports` list. Imported files contribute fields to one root-only `configkit.imports` list. Imported files contribute fields to one
logical pipeline document; they do not override fields supplied by the root logical pipeline document; they do not override fields supplied by the root
or another import. or another import.
- An omitted profile, an empty profile option, and the exact name `default` - An omitted overlay, an empty overlay option, and the exact name `default`
select the base configuration. Any other name selects select the base configuration. Any other name selects
`profiles/<name>.yml` relative to the pipeline root. `overlays/<name>.yml` relative to the pipeline root. Move previously selected
`profiles/<name>.yml` files there; the old directory is not searched, and
`--profile` is no longer accepted for Narratio pipeline selection. Explicit
imports from a directory named `profiles` remain valid.
- Campaign, session, and canonical-party documents are strict single-source - Campaign, session, and canonical-party documents are strict single-source
ConfigKit schemas. They do not enable imports, profiles, or `configkit` ConfigKit schemas. They do not enable imports, overlays, or `configkit`
directives. directives.
- Configured timeout and retry-delay durations must be positive. An omitted - Configured timeout and retry-delay durations must be positive. An omitted
artifact timeout continues to inherit its configured Scriptorium timeout. artifact timeout continues to inherit its configured Scriptorium timeout.
@@ -137,10 +140,10 @@ remote state with an unsafe legacy identity must be migrated before use.
- local (`audio_dir` or `audio_files`), or - local (`audio_dir` or `audio_files`), or
- S3 (`audio_s3.prefix`). - S3 (`audio_s3.prefix`).
### Pipeline imports and profiles ### Pipeline imports and overlays
Large pipeline configurations may be split into explicitly named fragments and Large pipeline configurations may be split into explicitly named fragments and
may use fixed-path profile overlays: may use fixed-path selected overlays:
```yaml ```yaml
configkit: configkit:
@@ -170,12 +173,12 @@ name the full field path and every source that claimed it. Narratio additionally
rejects an artifact or artifact-family key declared by more than one base file, rejects an artifact or artifact-family key declared by more than one base file,
even when those files contribute disjoint children. even when those files contribute disjoint children.
Profile names are case-sensitive and use ConfigKit's safe fixed-path naming Overlay names are case-sensitive and use ConfigKit's safe fixed-path naming
rules. Only the selected overlay is opened; a malformed unselected overlay does rules. Only the selected overlay is opened; a malformed unselected overlay does
not affect a base or different-profile load. Maps merge recursively, overlay not affect a base or different-overlay load. Maps merge recursively, overlay
scalars replace base scalars, and overlay lists replace base lists completely. scalars replace base scalars, and overlay lists replace base lists completely.
Explicit `false`, zero, empty strings, empty containers, and null values remain Explicit `false`, zero, empty strings, empty containers, and null values remain
explicit inputs and are not refilled by defaults. Profiles cannot inherit, explicit inputs and are not refilled by defaults. Overlays cannot inherit,
stack, import files, or declare another ConfigKit control section. stack, import files, or declare another ConfigKit control section.
During ConfigKit loading, Narratio applies fresh typed defaults, recorded During ConfigKit loading, Narratio applies fresh typed defaults, recorded
@@ -183,7 +186,7 @@ entry-level defaults and normalization, read-only file-local validation, and
ordinary path resolution before computing a deterministic effective ordinary path resolution before computing a deterministic effective
configuration digest. The digest represents the normalized, secret-free configuration digest. The digest represents the normalized, secret-free
runtime pipeline mapping; it excludes ConfigKit directives, source runtime pipeline mapping; it excludes ConfigKit directives, source
provenance, profile identity, and raw environment secret values. Equivalent provenance, overlay identity, and raw environment secret values. Equivalent
effective mappings therefore have the same digest regardless of how fields are effective mappings therefore have the same digest regardless of how fields are
split among the root and imports. split among the root and imports.
@@ -400,9 +403,9 @@ empty string or `default`, which explicitly select Notarius's composed base
configuration. Notarius owns selector interpretation and validation; Narratio configuration. Notarius owns selector interpretation and validation; Narratio
does not restrict profile names, resolve profile files, or rewrite the value. does not restrict profile names, resolve profile files, or rewrite the value.
This selection is independent of Narratio's own `--profile` selection. A This selection is independent of Narratio's own `--overlay` selection. A
Narratio overlay may explicitly select a differently named Notarius overlay, Narratio overlay may explicitly select a differently named Notarius overlay,
but Narratio never forwards its own profile name automatically. It is also but Narratio never forwards its own overlay name automatically. It is also
independent of `pipeline.notarius.llm_api_key`: an overlay may change either or independent of `pipeline.notarius.llm_api_key`: an overlay may change either or
both fields according to the deployed Notarius configuration. See the both fields according to the deployed Notarius configuration. See the
[Notarius integration contract](./integrations/notarius.md) for invocation and [Notarius integration contract](./integrations/notarius.md) for invocation and

View File

@@ -43,7 +43,7 @@ field acquire no additional version requirement. Omitted or null configuration
adds no flag. Supplied empty, `default`, and named selectors are forwarded adds no flag. Supplied empty, `default`, and named selectors are forwarded
without normalization so Notarius can apply its own ConfigKit semantics. This without normalization so Notarius can apply its own ConfigKit semantics. This
option selects a Notarius configuration overlay, not a PromptKit profile through option selects a Notarius configuration overlay, not a PromptKit profile through
`--llm-profile`, and Narratio never substitutes its own selected profile name. `--llm-profile`, and Narratio never substitutes its own selected overlay name.
A missing or invalid downstream overlay is an ordinary Notarius subprocess A missing or invalid downstream overlay is an ordinary Notarius subprocess
failure: Narratio does not retry without the selector or fall back to another failure: Narratio does not retry without the selector or fall back to another
profile. profile.

View File

@@ -9,9 +9,9 @@ pipeline-loading boundary implemented by `internal/config`.
`LoadPipeline` assembles and validates a pipeline in this order: `LoadPipeline` assembles and validates a pipeline in this order:
1. Give ConfigKit a fresh typed `PipelineConfig` default value, the root file, 1. Give ConfigKit a fresh typed `PipelineConfig` default value, the root file,
enabled imports, and the caller's profile selection. Omitted, empty, and enabled imports, and the caller's overlay selection. Omitted, empty, and
exact `default` selections use the base; named profiles use the fixed exact `default` selections use the base; named overlays use the fixed
`profiles/<name>.yml` convention. `overlays/<name>.yml` convention.
2. ConfigKit parses the root and declared imports, additively merges base 2. ConfigKit parses the root and declared imports, additively merges base
sources, applies only the selected overlay, and strictly decodes the result. sources, applies only the selected overlay, and strictly decodes the result.
3. The definition resolver applies recorded entry-level and inherited defaults, 3. The definition resolver applies recorded entry-level and inherited defaults,
@@ -45,7 +45,7 @@ a temporary downloaded session file therefore cannot invalidate the resolved
pipeline or campaign context. pipeline or campaign context.
Campaign and session files use independent ConfigKit definitions with imports Campaign and session files use independent ConfigKit definitions with imports
and profiles disabled. Campaign files normally load directly from their source; and overlays disabled. Campaign files normally load directly from their source;
session files are first read through the 8 MiB bounded-read boundary because the session files are first read through the 8 MiB bounded-read boundary because the
application must reject unresolved template placeholders before passing an exact application must reject unresolved template placeholders before passing an exact
byte snapshot to ConfigKit. Local and remotely downloaded session bytes then use byte snapshot to ConfigKit. Local and remotely downloaded session bytes then use
@@ -53,8 +53,9 @@ the same typed loader and retain the existing post-load identity expectations,
stable-input precedence, and source-relative path semantics. stable-input precedence, and source-relative path semantics.
The application also has a separate read-only inspection resolver for `config The application also has a separate read-only inspection resolver for `config
validate`, `config show`, `config sources`, and `config artifacts`. It uses the same production root/profile and validate`, `config show`, `config sources`, and `config artifacts`. It uses the
campaign selection functions, but never routes through session discovery, same production root, overlay, and campaign selection functions, but never routes
through session discovery,
remote-session download, adapter composition, workspace remote-session download, adapter composition, workspace
initialization, manifest access, or cleanup. A pipeline with retained artifact initialization, manifest access, or cleanup. A pipeline with retained artifact
family declarations must resolve its selected campaign before ordinary pipeline family declarations must resolve its selected campaign before ordinary pipeline
@@ -73,7 +74,7 @@ commands do not marshal runtime objects directly.
`EffectivePipelineSources` and `EffectiveCampaignSources` provide the separate `EffectivePipelineSources` and `EffectiveCampaignSources` provide the separate
safe provenance projection for `config sources`. Pipeline ownership begins with safe provenance projection for `config sources`. Pipeline ownership begins with
the complete logical field paths projected from the schema and classifies the complete logical field paths projected from the schema and classifies
each contributor as root, import, profile, or centralized default. The each contributor as root, import, overlay, 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
@@ -106,7 +107,7 @@ configured roster authority; player and session-party fields are unsupported.
## Canonical Party Domain ## Canonical Party Domain
`ParseParty` is the package-owned boundary for parsing a party source. Its `ParseParty` is the package-owned boundary for parsing a party source. Its
single-source ConfigKit definition disables imports and profiles, requires single-source ConfigKit definition disables imports and overlays, requires
`schema_version` through `VersionRule`, and strictly decodes the `schema_version` through `VersionRule`, and strictly decodes the
character-keyed typed schema before applying Narratio's party-domain checks. character-keyed typed schema before applying Narratio's party-domain checks.
Successful metadata coordinates recover authored character-map order without a Successful metadata coordinates recover authored character-map order without a
@@ -144,14 +145,14 @@ not compatibility contracts.
## Diagnostics And Runtime Metadata ## Diagnostics And Runtime Metadata
Syntax, duplicate-key, merge, profile, and schema failures include the Syntax, duplicate-key, merge, overlay, and schema failures include the
relevant source name and full field path. Additive conflicts report every relevant source name and full field path. Additive conflicts report every
claiming source so operators can repair the split without repeatedly claiming source so operators can repair the split without repeatedly
rediscovering additional conflicts. rediscovering additional conflicts.
The loaded pipeline retains private runtime metadata for the absolute root The loaded pipeline retains private runtime metadata for the absolute root
path, ordered imports, selected profile name and selection source, selected path, ordered imports, selected overlay name, source, and path, original
overlay, original ConfigKit metadata and source snapshots, effective digest, ConfigKit metadata and source snapshots, effective digest,
and leaf ownership. Base and leaf ownership. Base
leaves retain their root/import owners, replaced leaves belong to the selected leaves retain their root/import owners, replaced leaves belong to the selected
overlay, and centrally supplied values use the synthetic `default` owner. This overlay, and centrally supplied values use the synthetic `default` owner. This
@@ -163,7 +164,7 @@ the same typed safe projection used by show and diff. Runtime Notarius paths rem
execution, but the digest substitutes their normalized logical values captured execution, but the digest substitutes their normalized logical values captured
before root-relative resolution, so relocating an equivalent configuration before root-relative resolution, so relocating an equivalent configuration
bundle does not change provenance. Because ConfigKit and resolution metadata bundle does not change provenance. Because ConfigKit and resolution metadata
are private, the digest excludes source layout, profile name, and ownership. are private, the digest excludes source layout, overlay name, and ownership.
Configuration stores credentials as ConfigKit typed references. A successful Configuration stores credentials as ConfigKit typed references. A successful
load resolves their values inside the opaque typed boundary, while this safe load resolves their values inside the opaque typed boundary, while this safe
projection serializes only `EnvName()` identities. Credential-value changes are projection serializes only `EnvName()` identities. Credential-value changes are
@@ -177,7 +178,7 @@ for later runtime expansion.
## Test Surfaces ## Test Surfaces
`pipeline_configkit_test.go` exercises real imports and fixed-path profiles, `pipeline_configkit_test.go` exercises real imports and fixed-path overlays,
base selection, unselected-overlay isolation, source attribution, and Narratio's base selection, unselected-overlay isolation, source attribution, and Narratio's
unique artifact/family base-declaration rule. Existing focused configuration unique artifact/family base-declaration rule. Existing focused configuration
tests protect defaults, explicit-value semantics, strict decoding, validation, tests protect defaults, explicit-value semantics, strict decoding, validation,

View File

@@ -23,9 +23,11 @@ portable opaque segments. Unsafe legacy identities are rejected with migration
guidance rather than being normalized into a different workspace or remote guidance rather than being normalized into a different workspace or remote
namespace. namespace.
Decoding is strict at every typed manifest field. Unknown fields are rejected; Decoding is strict at typed execution-state fields. Unknown informational fields
the schema version remains `narratio.session-manifest.v1` and there is no inside `effective_config` are ignored so older selection labels can be read;
compatibility reader that silently drops retired state. Cleanup progress uses unknown fields elsewhere are rejected. The schema version remains
`narratio.session-manifest.v1` and there is no compatibility reader that
silently drops retired execution state. Cleanup progress uses
only the cleanup entry in `stages`. only the cleanup entry in `stages`.
Every persisted session manifest has a non-nil `stages` map. Each entry is Every persisted session manifest has a non-nil `stages` map. Each entry is
@@ -306,10 +308,10 @@ after such a private content change.
The session manifest is the authoritative stage-progress ledger across The session manifest is the authoritative stage-progress ledger across
invocations. It retains the most recently resolved invocation's bounded invocations. It retains the most recently resolved invocation's bounded
configuration provenance. It identifies the selected profile name and source configuration provenance. It identifies the selected overlay name and source
(`default` or `cli`) plus the effective configuration digest, but never a raw (`explicit` when selected) plus the effective configuration digest, but never a raw
secret or profile content. This provenance is informational: it does not secret or overlay content. This provenance is informational: it does not
participate in stage resume or cache decisions. A profile change therefore participate in stage resume or cache decisions. An overlay change therefore
invalidates only stages whose semantic configuration changed. When a private invalidates only stages whose semantic configuration changed. When a private
external-tool model, module, prompt, or profile changes behind an unchanged external-tool model, module, prompt, or profile changes behind an unchanged
configured identifier, use `--force` for the affected work. configured identifier, use `--force` for the affected work.

View File

@@ -70,19 +70,19 @@ narratio run 2026-04-04
narratio session status 2026-04-04 narratio session status 2026-04-04
``` ```
Run, plan, and status output identify the resolved pipeline profile (or `none`) Run, plan, and status output identify the resolved pipeline overlay (or `none`)
and effective configuration digest. Status distinguishes the current resolved and effective configuration digest. Status distinguishes the current resolved
value from the last value persisted in the session manifest, which helps value from the last value persisted in the session manifest, which helps
diagnose profile switches without changing resume authority. diagnose overlay switches without changing resume authority.
Before switching an operational profile, compare its effective meaning with the Before switching an operational overlay, compare its effective meaning with the
production base through `narratio config diff default <profile>`. production base through `narratio config diff default <overlay>`.
The command is read-only and succeeds whether it finds differences or not. Its The command is read-only and succeeds whether it finds differences or not. Its
sorted records describe defaulted, expanded concrete configuration—not source sorted records describe defaulted, expanded concrete configuration—not source
file layout—so it can be used to review model, artifact, and publish changes file layout—so it can be used to review model, artifact, and publish changes
without creating a session or run. Use `narratio config artifacts` to review the without creating a session or run. Use `narratio config artifacts` to review the
concrete artifact identities, definitions, outputs, and dependencies. Select the concrete artifact identities, definitions, outputs, and dependencies. Select the
same campaign explicitly when profiles could resolve different campaign paths; same campaign explicitly when overlays could resolve different campaign paths;
see the [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff) see the [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
for syntax and record format. for syntax and record format.
@@ -284,17 +284,17 @@ intends to replace otherwise-current artifacts.
### Developing With A Notarius Configuration Profile ### Developing With A Notarius Configuration Profile
A Narratio development overlay can explicitly select a Notarius overlay without A Narratio development overlay can explicitly select a Notarius overlay without
requiring the names to match. For example, `profiles/testing.yml` may contain: requiring the names to match. For example, `overlays/testing.yml` may contain:
```yaml ```yaml
notarius: notarius:
config_profile: downstream-economy config_profile: downstream-economy
``` ```
Selecting Narratio's `testing` profile then supplies `downstream-economy` only Selecting Narratio's `testing` overlay then supplies `downstream-economy` only
when extraction executes. Switching that explicit selector makes a recorded when extraction executes. Switching that explicit selector makes a recorded
extraction non-current and reruns it when `extract` is in the selected stage extraction non-current and reruns it when `extract` is in the selected stage
range; the usual dependent invalidation then applies. Selecting a profile does range; the usual dependent invalidation then applies. Selecting an overlay does
not create a separate workspace or retain a separate current extraction result. not create a separate workspace or retain a separate current extraction result.
Narratio cannot observe edits inside Notarius configuration behind an unchanged Narratio cannot observe edits inside Notarius configuration behind an unchanged

View File

@@ -152,13 +152,13 @@ Configuration is strict, explicit, centralized, and operator-oriented.
- Empty configured values do not silently replace meaningful defaults. - Empty configured values do not silently replace meaningful defaults.
- Validation rejects invalid configuration before stage execution where - Validation rejects invalid configuration before stage execution where
practical. practical.
- Root-owned imports and one selected profile resolve deterministically through - Root-owned imports and one selected overlay resolve deterministically through
the configuration owner; commands do not implement their own merge rules. the configuration owner; commands do not implement their own merge rules.
- Canonical party rosters are campaign-owned. Their derived players projection - Canonical party rosters are campaign-owned. Their derived players projection
and concrete character-family artifacts are resolved before runtime stages and concrete character-family artifacts are resolved before runtime stages
or adapters receive configuration. or adapters receive configuration.
- Resume uses stage- or artifact-owned semantic evidence for observable - Resume uses stage- or artifact-owned semantic evidence for observable
result-affecting configuration; profile identity and an effective digest are result-affecting configuration; overlay identity and an effective digest are
provenance, never blanket cache keys. provenance, never blanket cache keys.
- Session templating remains narrow and deterministic rather than becoming a - Session templating remains narrow and deterministic rather than becoming a
general configuration language. general configuration language.

View File

@@ -1,6 +1,6 @@
# Implementation Plan: Audita v2 and Shared Provider Credentials # Implementation Plan: Audita v2 and Shared Provider Credentials
Status: Not started. Status: In progress.
## Scope and working instructions ## Scope and working instructions
@@ -164,7 +164,12 @@ extraction fingerprints, references, force, resume, or chunk-cache behavior.
## Stage 1: Adopt ConfigKit v0.4.0 and pipeline overlays ## Stage 1: Adopt ConfigKit v0.4.0 and pipeline overlays
Status: Not started. Status: Complete (2026-09-25).
Verification: `go test ./internal/config ./internal/app ./internal/manifest`,
`go test ./...`, `go test ./internal/doccheck`,
`go test ./internal/config -run '^TestExamplesLoadAndValidate$'`, and
`git diff --check` passed.
Preparation: ConfigKit v0.4.0 release notes and selected-overlay migration guide Preparation: ConfigKit v0.4.0 release notes and selected-overlay migration guide
in `../ConfigKit/docs/`; Narratio `internal/config/pipeline_configkit.go`, in `../ConfigKit/docs/`; Narratio `internal/config/pipeline_configkit.go`,

View File

@@ -102,7 +102,7 @@ Safe fix:
Relevant reference: [Canonical Party Input](./integrations/party.md). Relevant reference: [Canonical Party Input](./integrations/party.md).
## Unexpected imported or profile value ## Unexpected imported or overlay value
Symptom: Symptom:
@@ -112,21 +112,21 @@ Symptom:
Diagnostics: Diagnostics:
```bash ```bash
narratio config sources --config /path/pipeline.yml --profile testing narratio config sources --config /path/pipeline.yml --overlay testing
``` ```
Add `--campaign` or `--campaign-file` when the pipeline has party-driven Add `--campaign` or `--campaign-file` when the pipeline has party-driven
artifact families. The output identifies each effective logical field's root, artifact families. The output identifies each effective logical field's root,
import, profile, default, campaign, party, or family source without printing import, overlay, default, campaign, party, or family source without printing
the field value or credential contents. the field value or credential contents.
Safe fix: Safe fix:
- move a duplicated base field so it has one owner; - move a duplicated base field so it has one owner;
- correct the selected profile or its overlay; or - correct the selected overlay; or
- correct the campaign party/family declaration that owns generated values. - correct the campaign party/family declaration that owns generated values.
To review what would actually change before switching profiles, run `config To review what would actually change before switching overlays, run `config
diff` with the same pipeline and campaign selectors. It compares normalized diff` with the same pipeline and campaign selectors. It compares normalized
effective values rather than YAML formatting or source-file layout. effective values rather than YAML formatting or source-file layout.

View File

@@ -14,16 +14,16 @@ in the [configuration reference](../docs/config.md).
- [Production-base/testing-overlay bundle](production-testing/pipeline.yml): - [Production-base/testing-overlay bundle](production-testing/pipeline.yml):
explicit `conf.d` imports, one ordinary artifact per `artifacts/<key>.yml`, explicit `conf.d` imports, one ordinary artifact per `artifacts/<key>.yml`,
one family per `artifact-families/<key>.yml`, and the fixed one family per `artifact-families/<key>.yml`, and the fixed
`profiles/testing.yml` overlay. Production values live in the base. The `overlays/testing.yml` overlay. Production values live in the base. The
bundle demonstrates an input-only artifact dependency, two character families bundle demonstrates an input-only artifact dependency, two character families
sharing the campaign party, and a testing-only disabled artifact. Inspect it sharing the campaign party, and a testing-only disabled artifact. Inspect it
offline with: offline with:
```sh ```sh
narratio config validate --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml narratio config validate --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
narratio config show --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing narratio config show --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --overlay testing
narratio config sources --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing narratio config sources --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --overlay testing
narratio config artifacts --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing narratio config artifacts --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --overlay testing
narratio config diff default testing --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml narratio config diff default testing --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
``` ```

View File

@@ -135,7 +135,7 @@ notarius:
# the selected Notarius/PromptKit profile; never put the credential here. # the selected Notarius/PromptKit profile; never put the credential here.
llm_api_key: NOTARIUS_LLM_API_KEY llm_api_key: NOTARIUS_LLM_API_KEY
# Optionally select a Notarius ConfigKit overlay when extraction runs. This # Optionally select a Notarius ConfigKit overlay when extraction runs. This
# is independent of Narratio's selected profile and may use a different name. # is independent of Narratio's selected overlay and may use a different name.
# See docs/config.md and docs/integrations/notarius.md for the contracts. # See docs/config.md and docs/integrations/notarius.md for the contracts.
# config_profile: downstream-economy # config_profile: downstream-economy
config_path: /usr/local/etc/notarius/config.yml config_path: /usr/local/etc/notarius/config.yml

2
go.mod
View File

@@ -3,7 +3,7 @@ module gitea.maximumdirect.net/eric/narratio
go 1.26.0 go 1.26.0
require ( require (
gitea.maximumdirect.net/eric/ConfigKit v0.3.1 gitea.maximumdirect.net/eric/ConfigKit v0.4.0
github.com/aws/aws-sdk-go-v2/config v1.32.17 github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 github.com/aws/aws-sdk-go-v2/credentials v1.19.16
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0

4
go.sum
View File

@@ -1,5 +1,5 @@
gitea.maximumdirect.net/eric/ConfigKit v0.3.1 h1:etfTI/dxO3NnS18La0V6IeUh/Iv+3PjVfEyaN8hTuOM= gitea.maximumdirect.net/eric/ConfigKit v0.4.0 h1:fo+vKff3zTGeWqJIA38NtT84b6OUP//9ZmBH3lFuKgk=
gitea.maximumdirect.net/eric/ConfigKit v0.3.1/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk= gitea.maximumdirect.net/eric/ConfigKit v0.4.0/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=

View File

@@ -256,12 +256,12 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validated); err != nil { if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validated); err != nil {
t.Fatalf("validate production base: %v", err) t.Fatalf("validate production base: %v", err)
} }
if !strings.Contains(validated.String(), "profile=none") { if !strings.Contains(validated.String(), "overlay=none") {
t.Fatalf("validate output = %q, want unprofiled production base", validated.String()) t.Fatalf("validate output = %q, want production base", validated.String())
} }
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigShow, ConfigSources} { for _, command := range []func(context.Context, []string, io.Writer) error{ConfigShow, ConfigSources} {
if err := command(context.Background(), []string{ if err := command(context.Background(), []string{
"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing", "--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing",
}, io.Discard); err != nil { }, io.Discard); err != nil {
t.Fatalf("testing inspection command %T: %v", command, err) t.Fatalf("testing inspection command %T: %v", command, err)
} }
@@ -278,13 +278,13 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi
common := []string{ common := []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath,
"--session", sessionPath, "--profile", "testing", "--session", sessionPath, "--overlay", "testing",
} }
var planned bytes.Buffer var planned bytes.Buffer
if err := Plan(context.Background(), common, &planned); err != nil { if err := Plan(context.Background(), common, &planned); err != nil {
t.Fatalf("plan testing bundle: %v", err) t.Fatalf("plan testing bundle: %v", err)
} }
if !strings.Contains(planned.String(), "profile=testing") { if !strings.Contains(planned.String(), "overlay=testing") {
t.Fatalf("plan output = %q, want testing provenance", planned.String()) t.Fatalf("plan output = %q, want testing provenance", planned.String())
} }
@@ -305,7 +305,7 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi
if !reflect.DeepEqual(capturedPlan.Names(), []string{"analyze"}) { if !reflect.DeepEqual(capturedPlan.Names(), []string{"analyze"}) {
t.Fatalf("bounded plan = %#v, want analyze only", capturedPlan.Names()) t.Fatalf("bounded plan = %#v, want analyze only", capturedPlan.Names())
} }
if profile, ok := config.SelectedPipelineProfile(capturedCfg.Pipeline); !ok || profile.Name != "testing" { if profile, ok := config.SelectedPipelineOverlay(capturedCfg.Pipeline); !ok || profile.Name != "testing" {
t.Fatalf("captured profile = %#v, selected=%t", profile, ok) t.Fatalf("captured profile = %#v, selected=%t", profile, ok)
} }
if digest := config.EffectivePipelineDigest(capturedCfg.Pipeline); digest == "" || !strings.Contains(runOut.String(), "digest="+digest) { if digest := config.EffectivePipelineDigest(capturedCfg.Pipeline); digest == "" || !strings.Contains(runOut.String(), "digest="+digest) {
@@ -391,7 +391,7 @@ func loadAssembledSplitBundleConfig(t *testing.T, profile string) *config.Config
pipelinePath, campaignPath, sessionPath := assembledSplitBundlePaths() pipelinePath, campaignPath, sessionPath := assembledSplitBundlePaths()
options := config.SessionLoadOptions{} options := config.SessionLoadOptions{}
if profile != "" { if profile != "" {
options.Profile = &profile options.Overlay = &profile
} }
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, options) cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, options)
if err != nil { if err != nil {
@@ -415,8 +415,8 @@ func assertAssembledProvenance(t *testing.T, store *manifest.LocalStore, summary
t.Fatal(err) t.Fatal(err)
} }
value := session.EffectiveConfig value := session.EffectiveConfig
profileMatches := value != nil && ((profile == "" && value.SelectedProfile == nil) || profileMatches := value != nil && ((profile == "" && value.SelectedOverlay == nil) ||
(profile != "" && value.SelectedProfile != nil && value.SelectedProfile.Name == profile)) (profile != "" && value.SelectedOverlay != nil && value.SelectedOverlay.Name == profile))
if !profileMatches || value.EffectiveConfigDigest != digest { if !profileMatches || value.EffectiveConfigDigest != digest {
t.Fatalf("effective configuration provenance = %#v, want profile=%q digest=%q", value, profile, digest) t.Fatalf("effective configuration provenance = %#v, want profile=%q digest=%q", value, profile, digest)
} }

View File

@@ -57,7 +57,7 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
{name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"}, {name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"},
{name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"}, {name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"},
{name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"}, {name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"},
{name: "profile", args: []string{"session", "--profile", "production", "--profile=testing"}, want: "--profile may be specified only once"}, {name: "overlay", args: []string{"session", "--overlay", "production", "--overlay=testing"}, want: "--overlay may be specified only once"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -69,29 +69,29 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
} }
} }
func TestBoundedRunParsingRetainsExplicitProfilePresence(t *testing.T) { func TestBoundedRunParsingRetainsExplicitOverlayPresence(t *testing.T) {
withoutProfile, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard) withoutOverlay, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := withoutProfile.Config.sessionOptions().Profile; got != nil { if got := withoutOverlay.Config.sessionOptions().Overlay; got != nil {
t.Fatalf("omitted profile = %#v, want nil", got) t.Fatalf("omitted overlay = %#v, want nil", got)
} }
withProfile, err := parseBoundedRunRequest("run", []string{"session", "--profile", "testing"}, io.Discard) withOverlay, err := parseBoundedRunRequest("run", []string{"session", "--overlay", "testing"}, io.Discard)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := withProfile.Config.sessionOptions().Profile; got == nil || *got != "testing" { if got := withOverlay.Config.sessionOptions().Overlay; got == nil || *got != "testing" {
t.Fatalf("explicit profile = %#v, want testing", got) t.Fatalf("explicit overlay = %#v, want testing", got)
} }
explicitEmpty, err := parseBoundedRunRequest("run", []string{"session", "--profile", ""}, io.Discard) explicitEmpty, err := parseBoundedRunRequest("run", []string{"session", "--overlay", ""}, io.Discard)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := explicitEmpty.Config.sessionOptions().Profile; got == nil || *got != "" { if got := explicitEmpty.Config.sessionOptions().Overlay; got == nil || *got != "" {
t.Fatalf("explicit empty profile = %#v, want non-nil empty", got) t.Fatalf("explicit empty overlay = %#v, want non-nil empty", got)
} }
} }

View File

@@ -93,7 +93,7 @@ func cleanAllLocalWithDependencies(flags commonConfigFlags, dryRun, clearCache b
strings.TrimSpace(flags.previousSessionID) != "" { strings.TrimSpace(flags.previousSessionID) != "" {
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id") return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
} }
_, pipelineCfg, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()}, flags.secretsDir.pointer(), deps) _, pipelineCfg, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Overlay: flags.overlay.pointer()}, flags.secretsDir.pointer(), deps)
if err != nil { if err != nil {
return fmt.Errorf("clean: %w", err) return fmt.Errorf("clean: %w", err)
} }

View File

@@ -80,22 +80,22 @@ func TestConfigArtifactsReportsGeneratedFamilyProvenance(t *testing.T) {
} }
} }
func TestConfigArtifactsReportsProfiledFamilyContributors(t *testing.T) { func TestConfigArtifactsReportsOverlayFamilyContributors(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t) pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
profilePath := filepath.Join(filepath.Dir(pipelinePath), "profiles", "testing.yml") overlayPath := filepath.Join(filepath.Dir(pipelinePath), "overlays", "testing.yml")
mustWriteTestFile(t, profilePath, `scriptorium: mustWriteTestFile(t, overlayPath, `scriptorium:
artifact_families: artifact_families:
character_note: character_note:
prompt_id: dnd.character_note.testing prompt_id: dnd.character_note.testing
`) `)
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"} args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing"}
var catalog bytes.Buffer var catalog bytes.Buffer
if err := ConfigArtifacts(context.Background(), args, &catalog); err != nil { if err := ConfigArtifacts(context.Background(), args, &catalog); err != nil {
t.Fatal(err) t.Fatal(err)
} }
records := catalogRecordsByKey(t, catalog.String()) records := catalogRecordsByKey(t, catalog.String())
if got := strings.Split(records["character_note_arannis"][2], ","); !reflect.DeepEqual(got, []string{pipelinePath, profilePath}) { if got := strings.Split(records["character_note_arannis"][2], ","); !reflect.DeepEqual(got, []string{overlayPath, pipelinePath}) {
t.Fatalf("generated family definitions = %#v", got) t.Fatalf("generated family definitions = %#v", got)
} }
@@ -103,16 +103,16 @@ func TestConfigArtifactsReportsProfiledFamilyContributors(t *testing.T) {
if err := ConfigSources(context.Background(), args, &sources); err != nil { if err := ConfigSources(context.Background(), args, &sources); err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := "scriptorium.artifacts.character_note_arannis.prompt_id\tfamily\t" + profilePath want := "scriptorium.artifacts.character_note_arannis.prompt_id\tfamily\t" + overlayPath
if !strings.Contains(sources.String(), want) { if !strings.Contains(sources.String(), want) {
t.Fatalf("sources output missing profiled family contributor %q:\n%s", want, sources.String()) t.Fatalf("sources output missing overlay family contributor %q:\n%s", want, sources.String())
} }
} }
func TestConfigArtifactsReportsBaseAndProfileContributors(t *testing.T) { func TestConfigArtifactsReportsBaseAndOverlayContributors(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml") pipelinePath := filepath.Join(dir, "pipeline.yml")
profilePath := filepath.Join(dir, "profiles", "testing.yml") overlayPath := filepath.Join(dir, "overlays", "testing.yml")
mustWriteTestFile(t, pipelinePath, `workspace: mustWriteTestFile(t, pipelinePath, `workspace:
root: /srv/narratio root: /srv/narratio
whisperx: whisperx:
@@ -124,26 +124,26 @@ scriptorium:
enabled: false enabled: false
output_path: artifacts/notes.md output_path: artifacts/notes.md
`) `)
mustWriteTestFile(t, profilePath, `scriptorium: mustWriteTestFile(t, overlayPath, `scriptorium:
artifacts: artifacts:
notes: notes:
enabled: true enabled: true
prompt_id: notes prompt_id: notes
profile_only: overlay_only:
enabled: false enabled: false
output_path: artifacts/profile-only.md output_path: artifacts/overlay-only.md
`) `)
var out bytes.Buffer var out bytes.Buffer
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &out); err != nil { if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--overlay", "testing"}, &out); err != nil {
t.Fatal(err) t.Fatal(err)
} }
records := catalogRecordsByKey(t, out.String()) records := catalogRecordsByKey(t, out.String())
if got := strings.Split(records["notes"][2], ","); !reflect.DeepEqual(got, []string{pipelinePath, profilePath}) { if got := strings.Split(records["notes"][2], ","); !reflect.DeepEqual(got, []string{overlayPath, pipelinePath}) {
t.Fatalf("notes definitions = %#v", got) t.Fatalf("notes definitions = %#v", got)
} }
if got := records["profile_only"][2]; got != profilePath { if got := records["overlay_only"][2]; got != overlayPath {
t.Fatalf("profile-only definition = %q, want %q", got, profilePath) t.Fatalf("overlay-only definition = %q, want %q", got, overlayPath)
} }
} }

View File

@@ -17,7 +17,7 @@ type inspectionFlags struct {
pipelinePath string pipelinePath string
campaignPath string campaignPath string
campaignFilePath string campaignFilePath string
profile singletonStringFlag overlay singletonStringFlag
secretsDir singletonStringFlag secretsDir singletonStringFlag
} }
@@ -77,8 +77,8 @@ func configValidateWithDependencies(_ context.Context, args []string, out io.Wri
if err != nil { if err != nil {
return fmt.Errorf("config validate: %w", err) return fmt.Errorf("config validate: %w", err)
} }
_, err = fmt.Fprintf(out, "Configuration valid: root=%s; profile=%s; digest=%s\n", _, err = fmt.Fprintf(out, "Configuration valid: root=%s; overlay=%s; digest=%s\n",
inspectionRootPath(resolved), inspectionProfile(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline)) inspectionRootPath(resolved), inspectionOverlay(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline))
return err return err
} }
@@ -214,12 +214,12 @@ func parseInspectionFlags(command string, args []string, out io.Writer) (inspect
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID") fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
flags.profile.name = "profile" flags.overlay.name = "overlay"
fs.Var(&flags.profile, "profile", "named pipeline profile") fs.Var(&flags.overlay, "overlay", "named pipeline overlay")
flags.secretsDir.name = "secrets-dir" flags.secretsDir.name = "secrets-dir"
fs.Var(&flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)") fs.Var(&flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintf(out, "Usage: narratio %s [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <path>]\n\n", command) fmt.Fprintf(out, "Usage: narratio %s [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <path>]\n\n", command)
fmt.Fprintln(out, "Resolves configuration without a session, workspace, manifest, adapters, or external services.") fmt.Fprintln(out, "Resolves configuration without a session, workspace, manifest, adapters, or external services.")
fmt.Fprintln(out) fmt.Fprintln(out)
fmt.Fprintln(out, "Flags:") fmt.Fprintln(out, "Flags:")
@@ -238,7 +238,7 @@ func parseInspectionFlags(command string, args []string, out io.Writer) (inspect
} }
func resolveInspectionConfigWithDependencies(flags inspectionFlags, deps commandDependencies) (*inspectionConfig, error) { func resolveInspectionConfigWithDependencies(flags inspectionFlags, deps commandDependencies) (*inspectionConfig, error) {
pipelinePath, pipeline, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()}, flags.secretsDir.pointer(), deps) pipelinePath, pipeline, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Overlay: flags.overlay.pointer()}, flags.secretsDir.pointer(), deps)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -315,9 +315,9 @@ func inspectionRootPath(resolved *inspectionConfig) string {
return resolved.PipelinePath return resolved.PipelinePath
} }
func inspectionProfile(pipeline *config.PipelineConfig) string { func inspectionOverlay(pipeline *config.PipelineConfig) string {
if profile, ok := config.SelectedPipelineProfile(pipeline); ok { if overlay, ok := config.SelectedPipelineOverlay(pipeline); ok {
return profile.Name return overlay.Name
} }
return "none" return "none"
} }
@@ -325,7 +325,7 @@ func inspectionProfile(pipeline *config.PipelineConfig) string {
func writeConfigCommandUsage(out io.Writer) { func writeConfigCommandUsage(out io.Writer) {
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources|artifacts|diff>") fmt.Fprintln(out, "Usage: narratio config <validate|show|sources|artifacts|diff>")
fmt.Fprintln(out) fmt.Fprintln(out)
fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, config artifacts to list concrete artifacts, or config diff to compare two profiles.") fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, config artifacts to list concrete artifacts, or config diff to compare two overlays.")
} }
func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error { func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error {
@@ -343,11 +343,11 @@ func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) erro
return err return err
} }
} }
if profile, ok := config.SelectedPipelineProfile(resolved.Pipeline); ok { if overlay, ok := config.SelectedPipelineOverlay(resolved.Pipeline); ok {
if _, err := fmt.Fprintf(out, "profile: name=%s selection=%s overlay=%s\n", profile.Name, profile.Source, config.NormalizedConfigurationPath(profile.OverlayPath)); err != nil { if _, err := fmt.Fprintf(out, "overlay: name=%s selection=%s path=%s\n", overlay.Name, overlay.Source, config.NormalizedConfigurationPath(overlay.OverlayPath)); err != nil {
return err return err
} }
} else if _, err := fmt.Fprintln(out, "profile: none"); err != nil { } else if _, err := fmt.Fprintln(out, "overlay: none"); err != nil {
return err return err
} }
if resolved.Campaign == nil { if resolved.Campaign == nil {

View File

@@ -34,6 +34,7 @@ func TestConfigCommandHelpAndDispatch(t *testing.T) {
{name: "stage flag", args: []string{"config", "show", "--from", "prepare"}, want: "flag provided but not defined", code: 1}, {name: "stage flag", args: []string{"config", "show", "--from", "prepare"}, want: "flag provided but not defined", code: 1},
{name: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1}, {name: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1},
{name: "artifact flag", args: []string{"config", "show", "--artifacts", "recap"}, want: "flag provided but not defined", code: 1}, {name: "artifact flag", args: []string{"config", "show", "--artifacts", "recap"}, want: "flag provided but not defined", code: 1},
{name: "retired selector", args: []string{"config", "validate", "--profile", "testing"}, want: "flag provided but not defined", code: 1},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -102,7 +103,7 @@ whisperx:
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &validateOut); err != nil { if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &validateOut); err != nil {
t.Fatalf("ConfigValidate() error = %v", err) t.Fatalf("ConfigValidate() error = %v", err)
} }
if !strings.Contains(validateOut.String(), "Configuration valid: root=") || !strings.Contains(validateOut.String(), "; profile=none; digest=") { if !strings.Contains(validateOut.String(), "Configuration valid: root=") || !strings.Contains(validateOut.String(), "; overlay=none; digest=") {
t.Fatalf("validate output = %q", validateOut.String()) t.Fatalf("validate output = %q", validateOut.String())
} }
@@ -127,7 +128,7 @@ whisperx:
if strings.Contains(sourcesOut.String(), secret) { if strings.Contains(sourcesOut.String(), secret) {
t.Fatalf("sources output leaked a raw secret: %q", sourcesOut.String()) t.Fatalf("sources output leaked a raw secret: %q", sourcesOut.String())
} }
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n") writeInspectionOverlays(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n")
var diffOut bytes.Buffer var diffOut bytes.Buffer
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diffOut); err != nil { if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diffOut); err != nil {
t.Fatalf("ConfigDiff() error = %v", err) t.Fatalf("ConfigDiff() error = %v", err)
@@ -143,10 +144,10 @@ whisperx:
} }
} }
func TestConfigDiffReportsSemanticProfileChanges(t *testing.T) { func TestConfigDiffReportsSemanticOverlayChanges(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
writeInspectionProfiles(t, pipelinePath, `workspace: writeInspectionOverlays(t, pipelinePath, `workspace:
cleanup_after_publish: true cleanup_after_publish: true
publish: publish:
upload_run: true upload_run: true
@@ -203,7 +204,7 @@ scriptorium:
t.Fatalf("reversed ConfigDiff() error = %v", err) t.Fatalf("reversed ConfigDiff() error = %v", err)
} }
if !strings.Contains(reversed.String(), "changed\twhisperx.language\t\"fr\"\t\"en\"\n") { if !strings.Contains(reversed.String(), "changed\twhisperx.language\t\"fr\"\t\"en\"\n") {
t.Fatalf("reversed diff did not independently resolve profiles:\n%s", reversed.String()) t.Fatalf("reversed diff did not independently resolve overlays:\n%s", reversed.String())
} }
} }
@@ -219,7 +220,7 @@ audita:
scriptorium: scriptorium:
artifacts: null artifacts: null
`) `)
mustWriteTestFile(t, filepath.Join(dir, "profiles", "empty.yml"), `audita: mustWriteTestFile(t, filepath.Join(dir, "overlays", "empty.yml"), `audita:
modules: [] modules: []
scriptorium: scriptorium:
artifacts: {} artifacts: {}
@@ -229,7 +230,7 @@ scriptorium:
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &base); err != nil { if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &base); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "empty"}, &empty); err != nil { if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "empty"}, &empty); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, want := range []string{"modules: null\n", "artifacts: null\n"} { for _, want := range []string{"modules: null\n", "artifacts: null\n"} {
@@ -239,7 +240,7 @@ scriptorium:
} }
for _, want := range []string{"modules: []\n", "artifacts: {}\n"} { for _, want := range []string{"modules: []\n", "artifacts: {}\n"} {
if !strings.Contains(empty.String(), want) { if !strings.Contains(empty.String(), want) {
t.Fatalf("empty-profile show output missing %q:\n%s", want, empty.String()) t.Fatalf("empty-overlay show output missing %q:\n%s", want, empty.String())
} }
} }
@@ -259,7 +260,7 @@ scriptorium:
func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) { func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t) pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
writeInspectionProfiles(t, pipelinePath, `scriptorium: writeInspectionOverlays(t, pipelinePath, `scriptorium:
artifact_families: artifact_families:
character_note: character_note:
enabled: false enabled: false
@@ -301,11 +302,11 @@ func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) {
func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) { func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: en\n") writeInspectionOverlays(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: en\n")
var equal bytes.Buffer var equal bytes.Buffer
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &equal); err != nil { if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &equal); err != nil {
t.Fatalf("ConfigDiff() equal profiles error = %v", err) t.Fatalf("ConfigDiff() equal overlays error = %v", err)
} }
if got := equal.String(); got != "no differences\n" { if got := equal.String(); got != "no differences\n" {
t.Fatalf("equal diff output = %q", got) t.Fatalf("equal diff output = %q", got)
@@ -316,7 +317,7 @@ func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
{"production", "--config", pipelinePath}, {"production", "--config", pipelinePath},
{"production", "production", "--config", pipelinePath}, {"production", "production", "--config", pipelinePath},
{"unknown", "testing", "--config", pipelinePath}, {"unknown", "testing", "--config", pipelinePath},
{"production", "testing", "--config", pipelinePath, "--profile", "production"}, {"production", "testing", "--config", pipelinePath, "--overlay", "production"},
{"production", "testing", "--config", pipelinePath, "--config", pipelinePath}, {"production", "testing", "--config", pipelinePath, "--config", pipelinePath},
{"production", "testing", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath}, {"production", "testing", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath},
} { } {
@@ -326,10 +327,10 @@ func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
} }
} }
func TestConfigDiffBootstrapsSecretsOnceForBothProfiles(t *testing.T) { func TestConfigDiffBootstrapsSecretsOnceForBothOverlays(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n") writeInspectionOverlays(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n")
secretsDir := t.TempDir() secretsDir := t.TempDir()
const secretName = "NARRATIO_DIFF_BOOTSTRAP_REPORT" const secretName = "NARRATIO_DIFF_BOOTSTRAP_REPORT"
restoreEnvAfterTest(t, secretName) restoreEnvAfterTest(t, secretName)
@@ -393,27 +394,27 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
writeInspectionProfiles(t, pipelinePath, writeInspectionOverlays(t, pipelinePath,
"audita:\n model: production\n llm_api_key: NARRATIO_INSPECT_LLM_ONE\n", "audita:\n model: production\n llm_api_key: NARRATIO_INSPECT_LLM_ONE\n",
"audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\nnotarius:\n llm_api_key: NARRATIO_INSPECT_NOTARIUS_TWO\n", "audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\nnotarius:\n llm_api_key: NARRATIO_INSPECT_NOTARIUS_TWO\n",
) )
mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "profiles", "without-notarius-key.yml"), "notarius:\n llm_api_key: null\n") mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "overlays", "without-notarius-key.yml"), "notarius:\n llm_api_key: null\n")
var validate bytes.Buffer var validate bytes.Buffer
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &validate); err != nil { if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &validate); err != nil {
t.Fatalf("config validate: %v", err) t.Fatalf("config validate: %v", err)
} }
var show bytes.Buffer var show bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &show); err != nil { if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &show); err != nil {
t.Fatalf("config show: %v", err) t.Fatalf("config show: %v", err)
} }
var sources bytes.Buffer var sources bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &sources); err != nil { if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &sources); err != nil {
t.Fatalf("config sources: %v", err) t.Fatalf("config sources: %v", err)
} }
var catalog bytes.Buffer var catalog bytes.Buffer
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &catalog); err != nil { if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &catalog); err != nil {
t.Fatalf("config artifacts: %v", err) t.Fatalf("config artifacts: %v", err)
} }
var diff bytes.Buffer var diff bytes.Buffer
@@ -421,7 +422,7 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
t.Fatalf("config diff: %v", err) t.Fatalf("config diff: %v", err)
} }
var removed bytes.Buffer var removed bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "without-notarius-key"}, &removed); err != nil { if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "without-notarius-key"}, &removed); err != nil {
t.Fatalf("config show with removed Notarius credential: %v", err) t.Fatalf("config show with removed Notarius credential: %v", err)
} }
for label, output := range map[string]string{"show": show.String(), "sources": sources.String(), "artifacts": catalog.String(), "diff": diff.String()} { for label, output := range map[string]string{"show": show.String(), "sources": sources.String(), "artifacts": catalog.String(), "diff": diff.String()} {
@@ -442,7 +443,7 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
t.Fatalf("sources omitted Notarius credential ownership: %q", sources.String()) t.Fatalf("sources omitted Notarius credential ownership: %q", sources.String())
} }
if strings.Contains(removed.String(), "llm_api_key") || strings.Contains(removed.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") { if strings.Contains(removed.String(), "llm_api_key") || strings.Contains(removed.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") {
t.Fatalf("profile null did not remove Notarius credential: %q", removed.String()) t.Fatalf("overlay null did not remove Notarius credential: %q", removed.String())
} }
} }
@@ -476,35 +477,35 @@ func TestConfigCommandsInspectMaintainedSplitBundleWithoutRuntimeState(t *testin
t.Fatalf("ConfigDiff() error = %v", err) t.Fatalf("ConfigDiff() error = %v", err)
} }
if !strings.Contains(diff.String(), "audita.model") { if !strings.Contains(diff.String(), "audita.model") {
t.Fatalf("split bundle profile diff = %q, want model change", diff.String()) t.Fatalf("split bundle overlay diff = %q, want model change", diff.String())
} }
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) { if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
t.Fatalf("inspection created example workspace: %v", err) t.Fatalf("inspection created example workspace: %v", err)
} }
} }
func writeInspectionProfiles(t *testing.T, pipelinePath, production, testing string) { func writeInspectionOverlays(t *testing.T, pipelinePath, production, testing string) {
t.Helper() t.Helper()
profilesDir := filepath.Join(filepath.Dir(pipelinePath), "profiles") overlaysDir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
mustWriteTestFile(t, filepath.Join(profilesDir, "production.yml"), production) mustWriteTestFile(t, filepath.Join(overlaysDir, "production.yml"), production)
mustWriteTestFile(t, filepath.Join(profilesDir, "testing.yml"), testing) mustWriteTestFile(t, filepath.Join(overlaysDir, "testing.yml"), testing)
} }
func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) { func TestConfigCommandsSelectOverlaysAndCampaigns(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath) enableCommandTestOverlays(t, pipelinePath)
var defaultOut bytes.Buffer var defaultOut bytes.Buffer
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &defaultOut); err != nil { if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &defaultOut); err != nil {
t.Fatalf("default ConfigValidate() error = %v", err) t.Fatalf("default ConfigValidate() error = %v", err)
} }
if !strings.Contains(defaultOut.String(), "profile=none") { if !strings.Contains(defaultOut.String(), "overlay=none") {
t.Fatalf("default output = %q", defaultOut.String()) t.Fatalf("default output = %q", defaultOut.String())
} }
var explicitOut bytes.Buffer var explicitOut bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &explicitOut); err != nil { if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "testing"}, &explicitOut); err != nil {
t.Fatalf("explicit ConfigShow() error = %v", err) t.Fatalf("explicit ConfigShow() error = %v", err)
} }
if !strings.Contains(explicitOut.String(), "language: fr") { if !strings.Contains(explicitOut.String(), "language: fr") {
@@ -589,9 +590,9 @@ whisperx:
func TestConfigSourcesReportsStableOwnership(t *testing.T) { func TestConfigSourcesReportsStableOwnership(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath) enableCommandTestOverlays(t, pipelinePath)
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"} args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing"}
var first, second bytes.Buffer var first, second bytes.Buffer
if err := ConfigSources(context.Background(), args, &first); err != nil { if err := ConfigSources(context.Background(), args, &first); err != nil {
t.Fatalf("first ConfigSources() error = %v", err) t.Fatalf("first ConfigSources() error = %v", err)
@@ -606,13 +607,13 @@ func TestConfigSourcesReportsStableOwnership(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"root: ", "root: ",
"imports: none", "imports: none",
"profile: name=testing selection=explicit overlay=", "overlay: name=testing selection=explicit path=",
"campaign: id=sample-campaign source=", "campaign: id=sample-campaign source=",
"party: source=", "party: 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\toverlay\t",
"derived.players\tparty\t", "derived.players\tparty\t",
"trim.enabled\tdefault\tdefault", "trim.enabled\tdefault\tdefault",
} { } {
@@ -647,10 +648,10 @@ func TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins(t *testing.T) {
} }
} }
func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing.T) { func TestConfigSourcesPreservesOverlayOwnershipThroughFamilyExpansion(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t) pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
profilePath := filepath.Join(filepath.Dir(pipelinePath), "profiles", "testing.yml") overlayPath := filepath.Join(filepath.Dir(pipelinePath), "overlays", "testing.yml")
mustWriteTestFile(t, profilePath, `scriptorium: mustWriteTestFile(t, overlayPath, `scriptorium:
artifact_families: artifact_families:
character_note: character_note:
enabled: true enabled: true
@@ -658,12 +659,12 @@ func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing
`) `)
var out bytes.Buffer var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"}, &out); err != nil { if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing"}, &out); err != nil {
t.Fatalf("ConfigSources() error = %v", err) t.Fatalf("ConfigSources() error = %v", err)
} }
want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + profilePath want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + overlayPath
if !strings.Contains(out.String(), want) { if !strings.Contains(out.String(), want) {
t.Fatalf("sources output missing profile-generated ownership %q:\n%s", want, out.String()) t.Fatalf("sources output missing overlay-generated ownership %q:\n%s", want, out.String())
} }
} }
@@ -671,7 +672,7 @@ func TestConfigSourcesAttributesInferredArtifactDependencies(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml") pipelinePath := filepath.Join(dir, "pipeline.yml")
inputPath := filepath.Join(dir, "input-only.yml") inputPath := filepath.Join(dir, "input-only.yml")
profilePath := filepath.Join(dir, "profiles", "testing.yml") overlayPath := filepath.Join(dir, "overlays", "testing.yml")
mustWriteTestFile(t, pipelinePath, `configkit: mustWriteTestFile(t, pipelinePath, `configkit:
imports: imports:
- path: input-only.yml - path: input-only.yml
@@ -701,7 +702,7 @@ scriptorium:
prior: prior:
source: narratio.artifact.first source: narratio.artifact.first
`) `)
mustWriteTestFile(t, profilePath, `scriptorium: mustWriteTestFile(t, overlayPath, `scriptorium:
artifacts: artifacts:
mixed: mixed:
inputs: inputs:
@@ -710,13 +711,13 @@ scriptorium:
`) `)
var out bytes.Buffer var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &out); err != nil { if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--overlay", "testing"}, &out); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, want := range []string{ for _, want := range []string{
"scriptorium.artifacts.input_only.depends_on\timport\t" + inputPath, "scriptorium.artifacts.input_only.depends_on\timport\t" + inputPath,
"scriptorium.artifacts.mixed.depends_on\troot\t" + pipelinePath, "scriptorium.artifacts.mixed.depends_on\troot\t" + pipelinePath,
"scriptorium.artifacts.mixed.depends_on\tprofile\t" + profilePath, "scriptorium.artifacts.mixed.depends_on\toverlay\t" + overlayPath,
} { } {
if !strings.Contains(out.String(), want) { if !strings.Contains(out.String(), want) {
t.Fatalf("sources output missing dependency contributor %q:\n%s", want, out.String()) t.Fatalf("sources output missing dependency contributor %q:\n%s", want, out.String())
@@ -727,11 +728,11 @@ scriptorium:
} }
} }
func TestConfigSourcesReportsRootImportProfileAndDefaultOwnership(t *testing.T) { func TestConfigSourcesReportsRootImportOverlayAndDefaultOwnership(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
rootPath := filepath.Join(dir, "pipeline.yml") rootPath := filepath.Join(dir, "pipeline.yml")
importPath := filepath.Join(dir, "base.yml") importPath := filepath.Join(dir, "base.yml")
profilePath := filepath.Join(dir, "profiles", "testing.yml") overlayPath := filepath.Join(dir, "overlays", "testing.yml")
mustWriteTestFile(t, rootPath, `configkit: mustWriteTestFile(t, rootPath, `configkit:
imports: imports:
- path: base.yml - path: base.yml
@@ -741,19 +742,19 @@ whisperx:
transcribe_url: https://transcription.example.com/transcribe transcribe_url: https://transcription.example.com/transcribe
`) `)
mustWriteTestFile(t, importPath, "storage:\n backend: local\n") mustWriteTestFile(t, importPath, "storage:\n backend: local\n")
mustWriteTestFile(t, profilePath, "whisperx:\n language: fr\n") mustWriteTestFile(t, overlayPath, "whisperx:\n language: fr\n")
var out bytes.Buffer var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", rootPath, "--profile", "testing"}, &out); err != nil { if err := ConfigSources(context.Background(), []string{"--config", rootPath, "--overlay", "testing"}, &out); err != nil {
t.Fatalf("ConfigSources() error = %v", err) t.Fatalf("ConfigSources() error = %v", err)
} }
output := out.String() output := out.String()
for _, want := range []string{ for _, want := range []string{
"import: " + importPath, "import: " + importPath,
"profile: name=testing selection=explicit overlay=" + profilePath, "overlay: name=testing selection=explicit path=" + overlayPath,
"workspace.root\troot\t" + rootPath, "workspace.root\troot\t" + rootPath,
"storage.backend\timport\t" + importPath, "storage.backend\timport\t" + importPath,
"whisperx.language\tprofile\t" + profilePath, "whisperx.language\toverlay\t" + overlayPath,
"trim.enabled\tdefault\tdefault", "trim.enabled\tdefault\tdefault",
} { } {
if !strings.Contains(output, want) { if !strings.Contains(output, want) {

View File

@@ -20,8 +20,8 @@ type configDiffFlags struct {
} }
type configDiffRequest struct { type configDiffRequest struct {
leftProfile string leftOverlay string
rightProfile string rightOverlay string
flags configDiffFlags flags configDiffFlags
} }
@@ -37,7 +37,7 @@ type configDiffRecord struct {
Right string Right string
} }
// ConfigDiff compares two explicitly selected profiles through the same // ConfigDiff compares two explicitly selected overlays through the same
// read-only configuration resolution boundary used by config validate, show, // read-only configuration resolution boundary used by config validate, show,
// and sources. Differences describe normalized effective values rather than // and sources. Differences describe normalized effective values rather than
// the layout of root, import, or overlay files. // the layout of root, import, or overlay files.
@@ -100,7 +100,7 @@ func configDiffWithDependencies(_ context.Context, args []string, out io.Writer,
} }
func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, error) { func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, error) {
profiles, flagArgs := splitConfigDiffArguments(args) overlays, flagArgs := splitConfigDiffArguments(args)
fs := flag.NewFlagSet("config diff", flag.ContinueOnError) fs := flag.NewFlagSet("config diff", flag.ContinueOnError)
fs.SetOutput(out) fs.SetOutput(out)
var request configDiffRequest var request configDiffRequest
@@ -113,9 +113,9 @@ func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, er
fs.Var(&request.flags.campaignFilePath, "campaign-file", "path to campaign.yml") fs.Var(&request.flags.campaignFilePath, "campaign-file", "path to campaign.yml")
fs.Var(&request.flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)") fs.Var(&request.flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintln(out, "Usage: narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <path>]") fmt.Fprintln(out, "Usage: narratio config diff <left-overlay> <right-overlay> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <path>]")
fmt.Fprintln(out) fmt.Fprintln(out)
fmt.Fprintln(out, "Compares fully resolved profiles without a session, workspace, manifest, adapters, or external services.") fmt.Fprintln(out, "Compares fully resolved overlays without a session, workspace, manifest, adapters, or external services.")
fmt.Fprintln(out) fmt.Fprintln(out)
fmt.Fprintln(out, "Flags:") fmt.Fprintln(out, "Flags:")
fs.PrintDefaults() fs.PrintDefaults()
@@ -123,30 +123,30 @@ func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, er
if err := fs.Parse(flagArgs); err != nil { if err := fs.Parse(flagArgs); err != nil {
return configDiffRequest{}, fmt.Errorf("config diff: invalid flags: %w", err) return configDiffRequest{}, fmt.Errorf("config diff: invalid flags: %w", err)
} }
profiles = append(profiles, fs.Args()...) overlays = append(overlays, fs.Args()...)
if len(profiles) != 2 || strings.TrimSpace(profiles[0]) == "" || strings.TrimSpace(profiles[1]) == "" { if len(overlays) != 2 || strings.TrimSpace(overlays[0]) == "" || strings.TrimSpace(overlays[1]) == "" {
return configDiffRequest{}, fmt.Errorf("config diff: exactly two non-empty profile names are required") return configDiffRequest{}, fmt.Errorf("config diff: exactly two non-empty overlay names are required")
} }
if profiles[0] == profiles[1] { if overlays[0] == overlays[1] {
return configDiffRequest{}, fmt.Errorf("config diff: profile names must differ") return configDiffRequest{}, fmt.Errorf("config diff: overlay names must differ")
} }
if request.flags.campaignPath.set && request.flags.campaignFilePath.set { if request.flags.campaignPath.set && request.flags.campaignFilePath.set {
return configDiffRequest{}, fmt.Errorf("config diff: --campaign and --campaign-file are mutually exclusive") return configDiffRequest{}, fmt.Errorf("config diff: --campaign and --campaign-file are mutually exclusive")
} }
request.leftProfile = profiles[0] request.leftOverlay = overlays[0]
request.rightProfile = profiles[1] request.rightOverlay = overlays[1]
return request, nil return request, nil
} }
// splitConfigDiffArguments accepts the documented positional-first syntax and // splitConfigDiffArguments accepts the documented positional-first syntax and
// also permits flags before or between profile names. Values belonging to the // also permits flags before or between overlay names. Values belonging to the
// supported string flags stay with their flag so they are not mistaken for // supported string flags stay with their flag so they are not mistaken for
// profile names. // overlay names.
func splitConfigDiffArguments(args []string) (profiles, flagArgs []string) { func splitConfigDiffArguments(args []string) (overlays, flagArgs []string) {
for index := 0; index < len(args); index++ { for index := 0; index < len(args); index++ {
argument := args[index] argument := args[index]
if !strings.HasPrefix(argument, "-") || argument == "-" { if !strings.HasPrefix(argument, "-") || argument == "-" {
profiles = append(profiles, argument) overlays = append(overlays, argument)
continue continue
} }
flagArgs = append(flagArgs, argument) flagArgs = append(flagArgs, argument)
@@ -161,12 +161,12 @@ func splitConfigDiffArguments(args []string) (profiles, flagArgs []string) {
index++ index++
flagArgs = append(flagArgs, args[index]) flagArgs = append(flagArgs, args[index])
} }
return profiles, flagArgs return overlays, flagArgs
} }
func configDiffStringFlag(name string) bool { func configDiffStringFlag(name string) bool {
switch name { switch name {
case "config", "campaign", "campaign-file", "profile", "secrets-dir": case "config", "campaign", "campaign-file", "secrets-dir":
return true return true
default: default:
return false return false
@@ -175,13 +175,13 @@ func configDiffStringFlag(name string) bool {
func resolveConfigDiff(request configDiffRequest, deps commandDependencies) (*configDiffResolved, error) { func resolveConfigDiff(request configDiffRequest, deps commandDependencies) (*configDiffResolved, error) {
deps = normalizeCommandDependencies(deps) deps = normalizeCommandDependencies(deps)
pipelinePath, leftPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Profile: &request.leftProfile}, request.flags.secretsDir.pointer(), deps) pipelinePath, leftPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Overlay: &request.leftOverlay}, request.flags.secretsDir.pointer(), deps)
if err != nil { if err != nil {
return nil, fmt.Errorf("load left pipeline profile: %w", err) return nil, fmt.Errorf("load left pipeline overlay: %w", err)
} }
_, rightPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Profile: &request.rightProfile}, request.flags.secretsDir.pointer(), deps) _, rightPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Overlay: &request.rightOverlay}, request.flags.secretsDir.pointer(), deps)
if err != nil { if err != nil {
return nil, fmt.Errorf("load right pipeline profile: %w", err) return nil, fmt.Errorf("load right pipeline overlay: %w", err)
} }
needsCampaign := pipelineHasArtifactFamilies(leftPipeline) || pipelineHasArtifactFamilies(rightPipeline) needsCampaign := pipelineHasArtifactFamilies(leftPipeline) || pipelineHasArtifactFamilies(rightPipeline)
hasCampaignSelection := request.flags.campaignPath.set || request.flags.campaignFilePath.set hasCampaignSelection := request.flags.campaignPath.set || request.flags.campaignFilePath.set
@@ -259,7 +259,7 @@ func resolveSharedDiffCampaignPath(left, right *config.PipelineConfig, flags con
return "", rightErr return "", rightErr
} }
if leftPath != rightPath { if leftPath != rightPath {
return "", fmt.Errorf("profile selections resolve different campaign files; specify --campaign-file to compare one campaign") return "", fmt.Errorf("overlay selections resolve different campaign files; specify --campaign-file to compare one campaign")
} }
return leftPath, nil return leftPath, nil
} }

View File

@@ -46,7 +46,7 @@ func loadCommandConfigWithDependencies(ctx context.Context, pipelineFlag, campai
} }
}() }()
base, err := loadPipelineCampaignConfigWithDependencies(pipelineFlag, campaignFlag, campaignFileFlag, config.PipelineLoadOptions{Profile: sessionOpts.Profile}, secretsDir, deps) base, err := loadPipelineCampaignConfigWithDependencies(pipelineFlag, campaignFlag, campaignFileFlag, config.PipelineLoadOptions{Overlay: sessionOpts.Overlay}, secretsDir, deps)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -14,13 +14,13 @@ func effectiveConfigProvenance(cfg *config.Config) *manifest.EffectiveConfigProv
provenance := &manifest.EffectiveConfigProvenance{ provenance := &manifest.EffectiveConfigProvenance{
EffectiveConfigDigest: config.EffectivePipelineDigest(cfg.Pipeline), EffectiveConfigDigest: config.EffectivePipelineDigest(cfg.Pipeline),
} }
if selected, ok := config.SelectedPipelineProfile(cfg.Pipeline); ok { if selected, ok := config.SelectedPipelineOverlay(cfg.Pipeline); ok {
provenance.SelectedProfile = &manifest.SelectedProfileProvenance{ provenance.SelectedOverlay = &manifest.SelectedOverlayProvenance{
Name: selected.Name, Name: selected.Name,
Source: selected.Source, Source: selected.Source,
} }
} }
if provenance.SelectedProfile == nil && provenance.EffectiveConfigDigest == "" { if provenance.SelectedOverlay == nil && provenance.EffectiveConfigDigest == "" {
return nil return nil
} }
return provenance return provenance
@@ -35,30 +35,30 @@ func applyEffectiveConfigProvenance(m *manifest.Manifest, cfg *config.Config) {
func effectiveConfigSummary(cfg *config.Config) string { func effectiveConfigSummary(cfg *config.Config) string {
provenance := effectiveConfigProvenance(cfg) provenance := effectiveConfigProvenance(cfg)
if provenance == nil { if provenance == nil {
return "profile=none digest=unavailable" return "overlay=none digest=unavailable"
} }
profile := "none" overlay := "none"
if provenance.SelectedProfile != nil { if provenance.SelectedOverlay != nil {
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")" overlay = provenance.SelectedOverlay.Name + " (" + provenance.SelectedOverlay.Source + ")"
} }
digest := provenance.EffectiveConfigDigest digest := provenance.EffectiveConfigDigest
if digest == "" { if digest == "" {
digest = "unavailable" digest = "unavailable"
} }
return fmt.Sprintf("profile=%s digest=%s", profile, digest) return fmt.Sprintf("overlay=%s digest=%s", overlay, digest)
} }
func persistedEffectiveConfigSummary(provenance *manifest.EffectiveConfigProvenance) string { func persistedEffectiveConfigSummary(provenance *manifest.EffectiveConfigProvenance) string {
if provenance == nil { if provenance == nil {
return "profile=none digest=unavailable" return "overlay=none digest=unavailable"
} }
profile := "none" overlay := "none"
if provenance.SelectedProfile != nil { if provenance.SelectedOverlay != nil {
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")" overlay = provenance.SelectedOverlay.Name + " (" + provenance.SelectedOverlay.Source + ")"
} }
digest := provenance.EffectiveConfigDigest digest := provenance.EffectiveConfigDigest
if digest == "" { if digest == "" {
digest = "unavailable" digest = "unavailable"
} }
return fmt.Sprintf("profile=%s digest=%s", profile, digest) return fmt.Sprintf("overlay=%s digest=%s", overlay, digest)
} }

View File

@@ -817,7 +817,7 @@ func TestExtractLifecycleRealConfigProfilesSelectNotariusExplicitly(t *testing.T
if err := os.WriteFile(filepath.Join(filepath.Dir(pipelinePath), "notarius.yml"), []byte("pipelines: {}\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(filepath.Dir(pipelinePath), "notarius.yml"), []byte("pipelines: {}\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
profilesDir := filepath.Join(filepath.Dir(pipelinePath), "profiles") profilesDir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
if err := os.MkdirAll(profilesDir, 0o755); err != nil { if err := os.MkdirAll(profilesDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -832,7 +832,7 @@ func TestExtractLifecycleRealConfigProfilesSelectNotariusExplicitly(t *testing.T
} }
load := func(profile string) *config.Config { load := func(profile string) *config.Config {
t.Helper() t.Helper()
loaded, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{Profile: &profile}) loaded, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{Overlay: &profile})
if err != nil { if err != nil {
t.Fatalf("load profile %q: %v", profile, err) t.Fatalf("load profile %q: %v", profile, err)
} }

View File

@@ -23,7 +23,7 @@ type commonConfigFlags struct {
sessionPath string sessionPath string
sessionID string sessionID string
previousSessionID string previousSessionID string
profile singletonStringFlag overlay singletonStringFlag
secretsDir singletonStringFlag secretsDir singletonStringFlag
} }
@@ -34,8 +34,8 @@ func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml") fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier") fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
flags.profile.name = "profile" flags.overlay.name = "overlay"
fs.Var(&flags.profile, "profile", "named pipeline profile") fs.Var(&flags.overlay, "overlay", "named pipeline overlay")
flags.secretsDir.name = "secrets-dir" flags.secretsDir.name = "secrets-dir"
fs.Var(&flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)") fs.Var(&flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
} }
@@ -44,7 +44,7 @@ func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
return config.SessionLoadOptions{ return config.SessionLoadOptions{
SessionID: f.sessionID, SessionID: f.sessionID,
PreviousSessionID: f.previousSessionID, PreviousSessionID: f.previousSessionID,
Profile: f.profile.pointer(), Overlay: f.overlay.pointer(),
} }
} }

View File

@@ -27,7 +27,7 @@ func sessionInitWithDependencies(ctx context.Context, args []string, out io.Writ
fs := flag.NewFlagSet("session init", flag.ContinueOnError) fs := flag.NewFlagSet("session init", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
var profile singletonStringFlag var overlay singletonStringFlag
var secretsDir singletonStringFlag var secretsDir singletonStringFlag
var remote, force bool var remote, force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
@@ -40,8 +40,8 @@ func sessionInitWithDependencies(ctx context.Context, args []string, out io.Writ
fs.StringVar(&output, "output", "", "local output session.yml path") fs.StringVar(&output, "output", "", "local output session.yml path")
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix") fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory") fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
profile.name = "profile" overlay.name = "overlay"
fs.Var(&profile, "profile", "named pipeline profile") fs.Var(&overlay, "overlay", "named pipeline overlay")
secretsDir.name = "secrets-dir" secretsDir.name = "secrets-dir"
fs.Var(&secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)") fs.Var(&secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix") fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
@@ -59,7 +59,7 @@ func sessionInitWithDependencies(ctx context.Context, args []string, out io.Writ
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive") return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
} }
base, err := loadPipelineCampaignConfigWithDependencies(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Profile: profile.pointer()}, secretsDir.pointer(), deps) base, err := loadPipelineCampaignConfigWithDependencies(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Overlay: overlay.pointer()}, secretsDir.pointer(), deps)
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }

View File

@@ -11,10 +11,10 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
) )
func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) { func TestRunOverlaySelectionFlowsThroughSharedLoader(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath) enableCommandTestOverlays(t, pipelinePath)
var language string var language string
executor := func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) { executor := func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
@@ -23,12 +23,17 @@ func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
} }
if err := runWithPipelineExecutor(context.Background(), []string{ if err := runWithPipelineExecutor(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "testing", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--overlay", "testing",
}, io.Discard, executor); err != nil { }, io.Discard, executor); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if language != "fr" { if language != "fr" {
t.Fatalf("explicit profile language = %q, want fr", language) t.Fatalf("explicit overlay language = %q, want fr", language)
}
if err := runWithPipelineExecutor(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "testing",
}, io.Discard, executor); err == nil {
t.Fatal("retired pipeline selector was accepted")
} }
if err := runWithPipelineExecutor(context.Background(), []string{ if err := runWithPipelineExecutor(context.Background(), []string{
@@ -40,25 +45,25 @@ func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
t.Fatalf("base configuration language = %q, want en", language) t.Fatalf("base configuration language = %q, want en", language)
} }
for _, profile := range []string{"unknown"} { for _, overlay := range []string{"unknown"} {
err := runWithPipelineExecutor(context.Background(), []string{ err := runWithPipelineExecutor(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", profile, "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--overlay", overlay,
}, io.Discard, executor) }, io.Discard, executor)
if err == nil || !strings.Contains(err.Error(), "profile") { if err == nil || !strings.Contains(err.Error(), "overlay") {
t.Fatalf("profile %q error = %v, want selection rejection", profile, err) t.Fatalf("overlay %q error = %v, want selection rejection", overlay, err)
} }
} }
if err := runWithPipelineExecutor(context.Background(), []string{ if err := runWithPipelineExecutor(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--overlay", "",
}, io.Discard, executor); err != nil { }, io.Discard, executor); err != nil {
t.Fatalf("explicit empty profile should select base: %v", err) t.Fatalf("explicit empty overlay should select base: %v", err)
} }
} }
func enableCommandTestProfiles(t *testing.T, pipelinePath string) { func enableCommandTestOverlays(t *testing.T, pipelinePath string) {
t.Helper() t.Helper()
dir := filepath.Join(filepath.Dir(pipelinePath), "profiles") dir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -14,7 +14,7 @@ import (
func TestRegenerateArtifactsForwardsCanonicalPipelineRequest(t *testing.T) { func TestRegenerateArtifactsForwardsCanonicalPipelineRequest(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath) enableCommandTestOverlays(t, pipelinePath)
var capturedPlan BoundedPlan var capturedPlan BoundedPlan
var capturedOptions RunOptions var capturedOptions RunOptions
executor := func(_ context.Context, cfg *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) { executor := func(_ context.Context, cfg *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
@@ -30,7 +30,7 @@ func TestRegenerateArtifactsForwardsCanonicalPipelineRequest(t *testing.T) {
"--config", pipelinePath, "--config", pipelinePath,
"--campaign-file", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--profile", "testing", "--overlay", "testing",
}, io.Discard, io.Discard, executor) }, io.Discard, io.Discard, executor)
if code != 0 { if code != 0 {
t.Fatalf("executeWithPipelineExecutor() code = %d, want 0", code) t.Fatalf("executeWithPipelineExecutor() code = %d, want 0", code)

View File

@@ -80,7 +80,7 @@ func runStageWithDependencies(ctx context.Context, args []string, out io.Writer,
SessionPath: flags.sessionPath, SessionPath: flags.sessionPath,
SessionID: flags.sessionID, SessionID: flags.sessionID,
PreviousSessionID: flags.previousSessionID, PreviousSessionID: flags.previousSessionID,
Profile: flags.profile.pointer(), Overlay: flags.overlay.pointer(),
SecretsDir: flags.secretsDir.pointer(), SecretsDir: flags.secretsDir.pointer(),
Force: force, Force: force,
SelectedArtifacts: normalizedArtifacts, SelectedArtifacts: normalizedArtifacts,
@@ -139,7 +139,7 @@ func analyzeWithDependencies(ctx context.Context, args []string, out io.Writer,
SessionPath: flags.sessionPath, SessionPath: flags.sessionPath,
SessionID: flags.sessionID, SessionID: flags.sessionID,
PreviousSessionID: flags.previousSessionID, PreviousSessionID: flags.previousSessionID,
Profile: flags.profile.pointer(), Overlay: flags.overlay.pointer(),
SecretsDir: flags.secretsDir.pointer(), SecretsDir: flags.secretsDir.pointer(),
Force: true, Force: true,
SelectedArtifacts: normalizedArtifacts, SelectedArtifacts: normalizedArtifacts,
@@ -196,7 +196,7 @@ func publishWithDependencies(ctx context.Context, args []string, out io.Writer,
SessionPath: flags.sessionPath, SessionPath: flags.sessionPath,
SessionID: flags.sessionID, SessionID: flags.sessionID,
PreviousSessionID: flags.previousSessionID, PreviousSessionID: flags.previousSessionID,
Profile: flags.profile.pointer(), Overlay: flags.overlay.pointer(),
SecretsDir: flags.secretsDir.pointer(), SecretsDir: flags.secretsDir.pointer(),
Force: true, Force: true,
SelectedArtifacts: normalizedArtifacts, SelectedArtifacts: normalizedArtifacts,
@@ -224,7 +224,7 @@ type singleStageCommand struct {
SessionPath string SessionPath string
SessionID string SessionID string
PreviousSessionID string PreviousSessionID string
Profile *string Overlay *string
SecretsDir *string SecretsDir *string
Force bool Force bool
SelectedArtifacts []string SelectedArtifacts []string
@@ -240,7 +240,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand, executor
loaded, err := loadCommandConfigWithDependencies(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{ loaded, err := loadCommandConfigWithDependencies(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{
SessionID: req.SessionID, SessionID: req.SessionID,
PreviousSessionID: req.PreviousSessionID, PreviousSessionID: req.PreviousSessionID,
Profile: req.Profile, Overlay: req.Overlay,
}, req.SecretsDir, deps) }, req.SecretsDir, deps)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", req.CommandName, err) return nil, fmt.Errorf("%s: %w", req.CommandName, err)

View File

@@ -93,7 +93,7 @@ func declarationDefinitionSources(cfg *PipelineConfig, declaration configkit.Pat
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, change := range cfg.resolution.configkitMetadata.History(declaration) { for _, change := range cfg.resolution.configkitMetadata.History(declaration) {
if change.Origin.Layer != configkit.LayerBase && change.Origin.Layer != configkit.LayerProfile { if change.Origin.Layer != configkit.LayerBase && change.Origin.Layer != configkit.LayerOverlay {
continue continue
} }
for _, location := range change.Origin.Locations { for _, location := range change.Origin.Locations {

View File

@@ -14,21 +14,21 @@ const (
effectivePipelineDigestVersion = "narratio-effective-pipeline-v3\n" effectivePipelineDigestVersion = "narratio-effective-pipeline-v3\n"
) )
// PipelineProfileProvenance identifies the profile selected while resolving a // PipelineOverlayProvenance identifies the overlay selected while resolving a
// pipeline. It contains only non-secret selection metadata. // pipeline. It contains only non-secret selection metadata.
type PipelineProfileProvenance struct { type PipelineOverlayProvenance struct {
Name string Name string
Source string Source string
OverlayPath string OverlayPath string
} }
// SelectedPipelineProfile reports the profile used to resolve cfg, if any. // SelectedPipelineOverlay reports the overlay used to resolve cfg, if any.
func SelectedPipelineProfile(cfg *PipelineConfig) (*PipelineProfileProvenance, bool) { func SelectedPipelineOverlay(cfg *PipelineConfig) (*PipelineOverlayProvenance, bool) {
if cfg == nil || cfg.resolution == nil || cfg.resolution.selectedProfile == nil { if cfg == nil || cfg.resolution == nil || cfg.resolution.selectedOverlay == nil {
return nil, false return nil, false
} }
selection := cfg.resolution.selectedProfile selection := cfg.resolution.selectedOverlay
return &PipelineProfileProvenance{Name: selection.name, Source: selection.source, OverlayPath: selection.overlayPath}, true return &PipelineOverlayProvenance{Name: selection.name, Source: selection.source, OverlayPath: selection.overlayPath}, true
} }
// EffectivePipelineDigest reports the deterministic secret-free digest for a // EffectivePipelineDigest reports the deterministic secret-free digest for a

View File

@@ -401,8 +401,8 @@ func pipelineSourceRole(cfg *PipelineConfig, source string) string {
if source == cfg.resolution.rootPath { if source == cfg.resolution.rootPath {
return "root" return "root"
} }
if cfg.resolution.selectedProfile != nil && source == cfg.resolution.selectedProfile.overlayPath { if cfg.resolution.selectedOverlay != nil && source == cfg.resolution.selectedOverlay.overlayPath {
return "profile" return "overlay"
} }
for _, imported := range cfg.resolution.imports { for _, imported := range cfg.resolution.imports {
if source == imported { if source == imported {

View File

@@ -15,14 +15,14 @@ func LoadPipeline(path string) (*PipelineConfig, error) {
return LoadPipelineWithOptions(path, PipelineLoadOptions{}) return LoadPipelineWithOptions(path, PipelineLoadOptions{})
} }
// PipelineLoadOptions carries an optional explicit profile selection. Nil, // PipelineLoadOptions carries an optional explicit overlay selection. Nil,
// empty, and the exact name "default" select the base configuration. // empty, and the exact name "default" select the base configuration.
type PipelineLoadOptions struct { type PipelineLoadOptions struct {
Profile *string Overlay *string
} }
// LoadPipelineWithOptions loads pipeline configuration with strict field // LoadPipelineWithOptions loads pipeline configuration with strict field
// checking and optional named-profile selection. // checking and optional named-overlay selection.
func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineConfig, error) { func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineConfig, error) {
cfg, err := loadPipelineWithConfigKit(path, opts) cfg, err := loadPipelineWithConfigKit(path, opts)
if err != nil { if err != nil {
@@ -31,20 +31,20 @@ func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineCo
return finalizeLoadedPipeline(path, cfg) return finalizeLoadedPipeline(path, cfg)
} }
// LoadPipelineProfilePair resolves two profiles as independent ConfigKit loads. // LoadPipelineOverlayPair resolves two overlays as independent ConfigKit loads.
func LoadPipelineProfilePair(path, leftProfile, rightProfile string) (*PipelineConfig, *PipelineConfig, error) { func LoadPipelineOverlayPair(path, leftOverlay, rightOverlay string) (*PipelineConfig, *PipelineConfig, error) {
if leftProfile == rightProfile { if leftOverlay == rightOverlay {
return nil, nil, fmt.Errorf("load pipeline profiles: left and right profile selections must differ") return nil, nil, fmt.Errorf("load pipeline overlays: left and right overlay selections must differ")
} }
leftOptions := PipelineLoadOptions{Profile: &leftProfile} leftOptions := PipelineLoadOptions{Overlay: &leftOverlay}
rightOptions := PipelineLoadOptions{Profile: &rightProfile} rightOptions := PipelineLoadOptions{Overlay: &rightOverlay}
left, err := LoadPipelineWithOptions(path, leftOptions) left, err := LoadPipelineWithOptions(path, leftOptions)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("load left pipeline profile: %w", err) return nil, nil, fmt.Errorf("load left pipeline overlay: %w", err)
} }
right, err := LoadPipelineWithOptions(path, rightOptions) right, err := LoadPipelineWithOptions(path, rightOptions)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("load right pipeline profile: %w", err) return nil, nil, fmt.Errorf("load right pipeline overlay: %w", err)
} }
return left, right, nil return left, right, nil
} }
@@ -76,7 +76,7 @@ func LoadSession(path string) (*SessionConfig, error) {
type SessionLoadOptions struct { type SessionLoadOptions struct {
SessionID string SessionID string
PreviousSessionID string PreviousSessionID string
Profile *string Overlay *string
} }
// LoadSessionWithOptions loads session configuration from a YAML file with // LoadSessionWithOptions loads session configuration from a YAML file with
@@ -143,7 +143,7 @@ func Load(pipelinePath string, paths ...string) (*Config, error) {
// LoadWithSessionOptions loads and resolves combined pipeline, campaign, and // LoadWithSessionOptions loads and resolves combined pipeline, campaign, and
// session configuration with expected session identity checks. // session configuration with expected session identity checks.
func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) { func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
pipelineCfg, err := LoadPipelineWithOptions(pipelinePath, PipelineLoadOptions{Profile: sessionOpts.Profile}) pipelineCfg, err := LoadPipelineWithOptions(pipelinePath, PipelineLoadOptions{Overlay: sessionOpts.Overlay})
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -358,7 +358,7 @@ func TestExamplesLoadAndValidate(t *testing.T) {
t.Run(profile.name, func(t *testing.T) { t.Run(profile.name, func(t *testing.T) {
options := PipelineLoadOptions{} options := PipelineLoadOptions{}
if profile.selection != "" { if profile.selection != "" {
options.Profile = &profile.selection options.Overlay = &profile.selection
} }
pipeline, err := LoadPipelineWithOptions(pipelinePath, options) pipeline, err := LoadPipelineWithOptions(pipelinePath, options)
if err != nil { if err != nil {
@@ -381,12 +381,12 @@ func TestExamplesLoadAndValidate(t *testing.T) {
if cfg.Pipeline.Audita.LLMAPIKey != nil || cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil { if cfg.Pipeline.Audita.LLMAPIKey != nil || cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil {
t.Fatalf("split bundle must remain offline and secret-free: audita=%#v storage=%#v", cfg.Pipeline.Audita, cfg.Pipeline.Storage) t.Fatalf("split bundle must remain offline and secret-free: audita=%#v storage=%#v", cfg.Pipeline.Audita, cfg.Pipeline.Storage)
} }
selectedProfile, selected := SelectedPipelineProfile(cfg.Pipeline) selectedOverlay, selected := SelectedPipelineOverlay(cfg.Pipeline)
if profile.selection == "" && selected { if profile.selection == "" && selected {
t.Fatalf("production base unexpectedly selected profile %#v", selectedProfile) t.Fatalf("production base unexpectedly selected overlay %#v", selectedOverlay)
} }
if profile.selection != "" && (!selected || selectedProfile.Name != profile.selection) { if profile.selection != "" && (!selected || selectedOverlay.Name != profile.selection) {
t.Fatalf("selected profile = %#v, want %q", selectedProfile, profile.selection) t.Fatalf("selected overlay = %#v, want %q", selectedOverlay, profile.selection)
} }
for _, key := range []string{ for _, key := range []string{
"session_recap", "session_recap",

View File

@@ -16,7 +16,7 @@ type pipelineResolutionMetadata struct {
rootPath string rootPath string
imports []string imports []string
sources []string sources []string
selectedProfile *pipelineProfileSelection selectedOverlay *pipelineOverlaySelection
effectiveDigest string effectiveDigest string
logicalNotariusConfig string logicalNotariusConfig string
logicalNotariusWorking string logicalNotariusWorking string
@@ -29,7 +29,7 @@ type pipelineResolutionMetadata struct {
configkitSources []configkit.SourceInfo configkitSources []configkit.SourceInfo
} }
type pipelineProfileSelection struct { type pipelineOverlaySelection struct {
name string name string
source string source string
overlayPath string overlayPath string
@@ -41,8 +41,8 @@ type pipelineFieldOwnership struct {
} }
func loadPipelineWithConfigKit(path string, opts PipelineLoadOptions) (*PipelineConfig, error) { func loadPipelineWithConfigKit(path string, opts PipelineLoadOptions) (*PipelineConfig, error) {
profile := selectedPipelineProfileName(opts) overlay := selectedPipelineOverlayName(opts)
result, err := loadPipelineConfigKitResult(path, profile) result, err := loadPipelineConfigKitResult(path, overlay)
if err != nil { if err != nil {
return nil, describePipelineLoadError(err) return nil, describePipelineLoadError(err)
} }
@@ -55,10 +55,10 @@ func loadPipelineWithConfigKit(path string, opts PipelineLoadOptions) (*Pipeline
return &cfg, nil return &cfg, nil
} }
func loadPipelineConfigKitResult(path, profile string) (*configkit.Result[PipelineConfig], error) { func loadPipelineConfigKitResult(path, overlay string) (*configkit.Result[PipelineConfig], error) {
return configkit.Load(configkit.File(path), configkit.Options[PipelineConfig]{ return configkit.Load(configkit.File(path), configkit.Options[PipelineConfig]{
Imports: true, Imports: true,
Profile: profile, Overlay: overlay,
Definition: configkit.Definition[PipelineConfig]{ Definition: configkit.Definition[PipelineConfig]{
Defaults: pipelineConfigDefaults, Defaults: pipelineConfigDefaults,
Resolve: resolvePipelineConfig, Resolve: resolvePipelineConfig,
@@ -104,11 +104,11 @@ func describeConfigKitLoadError(kind string, err error) error {
return fmt.Errorf("load %s config: strict decode failed or validation failed: %w: %s", kind, err, strings.TrimSpace(diagnostics.String())) return fmt.Errorf("load %s config: strict decode failed or validation failed: %w: %s", kind, err, strings.TrimSpace(diagnostics.String()))
} }
func selectedPipelineProfileName(opts PipelineLoadOptions) string { func selectedPipelineOverlayName(opts PipelineLoadOptions) string {
if opts.Profile == nil || *opts.Profile == "" || *opts.Profile == "default" { if opts.Overlay == nil || *opts.Overlay == "" || *opts.Overlay == "default" {
return "" return ""
} }
return *opts.Profile return *opts.Overlay
} }
func pipelineConfigDefaults() PipelineConfig { func pipelineConfigDefaults() PipelineConfig {
@@ -282,8 +282,8 @@ func pipelineMetadataFromConfigKit(cfg *PipelineConfig, result *configkit.Result
} else if source.Layer == configkit.LayerBase { } else if source.Layer == configkit.LayerBase {
resolved.imports = append(resolved.imports, path) resolved.imports = append(resolved.imports, path)
} }
if source.Layer == configkit.LayerProfile { if source.Layer == configkit.LayerOverlay {
resolved.selectedProfile = &pipelineProfileSelection{name: selectedPipelineProfileName(opts), source: "explicit", overlayPath: path} resolved.selectedOverlay = &pipelineOverlaySelection{name: selectedPipelineOverlayName(opts), source: "explicit", overlayPath: path}
} }
} }
detailed, err := pipelineMetadataPaths(cfg) detailed, err := pipelineMetadataPaths(cfg)
@@ -347,7 +347,7 @@ func rejectDuplicateBaseDefinitions(metadata configkit.Metadata, sources []confi
pathsByID[source.ID] = source.Path pathsByID[source.ID] = source.Path
} }
// Enumerate retained declarations, not just surviving effective keys. Loading // Enumerate retained declarations, not just surviving effective keys. Loading
// the base separately would also resolve credentials removed by the profile. // the base separately would also resolve credentials removed by the overlay.
// ConfigKit returns canonical JSON Pointers in deterministic order. // ConfigKit returns canonical JSON Pointers in deterministic order.
for _, path := range metadata.HistoricalPaths() { for _, path := range metadata.HistoricalPaths() {
parts := strings.Split(path.String(), "/") parts := strings.Split(path.String(), "/")

View File

@@ -8,7 +8,7 @@ import (
"testing" "testing"
) )
func TestPipelineConfigKitImportsProfileAndBaseSelection(t *testing.T) { func TestPipelineConfigKitImportsOverlayAndBaseSelection(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
writePipelineFixture(t, dir, "pipeline.yml", `configkit: writePipelineFixture(t, dir, "pipeline.yml", `configkit:
imports: imports:
@@ -24,33 +24,62 @@ whisperx:
enabled: false enabled: false
output_path: artifacts/recap.md output_path: artifacts/recap.md
`) `)
writePipelineFixture(t, dir, "profiles/testing.yml", "workspace:\n root: /srv/testing\n") writePipelineFixture(t, dir, "overlays/testing.yml", "workspace:\n root: /srv/testing\n")
writePipelineFixture(t, dir, "profiles/unselected.yml", "unknown: [\n") writePipelineFixture(t, dir, "overlays/identical.yml", "workspace:\n root: /srv/base\n")
writePipelineFixture(t, dir, "overlays/unselected.yml", "unknown: [\n")
root := filepath.Join(dir, "pipeline.yml") root := filepath.Join(dir, "pipeline.yml")
var baseDigest string
for _, profile := range []*string{nil, stringPointer(""), stringPointer("default")} { for _, profile := range []*string{nil, stringPointer(""), stringPointer("default")} {
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: profile}) cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: profile})
if err != nil { if err != nil {
t.Fatalf("load base selection %#v: %v", profile, err) t.Fatalf("load base selection %#v: %v", profile, err)
} }
if cfg.Workspace.Root != "/srv/base" { if cfg.Workspace.Root != "/srv/base" {
t.Fatalf("base workspace = %q", cfg.Workspace.Root) t.Fatalf("base workspace = %q", cfg.Workspace.Root)
} }
if baseDigest == "" {
baseDigest = EffectivePipelineDigest(cfg)
} else if EffectivePipelineDigest(cfg) != baseDigest {
t.Fatal("equivalent base selections changed effective digest")
}
}
identical := "identical"
unchanged, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &identical})
if err != nil {
t.Fatal(err)
}
if EffectivePipelineDigest(unchanged) != baseDigest {
t.Fatal("equivalent overlay changed effective digest")
} }
selected := "testing" selected := "testing"
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &selected}) cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &selected})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if cfg.Workspace.Root != "/srv/testing" || len(EffectivePipelineImports(cfg)) != 2 { if cfg.Workspace.Root != "/srv/testing" || len(EffectivePipelineImports(cfg)) != 2 {
t.Fatalf("profiled pipeline = %#v, imports=%#v", cfg.Workspace, EffectivePipelineImports(cfg)) t.Fatalf("profiled pipeline = %#v, imports=%#v", cfg.Workspace, EffectivePipelineImports(cfg))
} }
profile, ok := SelectedPipelineProfile(cfg) profile, ok := SelectedPipelineOverlay(cfg)
if !ok || profile.Name != "testing" || profile.OverlayPath != filepath.Join(dir, "profiles", "testing.yml") { if !ok || profile.Name != "testing" || profile.OverlayPath != filepath.Join(dir, "overlays", "testing.yml") {
t.Fatalf("selected profile = %#v, %v", profile, ok) t.Fatalf("selected profile = %#v, %v", profile, ok)
} }
} }
func TestPipelineOverlayDoesNotDiscoverLegacyDirectory(t *testing.T) {
dir := t.TempDir()
writePipelineFixture(t, dir, "pipeline.yml", "configkit:\n imports:\n - path: profiles/shared.yml\n")
writePipelineFixture(t, dir, "profiles/shared.yml", "workspace:\n root: /srv/base\n")
writePipelineFixture(t, dir, "profiles/testing.yml", "workspace:\n root: /srv/old\n")
if base, err := LoadPipeline(filepath.Join(dir, "pipeline.yml")); err != nil || base.Workspace.Root != "/srv/base" {
t.Fatalf("explicit import from profiles directory = %#v, %v", base, err)
}
selected := "testing"
if _, err := LoadPipelineWithOptions(filepath.Join(dir, "pipeline.yml"), PipelineLoadOptions{Overlay: &selected}); err == nil || !strings.Contains(err.Error(), "overlay.missing") {
t.Fatalf("legacy selected file result = %v, want missing overlay", err)
}
}
func TestPipelineConfigKitRejectsDuplicateBaseDeclarations(t *testing.T) { func TestPipelineConfigKitRejectsDuplicateBaseDeclarations(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name, first, second, clearedContainer string name, first, second, clearedContainer string
@@ -69,14 +98,14 @@ whisperx:
`) `)
writePipelineFixture(t, dir, "first.yml", test.first) writePipelineFixture(t, dir, "first.yml", test.first)
writePipelineFixture(t, dir, "second.yml", test.second) writePipelineFixture(t, dir, "second.yml", test.second)
writePipelineFixture(t, dir, "profiles/clear-container.yml", test.clearedContainer) writePipelineFixture(t, dir, "overlays/clear-container.yml", test.clearedContainer)
writePipelineFixture(t, dir, "profiles/clear-scriptorium.yml", "scriptorium: null\n") writePipelineFixture(t, dir, "overlays/clear-scriptorium.yml", "scriptorium: null\n")
for _, profile := range []string{"", "clear-container", "clear-scriptorium"} { for _, profile := range []string{"", "clear-container", "clear-scriptorium"} {
t.Run(profile, func(t *testing.T) { t.Run(profile, func(t *testing.T) {
options := PipelineLoadOptions{} options := PipelineLoadOptions{}
if profile != "" { if profile != "" {
options.Profile = &profile options.Overlay = &profile
} }
_, err := LoadPipelineWithOptions(filepath.Join(dir, "pipeline.yml"), options) _, err := LoadPipelineWithOptions(filepath.Join(dir, "pipeline.yml"), options)
if err == nil || !strings.Contains(err.Error(), "declared by multiple base files") || if err == nil || !strings.Contains(err.Error(), "declared by multiple base files") ||
@@ -89,7 +118,7 @@ whisperx:
} }
} }
func TestPipelineProfileDoesNotRequireUnusedBaseCredentials(t *testing.T) { func TestPipelineOverlayDoesNotRequireUnusedBaseCredentials(t *testing.T) {
const production = "NARRATIO_PROFILE_UNUSED_PROD_KEY" const production = "NARRATIO_PROFILE_UNUSED_PROD_KEY"
const testingKey = "NARRATIO_PROFILE_TEST_KEY" const testingKey = "NARRATIO_PROFILE_TEST_KEY"
t.Setenv(production, "") t.Setenv(production, "")
@@ -101,9 +130,9 @@ func TestPipelineProfileDoesNotRequireUnusedBaseCredentials(t *testing.T) {
t.Run(credential, func(t *testing.T) { t.Run(credential, func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\naudita:\n llm_api_key: "+production+"\n") root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\naudita:\n llm_api_key: "+production+"\n")
writePipelineFixture(t, dir, "profiles/testing.yml", "audita:\n llm_api_key: "+credential+"\n") writePipelineFixture(t, dir, "overlays/testing.yml", "audita:\n llm_api_key: "+credential+"\n")
profile := "testing" profile := "testing"
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile}) cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -130,9 +159,9 @@ func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) {
t.Run(credential, func(t *testing.T) { t.Run(credential, func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+baseKey+"\n") root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+baseKey+"\n")
overlay := writePipelineFixture(t, dir, "profiles/testing.yml", "notarius:\n llm_api_key: "+credential+"\n") overlay := writePipelineFixture(t, dir, "overlays/testing.yml", "notarius:\n llm_api_key: "+credential+"\n")
profile := "testing" profile := "testing"
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile}) cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -187,8 +216,8 @@ func TestNotariusConfigProfileComposition(t *testing.T) {
root := writePipelineFixture(t, dir, "pipeline.yml", rootYAML) root := writePipelineFixture(t, dir, "pipeline.yml", rootYAML)
options := PipelineLoadOptions{} options := PipelineLoadOptions{}
if test.overlayName != "" { if test.overlayName != "" {
writePipelineFixture(t, dir, "profiles/"+test.overlayName+".yml", test.overlay) writePipelineFixture(t, dir, "overlays/"+test.overlayName+".yml", test.overlay)
options.Profile = &test.overlayName options.Overlay = &test.overlayName
} }
cfg, err := LoadPipelineWithOptions(root, options) cfg, err := LoadPipelineWithOptions(root, options)
if err != nil { if err != nil {
@@ -238,9 +267,9 @@ func TestNotariusCacheControlProfileReplacementAndNullReset(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n resume: false\n chunk_cache: bypass\n") root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n resume: false\n chunk_cache: bypass\n")
overlay := writePipelineFixture(t, dir, "profiles/testing.yml", test.overlay) overlay := writePipelineFixture(t, dir, "overlays/testing.yml", test.overlay)
profile := "testing" profile := "testing"
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile}) cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -282,9 +311,9 @@ whisperx:
transcribe_url: https://transcription.example.test/transcribe transcribe_url: https://transcription.example.test/transcribe
`) `)
writePipelineFixture(t, dir, "artifact.yml", "scriptorium:\n artifacts:\n recap:\n prompt_id: base\n output_path: artifacts/recap.md\n") writePipelineFixture(t, dir, "artifact.yml", "scriptorium:\n artifacts:\n recap:\n prompt_id: base\n output_path: artifacts/recap.md\n")
writePipelineFixture(t, dir, "profiles/testing.yml", "scriptorium:\n artifacts:\n recap:\n prompt_id: testing\n") writePipelineFixture(t, dir, "overlays/testing.yml", "scriptorium:\n artifacts:\n recap:\n prompt_id: testing\n")
profile := "testing" profile := "testing"
cfg, err := LoadPipelineWithOptions(filepath.Join(dir, "pipeline.yml"), PipelineLoadOptions{Profile: &profile}) cfg, err := LoadPipelineWithOptions(filepath.Join(dir, "pipeline.yml"), PipelineLoadOptions{Overlay: &profile})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -1,6 +1,7 @@
package manifest package manifest
import ( import (
"encoding/json"
"strings" "strings"
"time" "time"
@@ -71,13 +72,25 @@ type StageRecord struct {
// EffectiveConfigProvenance records bounded non-secret configuration identity // EffectiveConfigProvenance records bounded non-secret configuration identity
// for one resolved invocation. // for one resolved invocation.
type EffectiveConfigProvenance struct { type EffectiveConfigProvenance struct {
SelectedProfile *SelectedProfileProvenance `json:"selected_profile,omitempty"` SelectedOverlay *SelectedOverlayProvenance `json:"selected_overlay,omitempty"`
EffectiveConfigDigest string `json:"effective_config_digest,omitempty"` EffectiveConfigDigest string `json:"effective_config_digest,omitempty"`
} }
// SelectedProfileProvenance identifies an explicitly or default-selected // UnmarshalJSON accepts unknown informational provenance from older manifests.
// pipeline profile without recording profile content. // The enclosing manifest and its execution state remain strictly decoded.
type SelectedProfileProvenance struct { func (p *EffectiveConfigProvenance) UnmarshalJSON(data []byte) error {
type provenance EffectiveConfigProvenance
var decoded provenance
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*p = EffectiveConfigProvenance(decoded)
return nil
}
// SelectedOverlayProvenance identifies an explicitly or default-selected
// pipeline overlay without recording overlay content.
type SelectedOverlayProvenance struct {
Name string `json:"name"` Name string `json:"name"`
Source string `json:"source"` Source string `json:"source"`
} }

View File

@@ -34,7 +34,7 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
m.S3SessionPrefix = "dnd/campaigns/forsaken/sessions/2026-05-03/" m.S3SessionPrefix = "dnd/campaigns/forsaken/sessions/2026-05-03/"
m.S3RunPrefix = "dnd/campaigns/forsaken/sessions/2026-05-03/runs/20260515T031522Z-a1b2c3d4/" m.S3RunPrefix = "dnd/campaigns/forsaken/sessions/2026-05-03/runs/20260515T031522Z-a1b2c3d4/"
m.EffectiveConfig = &EffectiveConfigProvenance{ m.EffectiveConfig = &EffectiveConfigProvenance{
SelectedProfile: &SelectedProfileProvenance{Name: "production", Source: "default"}, SelectedOverlay: &SelectedOverlayProvenance{Name: "production", Source: "default"},
EffectiveConfigDigest: "001122", EffectiveConfigDigest: "001122",
} }
@@ -57,7 +57,7 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
if loaded.RunID != "20260515T031522Z-a1b2c3d4" { if loaded.RunID != "20260515T031522Z-a1b2c3d4" {
t.Fatalf("RunID = %q, want run id", loaded.RunID) t.Fatalf("RunID = %q, want run id", loaded.RunID)
} }
if loaded.EffectiveConfig == nil || loaded.EffectiveConfig.SelectedProfile == nil || loaded.EffectiveConfig.SelectedProfile.Name != "production" || loaded.EffectiveConfig.EffectiveConfigDigest != "001122" { if loaded.EffectiveConfig == nil || loaded.EffectiveConfig.SelectedOverlay == nil || loaded.EffectiveConfig.SelectedOverlay.Name != "production" || loaded.EffectiveConfig.EffectiveConfigDigest != "001122" {
t.Fatalf("effective config provenance = %#v", loaded.EffectiveConfig) t.Fatalf("effective config provenance = %#v", loaded.EffectiveConfig)
} }
stage, ok := loaded.Stages["prepare"] stage, ok := loaded.Stages["prepare"]
@@ -75,6 +75,21 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
} }
} }
func TestLocalStoreLoadsManifestWithHistoricalSelectionProvenance(t *testing.T) {
path := filepath.Join(t.TempDir(), "manifest.json")
data := `{"schema_version":"narratio.session-manifest.v1","session_id":"2026-05-03","created_at":"2026-05-03T10:00:00Z","updated_at":"2026-05-03T10:00:00Z","effective_config":{"selected_profile":{"name":"testing","source":"explicit"},"effective_config_digest":"001122"},"stages":{}}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
loaded, err := (&LocalStore{}).Load(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if loaded.EffectiveConfig == nil || loaded.EffectiveConfig.EffectiveConfigDigest != "001122" || loaded.EffectiveConfig.SelectedOverlay != nil {
t.Fatalf("historical informational provenance = %#v", loaded.EffectiveConfig)
}
}
func TestLocalStorePreservesOptionalArtifactMetadata(t *testing.T) { func TestLocalStorePreservesOptionalArtifactMetadata(t *testing.T) {
store := &LocalStore{} store := &LocalStore{}
ctx := context.Background() ctx := context.Background()