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-id <session_id>`
- `--previous-session-id <session_id>`
- `--profile <name>`
- `--overlay <name>`
- `--secrets-dir <directory>`
Rules:
@@ -54,9 +54,9 @@ Rules:
- if both positional `<session_id>` and `--session-id` are provided, values must match.
- `--previous-session-id` is a strict expectation: the selected session file
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
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`.
- `--secrets-dir` selects ConfigKit credential-file bootstrap explicitly. A
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`
```bash
narratio config validate [--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>] [--profile <name>] [--secrets-dir <directory>]
narratio config sources [--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>] [--profile <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 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>] [--overlay <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>] [--overlay <name>] [--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 session nor create a workspace, manifest, run, lock, adapter, or remote
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.
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`
writes one deterministic, secret-free YAML document containing defaulted and
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
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`.
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
one family record and one party record at the same logical path. The output
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
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
not accept `--profile`; the two positional names must be distinct, declared
profiles. When party-driven families are present, both profiles must resolve to
the same selected campaign and party. Use `--campaign-file` if profile-specific
not accept `--overlay`; the two positional names must be distinct, valid
overlays. When party-driven families are present, both overlays must resolve to
the same selected campaign and party. Use `--campaign-file` if overlay-specific
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:
```text
@@ -151,7 +151,7 @@ changed <path> <left-value> <right-value>
Mappings are flattened to their logical field paths; lists remain one atomic
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.
### `version`
@@ -189,7 +189,7 @@ Behavior:
- writes the session manifest as the sole durable progress ledger. Each
invocation also creates its run-local directory and returns an immediate
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
`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
for selected stages only without creating the local workdir or changing the
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.
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
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`,
and `narratio config artifacts` use the same `--config`, `--campaign`,
`--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
of the ordinary ConfigKit load but expose only their environment-variable names;
they do not create runtime state.
`narratio config diff <left-profile> <right-profile>` uses the same pipeline and
campaign selectors, resolves each named profile with an independent ConfigKit
load, and does not accept a separate `--profile` flag.
`narratio config diff <left-overlay> <right-overlay>` uses the same pipeline and
campaign selectors, resolves each named overlay with an independent ConfigKit
load, and does not accept a separate `--overlay` flag.
Campaign selection is optional only when the resolved pipeline has no
`scriptorium.artifact_families`. When families are declared, Narratio selects a
campaign through an explicit flag or `pipeline.campaigns.default_campaign_id`,
then parses the campaign-owned party and expands concrete artifacts and any
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
ConfigKit directives and family declarations omitted. This review projection is
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
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)
@@ -83,7 +83,7 @@ difference.
Use the [production/testing bundle](../examples/production-testing/pipeline.yml)
as the complete copyable migration reference. Keep production settings in the
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
rosters to [canonical party input](integrations/party.md), then express character
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
logical pipeline document; they do not override fields supplied by the root
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
`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
ConfigKit schemas. They do not enable imports, profiles, or `configkit`
ConfigKit schemas. They do not enable imports, overlays, or `configkit`
directives.
- Configured timeout and retry-delay durations must be positive. An omitted
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
- S3 (`audio_s3.prefix`).
### Pipeline imports and profiles
### Pipeline imports and overlays
Large pipeline configurations may be split into explicitly named fragments and
may use fixed-path profile overlays:
may use fixed-path selected overlays:
```yaml
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,
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
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.
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.
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
configuration digest. The digest represents the normalized, secret-free
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
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
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,
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
both fields according to the deployed Notarius configuration. See the
[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
without normalization so Notarius can apply its own ConfigKit semantics. This
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
failure: Narratio does not retry without the selector or fall back to another
profile.

View File

@@ -9,9 +9,9 @@ pipeline-loading boundary implemented by `internal/config`.
`LoadPipeline` assembles and validates a pipeline in this order:
1. Give ConfigKit a fresh typed `PipelineConfig` default value, the root file,
enabled imports, and the caller's profile selection. Omitted, empty, and
exact `default` selections use the base; named profiles use the fixed
`profiles/<name>.yml` convention.
enabled imports, and the caller's overlay selection. Omitted, empty, and
exact `default` selections use the base; named overlays use the fixed
`overlays/<name>.yml` convention.
2. ConfigKit parses the root and declared imports, additively merges base
sources, applies only the selected overlay, and strictly decodes the result.
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.
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
application must reject unresolved template placeholders before passing an exact
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.
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
campaign selection functions, but never routes through session discovery,
validate`, `config show`, `config sources`, and `config artifacts`. It uses the
same production root, overlay, and campaign selection functions, but never routes
through session discovery,
remote-session download, adapter composition, workspace
initialization, manifest access, or cleanup. A pipeline with retained artifact
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
safe provenance projection for `config sources`. Pipeline ownership begins with
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
canonical-party records, and does the same for generated publish rules.
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
`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
character-keyed typed schema before applying Narratio's party-domain checks.
Successful metadata coordinates recover authored character-map order without a
@@ -144,14 +145,14 @@ not compatibility contracts.
## 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
claiming source so operators can repair the split without repeatedly
rediscovering additional conflicts.
The loaded pipeline retains private runtime metadata for the absolute root
path, ordered imports, selected profile name and selection source, selected
overlay, original ConfigKit metadata and source snapshots, effective digest,
path, ordered imports, selected overlay name, source, and path, original
ConfigKit metadata and source snapshots, effective digest,
and leaf ownership. Base
leaves retain their root/import owners, replaced leaves belong to the selected
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
before root-relative resolution, so relocating an equivalent configuration
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
load resolves their values inside the opaque typed boundary, while this safe
projection serializes only `EnvName()` identities. Credential-value changes are
@@ -177,7 +178,7 @@ for later runtime expansion.
## 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
unique artifact/family base-declaration rule. Existing focused configuration
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
namespace.
Decoding is strict at every typed manifest field. Unknown fields are rejected;
the schema version remains `narratio.session-manifest.v1` and there is no
compatibility reader that silently drops retired state. Cleanup progress uses
Decoding is strict at typed execution-state fields. Unknown informational fields
inside `effective_config` are ignored so older selection labels can be read;
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`.
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
invocations. It retains the most recently resolved invocation's bounded
configuration provenance. It identifies the selected profile name and source
(`default` or `cli`) plus the effective configuration digest, but never a raw
secret or profile content. This provenance is informational: it does not
participate in stage resume or cache decisions. A profile change therefore
configuration provenance. It identifies the selected overlay name and source
(`explicit` when selected) plus the effective configuration digest, but never a raw
secret or overlay content. This provenance is informational: it does not
participate in stage resume or cache decisions. An overlay change therefore
invalidates only stages whose semantic configuration changed. When a private
external-tool model, module, prompt, or profile changes behind an unchanged
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
```
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
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
production base through `narratio config diff default <profile>`.
Before switching an operational overlay, compare its effective meaning with the
production base through `narratio config diff default <overlay>`.
The command is read-only and succeeds whether it finds differences or not. Its
sorted records describe defaulted, expanded concrete configuration—not source
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
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)
for syntax and record format.
@@ -284,17 +284,17 @@ intends to replace otherwise-current artifacts.
### Developing With A Notarius Configuration Profile
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
notarius:
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
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.
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.
- Validation rejects invalid configuration before stage execution where
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.
- Canonical party rosters are campaign-owned. Their derived players projection
and concrete character-family artifacts are resolved before runtime stages
or adapters receive configuration.
- 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.
- Session templating remains narrow and deterministic rather than becoming a
general configuration language.

View File

@@ -1,6 +1,6 @@
# Implementation Plan: Audita v2 and Shared Provider Credentials
Status: Not started.
Status: In progress.
## 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
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
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).
## Unexpected imported or profile value
## Unexpected imported or overlay value
Symptom:
@@ -112,21 +112,21 @@ Symptom:
Diagnostics:
```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
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.
Safe fix:
- 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.
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
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):
explicit `conf.d` imports, one ordinary artifact per `artifacts/<key>.yml`,
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
sharing the campaign party, and a testing-only disabled artifact. Inspect it
offline with:
```sh
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 sources --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 --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 --overlay 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
```

View File

@@ -135,7 +135,7 @@ notarius:
# the selected Notarius/PromptKit profile; never put the credential here.
llm_api_key: NOTARIUS_LLM_API_KEY
# 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.
# config_profile: downstream-economy
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
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/credentials v1.19.16
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.3.1/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
gitea.maximumdirect.net/eric/ConfigKit v0.4.0 h1:fo+vKff3zTGeWqJIA38NtT84b6OUP//9ZmBH3lFuKgk=
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/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
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 {
t.Fatalf("validate production base: %v", err)
}
if !strings.Contains(validated.String(), "profile=none") {
t.Fatalf("validate output = %q, want unprofiled production base", validated.String())
if !strings.Contains(validated.String(), "overlay=none") {
t.Fatalf("validate output = %q, want production base", validated.String())
}
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigShow, ConfigSources} {
if err := command(context.Background(), []string{
"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing",
"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing",
}, io.Discard); err != nil {
t.Fatalf("testing inspection command %T: %v", command, err)
}
@@ -278,13 +278,13 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi
common := []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath,
"--session", sessionPath, "--profile", "testing",
"--session", sessionPath, "--overlay", "testing",
}
var planned bytes.Buffer
if err := Plan(context.Background(), common, &planned); err != nil {
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())
}
@@ -305,7 +305,7 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi
if !reflect.DeepEqual(capturedPlan.Names(), []string{"analyze"}) {
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)
}
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()
options := config.SessionLoadOptions{}
if profile != "" {
options.Profile = &profile
options.Overlay = &profile
}
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, options)
if err != nil {
@@ -415,8 +415,8 @@ func assertAssembledProvenance(t *testing.T, store *manifest.LocalStore, summary
t.Fatal(err)
}
value := session.EffectiveConfig
profileMatches := value != nil && ((profile == "" && value.SelectedProfile == nil) ||
(profile != "" && value.SelectedProfile != nil && value.SelectedProfile.Name == profile))
profileMatches := value != nil && ((profile == "" && value.SelectedOverlay == nil) ||
(profile != "" && value.SelectedOverlay != nil && value.SelectedOverlay.Name == profile))
if !profileMatches || value.EffectiveConfigDigest != 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: "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: "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 {
t.Run(test.name, func(t *testing.T) {
@@ -69,29 +69,29 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
}
}
func TestBoundedRunParsingRetainsExplicitProfilePresence(t *testing.T) {
withoutProfile, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard)
func TestBoundedRunParsingRetainsExplicitOverlayPresence(t *testing.T) {
withoutOverlay, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard)
if err != nil {
t.Fatal(err)
}
if got := withoutProfile.Config.sessionOptions().Profile; got != nil {
t.Fatalf("omitted profile = %#v, want nil", got)
if got := withoutOverlay.Config.sessionOptions().Overlay; got != nil {
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 {
t.Fatal(err)
}
if got := withProfile.Config.sessionOptions().Profile; got == nil || *got != "testing" {
t.Fatalf("explicit profile = %#v, want testing", got)
if got := withOverlay.Config.sessionOptions().Overlay; got == nil || *got != "testing" {
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 {
t.Fatal(err)
}
if got := explicitEmpty.Config.sessionOptions().Profile; got == nil || *got != "" {
t.Fatalf("explicit empty profile = %#v, want non-nil empty", got)
if got := explicitEmpty.Config.sessionOptions().Overlay; got == nil || *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) != "" {
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 {
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)
profilePath := filepath.Join(filepath.Dir(pipelinePath), "profiles", "testing.yml")
mustWriteTestFile(t, profilePath, `scriptorium:
overlayPath := filepath.Join(filepath.Dir(pipelinePath), "overlays", "testing.yml")
mustWriteTestFile(t, overlayPath, `scriptorium:
artifact_families:
character_note:
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
if err := ConfigArtifacts(context.Background(), args, &catalog); err != nil {
t.Fatal(err)
}
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)
}
@@ -103,16 +103,16 @@ func TestConfigArtifactsReportsProfiledFamilyContributors(t *testing.T) {
if err := ConfigSources(context.Background(), args, &sources); err != nil {
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) {
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()
pipelinePath := filepath.Join(dir, "pipeline.yml")
profilePath := filepath.Join(dir, "profiles", "testing.yml")
overlayPath := filepath.Join(dir, "overlays", "testing.yml")
mustWriteTestFile(t, pipelinePath, `workspace:
root: /srv/narratio
whisperx:
@@ -124,26 +124,26 @@ scriptorium:
enabled: false
output_path: artifacts/notes.md
`)
mustWriteTestFile(t, profilePath, `scriptorium:
mustWriteTestFile(t, overlayPath, `scriptorium:
artifacts:
notes:
enabled: true
prompt_id: notes
profile_only:
overlay_only:
enabled: false
output_path: artifacts/profile-only.md
output_path: artifacts/overlay-only.md
`)
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)
}
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)
}
if got := records["profile_only"][2]; got != profilePath {
t.Fatalf("profile-only definition = %q, want %q", got, profilePath)
if got := records["overlay_only"][2]; got != overlayPath {
t.Fatalf("overlay-only definition = %q, want %q", got, overlayPath)
}
}

View File

@@ -17,7 +17,7 @@ type inspectionFlags struct {
pipelinePath string
campaignPath string
campaignFilePath string
profile singletonStringFlag
overlay singletonStringFlag
secretsDir singletonStringFlag
}
@@ -77,8 +77,8 @@ func configValidateWithDependencies(_ context.Context, args []string, out io.Wri
if err != nil {
return fmt.Errorf("config validate: %w", err)
}
_, err = fmt.Fprintf(out, "Configuration valid: root=%s; profile=%s; digest=%s\n",
inspectionRootPath(resolved), inspectionProfile(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline))
_, err = fmt.Fprintf(out, "Configuration valid: root=%s; overlay=%s; digest=%s\n",
inspectionRootPath(resolved), inspectionOverlay(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline))
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.campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
flags.profile.name = "profile"
fs.Var(&flags.profile, "profile", "named pipeline profile")
flags.overlay.name = "overlay"
fs.Var(&flags.overlay, "overlay", "named pipeline overlay")
flags.secretsDir.name = "secrets-dir"
fs.Var(&flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
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)
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) {
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 {
return nil, err
}
@@ -315,9 +315,9 @@ func inspectionRootPath(resolved *inspectionConfig) string {
return resolved.PipelinePath
}
func inspectionProfile(pipeline *config.PipelineConfig) string {
if profile, ok := config.SelectedPipelineProfile(pipeline); ok {
return profile.Name
func inspectionOverlay(pipeline *config.PipelineConfig) string {
if overlay, ok := config.SelectedPipelineOverlay(pipeline); ok {
return overlay.Name
}
return "none"
}
@@ -325,7 +325,7 @@ func inspectionProfile(pipeline *config.PipelineConfig) string {
func writeConfigCommandUsage(out io.Writer) {
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources|artifacts|diff>")
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 {
@@ -343,11 +343,11 @@ func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) erro
return err
}
}
if profile, ok := config.SelectedPipelineProfile(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 overlay, ok := config.SelectedPipelineOverlay(resolved.Pipeline); ok {
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
}
} else if _, err := fmt.Fprintln(out, "profile: none"); err != nil {
} else if _, err := fmt.Fprintln(out, "overlay: none"); err != nil {
return err
}
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: "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: "retired selector", args: []string{"config", "validate", "--profile", "testing"}, want: "flag provided but not defined", code: 1},
} {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
@@ -102,7 +103,7 @@ whisperx:
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &validateOut); err != nil {
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())
}
@@ -127,7 +128,7 @@ whisperx:
if strings.Contains(sourcesOut.String(), secret) {
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
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diffOut); err != nil {
t.Fatalf("ConfigDiff() error = %v", err)
@@ -143,10 +144,10 @@ whisperx:
}
}
func TestConfigDiffReportsSemanticProfileChanges(t *testing.T) {
func TestConfigDiffReportsSemanticOverlayChanges(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
writeInspectionProfiles(t, pipelinePath, `workspace:
writeInspectionOverlays(t, pipelinePath, `workspace:
cleanup_after_publish: true
publish:
upload_run: true
@@ -203,7 +204,7 @@ scriptorium:
t.Fatalf("reversed ConfigDiff() error = %v", err)
}
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:
artifacts: null
`)
mustWriteTestFile(t, filepath.Join(dir, "profiles", "empty.yml"), `audita:
mustWriteTestFile(t, filepath.Join(dir, "overlays", "empty.yml"), `audita:
modules: []
scriptorium:
artifacts: {}
@@ -229,7 +230,7 @@ scriptorium:
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &base); err != nil {
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)
}
for _, want := range []string{"modules: null\n", "artifacts: null\n"} {
@@ -239,7 +240,7 @@ scriptorium:
}
for _, want := range []string{"modules: []\n", "artifacts: {}\n"} {
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) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
writeInspectionProfiles(t, pipelinePath, `scriptorium:
writeInspectionOverlays(t, pipelinePath, `scriptorium:
artifact_families:
character_note:
enabled: false
@@ -301,11 +302,11 @@ func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) {
func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
workspaceRoot := t.TempDir()
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
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" {
t.Fatalf("equal diff output = %q", got)
@@ -316,7 +317,7 @@ func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
{"production", "--config", pipelinePath},
{"production", "production", "--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, "--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()
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()
const secretName = "NARRATIO_DIFF_BOOTSTRAP_REPORT"
restoreEnvAfterTest(t, secretName)
@@ -393,27 +394,27 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
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: 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
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)
}
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)
}
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)
}
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)
}
var diff bytes.Buffer
@@ -421,7 +422,7 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
t.Fatalf("config diff: %v", err)
}
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)
}
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())
}
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)
}
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) {
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()
profilesDir := filepath.Join(filepath.Dir(pipelinePath), "profiles")
mustWriteTestFile(t, filepath.Join(profilesDir, "production.yml"), production)
mustWriteTestFile(t, filepath.Join(profilesDir, "testing.yml"), testing)
overlaysDir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
mustWriteTestFile(t, filepath.Join(overlaysDir, "production.yml"), production)
mustWriteTestFile(t, filepath.Join(overlaysDir, "testing.yml"), testing)
}
func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) {
func TestConfigCommandsSelectOverlaysAndCampaigns(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath)
enableCommandTestOverlays(t, pipelinePath)
var defaultOut bytes.Buffer
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &defaultOut); err != nil {
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())
}
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)
}
if !strings.Contains(explicitOut.String(), "language: fr") {
@@ -589,9 +590,9 @@ whisperx:
func TestConfigSourcesReportsStableOwnership(t *testing.T) {
workspaceRoot := t.TempDir()
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
if err := ConfigSources(context.Background(), args, &first); err != nil {
t.Fatalf("first ConfigSources() error = %v", err)
@@ -606,13 +607,13 @@ func TestConfigSourcesReportsStableOwnership(t *testing.T) {
for _, want := range []string{
"root: ",
"imports: none",
"profile: name=testing selection=explicit overlay=",
"overlay: name=testing selection=explicit path=",
"campaign: id=sample-campaign source=",
"party: source=",
"digest: ",
"path\trole\tsource\n",
"workspace.root\troot\t",
"whisperx.language\tprofile\t",
"whisperx.language\toverlay\t",
"derived.players\tparty\t",
"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)
profilePath := filepath.Join(filepath.Dir(pipelinePath), "profiles", "testing.yml")
mustWriteTestFile(t, profilePath, `scriptorium:
overlayPath := filepath.Join(filepath.Dir(pipelinePath), "overlays", "testing.yml")
mustWriteTestFile(t, overlayPath, `scriptorium:
artifact_families:
character_note:
enabled: true
@@ -658,12 +659,12 @@ func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing
`)
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)
}
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) {
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()
pipelinePath := filepath.Join(dir, "pipeline.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:
imports:
- path: input-only.yml
@@ -701,7 +702,7 @@ scriptorium:
prior:
source: narratio.artifact.first
`)
mustWriteTestFile(t, profilePath, `scriptorium:
mustWriteTestFile(t, overlayPath, `scriptorium:
artifacts:
mixed:
inputs:
@@ -710,13 +711,13 @@ scriptorium:
`)
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)
}
for _, want := range []string{
"scriptorium.artifacts.input_only.depends_on\timport\t" + inputPath,
"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) {
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()
rootPath := filepath.Join(dir, "pipeline.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:
imports:
- path: base.yml
@@ -741,19 +742,19 @@ whisperx:
transcribe_url: https://transcription.example.com/transcribe
`)
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
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)
}
output := out.String()
for _, want := range []string{
"import: " + importPath,
"profile: name=testing selection=explicit overlay=" + profilePath,
"overlay: name=testing selection=explicit path=" + overlayPath,
"workspace.root\troot\t" + rootPath,
"storage.backend\timport\t" + importPath,
"whisperx.language\tprofile\t" + profilePath,
"whisperx.language\toverlay\t" + overlayPath,
"trim.enabled\tdefault\tdefault",
} {
if !strings.Contains(output, want) {

View File

@@ -20,8 +20,8 @@ type configDiffFlags struct {
}
type configDiffRequest struct {
leftProfile string
rightProfile string
leftOverlay string
rightOverlay string
flags configDiffFlags
}
@@ -37,7 +37,7 @@ type configDiffRecord struct {
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,
// and sources. Differences describe normalized effective values rather than
// 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) {
profiles, flagArgs := splitConfigDiffArguments(args)
overlays, flagArgs := splitConfigDiffArguments(args)
fs := flag.NewFlagSet("config diff", flag.ContinueOnError)
fs.SetOutput(out)
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.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
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, "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, "Flags:")
fs.PrintDefaults()
@@ -123,30 +123,30 @@ func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, er
if err := fs.Parse(flagArgs); err != nil {
return configDiffRequest{}, fmt.Errorf("config diff: invalid flags: %w", err)
}
profiles = append(profiles, fs.Args()...)
if len(profiles) != 2 || strings.TrimSpace(profiles[0]) == "" || strings.TrimSpace(profiles[1]) == "" {
return configDiffRequest{}, fmt.Errorf("config diff: exactly two non-empty profile names are required")
overlays = append(overlays, fs.Args()...)
if len(overlays) != 2 || strings.TrimSpace(overlays[0]) == "" || strings.TrimSpace(overlays[1]) == "" {
return configDiffRequest{}, fmt.Errorf("config diff: exactly two non-empty overlay names are required")
}
if profiles[0] == profiles[1] {
return configDiffRequest{}, fmt.Errorf("config diff: profile names must differ")
if overlays[0] == overlays[1] {
return configDiffRequest{}, fmt.Errorf("config diff: overlay names must differ")
}
if request.flags.campaignPath.set && request.flags.campaignFilePath.set {
return configDiffRequest{}, fmt.Errorf("config diff: --campaign and --campaign-file are mutually exclusive")
}
request.leftProfile = profiles[0]
request.rightProfile = profiles[1]
request.leftOverlay = overlays[0]
request.rightOverlay = overlays[1]
return request, nil
}
// 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
// profile names.
func splitConfigDiffArguments(args []string) (profiles, flagArgs []string) {
// overlay names.
func splitConfigDiffArguments(args []string) (overlays, flagArgs []string) {
for index := 0; index < len(args); index++ {
argument := args[index]
if !strings.HasPrefix(argument, "-") || argument == "-" {
profiles = append(profiles, argument)
overlays = append(overlays, argument)
continue
}
flagArgs = append(flagArgs, argument)
@@ -161,12 +161,12 @@ func splitConfigDiffArguments(args []string) (profiles, flagArgs []string) {
index++
flagArgs = append(flagArgs, args[index])
}
return profiles, flagArgs
return overlays, flagArgs
}
func configDiffStringFlag(name string) bool {
switch name {
case "config", "campaign", "campaign-file", "profile", "secrets-dir":
case "config", "campaign", "campaign-file", "secrets-dir":
return true
default:
return false
@@ -175,13 +175,13 @@ func configDiffStringFlag(name string) bool {
func resolveConfigDiff(request configDiffRequest, deps commandDependencies) (*configDiffResolved, error) {
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 {
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 {
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)
hasCampaignSelection := request.flags.campaignPath.set || request.flags.campaignFilePath.set
@@ -259,7 +259,7 @@ func resolveSharedDiffCampaignPath(left, right *config.PipelineConfig, flags con
return "", rightErr
}
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
}

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 {
return nil, err
}

View File

@@ -14,13 +14,13 @@ func effectiveConfigProvenance(cfg *config.Config) *manifest.EffectiveConfigProv
provenance := &manifest.EffectiveConfigProvenance{
EffectiveConfigDigest: config.EffectivePipelineDigest(cfg.Pipeline),
}
if selected, ok := config.SelectedPipelineProfile(cfg.Pipeline); ok {
provenance.SelectedProfile = &manifest.SelectedProfileProvenance{
if selected, ok := config.SelectedPipelineOverlay(cfg.Pipeline); ok {
provenance.SelectedOverlay = &manifest.SelectedOverlayProvenance{
Name: selected.Name,
Source: selected.Source,
}
}
if provenance.SelectedProfile == nil && provenance.EffectiveConfigDigest == "" {
if provenance.SelectedOverlay == nil && provenance.EffectiveConfigDigest == "" {
return nil
}
return provenance
@@ -35,30 +35,30 @@ func applyEffectiveConfigProvenance(m *manifest.Manifest, cfg *config.Config) {
func effectiveConfigSummary(cfg *config.Config) string {
provenance := effectiveConfigProvenance(cfg)
if provenance == nil {
return "profile=none digest=unavailable"
return "overlay=none digest=unavailable"
}
profile := "none"
if provenance.SelectedProfile != nil {
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
overlay := "none"
if provenance.SelectedOverlay != nil {
overlay = provenance.SelectedOverlay.Name + " (" + provenance.SelectedOverlay.Source + ")"
}
digest := provenance.EffectiveConfigDigest
if digest == "" {
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 {
if provenance == nil {
return "profile=none digest=unavailable"
return "overlay=none digest=unavailable"
}
profile := "none"
if provenance.SelectedProfile != nil {
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
overlay := "none"
if provenance.SelectedOverlay != nil {
overlay = provenance.SelectedOverlay.Name + " (" + provenance.SelectedOverlay.Source + ")"
}
digest := provenance.EffectiveConfigDigest
if digest == "" {
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 {
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 {
t.Fatal(err)
}
@@ -832,7 +832,7 @@ func TestExtractLifecycleRealConfigProfilesSelectNotariusExplicitly(t *testing.T
}
load := func(profile string) *config.Config {
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 {
t.Fatalf("load profile %q: %v", profile, err)
}

View File

@@ -23,7 +23,7 @@ type commonConfigFlags struct {
sessionPath string
sessionID string
previousSessionID string
profile singletonStringFlag
overlay 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.sessionID, "session-id", "", "session identifier")
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
flags.profile.name = "profile"
fs.Var(&flags.profile, "profile", "named pipeline profile")
flags.overlay.name = "overlay"
fs.Var(&flags.overlay, "overlay", "named pipeline overlay")
flags.secretsDir.name = "secrets-dir"
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{
SessionID: f.sessionID,
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.SetOutput(io.Discard)
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
var profile singletonStringFlag
var overlay singletonStringFlag
var secretsDir singletonStringFlag
var remote, force bool
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(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
profile.name = "profile"
fs.Var(&profile, "profile", "named pipeline profile")
overlay.name = "overlay"
fs.Var(&overlay, "overlay", "named pipeline overlay")
secretsDir.name = "secrets-dir"
fs.Var(&secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
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")
}
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 {
return fmt.Errorf("session init: %w", err)
}

View File

@@ -11,10 +11,10 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
func TestRunOverlaySelectionFlowsThroughSharedLoader(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath)
enableCommandTestOverlays(t, pipelinePath)
var language string
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{
"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 {
t.Fatal(err)
}
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{
@@ -40,25 +45,25 @@ func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
t.Fatalf("base configuration language = %q, want en", language)
}
for _, profile := range []string{"unknown"} {
for _, overlay := range []string{"unknown"} {
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)
if err == nil || !strings.Contains(err.Error(), "profile") {
t.Fatalf("profile %q error = %v, want selection rejection", profile, err)
if err == nil || !strings.Contains(err.Error(), "overlay") {
t.Fatalf("overlay %q error = %v, want selection rejection", overlay, err)
}
}
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 {
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()
dir := filepath.Join(filepath.Dir(pipelinePath), "profiles")
dir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}

View File

@@ -14,7 +14,7 @@ import (
func TestRegenerateArtifactsForwardsCanonicalPipelineRequest(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath)
enableCommandTestOverlays(t, pipelinePath)
var capturedPlan BoundedPlan
var capturedOptions RunOptions
executor := func(_ context.Context, cfg *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
@@ -30,7 +30,7 @@ func TestRegenerateArtifactsForwardsCanonicalPipelineRequest(t *testing.T) {
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
"--profile", "testing",
"--overlay", "testing",
}, io.Discard, io.Discard, executor)
if code != 0 {
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,
SessionID: flags.sessionID,
PreviousSessionID: flags.previousSessionID,
Profile: flags.profile.pointer(),
Overlay: flags.overlay.pointer(),
SecretsDir: flags.secretsDir.pointer(),
Force: force,
SelectedArtifacts: normalizedArtifacts,
@@ -139,7 +139,7 @@ func analyzeWithDependencies(ctx context.Context, args []string, out io.Writer,
SessionPath: flags.sessionPath,
SessionID: flags.sessionID,
PreviousSessionID: flags.previousSessionID,
Profile: flags.profile.pointer(),
Overlay: flags.overlay.pointer(),
SecretsDir: flags.secretsDir.pointer(),
Force: true,
SelectedArtifacts: normalizedArtifacts,
@@ -196,7 +196,7 @@ func publishWithDependencies(ctx context.Context, args []string, out io.Writer,
SessionPath: flags.sessionPath,
SessionID: flags.sessionID,
PreviousSessionID: flags.previousSessionID,
Profile: flags.profile.pointer(),
Overlay: flags.overlay.pointer(),
SecretsDir: flags.secretsDir.pointer(),
Force: true,
SelectedArtifacts: normalizedArtifacts,
@@ -224,7 +224,7 @@ type singleStageCommand struct {
SessionPath string
SessionID string
PreviousSessionID string
Profile *string
Overlay *string
SecretsDir *string
Force bool
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{
SessionID: req.SessionID,
PreviousSessionID: req.PreviousSessionID,
Profile: req.Profile,
Overlay: req.Overlay,
}, req.SecretsDir, deps)
if err != nil {
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{}{}
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
}
for _, location := range change.Origin.Locations {

View File

@@ -14,21 +14,21 @@ const (
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.
type PipelineProfileProvenance struct {
type PipelineOverlayProvenance struct {
Name string
Source string
OverlayPath string
}
// SelectedPipelineProfile reports the profile used to resolve cfg, if any.
func SelectedPipelineProfile(cfg *PipelineConfig) (*PipelineProfileProvenance, bool) {
if cfg == nil || cfg.resolution == nil || cfg.resolution.selectedProfile == nil {
// SelectedPipelineOverlay reports the overlay used to resolve cfg, if any.
func SelectedPipelineOverlay(cfg *PipelineConfig) (*PipelineOverlayProvenance, bool) {
if cfg == nil || cfg.resolution == nil || cfg.resolution.selectedOverlay == nil {
return nil, false
}
selection := cfg.resolution.selectedProfile
return &PipelineProfileProvenance{Name: selection.name, Source: selection.source, OverlayPath: selection.overlayPath}, true
selection := cfg.resolution.selectedOverlay
return &PipelineOverlayProvenance{Name: selection.name, Source: selection.source, OverlayPath: selection.overlayPath}, true
}
// 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 {
return "root"
}
if cfg.resolution.selectedProfile != nil && source == cfg.resolution.selectedProfile.overlayPath {
return "profile"
if cfg.resolution.selectedOverlay != nil && source == cfg.resolution.selectedOverlay.overlayPath {
return "overlay"
}
for _, imported := range cfg.resolution.imports {
if source == imported {

View File

@@ -15,14 +15,14 @@ func LoadPipeline(path string) (*PipelineConfig, error) {
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.
type PipelineLoadOptions struct {
Profile *string
Overlay *string
}
// 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) {
cfg, err := loadPipelineWithConfigKit(path, opts)
if err != nil {
@@ -31,20 +31,20 @@ func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineCo
return finalizeLoadedPipeline(path, cfg)
}
// LoadPipelineProfilePair resolves two profiles as independent ConfigKit loads.
func LoadPipelineProfilePair(path, leftProfile, rightProfile string) (*PipelineConfig, *PipelineConfig, error) {
if leftProfile == rightProfile {
return nil, nil, fmt.Errorf("load pipeline profiles: left and right profile selections must differ")
// LoadPipelineOverlayPair resolves two overlays as independent ConfigKit loads.
func LoadPipelineOverlayPair(path, leftOverlay, rightOverlay string) (*PipelineConfig, *PipelineConfig, error) {
if leftOverlay == rightOverlay {
return nil, nil, fmt.Errorf("load pipeline overlays: left and right overlay selections must differ")
}
leftOptions := PipelineLoadOptions{Profile: &leftProfile}
rightOptions := PipelineLoadOptions{Profile: &rightProfile}
leftOptions := PipelineLoadOptions{Overlay: &leftOverlay}
rightOptions := PipelineLoadOptions{Overlay: &rightOverlay}
left, err := LoadPipelineWithOptions(path, leftOptions)
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)
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
}
@@ -76,7 +76,7 @@ func LoadSession(path string) (*SessionConfig, error) {
type SessionLoadOptions struct {
SessionID string
PreviousSessionID string
Profile *string
Overlay *string
}
// 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
// session configuration with expected session identity checks.
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 {
return nil, err
}

View File

@@ -358,7 +358,7 @@ func TestExamplesLoadAndValidate(t *testing.T) {
t.Run(profile.name, func(t *testing.T) {
options := PipelineLoadOptions{}
if profile.selection != "" {
options.Profile = &profile.selection
options.Overlay = &profile.selection
}
pipeline, err := LoadPipelineWithOptions(pipelinePath, options)
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 {
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 {
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) {
t.Fatalf("selected profile = %#v, want %q", selectedProfile, profile.selection)
if profile.selection != "" && (!selected || selectedOverlay.Name != profile.selection) {
t.Fatalf("selected overlay = %#v, want %q", selectedOverlay, profile.selection)
}
for _, key := range []string{
"session_recap",

View File

@@ -16,7 +16,7 @@ type pipelineResolutionMetadata struct {
rootPath string
imports []string
sources []string
selectedProfile *pipelineProfileSelection
selectedOverlay *pipelineOverlaySelection
effectiveDigest string
logicalNotariusConfig string
logicalNotariusWorking string
@@ -29,7 +29,7 @@ type pipelineResolutionMetadata struct {
configkitSources []configkit.SourceInfo
}
type pipelineProfileSelection struct {
type pipelineOverlaySelection struct {
name string
source string
overlayPath string
@@ -41,8 +41,8 @@ type pipelineFieldOwnership struct {
}
func loadPipelineWithConfigKit(path string, opts PipelineLoadOptions) (*PipelineConfig, error) {
profile := selectedPipelineProfileName(opts)
result, err := loadPipelineConfigKitResult(path, profile)
overlay := selectedPipelineOverlayName(opts)
result, err := loadPipelineConfigKitResult(path, overlay)
if err != nil {
return nil, describePipelineLoadError(err)
}
@@ -55,10 +55,10 @@ func loadPipelineWithConfigKit(path string, opts PipelineLoadOptions) (*Pipeline
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]{
Imports: true,
Profile: profile,
Overlay: overlay,
Definition: configkit.Definition[PipelineConfig]{
Defaults: pipelineConfigDefaults,
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()))
}
func selectedPipelineProfileName(opts PipelineLoadOptions) string {
if opts.Profile == nil || *opts.Profile == "" || *opts.Profile == "default" {
func selectedPipelineOverlayName(opts PipelineLoadOptions) string {
if opts.Overlay == nil || *opts.Overlay == "" || *opts.Overlay == "default" {
return ""
}
return *opts.Profile
return *opts.Overlay
}
func pipelineConfigDefaults() PipelineConfig {
@@ -282,8 +282,8 @@ func pipelineMetadataFromConfigKit(cfg *PipelineConfig, result *configkit.Result
} else if source.Layer == configkit.LayerBase {
resolved.imports = append(resolved.imports, path)
}
if source.Layer == configkit.LayerProfile {
resolved.selectedProfile = &pipelineProfileSelection{name: selectedPipelineProfileName(opts), source: "explicit", overlayPath: path}
if source.Layer == configkit.LayerOverlay {
resolved.selectedOverlay = &pipelineOverlaySelection{name: selectedPipelineOverlayName(opts), source: "explicit", overlayPath: path}
}
}
detailed, err := pipelineMetadataPaths(cfg)
@@ -347,7 +347,7 @@ func rejectDuplicateBaseDefinitions(metadata configkit.Metadata, sources []confi
pathsByID[source.ID] = source.Path
}
// 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.
for _, path := range metadata.HistoricalPaths() {
parts := strings.Split(path.String(), "/")

View File

@@ -8,7 +8,7 @@ import (
"testing"
)
func TestPipelineConfigKitImportsProfileAndBaseSelection(t *testing.T) {
func TestPipelineConfigKitImportsOverlayAndBaseSelection(t *testing.T) {
dir := t.TempDir()
writePipelineFixture(t, dir, "pipeline.yml", `configkit:
imports:
@@ -24,33 +24,62 @@ whisperx:
enabled: false
output_path: artifacts/recap.md
`)
writePipelineFixture(t, dir, "profiles/testing.yml", "workspace:\n root: /srv/testing\n")
writePipelineFixture(t, dir, "profiles/unselected.yml", "unknown: [\n")
writePipelineFixture(t, dir, "overlays/testing.yml", "workspace:\n root: /srv/testing\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")
var baseDigest string
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 {
t.Fatalf("load base selection %#v: %v", profile, err)
}
if cfg.Workspace.Root != "/srv/base" {
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"
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &selected})
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &selected})
if err != nil {
t.Fatal(err)
}
if cfg.Workspace.Root != "/srv/testing" || len(EffectivePipelineImports(cfg)) != 2 {
t.Fatalf("profiled pipeline = %#v, imports=%#v", cfg.Workspace, EffectivePipelineImports(cfg))
}
profile, ok := SelectedPipelineProfile(cfg)
if !ok || profile.Name != "testing" || profile.OverlayPath != filepath.Join(dir, "profiles", "testing.yml") {
profile, ok := SelectedPipelineOverlay(cfg)
if !ok || profile.Name != "testing" || profile.OverlayPath != filepath.Join(dir, "overlays", "testing.yml") {
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) {
for _, test := range []struct {
name, first, second, clearedContainer string
@@ -69,14 +98,14 @@ whisperx:
`)
writePipelineFixture(t, dir, "first.yml", test.first)
writePipelineFixture(t, dir, "second.yml", test.second)
writePipelineFixture(t, dir, "profiles/clear-container.yml", test.clearedContainer)
writePipelineFixture(t, dir, "profiles/clear-scriptorium.yml", "scriptorium: null\n")
writePipelineFixture(t, dir, "overlays/clear-container.yml", test.clearedContainer)
writePipelineFixture(t, dir, "overlays/clear-scriptorium.yml", "scriptorium: null\n")
for _, profile := range []string{"", "clear-container", "clear-scriptorium"} {
t.Run(profile, func(t *testing.T) {
options := PipelineLoadOptions{}
if profile != "" {
options.Profile = &profile
options.Overlay = &profile
}
_, err := LoadPipelineWithOptions(filepath.Join(dir, "pipeline.yml"), options)
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 testingKey = "NARRATIO_PROFILE_TEST_KEY"
t.Setenv(production, "")
@@ -101,9 +130,9 @@ func TestPipelineProfileDoesNotRequireUnusedBaseCredentials(t *testing.T) {
t.Run(credential, func(t *testing.T) {
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")
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"
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile})
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile})
if err != nil {
t.Fatal(err)
}
@@ -130,9 +159,9 @@ func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) {
t.Run(credential, func(t *testing.T) {
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")
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"
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile})
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile})
if err != nil {
t.Fatal(err)
}
@@ -187,8 +216,8 @@ func TestNotariusConfigProfileComposition(t *testing.T) {
root := writePipelineFixture(t, dir, "pipeline.yml", rootYAML)
options := PipelineLoadOptions{}
if test.overlayName != "" {
writePipelineFixture(t, dir, "profiles/"+test.overlayName+".yml", test.overlay)
options.Profile = &test.overlayName
writePipelineFixture(t, dir, "overlays/"+test.overlayName+".yml", test.overlay)
options.Overlay = &test.overlayName
}
cfg, err := LoadPipelineWithOptions(root, options)
if err != nil {
@@ -238,9 +267,9 @@ func TestNotariusCacheControlProfileReplacementAndNullReset(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
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")
overlay := writePipelineFixture(t, dir, "profiles/testing.yml", test.overlay)
overlay := writePipelineFixture(t, dir, "overlays/testing.yml", test.overlay)
profile := "testing"
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile})
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile})
if err != nil {
t.Fatal(err)
}
@@ -282,9 +311,9 @@ whisperx:
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, "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"
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 {
t.Fatal(err)
}

View File

@@ -1,6 +1,7 @@
package manifest
import (
"encoding/json"
"strings"
"time"
@@ -71,13 +72,25 @@ type StageRecord struct {
// EffectiveConfigProvenance records bounded non-secret configuration identity
// for one resolved invocation.
type EffectiveConfigProvenance struct {
SelectedProfile *SelectedProfileProvenance `json:"selected_profile,omitempty"`
SelectedOverlay *SelectedOverlayProvenance `json:"selected_overlay,omitempty"`
EffectiveConfigDigest string `json:"effective_config_digest,omitempty"`
}
// SelectedProfileProvenance identifies an explicitly or default-selected
// pipeline profile without recording profile content.
type SelectedProfileProvenance struct {
// UnmarshalJSON accepts unknown informational provenance from older manifests.
// The enclosing manifest and its execution state remain strictly decoded.
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"`
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.S3RunPrefix = "dnd/campaigns/forsaken/sessions/2026-05-03/runs/20260515T031522Z-a1b2c3d4/"
m.EffectiveConfig = &EffectiveConfigProvenance{
SelectedProfile: &SelectedProfileProvenance{Name: "production", Source: "default"},
SelectedOverlay: &SelectedOverlayProvenance{Name: "production", Source: "default"},
EffectiveConfigDigest: "001122",
}
@@ -57,7 +57,7 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
if loaded.RunID != "20260515T031522Z-a1b2c3d4" {
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)
}
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) {
store := &LocalStore{}
ctx := context.Background()