Compare commits
10 Commits
ed4623bc41
...
6b0778b420
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b0778b420 | |||
| 5b92b3e790 | |||
| cc43906ae9 | |||
| dcaa2ad9c6 | |||
| 9d68cfcf2f | |||
| 2d346733d6 | |||
| cd75bf6d5a | |||
| e08de98ae6 | |||
| df213bc2ba | |||
| 869e68e834 |
@@ -98,6 +98,8 @@ a session nor create a workspace, manifest, run, lock, adapter, or remote
|
||||
connection. Like every pipeline-loading command, they bootstrap the selected or
|
||||
conventional secrets directory before resolving configuration; `config show`
|
||||
never includes bootstrap reports or secret values in its YAML output.
|
||||
Typed credential references, including `notarius.llm_api_key`, must therefore
|
||||
resolve even though these inspection commands never construct an adapter.
|
||||
|
||||
Campaign selection is optional for a pipeline without party-driven artifact
|
||||
families. A pipeline with `scriptorium.artifact_families` needs a selected or
|
||||
@@ -175,6 +177,9 @@ Behavior:
|
||||
- rejects unknown endpoints and a `--from` endpoint after `--through`;
|
||||
- runs `render` before `extract`; an omitted or disabled Notarius
|
||||
configuration records an explicit `notarius_disabled` self-skip;
|
||||
- resolves every selected typed credential during the initial configuration
|
||||
load, including a Notarius credential when extract is disabled, outside the
|
||||
selected range, or later skipped as resumable;
|
||||
- skips already-succeeded stages unless `--force` is set or a stage-specific
|
||||
resume check finds its durable result obsolete;
|
||||
- applies `--force` only to stages in the selected range;
|
||||
|
||||
@@ -227,8 +227,12 @@ inputs:
|
||||
- Do not place raw secrets in YAML.
|
||||
- Credential fields contain environment-variable names, represented internally
|
||||
as ConfigKit typed secret references (for example
|
||||
`pipeline.audita.llm_api_key: AUDITA_LLM_API_KEY`). A configured reference
|
||||
`pipeline.notarius.llm_api_key: NOTARIUS_LLM_API_KEY`). A configured reference
|
||||
must resolve to a nonempty environment value during load.
|
||||
- Every surviving reference is resolved during ordinary pipeline loading,
|
||||
regardless of whether its integration is disabled, outside the selected
|
||||
stage range, or eligible for resume. Omit the field or set it to `null` when
|
||||
that deployment should not require the credential.
|
||||
- Every command that loads a pipeline accepts `--secrets-dir <path>`. When the
|
||||
flag is omitted, Narratio uses a present `secrets/` directory beside the
|
||||
resolved root `pipeline.yml`; an absent conventional directory is ignored.
|
||||
@@ -353,6 +357,7 @@ Rules:
|
||||
| `pipeline.trim.seriatim.report` | bool | No | `false` |
|
||||
| `pipeline.notarius.enabled` | bool | No | `false` |
|
||||
| `pipeline.notarius.binary` | string | No | `notarius` |
|
||||
| `pipeline.notarius.llm_api_key` | secret reference | No | unset; exact environment-variable name forwarded to Notarius |
|
||||
| `pipeline.notarius.config_path` | string | Conditional | required when enabled; relative paths resolve from the pipeline file directory |
|
||||
| `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled |
|
||||
| `pipeline.notarius.timeout` | duration | No | `3h`; must be positive |
|
||||
@@ -373,6 +378,34 @@ Rules:
|
||||
| `pipeline.scriptorium.artifact_families` | map | No | empty; expands one ordinary artifact per canonical party character |
|
||||
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
|
||||
|
||||
### Notarius Credential
|
||||
|
||||
`pipeline.notarius.llm_api_key` optionally names the one environment variable
|
||||
to forward to the Notarius child process:
|
||||
|
||||
```yaml
|
||||
notarius:
|
||||
llm_api_key: OPENROUTER_API_KEY
|
||||
```
|
||||
|
||||
The field contains the variable name, not a credential value. That name must
|
||||
match `api_key_env` in the Notarius/PromptKit profile selected by the deployed
|
||||
Notarius pipeline; Narratio does not infer or remap provider names. Omission or
|
||||
`null` forwards nothing. A non-null selected reference must resolve at pipeline
|
||||
load time through the process environment or existing secrets-directory
|
||||
bootstrap, even when Notarius is disabled, extract is outside the requested
|
||||
range, or a completed extraction can be reused.
|
||||
|
||||
The Notarius child receives only Narratio's minimal execution environment plus
|
||||
this exact selected name and loaded value. Other credentials are not forwarded.
|
||||
Changing the value, name, presence, or absence does not invalidate extract's
|
||||
semantic resume evidence. The public name remains part of safe effective
|
||||
inspection, so renaming it may appear in `config diff` and change the effective
|
||||
configuration digest; rotating only its value does neither.
|
||||
|
||||
See the [Notarius integration contract](./integrations/notarius.md) for the
|
||||
delivery and redaction boundary.
|
||||
|
||||
### Notarius Reference Bindings
|
||||
|
||||
`pipeline.notarius.references` maps a Notarius CLI selector to a prepared
|
||||
|
||||
@@ -75,6 +75,27 @@ It does not pass a Narratio session ID or run `notarius config validate`
|
||||
automatically; the configured working directory and Narratio's minimal child
|
||||
environment apply to the subprocess.
|
||||
|
||||
When `pipeline.notarius.llm_api_key` is absent or null, Narratio adds no
|
||||
credential to that environment. When configured, the field names an environment
|
||||
variable whose already-loaded nonempty value is forwarded under that exact
|
||||
name. Operators must use the same name as the `api_key_env` in the selected
|
||||
Notarius/PromptKit profile. Narratio does not inspect the Notarius configuration
|
||||
to infer a provider or destination name, and it does not forward unrelated
|
||||
credentials from its parent environment or secrets directory.
|
||||
|
||||
The selected name is registered with Narratio's shared streaming redactor.
|
||||
Credential bytes written by the child to captured stdout, captured stderr, or
|
||||
stderr-derived failure diagnostics are replaced with `<redacted>`, including
|
||||
custom names not known to the built-in sensitive-name list. Narratio does not
|
||||
serialize the credential into arguments, stage metadata, manifests, or
|
||||
generated configuration.
|
||||
|
||||
This captured-stream protection is not a filesystem sandbox. Notarius remains
|
||||
responsible for keeping credentials out of files it writes directly, including
|
||||
bundle artifacts and its own diagnostics. Narratio does not scan or rewrite
|
||||
arbitrary downstream files and cannot promise redaction of transformed or
|
||||
encoded credentials.
|
||||
|
||||
## Accepted Result
|
||||
|
||||
Narratio's supported invocation baseline is Notarius v0.6.0. The accepted
|
||||
|
||||
@@ -65,6 +65,11 @@ Notarius is composed only when extraction is enabled; the extract stage owns
|
||||
prepared reference resolution, receipt, bundle, and configured-lane policy.
|
||||
The adapter validates the ordered selector/absolute-path pairs and is the sole
|
||||
owner of serializing them as repeated `--reference` arguments before `--json`.
|
||||
Its request may also carry one already-resolved opaque ConfigKit secret
|
||||
reference. Only the Notarius adapter reads that value, rejects an unresolved or
|
||||
empty programmatic reference, and maps the exact configured name/value into the
|
||||
shared subprocess override and sensitive-name fields. It never rereads the
|
||||
parent environment. A nil reference preserves the previous invocation.
|
||||
|
||||
Application-owned object-store construction loads configured filesystem
|
||||
secrets before invoking the invocation's storage factory. Production commands
|
||||
|
||||
@@ -168,6 +168,10 @@ 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
|
||||
therefore never hashed; reference-name changes remain ordinary provenance.
|
||||
The optional Notarius credential follows the same path even for disabled or
|
||||
resumable extraction; normalization copies the opaque reference without reading
|
||||
or resolving it again. Direct YAML serialization has a configuration-owned safe
|
||||
projection so only the reference name can leave this boundary.
|
||||
`recomputePipelineEffectiveDigest` is the single package-owned refresh point
|
||||
for later runtime expansion.
|
||||
|
||||
|
||||
@@ -53,11 +53,12 @@ adapters. The normative rules for these relationships remain in
|
||||
[Architecture](../policy/architecture.md).
|
||||
|
||||
Command execution normalizes one private dependency value per invocation.
|
||||
Configuration reads, secret-directory reads, remote downloads, object-store
|
||||
construction, and restore phase functions use that value throughout the
|
||||
command. Public command entry points supply production defaults, while focused
|
||||
tests replace only the external or sequencing boundary they exercise without
|
||||
mutating package state.
|
||||
Pipeline configuration-path discovery, configuration reads, secret-directory
|
||||
reads, remote downloads, object-store construction, and restore phase functions
|
||||
use that value throughout the command. Public command entry points supply the
|
||||
canonical discovery and production defaults, while focused tests may provide
|
||||
invocation-owned discovery candidates or replace only the external or sequencing
|
||||
boundary they exercise without mutating package state.
|
||||
|
||||
Pipeline execution and `session plan` share the same inclusive contiguous-range
|
||||
model and the same pure pre-execution decision over recorded status, force, and
|
||||
|
||||
@@ -24,7 +24,8 @@ procedures belong in [Operations](../operations.md).
|
||||
4. fingerprints the byte- and provenance-bearing Notarius invocation evidence,
|
||||
including sorted reference identities;
|
||||
5. creates a run-local staging directory and invokes the injected
|
||||
`notarius.Runner`;
|
||||
`notarius.Runner`, carrying the optional already-resolved credential
|
||||
reference without exposing its value;
|
||||
6. revalidates the reference snapshots, then validates the v2 successful
|
||||
receipt, confined index, management documents, configured required lane
|
||||
descriptors, validation summaries, and regular payload files;
|
||||
@@ -61,6 +62,10 @@ semantic fingerprint. It covers enablement, Notarius pipeline identity, sorted
|
||||
reference selector/source mappings, sorted declared output contracts, and each
|
||||
canonical `narratio.extraction.<key>` output identity. It excludes executable,
|
||||
timeout, working directory, config path, and private Notarius config contents.
|
||||
Credential presence, reference name, and resolved value are likewise
|
||||
operational-only and excluded. Ordinary command loading still resolves a
|
||||
selected reference before resume evaluation, but a credential-only change does
|
||||
not rerun a valid completed extraction or stale its dependents.
|
||||
|
||||
`internal/stage/extract_resume.go` then permits a skip only when the existing
|
||||
stage record still matches the current byte- and provenance-bearing invocation
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
# ConfigKit Adoption and Platform Simplification
|
||||
|
||||
**Status:** Accepted; implementation pending
|
||||
|
||||
## Purpose
|
||||
|
||||
Narratio currently owns a substantial YAML configuration subsystem for strict
|
||||
decoding, explicit imports, additive composition, profile overlays, presence
|
||||
tracking, provenance, and effective-value inspection. ConfigKit was extracted
|
||||
from this implementation and now provides those generic responsibilities as a
|
||||
maintained library.
|
||||
|
||||
Narratio 2.0 will adopt ConfigKit as its configuration foundation, remove the
|
||||
superseded generic infrastructure, and retain only Narratio-specific configuration
|
||||
policy and domain resolution. The same major release will narrow the supported
|
||||
platform set to the systems that Narratio is expected to run on in practice.
|
||||
|
||||
This roadmap defines the desired contracts and end state. The
|
||||
[implementation plan](implementation.md) owns sequencing and completion gates.
|
||||
|
||||
## Goals
|
||||
|
||||
- Reduce Narratio-owned configuration code and tests by delegating generic YAML
|
||||
mechanics to ConfigKit.
|
||||
- Establish one strict and explainable configuration lifecycle rather than
|
||||
retaining parallel or compatibility loaders.
|
||||
- Preserve Narratio's application-specific defaults, validation, campaign/session
|
||||
resolution, party handling, artifact-family expansion, digests, and CLI policy.
|
||||
- Make individual artifact definitions easy to organize, identify, inspect, and
|
||||
reuse as dependencies of later artifacts.
|
||||
- Keep raw credentials out of YAML and adopt a predictable secrets-directory
|
||||
convention that works before configuration loading.
|
||||
- Remove unsupported platform code and release obligations that provide no known
|
||||
user value.
|
||||
- Deliver the resulting contract changes as Narratio v2.0.0 with a clear migration
|
||||
guide.
|
||||
|
||||
## Platform Contract
|
||||
|
||||
Narratio's v2 platform matrix is:
|
||||
|
||||
| Platform | Status | Required evidence and deliverables |
|
||||
| --- | --- | --- |
|
||||
| Linux amd64 | Primary, supported | Default tests, race checks, static analysis, build validation, and a release asset |
|
||||
| Linux arm64 | Primary, supported | Cross-build validation and a release asset; native validation is used when an appropriate runner is available |
|
||||
| macOS arm64 | Best effort | Cross-build validation and a release asset; native CI and platform-equivalent runtime guarantees are not required release gates |
|
||||
| macOS amd64 | Unsupported | No build gate, release asset, compatibility promise, or platform-specific maintenance |
|
||||
| Windows | Unsupported | No build gate, release asset, compatibility promise, or platform-specific maintenance |
|
||||
|
||||
Windows- and Intel-Mac-specific code, tests, CI entries, release packaging, and
|
||||
documentation are removed when they no longer serve a supported build. Portable
|
||||
code should remain portable where doing so is natural, but accidental compilation
|
||||
on an unsupported target does not create a support commitment.
|
||||
|
||||
Best-effort macOS arm64 support means Narratio intends to keep the target buildable
|
||||
and will consider reported defects when practical. Lack of a native runner or an
|
||||
unverified platform-specific behavior does not block a release. A target for which
|
||||
Narratio publishes an asset must still pass its configured cross-build gate.
|
||||
|
||||
## ConfigKit Dependency and Ownership
|
||||
|
||||
Narratio will depend on the tagged ConfigKit module at an exact reviewed version,
|
||||
initially `gitea.maximumdirect.net/eric/ConfigKit@v0.1.1`. ConfigKit is pre-1.0 and
|
||||
may make compatibility changes in later minor versions, so dependency upgrades are
|
||||
intentional changes accompanied by contract review and focused Narratio integration
|
||||
tests; Narratio does not follow an unpinned branch or automatically accept a newer
|
||||
minor release.
|
||||
|
||||
ConfigKit owns:
|
||||
|
||||
- bounded file, byte, and reader input;
|
||||
- the accepted YAML syntax and strict typed decoding;
|
||||
- import declarations, traversal, containment, and resource limits;
|
||||
- additive composition and conflict attribution;
|
||||
- selected-profile overlay semantics;
|
||||
- explicit input presence and source provenance;
|
||||
- application-default and recorded-resolution coordination;
|
||||
- structured configuration diagnostics;
|
||||
- secret-reference parsing and resolution;
|
||||
- secrets-directory loading and its safe report; and
|
||||
- detached effective configuration inspection supplied by its public result.
|
||||
|
||||
Narratio owns:
|
||||
|
||||
- pipeline, campaign, session, party, and runtime domain types;
|
||||
- configuration-file discovery and CLI selection;
|
||||
- concrete defaults, normalization, and semantic validation;
|
||||
- campaign/session identity and stable-input resolution;
|
||||
- canonical party parsing requirements and authored-byte retention;
|
||||
- character artifact-family expansion and generated ownership attribution;
|
||||
- root-relative interpretation of Narratio-owned runtime paths;
|
||||
- effective pipeline digests and semantic profile diffs;
|
||||
- command output and exit behavior; and
|
||||
- adapter and runtime initialization after configuration succeeds.
|
||||
|
||||
Narratio must use ConfigKit through its public package. It must not import
|
||||
ConfigKit's private YAML implementation or reproduce ConfigKit's parser,
|
||||
composition, profile, provenance, or secret-bootstrap mechanics locally.
|
||||
|
||||
## Configuration Format
|
||||
|
||||
Narratio adopts ConfigKit's configuration conventions directly. The old Narratio
|
||||
`composition:` envelope, declared profile catalog, arbitrary overlay paths, and
|
||||
`composition.default_profile` are removed without a compatibility parser.
|
||||
|
||||
The root pipeline uses `configkit.imports` declarations. Import paths, optional
|
||||
imports, globs, recursion, ordering, composition conflicts, containment, and limits
|
||||
have the meanings defined by the pinned ConfigKit contract. Narratio does not add a
|
||||
second import syntax or narrower application-specific traversal implementation.
|
||||
|
||||
The unoverlaid root and its imports are the production configuration. A named
|
||||
profile is a ConfigKit overlay at `profiles/<name>.yml`; in particular,
|
||||
`profiles/testing.yml` contains inexpensive-model overrides and testing-only
|
||||
artifacts. Omitting `--profile`, or selecting ConfigKit's `default` profile, uses the
|
||||
production base. `--profile testing` applies the testing overlay. Narratio does not
|
||||
maintain a separate configured default-profile mechanism or validate unselected
|
||||
profile files.
|
||||
|
||||
ConfigKit's rules for explicit false, zero, empty, and null values replace
|
||||
Narratio's custom presence tracking. Application defaults and normalization use a
|
||||
ConfigKit definition and recorded resolution where the final value depends on
|
||||
whether input was present. `NormalizeConfig.UnmarshalYAML` and equivalent
|
||||
parser-coupled application hooks are removed.
|
||||
|
||||
Pipeline, campaign, session, and party inputs use the common ConfigKit loading
|
||||
boundary where its schema model can represent their types. Narratio may perform
|
||||
narrow pre- or post-load domain work, such as retaining authored party bytes,
|
||||
rejecting unresolved session-template placeholders, resolving stable inputs, and
|
||||
expanding artifact families. Such work must not recreate a second generic YAML
|
||||
loader.
|
||||
|
||||
## Artifact Definition Ergonomics
|
||||
|
||||
The canonical v2 configuration layout defines each ordinary Scriptorium artifact
|
||||
in its own file under `artifacts/`. The root pipeline imports the directory through
|
||||
ConfigKit, for example:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
imports:
|
||||
- glob: artifacts/*.yml
|
||||
```
|
||||
|
||||
Each file contributes one ordinary entry at
|
||||
`scriptorium.artifacts.<artifact_key>`. The map key is the authoritative Narratio
|
||||
artifact key and therefore makes the artifact's internal identity explicit in the
|
||||
definition itself:
|
||||
|
||||
```yaml
|
||||
# artifacts/session_recap.yml
|
||||
scriptorium:
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: artifacts/session_recap.md
|
||||
```
|
||||
|
||||
The canonical reusable source ID is derived without another configured identifier:
|
||||
`session_recap` becomes `narratio.artifact.session_recap`. The recommended filename
|
||||
is `<artifact_key>.yml`, but the filename is organizational rather than an identity
|
||||
source. Renaming or moving a file without changing its map key does not change the
|
||||
artifact contract. ConfigKit additive composition rejects duplicate artifact keys.
|
||||
|
||||
The one-artifact-per-file layout is the documented happy path and is used by
|
||||
maintained examples. Narratio does not prohibit an inline artifact or another
|
||||
ConfigKit-compatible source organization merely because it differs from that
|
||||
layout. Artifact identity always comes from the effective map key, never implicitly
|
||||
from a filename or import position.
|
||||
|
||||
Artifact families use the corresponding `artifact-families/*.yml` convention, with
|
||||
one `scriptorium.artifact_families.<family_key>` entry per file. The family key is
|
||||
explicit. Concrete member keys and source IDs continue to use Narratio's existing
|
||||
deterministic family/member naming rule after canonical party resolution.
|
||||
|
||||
Narratio adds a read-only `narratio config artifacts` command using the ordinary
|
||||
pipeline, profile, and campaign selectors. It reports every effective ordinary
|
||||
artifact's key, canonical `narratio.artifact.<key>` source ID, defining source,
|
||||
enabled state, output path, and effective dependencies. Generated family members
|
||||
also report their family origin. Output ordering is deterministic by artifact key,
|
||||
and the command does not require a session or initialize runtime adapters.
|
||||
|
||||
A configured-artifact input creates an implicit dependency on the referenced
|
||||
artifact. For example:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
recap:
|
||||
source: narratio.artifact.session_recap
|
||||
```
|
||||
|
||||
The effective dependency graph is the union of explicit `depends_on` entries and
|
||||
dependencies inferred from `narratio.artifact.<key>` inputs. Inferred and explicit
|
||||
duplicates collapse to one dependency. `depends_on` remains available for an
|
||||
ordering requirement when the upstream output is not supplied as an input.
|
||||
Validation applies the same existence, self-reference, and cycle rules to both
|
||||
kinds of dependency, and diagnostics identify the declaration or input that
|
||||
introduced an invalid edge. Operators no longer need to repeat an artifact input in
|
||||
`depends_on` merely to make that input valid.
|
||||
|
||||
## Effective Inspection, Digests, and Profile Diff
|
||||
|
||||
ConfigKit results are the authority for composed input presence, base/profile
|
||||
origins, diagnostics, and the initial typed effective configuration. Narratio's
|
||||
configuration commands adapt those results to existing application-facing command
|
||||
formats rather than exposing ConfigKit implementation types directly.
|
||||
|
||||
Party-dependent artifact families are expanded only after the selected campaign and
|
||||
canonical party are known. That derived state is intentionally outside ConfigKit's
|
||||
completed load snapshot. Narratio retains a narrow application-owned layer that:
|
||||
|
||||
- records generated family/member and publish-rule attribution;
|
||||
- projects the final post-expansion pipeline for operator inspection;
|
||||
- recomputes the effective pipeline digest after expansion; and
|
||||
- ensures stages and adapters receive only the final concrete configuration.
|
||||
|
||||
Narratio continues to own semantic `config diff` behavior because ConfigKit does
|
||||
not provide configuration diffs or fingerprints. Each side of a profile comparison
|
||||
may be loaded independently through ConfigKit. The former guarantee that both sides
|
||||
come from one parsed source snapshot is removed; operators must not rely on a
|
||||
meaningful comparison while concurrently modifying its source files. Equality is
|
||||
still based on normalized effective values rather than YAML formatting or source
|
||||
layout.
|
||||
|
||||
## Credential and Secrets Convention
|
||||
|
||||
Raw API keys and other credential values remain prohibited in pipeline, campaign,
|
||||
session, generated configuration, manifests, diagnostics, examples, and logs.
|
||||
Credential-bearing configuration fields use ConfigKit secret references: YAML
|
||||
contains an environment-variable name, while the resolved value remains available
|
||||
only through ConfigKit's trusted typed boundary. Fields should use credential names
|
||||
rather than legacy names ending in `_env` when the v2 schema is updated.
|
||||
|
||||
The `pipeline.secrets.env_dir` stanza is removed. Narratio selects a secrets
|
||||
directory before parsing the pipeline using this precedence:
|
||||
|
||||
1. an explicitly supplied `--secrets-dir PATH`; otherwise
|
||||
2. the `secrets` subdirectory beside the selected root pipeline file.
|
||||
|
||||
The conventional directory is derived from the resolved root pipeline path, never
|
||||
from the current working directory, an imported file, or a selected profile. A
|
||||
relative explicit CLI path is resolved from the caller's current working directory,
|
||||
consistent with an ordinary command-line path argument.
|
||||
|
||||
If an explicitly selected directory is missing or invalid, the command fails with a
|
||||
clear bootstrap diagnostic. If the conventional directory is absent, Narratio
|
||||
performs no directory bootstrap and continues; pre-existing environment variables
|
||||
may satisfy all references. If the conventional directory exists but ConfigKit
|
||||
rejects or cannot read it, the command fails. Already-set environment variables,
|
||||
including empty values, retain ConfigKit's documented precedence.
|
||||
|
||||
Narratio explicitly calls `configkit.LoadSecretsDir` at most once for a command,
|
||||
before the first ConfigKit load that may resolve secret references and before any
|
||||
concurrent work or runtime client initialization. This application-level convention
|
||||
does not make ConfigKit's ordinary `Load` mutate process-global state. The bootstrap
|
||||
report is rendered or summarized through a safe Narratio command boundary without
|
||||
secret values.
|
||||
|
||||
Narratio adopts ConfigKit's secret-directory rules and limits without restoring its
|
||||
old permission-mode, selected-directory-symlink, invalid-entry, byte-limit, or
|
||||
newline-trimming behavior through compatibility wrappers. Configuration inspection
|
||||
may require the referenced environment variables or the selected secrets directory
|
||||
because an ordinary successful ConfigKit load resolves effective typed secret
|
||||
references. Inspection output may show non-secret reference names where appropriate,
|
||||
but must never expose resolved values.
|
||||
|
||||
## Validation and Diagnostics
|
||||
|
||||
Narratio's semantic rules remain recognizable application code registered with the
|
||||
appropriate ConfigKit definition. Validation continues to reject invalid pipeline,
|
||||
campaign, session, party, adapter, publish, and artifact relationships before
|
||||
runtime work where practical. Validation order and exact prose are not compatibility
|
||||
contracts.
|
||||
|
||||
ConfigKit diagnostic codes, paths, and source locations should be preserved through
|
||||
the application error boundary where useful. Narratio adds command context and exit
|
||||
classification but does not parse ConfigKit error strings or expose raw parser
|
||||
errors. Secret values must remain absent from success and failure diagnostics.
|
||||
|
||||
## Compatibility and Migration
|
||||
|
||||
This work intentionally changes supported platforms, pipeline composition syntax,
|
||||
profile layout and selection, secret configuration, YAML semantics, diagnostics,
|
||||
and some inspection prerequisites. It is released as Narratio v2.0.0.
|
||||
|
||||
There is no runtime compatibility layer for the old `composition` or
|
||||
`pipeline.secrets` syntax. Maintained examples are converted to the ConfigKit
|
||||
format, and the v2 release documentation provides a concise migration guide that
|
||||
covers:
|
||||
|
||||
- converting imports to `configkit.imports` declarations;
|
||||
- folding the production overlay into the base configuration;
|
||||
- placing testing overrides in `profiles/testing.yml`;
|
||||
- moving ordinary artifacts and artifact families into the recommended individual
|
||||
definition files and using their map keys as canonical identities;
|
||||
- removing redundant `depends_on` entries when a configured-artifact input now
|
||||
supplies the same dependency;
|
||||
- moving secret files to the conventional sibling `secrets/` directory or using
|
||||
`--secrets-dir`;
|
||||
- renaming credential-reference fields where applicable;
|
||||
- ConfigKit's stricter YAML and explicit-value semantics;
|
||||
- the independent-load behavior of `config diff`; and
|
||||
- the removal of Windows and macOS amd64 artifacts.
|
||||
|
||||
Obsolete application implementation, tests, documentation, examples, CI paths, and
|
||||
release machinery are removed rather than left deprecated inside the v2 codebase.
|
||||
|
||||
## Required Changes
|
||||
|
||||
- Add and pin the ConfigKit module dependency and establish a small application
|
||||
integration boundary for definitions, result adaptation, and diagnostics.
|
||||
- Replace Narratio's generic YAML parsing, composition, import, profile, presence,
|
||||
and provenance implementation with ConfigKit public APIs.
|
||||
- Express existing defaults, presence-sensitive normalization, and semantic
|
||||
validation through ConfigKit definitions without moving Narratio domain policy
|
||||
into the library.
|
||||
- Adapt pipeline/campaign/session/party loading and preserve the existing
|
||||
application resolution steps that occur after their independent typed loads.
|
||||
- Rework effective configuration, sources, and diff commands around ConfigKit
|
||||
results plus Narratio's post-party generated-attribution layer.
|
||||
- Add the maintained per-artifact and per-family file layouts, the read-only
|
||||
`config artifacts` catalog, and effective dependency inference from configured
|
||||
artifact inputs.
|
||||
- Convert credential configuration to typed references, add the common
|
||||
`--secrets-dir` option, implement conventional-directory selection, and replace
|
||||
the custom API-key directory loader with `configkit.LoadSecretsDir`.
|
||||
- Update adapters to consume resolved credentials or safe environment reference
|
||||
names from typed configuration without independently rediscovering secret values.
|
||||
- Remove Windows and macOS amd64 build targets, release assets, CI entries,
|
||||
platform-specific implementation, and obsolete tests and documentation.
|
||||
- Convert maintained examples and add v2 migration and release documentation.
|
||||
- Retain focused Narratio tests for domain policy and assembled application
|
||||
behavior while deleting parser/composer/bootstrap mechanism tests now owned by
|
||||
ConfigKit. Tests remain deterministic, offline, credential-free, and aligned with
|
||||
the repository testing policy.
|
||||
- Update canonical architecture, configuration, CLI, operations, development,
|
||||
internal, troubleshooting, and release documentation only as the corresponding
|
||||
behavior is implemented.
|
||||
|
||||
## Target End State
|
||||
|
||||
Narratio v2.0.0 has one configuration infrastructure dependency and no duplicate
|
||||
YAML composition subsystem. Operators maintain a production base configuration,
|
||||
optional explicit imports, and fixed named overlays such as `testing`. ConfigKit
|
||||
provides strict parsing, composition, typed loading, provenance, diagnostics, and
|
||||
credential resolution; Narratio supplies domain meaning and its post-campaign
|
||||
artifact expansion.
|
||||
|
||||
Operators can add an artifact by creating one discoverable YAML file whose map key
|
||||
is its stable Narratio identity. `config artifacts` exposes that key, its canonical
|
||||
source ID, origin, output, and effective dependency set. A later artifact can reuse
|
||||
the output through `narratio.artifact.<key>` without repeating the same edge in
|
||||
`depends_on`, while explicit ordering-only dependencies remain supported.
|
||||
|
||||
Secrets normally live in a predictable `secrets/` directory beside the selected
|
||||
pipeline, with `--secrets-dir` available for deployment-specific placement. Raw
|
||||
credential values never enter YAML or inspection output. The bootstrap is explicit
|
||||
in application startup and ordinary ConfigKit loading remains free of process-global
|
||||
mutation.
|
||||
|
||||
Release and CI maintenance covers Linux amd64 and arm64 as the primary platforms
|
||||
and macOS arm64 on a best-effort basis. Windows and macOS amd64 carry no remaining
|
||||
support or release burden. The repository contains no legacy configuration parser,
|
||||
profile compatibility layer, obsolete platform implementation, or redundant tests
|
||||
for behavior owned by ConfigKit.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- A ConfigKit reference-only inspection or typed prepare/finalize lifecycle may be
|
||||
considered upstream if credential-free inspection becomes a demonstrated shared
|
||||
requirement. Narratio does not implement a private substitute in this migration.
|
||||
- ConfigKit hot reload, remote providers, automatic configuration migration, and
|
||||
application command frameworks remain outside Narratio's scope.
|
||||
- Native macOS arm64 CI may be added later when a reliable runner is available; it
|
||||
is not required for v2.0.0.
|
||||
@@ -1,522 +0,0 @@
|
||||
# ConfigKit Adoption Implementation Plan
|
||||
|
||||
Status: Complete
|
||||
|
||||
Implement the [ConfigKit adoption roadmap](configkit-adoption.md) in the order
|
||||
below. The target is Narratio v2.0.0: ConfigKit v0.1.1, the reduced platform
|
||||
matrix, and the artifact-authoring workflow described there. Each stage is a
|
||||
bounded implementation assignment for a gpt-5.6-terra coding agent. Mark a stage
|
||||
complete only after its changes and acceptance checks succeed; record a short
|
||||
completion and verification note beside its status.
|
||||
|
||||
## Instructions for every stage
|
||||
|
||||
- Read `docs/development.md` and all three policies under `docs/policy/`.
|
||||
Read the relevant roadmap section and the stage-specific sources listed below.
|
||||
Paths identify starting points, not an exhaustive edit allowlist; inspect callers
|
||||
and tests before changing a package boundary.
|
||||
- Use the tagged ConfigKit public API. The local `../ConfigKit` checkout is useful
|
||||
reference material, but verify its version before relying on it. The module
|
||||
cache for v0.1.1 is an alternative. Do not require a sibling checkout, a local
|
||||
`replace`, a Go workspace, an upstream patch, or an unpinned revision to build.
|
||||
- Keep Narratio domain policy in `internal/config`, startup and selectors in
|
||||
`internal/app`, and credential delivery in the owning adapters. Do not build
|
||||
a general configuration framework around ConfigKit.
|
||||
- Keep each stage buildable and run focused behavioral tests for its changed
|
||||
boundary. Temporary migration helpers may exist between stages only where
|
||||
named here; they must not introduce runtime format auto-detection or fallback.
|
||||
- Update each canonical current-behavior document when its behavior changes.
|
||||
The roadmap remains the owner of unimplemented work. Preserve historical
|
||||
release notes; their descriptions of old platforms and formats are history.
|
||||
- Tests use real ConfigKit and temporary files, with fake external adapters or
|
||||
storage where needed. Use synthetic credentials and isolate environment
|
||||
mutation; such tests must not run in parallel. Retain domain and security
|
||||
coverage, remove superseded mechanism tests, and avoid exact diagnostic prose
|
||||
or private callback choreography assertions.
|
||||
- Do not tag or publish v2.0.0 as part of this plan. Release preparation belongs
|
||||
here; release authorization and tag publication are a later task. The existing
|
||||
release completion boundary remains successful upstream tag push, with no CI
|
||||
polling or waiting.
|
||||
|
||||
## Decisions that apply across stages
|
||||
|
||||
The roadmap is authoritative for product scope. These implementation choices
|
||||
make that scope concrete:
|
||||
|
||||
- Enable ConfigKit imports and profile selection for pipeline files only.
|
||||
Campaign, session, and party are single-source definitions with imports and
|
||||
profiles disabled. Preserve their domain schemas and source-relative inputs.
|
||||
Omitted or empty profile and the exact name `default` select the base; other
|
||||
names use ConfigKit's fixed `profiles/<name>.yml` convention.
|
||||
- Defaults apply to absent fields. Explicit empty/zero/false/null values follow
|
||||
ConfigKit decoding and then domain validation; do not refill invalid explicit
|
||||
values with legacy defaults. Optional null stage sections remain absent;
|
||||
required sections fail domain validation rather than being resurrected.
|
||||
- Keep ConfigKit metadata/result snapshots separate from runtime-only Narratio
|
||||
state. ConfigKit discards ignored and unexported fields when detaching values;
|
||||
attach root paths, retained party bytes, family catalogs, and digest metadata
|
||||
after loading. No validator may mutate bound configuration.
|
||||
- Use one Narratio-owned safe effective projection for the final expanded
|
||||
pipeline. It contains configuration values and credential reference names,
|
||||
never resolved credential bytes. It feeds show, semantic diff, digest, and
|
||||
catalog data. ConfigKit metadata remains the authority for input provenance;
|
||||
Narratio adds only domain-generated attribution.
|
||||
- Rename `storage.s3.access_key_id_env` to `access_key_id`,
|
||||
`storage.s3.secret_access_key_env` to `secret_access_key`, and
|
||||
`audita.llm_api_key_env` to `llm_api_key`. Each is an optional `*configkit.Secret`.
|
||||
An explicit reference always resolves during loading, even if the associated
|
||||
stage will not execute. Require both S3 fields together or neither. With neither,
|
||||
retain the AWS SDK default credential chain; do not synthesize mandatory secret
|
||||
references that would break role-based authentication or local-only inspection.
|
||||
With no Audita reference, preserve its existing downstream credential discovery.
|
||||
Explicit references replace Narratio's old per-adapter environment lookup.
|
||||
- Keep absent S3 credentials out of defaults; migration examples explicitly name
|
||||
the existing AWS variables when static credentials are intended. A non-nil
|
||||
missing/empty reference fails instead of silently falling back. This preserves
|
||||
provider-managed credentials while making explicit configuration trustworthy.
|
||||
- `config artifacts` uses the existing configuration command selectors and text
|
||||
output convention. No new JSON mode, artifact creation wizard, inferred filename
|
||||
identity, or profile catalog is added.
|
||||
- Preserve the roadmap's unique base artifact definition rule explicitly:
|
||||
ConfigKit rejects conflicting leaves but can compose disjoint children of the
|
||||
same mapping. Use its public source metadata to reject an ordinary artifact key
|
||||
declared by multiple base files, even if their fields are disjoint. Apply the
|
||||
same rule to family keys. A selected profile may override or extend a definition.
|
||||
This is a narrow Narratio declaration rule, not a different merge algorithm.
|
||||
- Relax only the obsolete configuration/secrets filesystem restrictions identified
|
||||
by the roadmap. Do not remove session restore, cleanup, publication, workspace
|
||||
confinement, or ordinary shared-workspace mode guarantees as collateral work.
|
||||
|
||||
## Stage 1 — Reduce the platform and release matrix
|
||||
|
||||
Status: Complete — Reduced CI and release output to Linux amd64/arm64 and macOS
|
||||
arm64, removed Windows-only runtime implementations and accommodations, and
|
||||
verified focused tests, all three cross-builds, exact asset names, and embedded
|
||||
version reporting.
|
||||
|
||||
Read: roadmap “Platform Contract”; `scripts/build-release-assets.sh`,
|
||||
`scripts/check-release-candidate.sh`, `.woodpecker/`, `internal/releasecheck`,
|
||||
`docs/release.md`, and platform-specific files in application, file operations,
|
||||
and subprocess packages.
|
||||
|
||||
1. Make Linux amd64, Linux arm64, and macOS arm64 the only maintained build and
|
||||
release targets. Remove Windows suffix/packaging branches and macOS amd64
|
||||
targets from scripts, workflows, tests, and current documentation.
|
||||
2. Delete Windows-only implementations and tests; simplify paired build-tag
|
||||
files where the remaining implementations can be shared. Preserve portable
|
||||
code when retaining it adds no special support machinery. Preserve functioning
|
||||
Unix subprocess cancellation, locking, and file operations on both supported OSes.
|
||||
3. Retain the local Linux verification gates and cross-build gates for all three
|
||||
published assets. Do not introduce a native macOS runner requirement or claim
|
||||
that cross-compilation verifies runtime behavior.
|
||||
4. Update architecture/operations platform statements and contributor/release
|
||||
guidance. Keep existing release protections and the no-CI-wait boundary intact.
|
||||
|
||||
Acceptance: focused platform/subprocess/fileops/releasecheck tests pass; build all
|
||||
three targets with `CGO_ENABLED=0`; verify the asset builder produces exactly the
|
||||
three expected versioned binaries and the host binary reports the supplied version.
|
||||
Existing release tests still prove guarded publication. No current support promise
|
||||
or asset list retains Windows or macOS amd64.
|
||||
|
||||
## Stage 2 — Separate configuration normalization from validation
|
||||
|
||||
Status: Complete — Added explicit deterministic pipeline normalization before
|
||||
validation, made Notarius validation read-only on accepted and rejected inputs,
|
||||
documented the boundary, and verified config race tests plus app/stage consumers.
|
||||
|
||||
Read: `internal/config/load.go`, `defaults.go`, `normalize.go`, `validate*.go`,
|
||||
`artifact_families.go`, and ConfigKit's “Recorded application resolution”,
|
||||
“Semantic validation”, and “Defaults and typed decoding” contracts.
|
||||
|
||||
1. Separate value-producing normalization from read-only validation, including
|
||||
Notarius reference/output normalization currently performed by
|
||||
`validateNotarius`. Audit all validators for map, slice, pointer, and scalar
|
||||
mutations, including changes that would occur only on error paths.
|
||||
2. Keep reusable domain normalization ordinary package code. Validation checks the
|
||||
already normalized candidate and must work for both loaded and programmatically
|
||||
constructed values without silently fixing them.
|
||||
3. Inventory defaults in `load.go`: static defaults, per-map-entry/list-element
|
||||
defaults, and inherited or presence-sensitive defaults. Retain named constants
|
||||
in `defaults.go`. This inventory should guide code and concise comments, not
|
||||
become a permanent parallel defaults document.
|
||||
4. Keep the current loader calling normalization explicitly before validation
|
||||
during this preparatory stage. The ConfigKit resolver replaces that loading
|
||||
choreography in Stage 4. Preserve family expansion as an explicit domain step.
|
||||
|
||||
Acceptance: focused configuration tests prove normalization is deterministic and
|
||||
validation does not mutate accepted or rejected inputs. Cover representative
|
||||
Notarius normalization/collision handling and existing artifact/publish rules.
|
||||
Do not add a separate mutation test for every trivial validator.
|
||||
|
||||
## Stage 3 — Replace composition-dependent effective projection
|
||||
|
||||
Status: Complete — Introduced one typed safe projection for effective YAML,
|
||||
semantic values, source paths, and the versioned v3 digest; removed composition
|
||||
parsing from those consumers and verified ordering, expansion, relocation,
|
||||
nil/empty, exact-integer, CLI diff, and semantic-resume behavior.
|
||||
|
||||
Read: `internal/config/effective_pipeline.go`, `effective_digest.go`,
|
||||
`pipeline_composition.go`, `internal/app/config_commands.go`, the config-diff
|
||||
implementation, and `docs/internal/configuration.md`.
|
||||
|
||||
1. Introduce one package-owned, schema-specific safe projection of the effective
|
||||
pipeline. Use explicit typed projections with stable configuration field names;
|
||||
preserve null versus empty containers and exact integers. Ordinary nested
|
||||
configuration types may be reused where they contain no runtime state.
|
||||
Credential fields must be projected explicitly as reference names when Stage 7
|
||||
changes their types. Do not introduce a generic reflection schema engine.
|
||||
2. Render this projection as deterministic YAML using the existing YAML encoder;
|
||||
encoding output is not a replacement YAML loader. Produce semantic records
|
||||
directly from the safe projection using standard JSON encoding/decoding with
|
||||
number preservation if a plain value tree is useful. Do not reparse YAML or
|
||||
reconstruct `compositionNode` trees to compare or hash effective values.
|
||||
3. Make show, diff values, and digest use that common projection. Mapping order is
|
||||
immaterial; sequences remain atomic for diff; nil and empty remain distinct.
|
||||
Exclude family declarations once expanded, all runtime metadata, profile names,
|
||||
source locations, and raw credentials. Preserve logical Notarius path handling
|
||||
so moving an equivalent bundle does not change its digest.
|
||||
4. Version the changed digest encoding explicitly using the package's digest
|
||||
version constant. Digest/profile metadata remains provenance, never a blanket
|
||||
resume cache key. Keep stage/artifact semantic evidence behavior intact.
|
||||
5. Decouple enumeration of effective field paths from the legacy composition
|
||||
renderer. Input ownership can still come from the current loader until Stage 4.
|
||||
|
||||
Acceptance: show/diff/digest tests cover equivalent monolithic/split values,
|
||||
different map insertion order, null/empty distinction, family-expanded outputs,
|
||||
and bundle relocation. A meaningful artifact change changes the digest; formatting
|
||||
and profile naming alone do not. Existing semantic resume tests pass. No effective
|
||||
projection consumer needs the custom composition parser/renderer.
|
||||
|
||||
## Stage 4 — Cut pipeline loading over to ConfigKit
|
||||
|
||||
Status: Complete — Replaced pipeline composition with ConfigKit v0.1.1 imports,
|
||||
fixed-path profile selection, fresh typed defaults, recorded resolution, source
|
||||
metadata adaptation, and duplicate base artifact/family enforcement; removed the
|
||||
legacy parser and verified all tests plus the configuration race suite.
|
||||
|
||||
Read: ConfigKit v0.1.1 `docs/config.md`, `docs/consumers/README.md`, and its
|
||||
Narratio-shaped compiled example; Narratio `internal/config/load.go`,
|
||||
`composition.go`, `pipeline_composition.go`, and profile/composition tests.
|
||||
|
||||
1. Pin `gitea.maximumdirect.net/eric/ConfigKit v0.1.1` in `go.mod`/`go.sum`.
|
||||
Implement a small pipeline definition using `Load`, `File`, enabled imports,
|
||||
`Options.Profile`, defaults, recorded resolution, and read-only validation.
|
||||
2. Migrate static defaults to fresh default values. Apply map-entry, list-element,
|
||||
and inherited defaults with `Resolution.Default`/`IsExplicit`; use recorded
|
||||
normalization for transformations from Stage 2. Do not call old post-decode
|
||||
zero-filling defaults. Remove `NormalizeConfig.UnmarshalYAML` and its private
|
||||
presence bit. Include publication list-entry defaults, optional section
|
||||
allocation, and trim's Scriptorium requirement in this conversion.
|
||||
3. Perform file-local semantic checks in the definition. Defer checks needing
|
||||
canonical party expansion or campaign/session context to the existing explicit
|
||||
resolution boundary; do not reject references to future concrete family members
|
||||
merely because the campaign has not loaded yet. Run full checks after expansion.
|
||||
4. Replace pipeline composition and profile loaders. `configkit.imports` is the
|
||||
only directive; no `composition` or `_configkit` alias is accepted. Pass profile
|
||||
selection directly under the agreed convention; unselected overlays are not read.
|
||||
`LoadPipelineProfilePair` may remain a thin pair of independent loads, with no
|
||||
shared parse cache or paired source-snapshot promise.
|
||||
5. Adapt ConfigKit sources and metadata to Narratio's existing reporting records.
|
||||
Translate paths structurally using public path APIs, not error-message parsing.
|
||||
Preserve multiple contributing locations for composite/profile-owned mappings
|
||||
and distinguish defaults/resolution from authored values.
|
||||
Check artifact/family map-entry ownership for duplicate base declarations using
|
||||
those locations; ignore synthetic defaults and allow the selected overlay.
|
||||
6. Attach runtime metadata only after load succeeds. Preserve root-relative runtime
|
||||
path interpretation and capture logical paths before making them absolute.
|
||||
Retain family declarations and original ConfigKit metadata for later expansion.
|
||||
7. Replace generic mechanism tests with a small integration set proving actual
|
||||
Narratio wiring. Convert fixtures affected by this stage. Delete
|
||||
`composition.go`, obsolete pipeline traversal/overlay/presence machinery, and
|
||||
their redundant tests once the callers are migrated.
|
||||
|
||||
Acceptance: a real root with imports and a testing overlay loads; duplicate claims
|
||||
fail with useful source attribution, including two base files contributing disjoint
|
||||
fields to one artifact or family key; base/default selection works; malformed
|
||||
unselected profiles do not fail base loading. Omitted defaults and representative
|
||||
explicit false/zero/empty/null inputs obey the new semantics. Invalid domain values
|
||||
still fail. Commands use the new pipeline loader with no format fallback. Update
|
||||
`docs/config.md` and configuration internals for the implemented boundary.
|
||||
|
||||
## Stage 5 — Migrate campaign, session, and canonical party loading
|
||||
|
||||
Status: Complete — Migrated campaign, session, and canonical-party documents to
|
||||
independent ConfigKit definitions, preserved bounded session inspection, exact
|
||||
party bytes and metadata-derived authored order, and retained identity, source,
|
||||
family-expansion, and validation behavior; verified all tests and focused races.
|
||||
|
||||
Read: `internal/config/load.go`, `party.go`, `party_resolution.go`,
|
||||
`artifact_families.go`, session-template tests, `internal/app/config_loader.go`,
|
||||
and ConfigKit source/schema/version contracts.
|
||||
|
||||
1. Use separate ConfigKit definitions for campaign, session, and party. Replace
|
||||
`decodeStrictYAML*` and the custom party YAML-node parser. Model the party input
|
||||
as the existing string schema version and character-keyed typed map, with the
|
||||
existing `alias` list, ordered class list, and optional positive class level.
|
||||
2. Keep party domain checks: valid IDs, required display names and classes,
|
||||
trimmed/control-free names, case-insensitive name/alias ambiguity, and duplicate
|
||||
class checks. Validate the required version through `VersionRule`. Keep an
|
||||
exact independent copy of authored bytes outside ConfigKit's schema object.
|
||||
3. Preserve authored character order using successful metadata source coordinates
|
||||
for the single party input when constructing `CanonicalParty.Characters`.
|
||||
Preserve class/alias sequence order naturally. Family expansion and players
|
||||
projection continue using their existing deterministic ID sorting. Do not keep
|
||||
a second YAML parse just to recover mapping order.
|
||||
4. Preserve the narrow session placeholder rejection before typed load, expected
|
||||
session/previous-session identity checks, campaign selection, stable-input
|
||||
precedence, and local versus remotely downloaded session provenance.
|
||||
Retain the initially loaded pipeline/campaign during remote session acquisition.
|
||||
5. Where bytes must be retained or inspected before `ConfigKit.Bytes`, bound the
|
||||
initial read too, using the existing fileops bounded-read helper and a named
|
||||
package constant of 8 MiB matching the pinned source limit. Otherwise prefer
|
||||
`ConfigKit.File`. Do not read an unbounded file and rely on later rejection.
|
||||
6. Complete post-party family and publish-rule expansion, then full cross-config
|
||||
validation and digest refresh. Keep generated origins outside the library
|
||||
snapshot. Remove unused decoder plumbing and obsolete party parsing tests.
|
||||
|
||||
Acceptance: retained authored party bytes are identical; domain errors, aliases,
|
||||
class order, family origins, and derived players are correct. Local and remote
|
||||
session loads preserve identity, input path bases, and the original pipeline.
|
||||
Unknown fields and disabled directives fail through ConfigKit. Focused config,
|
||||
prepare/extract, and application loading tests pass.
|
||||
|
||||
## Stage 6 — Move secrets bootstrap before all configuration loads
|
||||
|
||||
Status: Complete — Added invocation-scoped ConfigKit secrets bootstrap before
|
||||
all pipeline loads and adapter construction, exposed consistent explicit and
|
||||
conventional directory selection across the CLI, removed the retired pipeline
|
||||
schema and custom loader, and verified focused, full, and race test suites.
|
||||
|
||||
Read: roadmap “Credential and Secrets Convention”; ConfigKit's bootstrap
|
||||
contract; `internal/app/secrets_env*.go`, `config_loader.go`, `config_commands.go`,
|
||||
`object_store.go`, command dependencies, and command argument owners.
|
||||
|
||||
1. Add a shared `--secrets-dir` selector to every CLI path that loads a pipeline,
|
||||
including configuration inspection/diff, execution aliases, planning, publish,
|
||||
and session/storage operations. Route all through one command-owned bootstrap
|
||||
context. Help/version and commands that do not load configuration do not bootstrap.
|
||||
2. Discover the root pipeline first. An explicit relative directory is based on
|
||||
caller CWD; otherwise choose `secrets/` beside the resolved root pipeline.
|
||||
An explicitly empty flag is invalid. Missing conventional directories are
|
||||
skipped; missing explicit directories and unreadable/invalid existing directories
|
||||
fail. Use ConfigKit's selected-directory symlink behavior without custom rejection.
|
||||
3. Call `LoadSecretsDir` at most once per command before any ConfigKit load or
|
||||
adapter creation, including both loads of config diff and remote session lookup.
|
||||
Use invocation state, not a package-global `sync.Once`. A new command gets its
|
||||
own bootstrap; library config loaders themselves never mutate the environment.
|
||||
4. Present safe bootstrap warnings/failures through stderr or the existing safe
|
||||
diagnostic boundary, never into YAML show output. Preserve the partial report
|
||||
on bootstrap failure. Do not add rollback or reinterpret ConfigKit file rules.
|
||||
5. Remove late bootstrap calls in object-store and runner composition, the custom
|
||||
directory reader and permission platform files, `pipeline.secrets`, its defaults
|
||||
and validators, and their obsolete tests. Convert affected fixtures immediately.
|
||||
|
||||
Acceptance: test absent conventional directory, explicit override from another
|
||||
CWD, invalid explicit directory, already-set empty/nonempty environment values,
|
||||
and bootstrap failure before adapter construction. A diff invocation and a remote
|
||||
session acquisition each bootstrap once. Config inspection requires no session,
|
||||
workspace writes, manifests, or runtime clients. Update CLI, config, operations,
|
||||
architecture, and troubleshooting to the new bootstrap/permission contract.
|
||||
|
||||
## Stage 7 — Resolve typed credentials and protect all output boundaries
|
||||
|
||||
Status: Complete — Migrated S3 and Audita credentials to optional ConfigKit
|
||||
typed references, passed resolved values directly to runtime adapters, preserved
|
||||
the AWS default provider chain when unconfigured, and verified reference-only
|
||||
inspection, provenance, generated configuration, manifest, and diagnostic
|
||||
boundaries with focused, full, and race test suites.
|
||||
|
||||
Read: ConfigKit “Typed secret references” and rendering contracts;
|
||||
`internal/config/config.go`, `defaults.go`, `validate_adapters.go`, the safe
|
||||
projection from Stage 3, `internal/adapters/storage/s3_backend.go`,
|
||||
`internal/adapters/audita/subprocess.go`, and their composition callers.
|
||||
|
||||
1. Implement the three renamed optional typed fields and S3 pair policy above.
|
||||
Reject old `_env` schema keys; remove their default-name and lookup helpers.
|
||||
Convert maintained fixtures with synthetic environment setup where references
|
||||
are present. Do not add credentials to unrelated local pipeline fixtures.
|
||||
2. Pass resolved S3 values to the SDK adapter's explicit static provider only when
|
||||
the pair is configured. With neither, preserve its default provider chain.
|
||||
Pass Audita the resolved value and safe reference name; deliver the value only
|
||||
through the child environment's existing `AUDITA_LLM_API_KEY` override.
|
||||
Do not re-read the environment for an explicitly resolved reference.
|
||||
3. Adapt every serialization, digest, source/diff output, diagnostic, generated
|
||||
invocation config, manifest, and provenance caller to safe reference names.
|
||||
Never YAML-marshal ConfigKit's opaque secret directly or call `Value()` in an
|
||||
inspection path. Secret changes alone must not alter effective digests or
|
||||
invalidate artifact evidence; reference-name changes may change provenance.
|
||||
4. Keep ConfigKit errors structured and safe. Domain validation messages must
|
||||
avoid raw credential values; do not unwrap private parser errors or interpolate
|
||||
whole trusted configuration structs into error messages.
|
||||
5. Preserve downstream tool ownership for Notarius/Scriptorium credentials that
|
||||
Narratio does not configure. Do not add a parallel credential schema for them.
|
||||
|
||||
Acceptance: use distinctive synthetic secrets to prove no leakage in show, sources,
|
||||
diff, catalog-ready projections, success/failure diagnostics, manifests, or generated
|
||||
Audita configuration. Verify the child receives the credential and an environment
|
||||
change after loading does not change explicit delivery. Test S3 paired references,
|
||||
partial-pair rejection, missing effective references, and nil SDK fallback offline.
|
||||
Update adapter and credential documentation and remove obsolete lookup tests.
|
||||
|
||||
## Stage 8 — Infer and normalize artifact dependencies once
|
||||
|
||||
Status: Complete — Finalized one normalized artifact graph from explicit ordering
|
||||
edges and configured-artifact inputs (including rewritten family members), retained
|
||||
edge origins for diagnostics, and verified focused/full tests plus the full race
|
||||
suite and an assembled input-only analyze workflow.
|
||||
|
||||
Read: `internal/config/validate_scriptorium.go`, `artifact_families.go`,
|
||||
`internal/artifactpolicy`, `internal/artifacts`, analyze planning/execution, and
|
||||
their artifact dependency and semantic-evidence tests.
|
||||
|
||||
1. At the final configuration resolution boundary, union explicit `depends_on`
|
||||
with configured-artifact input references. Use the existing source-ID parser;
|
||||
do not infer edges from filenames, arbitrary strings, or non-artifact inputs.
|
||||
2. Infer after family member inputs become concrete `narratio.artifact.<key>`
|
||||
sources. Normalize, deduplicate, and sort the effective dependency list once.
|
||||
Keep explicit ordering-only dependencies valid. Repeated finalization must not
|
||||
accumulate edges or alter ordering. Store source-edge attribution separately
|
||||
from the effective graph for diagnostics and generated provenance.
|
||||
3. Apply existing existence, self-reference, enabled-dependency, output, and cycle
|
||||
validation to the union. Remove the error requiring an input to be repeated in
|
||||
`depends_on`. Retain diagnostics pointing to the explicit list entry or the
|
||||
input that contributed an invalid edge, including family origin where relevant.
|
||||
4. Ensure execution, targeted analysis, planning, prerequisite closure, reuse,
|
||||
forced regeneration, and semantic evidence consume this same concrete graph.
|
||||
No adapter or command may infer a second graph. Refresh final projection/digest
|
||||
after inference; equivalent explicit and inferred declarations compare equally.
|
||||
|
||||
Acceptance: cover input-only dependency ordering, duplicate explicit/inferred edges,
|
||||
ordering-only edges, missing/self/cyclic references, and rewritten family inputs.
|
||||
Use an assembled fake-artifact workflow to prove upstream output is supplied to a
|
||||
dependent artifact and failure/resume retains successful predecessors. Existing
|
||||
force/selection and invalidation tests remain valid. Update config and analyze
|
||||
internals with the implemented inference rule.
|
||||
|
||||
## Stage 9 — Add the effective artifact catalog command
|
||||
|
||||
Status: Complete — Added the read-only `config artifacts` catalog over finalized
|
||||
configuration, including effective dependencies and definition/family/party
|
||||
provenance; documented it and verified focused, full, and race test suites.
|
||||
|
||||
Read: `internal/app/config_commands.go`, config diff/source rendering, the safe
|
||||
projection and family-origin catalogs, and `docs/cli.md`.
|
||||
|
||||
1. Add `narratio config artifacts` to dispatch and help. Reuse ordinary pipeline,
|
||||
profile, campaign, campaign-file, and secrets-directory parsing/resolution.
|
||||
Resolve a campaign when families require it or when explicitly selected; a
|
||||
pipeline with only ordinary artifacts can be inspected without a campaign.
|
||||
2. Render one stable record per effective concrete artifact in key order with
|
||||
key, `narratio.artifact.<key>` source ID, defining source location(s), enabled
|
||||
state, output path, and sorted effective dependencies. Family members additionally
|
||||
identify family key, character ID, defining family source, and party source.
|
||||
3. Derive identity from the map key. A profile may contribute to an existing
|
||||
artifact, so report contributing definition locations instead of inventing
|
||||
a single filename owner. Use a deterministic escaped tabular text rendering;
|
||||
include a header and no records for an empty catalog. Propagate writer errors.
|
||||
4. Use the same finalized state as show/sources/diff and runtime configuration.
|
||||
No session download, adapter initialization, manifest access, or workspace
|
||||
materialization is allowed. Secret bootstrap remains the agreed startup exception.
|
||||
|
||||
Acceptance: CLI tests cover ordinary and generated artifacts, disabled entries,
|
||||
profile-added/overridden artifacts, empty catalog, no-campaign ordinary inspection,
|
||||
required-campaign failure for families, stable ordering, and no runtime side effects.
|
||||
Check identity stays the same after moving an artifact file without changing its key.
|
||||
Document the command in its canonical CLI owner and link to config layout examples.
|
||||
|
||||
## Stage 10 — Finish the operator layout and v2 migration documentation
|
||||
|
||||
Status: Complete
|
||||
|
||||
Completion: Reworked the maintained bundle around an unprofiled production base,
|
||||
one-entry artifact and family imports, and a testing overlay; updated the example
|
||||
harness and inspection workflows; consolidated the v2 configuration guidance; and
|
||||
added the v2.0.0 operator migration checklist. Focused documentation/example tests,
|
||||
the offline inspection walkthrough, and the full normal/race suites pass.
|
||||
|
||||
Read: roadmap “Artifact Definition Ergonomics” and “Compatibility and Migration”;
|
||||
`examples/README.md`, existing example bundles and their loader test,
|
||||
`docs/config.md`, `docs/operations.md`, `docs/release.md`, and internal configuration
|
||||
and artifact documentation.
|
||||
|
||||
1. Convert maintained pipeline examples to a production base with explicit
|
||||
`configkit.imports`, `artifacts/<key>.yml`, `artifact-families/<key>.yml`, and
|
||||
`profiles/testing.yml`. Each recommended artifact/family file defines exactly
|
||||
one explicit map entry. Fold old production overlays into the base.
|
||||
2. Include an input-only dependency example and at least two character-family
|
||||
examples sharing a campaign-owned party roster. Keep supported inline artifacts
|
||||
legal; file layout is guidance, not a filename-based validation requirement.
|
||||
3. Update the example validation harness to load root configurations and selected
|
||||
testing profiles, not attempt to load each imported fragment as a full pipeline.
|
||||
Use synthetic environment setup for explicit references. No example includes
|
||||
real credentials or depends on local tools/cloud services to validate.
|
||||
4. Consolidate canonical documentation updates from earlier stages. Remove current
|
||||
references to old composition/profile catalogs, default-profile configuration,
|
||||
secrets.env_dir, credential `_env` fields, credential-free inspection guarantees,
|
||||
paired diff snapshots, and removed platform support. Explain explicit-value and
|
||||
null changes without promising reloadable inspection output.
|
||||
5. Add `docs/releases/v2.0.0.md` using the release-note structure, with a concrete
|
||||
migration checklist linking to the canonical config/CLI docs and examples.
|
||||
Include static S3-reference migration versus SDK credential discovery, new
|
||||
inspection prerequisites, artifact input dependency inference, and platform
|
||||
removals. Do not claim publication or edit older immutable release notes.
|
||||
|
||||
Acceptance: `go test ./internal/doccheck` and
|
||||
`go test ./internal/config -run '^TestExamplesLoadAndValidate$'` pass. Walk through
|
||||
the new operator examples with config validate/show/sources/diff/artifacts offline.
|
||||
Their identities, family members, origins, and testing overrides agree.
|
||||
|
||||
## Stage 11 — Verify the complete migration and remove remaining scaffolding
|
||||
|
||||
Status: Complete
|
||||
|
||||
Completion: Audited the accepted roadmap and the full change set from `1558c9c`;
|
||||
no legacy configuration loader, profile/composition traversal, presence store,
|
||||
custom secrets loader, or production YAML decoder remains. The migration removes a
|
||||
net 1,235 production and 843 test Go lines; remaining configuration code owns domain
|
||||
defaults, normalization, validation, expansion, provenance, and safe projections.
|
||||
Go 1.26.7 module hygiene, tests, race tests, vet, build, documentation, examples,
|
||||
and the v2.0.0 release-candidate checker pass. Linux amd64 was tested natively;
|
||||
Linux arm64 and macOS arm64 release assets were cross-built, with native macOS
|
||||
arm64 validation remaining best effort and outside the release gate.
|
||||
|
||||
Read: the whole feature roadmap, completed stage notes, and the current test and
|
||||
release procedures. Review the diff from the commit introducing this plan.
|
||||
|
||||
1. Audit every roadmap requirement against current code and executable evidence.
|
||||
Remove temporary migration adapters and any residual generic YAML parser,
|
||||
composer, profile traversal, explicit-presence store, old secrets loader,
|
||||
decoder hooks, or unused tests. `gopkg.in/yaml.v3` may remain for output encoding
|
||||
and unrelated external formats; it must not supply a second configuration loader.
|
||||
2. Confirm result-owned snapshots versus final expanded configuration are handled
|
||||
consistently; every inspection/runtime path sees normalized inferred dependencies
|
||||
and generated publish rules. Recheck secret safety and semantic resume boundaries.
|
||||
3. Inspect current platform conditions and asset lists. Retain historical notes but
|
||||
no active Windows/macOS amd64 obligation. Compare owned production/test code
|
||||
before and after; report removals and any remaining complexity with reasons.
|
||||
Do not chase a LOC quota by removing useful domain checks or regressions.
|
||||
4. Run the repository gates with the maintained Go 1.26 toolchain:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
go test ./internal/doccheck
|
||||
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||
```
|
||||
|
||||
5. Run `scripts/check-release-candidate.sh v2.0.0` after the release note and all
|
||||
candidate changes exist. This also verifies module hygiene and all three
|
||||
release assets with `GOWORK=off`. Resolve failures; do not bypass checks or
|
||||
publish a tag. Record native versus cross-build evidence accurately.
|
||||
|
||||
Acceptance: all gates pass and every roadmap outcome has direct evidence. No
|
||||
dependency on the sibling ConfigKit checkout remains. Record completion and any
|
||||
best-effort platform limitations, then hand off for a separate release decision.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None required before implementation. The agreed roadmap and the explicit choices
|
||||
above resolve the migration decisions. Upstream credential-free inspection and
|
||||
native macOS arm64 CI remain deferred, not prerequisites for this plan.
|
||||
@@ -1,331 +0,0 @@
|
||||
# ConfigKit Migration Review — Required Remediations
|
||||
|
||||
Reviewed 2026-09-07 at `f7a40af`, against the changes after `1558c9c`, the
|
||||
[feature roadmap](configkit-adoption.md), and the completed
|
||||
[implementation plan](implementation.md).
|
||||
|
||||
The main migration is implemented. The following findings are reproducible gaps
|
||||
in the intended behavior, not proposals to restore retired compatibility or
|
||||
filesystem restrictions. P2 denotes a correctness issue to fix before release;
|
||||
P3 denotes an operator-facing completeness issue also required by the accepted
|
||||
plan. No application code was changed during this review.
|
||||
|
||||
## 1. Derive explicit publish destinations after family expansion
|
||||
|
||||
Status: Resolved — P2
|
||||
|
||||
Resolution: Missing destinations are now derived through one shared domain helper
|
||||
both during ConfigKit resolution and again after concrete family expansion. Explicit
|
||||
destinations remain unchanged, while unknown sources still fail read-only publish
|
||||
validation. Regression coverage verifies resolved runtime and effective inspection
|
||||
output for generated members, explicit overrides, and unknown sources.
|
||||
|
||||
**Evidence:** `internal/config/pipeline_configkit.go:208` derives missing publish
|
||||
destinations during the ConfigKit resolver. A concrete family member does not
|
||||
exist yet, so the resolver ignores the failed derivation. After family expansion,
|
||||
`internal/config/artifact_families.go:141` calls the now-read-only publish
|
||||
validator, which rejects the still-empty destination. The previous implementation
|
||||
could derive it during that later validation.
|
||||
|
||||
**Reproduction:** In an otherwise valid pipeline, define a family `meta` over
|
||||
`party.characters`, with output pattern `artifacts/{character_id}.md`. Select a
|
||||
valid campaign whose party contains `alice`, and add:
|
||||
|
||||
```yaml
|
||||
publish:
|
||||
outputs:
|
||||
- source: narratio.artifact.meta_alice
|
||||
```
|
||||
|
||||
`config validate --config <pipeline> --campaign-file <campaign>` fails with
|
||||
`pipeline.publish.outputs[0].dest is required when destination cannot be derived
|
||||
from "narratio.artifact.meta_alice"`. Adding `dest: artifacts/alice.md` makes the
|
||||
same configuration pass. The destination is therefore available and derivable;
|
||||
the lifecycle omits the required final normalization step.
|
||||
|
||||
**Required change:** Complete publish destination resolution after concrete
|
||||
artifacts exist, before publish validation and final projection/digest generation.
|
||||
Share the domain derivation logic and keep validation read-only. Preserve
|
||||
explicit destinations and generated family publish-rule collision checks. Do not
|
||||
require redundant `dest` fields or revive mutation inside validators.
|
||||
|
||||
**Acceptance:** Test an explicit publish source naming a generated member with
|
||||
omitted destination, an explicit destination override, and a genuinely unknown
|
||||
source. Validate both effective inspection and resolved runtime configuration.
|
||||
|
||||
## 2. Stop re-defaulting an explicitly empty Notarius working directory
|
||||
|
||||
Status: Resolved — P2
|
||||
|
||||
Resolution: Removed both legacy blank-value fallbacks after ConfigKit resolution.
|
||||
An omitted working directory is still derived by the presence-aware resolver, an
|
||||
explicit relative directory is still rooted at the pipeline, and explicit empty or
|
||||
whitespace-only values remain empty and fail enabled-Notarius validation. Existing
|
||||
relocation-invariant digest coverage remains green.
|
||||
|
||||
**Evidence:** ConfigKit's resolver correctly uses presence-aware defaulting at
|
||||
`internal/config/pipeline_configkit.go:180`. However,
|
||||
`internal/config/load.go:454` subsequently replaces any blank working directory
|
||||
with the config file's directory, irrespective of input presence.
|
||||
`internal/config/effective_digest.go` also applies a blank-value fallback while
|
||||
capturing logical paths. These retained legacy fallbacks bypass the new rule that
|
||||
defaults apply only to absent fields.
|
||||
|
||||
**Reproduction:** Set `notarius.enabled: true`, a valid config path, pipeline ID
|
||||
and output map, and `notarius.working_directory: ""`. With
|
||||
`config_path: notarius/config.yml`, `config show` succeeds and reports
|
||||
`working_directory: notarius`; validation never sees the explicit invalid blank.
|
||||
|
||||
**Required change:** Let the ConfigKit resolver own the absent-field default.
|
||||
The subsequent runtime and logical-path conversions must preserve explicit
|
||||
emptiness so enabled-Notarius validation can reject it. Remove duplicate
|
||||
value-based defaulting rather than adding another private presence mechanism.
|
||||
|
||||
**Acceptance:** Omission still derives the expected directory; explicit empty
|
||||
and whitespace-only values fail when Notarius is enabled; a configured relative
|
||||
directory resolves from the pipeline root. Preserve digest relocation invariance.
|
||||
|
||||
## 3. Enforce unique base definitions even when a profile removes them
|
||||
|
||||
Status: Resolved — P2
|
||||
|
||||
Resolution: Loading now discovers base declarations through ConfigKit's public
|
||||
historical-path enumeration and metadata history from the selected result,
|
||||
without separately resolving the base (see finding 7). Duplicate ordinary or
|
||||
family definitions therefore fail even when an overlay nulls their container or
|
||||
all of Scriptorium. A single base owner
|
||||
with a legitimate profile override remains supported.
|
||||
|
||||
**Evidence:** `rejectDuplicateBaseDefinitions` at
|
||||
`internal/config/pipeline_configkit.go:354` enumerates only artifact/family keys
|
||||
remaining in the final typed configuration. It inspects history for those keys
|
||||
but never examines base declarations removed by an overlay.
|
||||
|
||||
**Reproduction:** Import `first.yml` defining
|
||||
`scriptorium.artifacts.recap.prompt_id` and `second.yml` defining
|
||||
`scriptorium.artifacts.recap.output_path`. Base validation correctly rejects the
|
||||
two-file declaration. Add `profiles/clear.yml` containing:
|
||||
|
||||
```yaml
|
||||
scriptorium:
|
||||
artifacts: null
|
||||
```
|
||||
|
||||
The same root with `--profile clear` passes. A selected profile can thus hide the
|
||||
base ownership conflict, contrary to the plan's unique base-definition rule.
|
||||
This concerns disjoint contributions to one artifact, not ConfigKit's generic
|
||||
conflicting-leaf checks, which still work.
|
||||
|
||||
**Required change:** Discover the complete base declaration set through public
|
||||
ConfigKit metadata/history, including declarations displaced by an overlay, and
|
||||
validate ownership independently of final effective-key survival. Keep legitimate
|
||||
profile overrides and profile-only definitions supported. Do not reparse source
|
||||
YAML or add a second composer.
|
||||
|
||||
**Acceptance:** Cover duplicate ordinary and family definitions hidden by a null
|
||||
artifact/family container or null Scriptorium section. Base and selected-profile
|
||||
loads must reject the same base conflict, while a single base declaration with a
|
||||
valid overlay remains accepted.
|
||||
|
||||
## 4. Preserve null versus empty collections in effective YAML
|
||||
|
||||
Status: Resolved — P2
|
||||
|
||||
Resolution: Effective YAML is now emitted from the credential-safe JSON value
|
||||
projection through an output-only YAML-node conversion. Explicit nulls, empty maps,
|
||||
empty slices, booleans, strings, and exact JSON numbers retain their semantic types.
|
||||
Public show/diff tests cover null and empty maps and slices, while digest,
|
||||
credential-redaction, deterministic output, and exact-integer coverage remain intact.
|
||||
|
||||
**Evidence:** `internal/config/effective_pipeline.go:154` marshals the typed
|
||||
projection directly with `yaml.Marshal`. The encoder emits nil slices as `[]`
|
||||
and nil maps as `{}`, while the JSON projection used by diff and digest preserves
|
||||
them as `null`. The shared input type does not guarantee shared output semantics.
|
||||
|
||||
**Reproduction:** Set `audita.modules: null` in a valid base and
|
||||
`audita.modules: []` in `profiles/empty.yml`. Both `config show` invocations emit
|
||||
`modules: []`, but `config diff default empty` reports:
|
||||
|
||||
```text
|
||||
changed audita.modules null []
|
||||
```
|
||||
|
||||
The commands disagree about the effective value. The same renderer also supplies
|
||||
the resolved pipeline diagnostic written by prepare.
|
||||
|
||||
**Required change:** Render YAML from a credential-safe projection that explicitly
|
||||
preserves nulls and empty collections, consistently with the semantic projection.
|
||||
Keep exact integers and safe credential reference names. An output-only conversion
|
||||
is appropriate; do not restore YAML decoding or a configuration parser. This
|
||||
requires truthful inspection, not a guarantee that inspection output is reloadable.
|
||||
|
||||
**Acceptance:** Verify null versus empty maps and slices through the public show
|
||||
boundary, and agreement with diff/digest distinctions. Retain credential-leakage
|
||||
and exact-integer regression coverage.
|
||||
|
||||
## 5. Complete provenance for generated artifacts and inferred dependencies
|
||||
|
||||
Status: Resolved — P3
|
||||
|
||||
Resolution: Generated catalog records now collect the complete base/profile family
|
||||
definition set from ConfigKit history. Effective source ownership for materialized
|
||||
dependency lists is rebuilt narrowly from retained explicit-list and input-edge
|
||||
origins, including mixed and generated-family dependencies. CLI tests verify a
|
||||
profiled family catalog agrees with generated source attribution and that inferred
|
||||
ordinary edges name their input contributor instead of the central default.
|
||||
|
||||
Two new reporting paths lose available declaration information:
|
||||
|
||||
- **Profile contributions to family members:**
|
||||
`internal/config/artifact_catalog.go:52` fills a generated member's definitions
|
||||
from the single `member.Source`. That source is chosen by the first matching
|
||||
non-default family field in `artifactFamilySource`, not the full set of effective
|
||||
contributors. Define family `meta` in the base and override its `prompt_id` in
|
||||
`profiles/testing.yml`. `config artifacts --profile testing` reports only the
|
||||
base file for `meta_alice`, even though `config sources` correctly attributes
|
||||
that member's prompt to the testing overlay.
|
||||
- **Inferred ordinary dependencies:** With `second.inputs.prior.source` set to
|
||||
`narratio.artifact.first` and no explicit dependency list, `config sources`
|
||||
reports `scriptorium.artifacts.second.depends_on` as `default / default`.
|
||||
`finalizeArtifactDependencies` stores the input-edge origin, but the effective
|
||||
ownership projection retains the pre-inference default attribution from
|
||||
`internal/config/effective_digest.go:55` and does not consult that origin.
|
||||
|
||||
**Impact:** Operators inspecting a testing profile or following a dependency can
|
||||
be directed to the wrong file or told a user-derived value is a central default.
|
||||
Runtime ordering and generated artifact values were correct in these reproductions.
|
||||
|
||||
**Required change:** Use ConfigKit's contributing locations for family definition
|
||||
sets, as for ordinary artifacts, and add narrow generated attribution for inferred
|
||||
dependency lists using their retained edge origins. Preserve family/character/party
|
||||
identity and deterministic ordering. Avoid introducing a second general provenance
|
||||
store or choosing a file based on the first alphabetically visited field.
|
||||
|
||||
**Acceptance:** A profiled family catalog exposes both relevant files; an inferred
|
||||
edge points back to its input declaration; mixed explicit/inferred dependencies
|
||||
retain their relevant contributors. Verify catalog and sources agree after expansion.
|
||||
|
||||
## 6. Surface successful secrets bootstrap reports, including skipped empty variables
|
||||
|
||||
Status: Resolved — P3
|
||||
|
||||
Resolution: Successful bootstrap reports now flow once through the command
|
||||
diagnostics channel, while direct internal command calls default that channel to
|
||||
discard. ConfigKit's value-free report makes skipped existing empty variables
|
||||
explicit and remains available before later typed-load failures; structured output
|
||||
stays isolated on stdout. Command-level coverage verifies successful reporting,
|
||||
skipped-empty reporting, value redaction, clean config-show output, and one report
|
||||
for a two-profile diff.
|
||||
|
||||
**Evidence:** `internal/app/secrets_bootstrap.go:36` discards the report whenever
|
||||
`LoadSecretsDir` returns no error. Reports are only rendered when directory
|
||||
bootstrap itself fails. This does not implement the roadmap's safe bootstrap
|
||||
reporting boundary or Stage 6's warning presentation requirement.
|
||||
|
||||
**Reproduction:** Place a synthetic key in `secrets/NARRATIO_REVIEW_KEY`, reference
|
||||
it from `audita.llm_api_key`, and invoke config validation with
|
||||
`NARRATIO_REVIEW_KEY=` in the environment. ConfigKit correctly skips the file
|
||||
because the variable already exists, then typed loading fails with `secret.empty`.
|
||||
Narratio shows only that load failure, omitting the bootstrap report's
|
||||
`skipped_existing` and `ExistingEmpty` information. A successful load likewise
|
||||
discards all bootstrap results.
|
||||
|
||||
**Required change:** Retain and render or summarize the safe report once per
|
||||
invocation through a diagnostic output channel, including when a later typed load
|
||||
fails. Make skipped-existing-empty outcomes explicit. Preserve process environment
|
||||
precedence and the existing partial report on failure; keep values out of all
|
||||
diagnostics and keep structured/YAML stdout uncontaminated. Do not add bootstrap
|
||||
rollback, retries, or repeated directory loading.
|
||||
|
||||
**Acceptance:** Command-level tests demonstrate safe success/report output and a
|
||||
useful skipped-empty diagnostic, without leaking a synthetic secret or polluting
|
||||
show output. A two-profile diff still bootstraps and reports only once.
|
||||
|
||||
## 7. Do not resolve unused base credentials when loading a profile
|
||||
|
||||
Status: Resolved — P2
|
||||
|
||||
Resolution: Upgraded to released ConfigKit v0.1.2 and replaced the extra base load
|
||||
with `Metadata.HistoricalPaths()` enumeration of declaration paths in the selected
|
||||
result. Ownership checks still inspect base-layer history for both ordinary
|
||||
artifacts and families, even when overlays remove them. Regression tests verify
|
||||
that a profile can replace an unset production credential with a resolvable testing
|
||||
reference or remove it with null. Existing hidden-duplicate and legitimate-override
|
||||
tests remain intact. No local dependency replacement or second composer is needed.
|
||||
|
||||
Follow-up review: 2026-09-07 at `3ce1981`. The original six reproductions are
|
||||
addressed, but the fix for finding 3 introduces this regression.
|
||||
|
||||
**Evidence:** `internal/config/pipeline_configkit.go:49-57` loads the base again
|
||||
without a profile to check duplicate base definitions. This calls the complete
|
||||
typed ConfigKit loader, including secret resolution, domain resolution, and
|
||||
validation—not just declaration ownership inspection. Consequently, loading a
|
||||
valid testing profile also requires credentials from the unselected base.
|
||||
|
||||
**Reproduction:** In an otherwise valid base, set `audita.llm_api_key` to
|
||||
`NARRATIO_REVIEW_PROD_KEY`. In `profiles/testing.yml`, replace it with
|
||||
`NARRATIO_REVIEW_TEST_KEY`. Leave the production variable unset and supply only
|
||||
the testing variable with a synthetic value. `config validate --config <root>
|
||||
--profile testing` fails with `secret.unset` at the base credential declaration.
|
||||
Supplying both variables makes it pass. A profile that instead sets
|
||||
`audita.llm_api_key: null` also fails when the unused production variable is
|
||||
unset. Both failures were reproduced through the current CLI.
|
||||
|
||||
**Impact:** Operators cannot use an independent testing credential environment
|
||||
or remove a production credential requirement through a profile. Commands fail
|
||||
despite having all credentials required by their effective configuration. The
|
||||
extra full load also unnecessarily repeats configuration processing.
|
||||
|
||||
**Required change:** Enforce unique base ownership without requiring the
|
||||
unselected base to succeed as a separately resolved runtime configuration.
|
||||
Inspect complete declaration history through ConfigKit's public metadata,
|
||||
including declarations displaced by overlays. If additional upstream metadata
|
||||
support is needed, address that explicitly rather than requiring unused secrets,
|
||||
suppressing arbitrary load failures, reparsing YAML, or restoring a second
|
||||
composer. Preserve the duplicate-definition protection added for finding 3.
|
||||
|
||||
**Acceptance:** Add public-boundary regression tests for a profile replacing an
|
||||
unset production credential with an available testing credential, and for a
|
||||
profile removing that credential with null. Both must load successfully without
|
||||
the production variable. Retain failures for duplicate base ordinary/family
|
||||
definitions hidden by null overlays, along with legitimate profile override and
|
||||
profile-only definition coverage. Resolve secrets only for the selected effective
|
||||
configuration.
|
||||
|
||||
## Follow-up verification
|
||||
|
||||
After adopting ConfigKit v0.1.2 and completing finding 7, the full
|
||||
`scripts/check-release-candidate.sh v2.0.0` check passed: module hygiene,
|
||||
formatting/whitespace, uncached tests and race tests, vet, build, documentation,
|
||||
maintained examples, and Linux amd64/arm64 plus macOS arm64 release cross-builds.
|
||||
`go mod verify` also passed. The new credential regressions failed before the
|
||||
ownership-check fix and passed afterward. No local module replacements, release
|
||||
tags, or pushes were used.
|
||||
|
||||
At `3ce1981`, `go test ./...`, `go test -race ./...`, `go vet ./...`, and
|
||||
`go build ./...` passed. The new finding was reproduced with a freshly built CLI,
|
||||
temporary configuration files, and synthetic credentials; no external adapters
|
||||
or cloud services were invoked. That review pass changed only this review record.
|
||||
|
||||
## Original verification and scope
|
||||
|
||||
The following checks passed at the reviewed commit:
|
||||
|
||||
- `go test ./...`
|
||||
- `go test -race ./...`
|
||||
- `go vet ./...`
|
||||
- `go build ./...`
|
||||
- The release asset builder for `v2.0.0`, producing exactly Linux amd64, Linux
|
||||
arm64, and macOS arm64 assets; the Linux amd64 binary reported the expected version.
|
||||
|
||||
The existing full suites include documentation and maintained-example checks.
|
||||
The findings above were additionally reproduced through a freshly built CLI with
|
||||
isolated temporary YAML fixtures and synthetic credentials. No live downstream
|
||||
tools or cloud services were invoked. Linux arm64 and macOS arm64 were cross-built,
|
||||
not tested natively. No tag was created or pushed.
|
||||
|
||||
The migration removes a net 1,235 production Go lines and 843 test Go lines versus
|
||||
`1558c9c` (physical diff lines, including comments and whitespace). ConfigKit owns
|
||||
generic loading/composition; Narratio retains domain resolution, orchestration,
|
||||
safe inspection, and adapter delivery. These findings require completing that
|
||||
design, not reversing the simplification.
|
||||
@@ -213,6 +213,40 @@ input validation.
|
||||
|
||||
Relevant reference: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior).
|
||||
|
||||
## Notarius credential reference unavailable
|
||||
|
||||
Symptom:
|
||||
|
||||
- configuration loading reports `secret.unset`, `secret.empty`, or an invalid
|
||||
name for `pipeline.notarius.llm_api_key`, including during inspection or a
|
||||
run that would not execute extraction.
|
||||
|
||||
Likely causes:
|
||||
|
||||
- the named environment variable is absent or empty;
|
||||
- the selected secrets directory lacks a file with that exact name;
|
||||
- a process environment variable exists but is empty, so bootstrap correctly
|
||||
leaves it in place; or
|
||||
- the Narratio reference name does not match `api_key_env` in the selected
|
||||
Notarius/PromptKit profile.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- set the exact nonempty environment variable or provide an equivalently named
|
||||
file through the conventional or explicit secrets directory;
|
||||
- remove or set the field to `null` when that deployment needs no credential;
|
||||
and
|
||||
- use `config show`, `config sources`, and `config diff` to inspect the public
|
||||
reference name and ownership without exposing its value.
|
||||
|
||||
Narratio resolves selected credential references during ordinary pipeline
|
||||
loading, before stage selection or resume. Credential changes alone do not
|
||||
invalidate a completed extraction, but the current reference must still resolve
|
||||
for the command to proceed.
|
||||
|
||||
Relevant references: [Secrets](./config.md#secrets-handling) and
|
||||
[Notarius credential](./config.md#notarius-credential).
|
||||
|
||||
## Notarius executable missing
|
||||
|
||||
Symptom:
|
||||
@@ -361,10 +395,10 @@ Symptom:
|
||||
|
||||
Likely causes:
|
||||
|
||||
- the executable/config path, pipeline ID, timeout, working directory, or
|
||||
configured output contracts changed;
|
||||
- the pipeline ID or configured output contracts changed;
|
||||
- a configured prepared reference selector, source, path, checksum, or byte
|
||||
size changed;
|
||||
- the direct trimmed transcript identity or bytes changed; or
|
||||
- the durable bundle, index, lane set, provenance, regular-file status, or
|
||||
checksum no longer validates.
|
||||
|
||||
@@ -374,6 +408,11 @@ Safe fix:
|
||||
an unsafe path or bundle symlink error as filesystem corruption or tampering
|
||||
and investigate it rather than replacing files manually.
|
||||
|
||||
Executable, config path, timeout, working directory, and Notarius credential
|
||||
changes are operational and do not by themselves invalidate reusable extraction
|
||||
evidence. A newly selected credential must nevertheless resolve during command
|
||||
configuration loading.
|
||||
|
||||
## Notarius transitive configuration changed
|
||||
|
||||
Symptom:
|
||||
@@ -726,7 +765,7 @@ Diagnostics:
|
||||
|
||||
```bash
|
||||
ls -la /path/to/secrets_dir
|
||||
env | sed 's/=.*//' | grep -E 'OBJECT_STORAGE|AWS|AUDITA|SCRIPTORIUM'
|
||||
env | sed 's/=.*//' | grep -E 'OBJECT_STORAGE|AWS|AUDITA|NOTARIUS|SCRIPTORIUM'
|
||||
```
|
||||
|
||||
Safe fix:
|
||||
|
||||
@@ -67,10 +67,10 @@ with the sample campaign and a compatible local- or S3-audio session.
|
||||
FLAC file before running transcription.
|
||||
|
||||
The examples contain environment-variable names but no credential values. Set
|
||||
`OBJECT_STORAGE_KEY_ID`, `OBJECT_STORAGE_KEY`, and `AUDITA_LLM_API_KEY` to
|
||||
nonempty synthetic or real values before loading the production-shaped and full
|
||||
annotated pipelines. They use fictional campaign content and reserved example
|
||||
domains.
|
||||
`OBJECT_STORAGE_KEY_ID`, `OBJECT_STORAGE_KEY`, `AUDITA_LLM_API_KEY`, and
|
||||
`NOTARIUS_LLM_API_KEY` to nonempty synthetic or real values before loading the
|
||||
examples that reference them. They use fictional campaign content and reserved
|
||||
example domains.
|
||||
|
||||
For a deployed bundle, keep credential files out of source control in a
|
||||
`secrets/` directory beside the root `pipeline.yml`, one file per referenced
|
||||
|
||||
@@ -131,6 +131,9 @@ notarius:
|
||||
# Optional structured extraction between trim and render.
|
||||
enabled: true
|
||||
binary: notarius
|
||||
# Optional environment-variable reference. Match this name to api_key_env in
|
||||
# the selected Notarius/PromptKit profile; never put the credential here.
|
||||
llm_api_key: NOTARIUS_LLM_API_KEY
|
||||
config_path: /usr/local/etc/notarius/config.yml
|
||||
pipeline_id: dnd-session
|
||||
timeout: 3h
|
||||
|
||||
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module gitea.maximumdirect.net/eric/narratio
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.1.2
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.3.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
4
go.sum
@@ -1,5 +1,5 @@
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.1.2 h1:uZ5Mvf271SLItXEvUpQYfAvCOVYj9RaoG0auCcbCcSU=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.1.2/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.3.0 h1:LgXEUjR0KsgwGBznOWa8FJTQ+iElctHtI1via5vKdu4=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.3.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=
|
||||
|
||||
@@ -4,6 +4,8 @@ package notarius
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
const ReceiptSchemaVersion = "notarius.run-result.v2"
|
||||
@@ -22,7 +24,9 @@ type ReferenceBinding struct {
|
||||
|
||||
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
|
||||
type RunRequest struct {
|
||||
Binary string
|
||||
Binary string
|
||||
// LLMAPIKey is an optional already-resolved credential reference.
|
||||
LLMAPIKey *configkit.Secret
|
||||
ConfigPath string
|
||||
PipelineID string
|
||||
InputPath string
|
||||
|
||||
@@ -52,6 +52,20 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
var envOverrides map[string]string
|
||||
var sensitiveEnvNames []string
|
||||
if req.LLMAPIKey != nil {
|
||||
if !req.LLMAPIKey.IsResolved() {
|
||||
return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty")
|
||||
}
|
||||
name := req.LLMAPIKey.EnvName()
|
||||
value := req.LLMAPIKey.Value()
|
||||
if value == "" {
|
||||
return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty")
|
||||
}
|
||||
envOverrides = map[string]string{name: value}
|
||||
sensitiveEnvNames = []string{name}
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"run", req.PipelineID,
|
||||
@@ -64,13 +78,15 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
||||
}
|
||||
args = append(args, "--json")
|
||||
processResult, err := r.run(ctx, subprocess.RunRequest{
|
||||
Executable: req.Binary,
|
||||
Args: args,
|
||||
WorkingDir: req.WorkingDirectory,
|
||||
Timeout: req.Timeout,
|
||||
DiagnosticOwner: "notarius",
|
||||
StdoutLogPath: req.ReceiptPath,
|
||||
StderrLogPath: req.LogPath,
|
||||
Executable: req.Binary,
|
||||
Args: args,
|
||||
WorkingDir: req.WorkingDirectory,
|
||||
Timeout: req.Timeout,
|
||||
DiagnosticOwner: "notarius",
|
||||
StdoutLogPath: req.ReceiptPath,
|
||||
StderrLogPath: req.LogPath,
|
||||
EnvOverrides: envOverrides,
|
||||
SensitiveEnvNames: sensitiveEnvNames,
|
||||
})
|
||||
baseResult := RunResult{
|
||||
ReceiptPath: req.ReceiptPath,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
sharedsubprocess "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||
)
|
||||
|
||||
@@ -76,6 +77,65 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t *testing.T) {
|
||||
const envName = "NARRATIO_NOTARIUS_CUSTOM_LLM_KEY"
|
||||
const loadedValue = "loaded-notarius-credential"
|
||||
credential := loadTestCredential(t, envName, loadedValue)
|
||||
t.Setenv(envName, "changed-parent-credential")
|
||||
|
||||
req := validRunRequest(t)
|
||||
req.LLMAPIKey = credential
|
||||
var captured sharedsubprocess.RunRequest
|
||||
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||
captured = processReq
|
||||
writeValidBundleAndReceipt(t, req, false)
|
||||
return sharedsubprocess.RunResult{ExitCode: 0}, nil
|
||||
}}
|
||||
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if len(captured.EnvOverrides) != 1 || captured.EnvOverrides[envName] != loadedValue {
|
||||
t.Fatal("subprocess did not receive exactly the loaded credential override")
|
||||
}
|
||||
if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{envName}) {
|
||||
t.Fatalf("sensitive environment names = %#v, want configured reference name", captured.SensitiveEnvNames)
|
||||
}
|
||||
wantArgs := []string{
|
||||
"run", req.PipelineID, "--config", req.ConfigPath, "--input", req.InputPath,
|
||||
"--output-dir", req.OutputRoot, "--json",
|
||||
}
|
||||
if !reflect.DeepEqual(captured.Args, wantArgs) {
|
||||
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRejectsInvalidProgrammaticCredentialBeforeLaunch(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
reference configkit.Secret
|
||||
}{
|
||||
{name: "unresolved", reference: configkit.SecretEnv("NARRATIO_NOTARIUS_UNRESOLVED_KEY")},
|
||||
{name: "empty", reference: configkit.SecretEnv("")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
req.LLMAPIKey = &test.reference
|
||||
started := false
|
||||
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||
started = true
|
||||
return sharedsubprocess.RunResult{}, nil
|
||||
}}
|
||||
_, err := runner.Run(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), "resolved and nonempty") {
|
||||
t.Fatalf("Run() error = %v, want credential validation failure", err)
|
||||
}
|
||||
if started {
|
||||
t.Fatal("subprocess started with an invalid credential")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerBuildsOrderedReferenceArguments(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
referenceRoot := t.TempDir()
|
||||
@@ -172,16 +232,23 @@ func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
|
||||
const envName = "NARRATIO_NOTARIUS_CUSTOM_CAPTURE_KEY"
|
||||
const canary = "notarius-captured-stream-canary"
|
||||
const unrelated = "NARRATIO_NOTARIUS_UNRELATED_PARENT_KEY"
|
||||
req := validRunRequest(t)
|
||||
req.LLMAPIKey = loadTestCredential(t, envName, canary)
|
||||
writeValidBundleAndReceipt(t, req, false)
|
||||
receiptFixture := req.ReceiptPath + ".fixture"
|
||||
data, err := os.ReadFile(req.ReceiptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(receipt) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(receiptFixture, data, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(receipt fixture) error = %v", err)
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(data, &receipt); err != nil {
|
||||
t.Fatalf("decode receipt fixture: %v", err)
|
||||
}
|
||||
receipt["credential_echo"] = canary
|
||||
writeJSONFile(t, receiptFixture, receipt)
|
||||
if err := os.Remove(req.ReceiptPath); err != nil {
|
||||
t.Fatalf("Remove(receipt) error = %v", err)
|
||||
}
|
||||
@@ -193,27 +260,55 @@ func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T)
|
||||
script := writeShellScript(t, `#!/bin/sh
|
||||
pwd > "$NOTARIUS_CAPTURE_DIR/working-directory"
|
||||
printf '%s' "$NOTARIUS_INHERITED_VALUE" > "$NOTARIUS_CAPTURE_DIR/environment"
|
||||
printf 'diagnostic stream\n' >&2
|
||||
if [ "$`+envName+`" != '`+canary+`' ]; then
|
||||
printf 'selected credential mismatch\n' >&2
|
||||
exit 8
|
||||
fi
|
||||
if [ -n "$`+unrelated+`" ]; then
|
||||
printf 'unrelated credential inherited\n' >&2
|
||||
exit 9
|
||||
fi
|
||||
printf '%s\n' "$`+envName+`" >&2
|
||||
cat "$NOTARIUS_RECEIPT_FIXTURE"
|
||||
`)
|
||||
req.Binary = script
|
||||
t.Setenv("NOTARIUS_CAPTURE_DIR", captureDir)
|
||||
t.Setenv("NOTARIUS_INHERITED_VALUE", "inherited-value")
|
||||
t.Setenv("NOTARIUS_RECEIPT_FIXTURE", receiptFixture)
|
||||
t.Setenv(unrelated, "unrelated-parent-credential")
|
||||
|
||||
if _, err := NewSubprocessRunner().Run(context.Background(), req); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
assertTextFile(t, filepath.Join(captureDir, "working-directory"), req.WorkingDirectory+"\n")
|
||||
assertTextFile(t, filepath.Join(captureDir, "environment"), "")
|
||||
assertTextFile(t, req.LogPath, "diagnostic stream\n")
|
||||
assertRedactedCredentialFile(t, req.LogPath, canary)
|
||||
receiptBytes, err := os.ReadFile(req.ReceiptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(receipt) error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(receiptBytes), "diagnostic stream") {
|
||||
t.Fatal("receipt contains stderr output")
|
||||
assertRedactedCredentialBytes(t, receiptBytes, canary)
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRedactsCredentialOnProcessFailure(t *testing.T) {
|
||||
const envName = "NARRATIO_NOTARIUS_CUSTOM_FAILURE_KEY"
|
||||
const canary = "notarius-failure-stream-canary"
|
||||
req := validRunRequest(t)
|
||||
req.LLMAPIKey = loadTestCredential(t, envName, canary)
|
||||
req.Binary = writeShellScript(t, `#!/bin/sh
|
||||
printf '%s\n' "$`+envName+`"
|
||||
printf '%s\n' "$`+envName+`" >&2
|
||||
exit 7
|
||||
`)
|
||||
_, err := NewSubprocessRunner().Run(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want process failure")
|
||||
}
|
||||
if strings.Contains(err.Error(), canary) || !strings.Contains(err.Error(), "<redacted>") {
|
||||
t.Fatal("process failure did not return redacted diagnostics")
|
||||
}
|
||||
assertRedactedCredentialFile(t, req.ReceiptPath, canary)
|
||||
assertRedactedCredentialFile(t, req.LogPath, canary)
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T) {
|
||||
@@ -579,6 +674,36 @@ func validRunRequest(t *testing.T) RunRequest {
|
||||
}
|
||||
}
|
||||
|
||||
func loadTestCredential(t *testing.T, envName, value string) *configkit.Secret {
|
||||
t.Helper()
|
||||
t.Setenv(envName, value)
|
||||
type testConfig struct {
|
||||
Credential *configkit.Secret `yaml:"credential"`
|
||||
}
|
||||
result, err := configkit.Load(configkit.Bytes("credential.yml", []byte("credential: "+envName+"\n")), configkit.Options[testConfig]{})
|
||||
if err != nil {
|
||||
t.Fatalf("load test credential: %v", err)
|
||||
}
|
||||
return result.Config.Credential
|
||||
}
|
||||
|
||||
func assertRedactedCredentialFile(t *testing.T, path, canary string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v", path, err)
|
||||
}
|
||||
assertRedactedCredentialBytes(t, data, canary)
|
||||
}
|
||||
|
||||
func assertRedactedCredentialBytes(t *testing.T, data []byte, canary string) {
|
||||
t.Helper()
|
||||
text := string(data)
|
||||
if strings.Contains(text, canary) || !strings.Contains(text, "<redacted>") {
|
||||
t.Fatal("captured stream did not contain the required redaction marker")
|
||||
}
|
||||
}
|
||||
|
||||
func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown bool) {
|
||||
t.Helper()
|
||||
bundle := filepath.Join(req.OutputRoot, "notarius-run-1")
|
||||
|
||||
@@ -272,13 +272,18 @@ func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
||||
deps, missingPath, executor := guardedMissingPipelineDiscovery(t)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"analyze", "2026-05-03"}, &stdout, &stderr)
|
||||
code := executeWithDependencies([]string{"analyze", "2026-05-03"}, &stdout, &stderr, deps, executor)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "analyze: no pipeline config path provided and no default pipeline config found; searched:") {
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "analyze: no pipeline config path provided") ||
|
||||
!strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
|
||||
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -371,13 +376,18 @@ func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecutePublishMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
||||
deps, missingPath, executor := guardedMissingPipelineDiscovery(t)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"publish", "2026-05-03"}, &stdout, &stderr)
|
||||
code := executeWithDependencies([]string{"publish", "2026-05-03"}, &stdout, &stderr, deps, executor)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "publish: no pipeline config path provided and no default pipeline config found; searched:") {
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "publish: no pipeline config path provided") ||
|
||||
!strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
|
||||
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
type commandDependencies struct {
|
||||
diagnostics io.Writer
|
||||
resolvePipelineConfigPath func(string) (string, error)
|
||||
loadPipelineConfig func(string, config.PipelineLoadOptions) (*config.PipelineConfig, error)
|
||||
downloadObjectToTemp func(context.Context, storage.ObjectStore, string, string) (string, error)
|
||||
loadSecretsDir func(string) (*configkit.SecretsReport, error)
|
||||
@@ -25,6 +26,9 @@ func normalizeCommandDependencies(deps commandDependencies) commandDependencies
|
||||
if deps.diagnostics == nil {
|
||||
deps.diagnostics = io.Discard
|
||||
}
|
||||
if deps.resolvePipelineConfigPath == nil {
|
||||
deps.resolvePipelineConfigPath = resolvePipelineConfigPath
|
||||
}
|
||||
if deps.loadPipelineConfig == nil {
|
||||
deps.loadPipelineConfig = config.LoadPipelineWithOptions
|
||||
}
|
||||
|
||||
@@ -59,9 +59,10 @@ func TestExecuteValidCommands(t *testing.T) {
|
||||
|
||||
func TestExecuteMissingRequiredFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
controlledDiscovery bool
|
||||
}{
|
||||
{name: "run missing session", args: []string{"run"}, want: "run: session_id is required"},
|
||||
{name: "plan old top-level removed", args: []string{"plan"}, want: `unknown command: "plan"`},
|
||||
@@ -69,7 +70,7 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
||||
{name: "resume removed", args: []string{"resume"}, want: `unknown command: "resume"`},
|
||||
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected stage name and session_id"},
|
||||
{name: "run-stage missing session", args: []string{"run-stage", "polish"}, want: "run-stage: expected stage name and session_id"},
|
||||
{name: "run missing config uses defaults", args: []string{"run", "2026-05-03", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||
{name: "run missing config uses defaults", args: []string{"run", "2026-05-03", "--session", "session.yml"}, want: "run: no pipeline config path provided", controlledDiscovery: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -77,7 +78,13 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := Execute(tc.args, &stdout, &stderr)
|
||||
deps := commandDependencies{}
|
||||
executor := pipelineExecutor(executePlan)
|
||||
missingPath := ""
|
||||
if tc.controlledDiscovery {
|
||||
deps, missingPath, executor = guardedMissingPipelineDiscovery(t)
|
||||
}
|
||||
code := executeWithDependencies(tc.args, &stdout, &stderr, deps, executor)
|
||||
if code == 0 {
|
||||
t.Fatalf("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -87,6 +94,11 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), tc.want) {
|
||||
t.Fatalf("stderr = %q, want to contain %q", stderr.String(), tc.want)
|
||||
}
|
||||
if tc.controlledDiscovery {
|
||||
if got := stderr.String(); !strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
|
||||
t.Fatalf("stderr = %q, want controlled searched path %q", got, missingPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,10 +367,12 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
|
||||
const firstCanary = "inspection-credential-canary-one"
|
||||
const secondCanary = "inspection-credential-canary-two"
|
||||
for name, value := range map[string]string{
|
||||
"NARRATIO_INSPECT_ACCESS": firstCanary,
|
||||
"NARRATIO_INSPECT_SECRET": firstCanary,
|
||||
"NARRATIO_INSPECT_LLM_ONE": firstCanary,
|
||||
"NARRATIO_INSPECT_LLM_TWO": secondCanary,
|
||||
"NARRATIO_INSPECT_ACCESS": firstCanary,
|
||||
"NARRATIO_INSPECT_SECRET": firstCanary,
|
||||
"NARRATIO_INSPECT_LLM_ONE": firstCanary,
|
||||
"NARRATIO_INSPECT_LLM_TWO": secondCanary,
|
||||
"NARRATIO_INSPECT_NOTARIUS_ONE": firstCanary,
|
||||
"NARRATIO_INSPECT_NOTARIUS_TWO": secondCanary,
|
||||
} {
|
||||
t.Setenv(name, value)
|
||||
}
|
||||
@@ -384,13 +386,23 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
|
||||
access_key_id: NARRATIO_INSPECT_ACCESS
|
||||
secret_access_key: NARRATIO_INSPECT_SECRET
|
||||
`, 1)
|
||||
pipelineYAML += `notarius:
|
||||
enabled: false
|
||||
llm_api_key: NARRATIO_INSPECT_NOTARIUS_ONE
|
||||
`
|
||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeInspectionProfiles(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\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")
|
||||
|
||||
var validate bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--profile", "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 {
|
||||
@@ -408,19 +420,41 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
|
||||
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diff); err != nil {
|
||||
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 {
|
||||
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()} {
|
||||
if strings.Contains(output, firstCanary) || strings.Contains(output, secondCanary) {
|
||||
t.Fatalf("%s output exposed credential value: %q", label, output)
|
||||
}
|
||||
}
|
||||
for _, reference := range []string{"NARRATIO_INSPECT_ACCESS", "NARRATIO_INSPECT_SECRET", "NARRATIO_INSPECT_LLM_ONE"} {
|
||||
for _, reference := range []string{"NARRATIO_INSPECT_ACCESS", "NARRATIO_INSPECT_SECRET", "NARRATIO_INSPECT_LLM_ONE", "NARRATIO_INSPECT_NOTARIUS_ONE"} {
|
||||
if !strings.Contains(show.String(), reference) {
|
||||
t.Fatalf("show omitted safe credential reference %q: %q", reference, show.String())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(diff.String(), "NARRATIO_INSPECT_LLM_ONE") || !strings.Contains(diff.String(), "NARRATIO_INSPECT_LLM_TWO") {
|
||||
if !strings.Contains(diff.String(), "NARRATIO_INSPECT_LLM_ONE") || !strings.Contains(diff.String(), "NARRATIO_INSPECT_LLM_TWO") ||
|
||||
!strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") || !strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_TWO") {
|
||||
t.Fatalf("diff omitted safe credential references: %q", diff.String())
|
||||
}
|
||||
if !strings.Contains(sources.String(), "notarius.llm_api_key") {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateRejectsUnavailableNotariusCredential(t *testing.T) {
|
||||
const envName = "NARRATIO_INSPECT_NOTARIUS_MISSING"
|
||||
restoreEnvAfterTest(t, envName)
|
||||
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
mustWriteTestFile(t, path, "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n")
|
||||
err := ConfigValidate(context.Background(), []string{"--config", path}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "secret.unset") {
|
||||
t.Fatalf("ConfigValidate() error = %v, want unavailable credential failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCommandsInspectMaintainedSplitBundleWithoutRuntimeState(t *testing.T) {
|
||||
|
||||
@@ -163,7 +163,7 @@ func loadPipelineCampaignConfigWithDependencies(pipelineFlag, campaignFlag, camp
|
||||
|
||||
func loadPipelineConfigWithDependencies(pipelineFlag string, opts config.PipelineLoadOptions, secretsDir *string, deps commandDependencies) (string, *config.PipelineConfig, error) {
|
||||
deps = normalizeCommandDependencies(deps)
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
||||
resolvedPipelinePath, err := deps.resolvePipelineConfigPath(pipelineFlag)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
@@ -485,6 +486,92 @@ func TestExtractLifecycleSkipsCurrentResumableResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleCredentialChangesRemainResumable(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
before func(*testing.T) *configkit.Secret
|
||||
after func(*testing.T) *configkit.Secret
|
||||
}{
|
||||
{
|
||||
name: "added",
|
||||
after: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_ADDED_KEY", "added-value")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "removed",
|
||||
before: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_REMOVED_KEY", "removed-value")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "renamed",
|
||||
before: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_FIRST_KEY", "first-value")
|
||||
},
|
||||
after: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_SECOND_KEY", "second-value")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "value rotated",
|
||||
before: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_ROTATED_KEY", "first-value")
|
||||
},
|
||||
after: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_ROTATED_KEY", "second-value")
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
if test.before != nil {
|
||||
cfg.Pipeline.Notarius.LLMAPIKey = test.before(t)
|
||||
}
|
||||
analyzeRuns := 0
|
||||
plan := extractionLifecyclePlan(t, &analyzeRuns)
|
||||
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("initial executeStages() error = %v", err)
|
||||
}
|
||||
cfg.Pipeline.Notarius.LLMAPIKey = nil
|
||||
if test.after != nil {
|
||||
cfg.Pipeline.Notarius.LLMAPIKey = test.after(t)
|
||||
}
|
||||
resumed, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
|
||||
if err != nil {
|
||||
t.Fatalf("resume executeStages() error = %v", err)
|
||||
}
|
||||
if len(resumed.Executed) != 0 || len(resumed.Skipped) != 2 || len(runner.requests) != 1 || analyzeRuns != 1 {
|
||||
t.Fatalf("credential-only change reexecuted work: summary=%#v requests=%d analyze=%d", resumed, len(runner.requests), analyzeRuns)
|
||||
}
|
||||
loaded := loadLifecycleManifest(t, cfg)
|
||||
if loaded.Stages["extract"].Status != manifest.StatusSucceeded || loaded.Stages["analyze"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("credential-only change invalidated stages: extract=%q analyze=%q", loaded.Stages["extract"].Status, loaded.Stages["analyze"].Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedPipelineAndManifestExcludeNotariusCredentialValue(t *testing.T) {
|
||||
const name = "NARRATIO_LIFECYCLE_PERSISTED_KEY"
|
||||
const canary = "notarius-persisted-credential-canary"
|
||||
cfg, env, _ := extractionLifecycleFixture(t, true)
|
||||
cfg.Pipeline.Notarius.LLMAPIKey = loadLifecycleNotariusCredential(t, name, canary)
|
||||
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
for _, path := range []string{paths.ManifestPath, filepath.Join(paths.InputsDir, "pipeline.resolved.yml")} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v", path, err)
|
||||
}
|
||||
if strings.Contains(string(data), canary) {
|
||||
t.Fatalf("persisted Narratio file %q contains the credential value", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleRerunsAfterDirectTranscriptChange(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
analyzeRuns := 0
|
||||
@@ -761,3 +848,17 @@ func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *st
|
||||
runner := &materializingNotariusRunner{cfg: cfg.Pipeline.Notarius}
|
||||
return cfg, &stage.Env{Notarius: runner}, runner
|
||||
}
|
||||
|
||||
func loadLifecycleNotariusCredential(t *testing.T, name, value string) *configkit.Secret {
|
||||
t.Helper()
|
||||
t.Setenv(name, value)
|
||||
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+name+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pipeline, err := config.LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pipeline.Notarius.LLMAPIKey
|
||||
}
|
||||
|
||||
@@ -164,19 +164,19 @@ func TestExecuteSessionInitRequiresSessionID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteSessionInitMissingDefaultConfigReportsSearchedPaths(t *testing.T) {
|
||||
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
|
||||
config.DefaultPipelineConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-pipeline.yml")}
|
||||
t.Cleanup(func() {
|
||||
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
|
||||
})
|
||||
deps, missingPath, executor := guardedMissingPipelineDiscovery(t)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr)
|
||||
code := executeWithDependencies([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr, deps, executor)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "session init: no pipeline config path provided and no default pipeline config found; searched:") {
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "session init: no pipeline config path provided") ||
|
||||
!strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
|
||||
t.Fatalf("stderr = %q, want default pipeline searched-path error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func TestResolvePipelineConfigPathWithCandidatesExplicitWins(t *testing.T) {
|
||||
@@ -55,11 +61,57 @@ func TestResolvePipelineConfigPathWithCandidatesPrecedence(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolvePipelineConfigPathWithCandidatesMissing(t *testing.T) {
|
||||
_, err := resolvePipelineConfigPathWithCandidates("", []string{"/does/not/exist/one.yml", "/does/not/exist/two.yml"})
|
||||
directory := t.TempDir()
|
||||
first := filepath.Join(directory, "one.yml")
|
||||
second := filepath.Join(directory, "two.yml")
|
||||
_, err := resolvePipelineConfigPathWithCandidates("", []string{first, second})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no default pipeline config found") {
|
||||
t.Fatalf("error = %q, want missing-defaults context", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), first) || !strings.Contains(err.Error(), second) {
|
||||
t.Fatalf("error = %q, want controlled searched paths", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMissingExplicitConfigDiscoversExistingDefaultBeforeLoading(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
missing := filepath.Join(directory, "missing.yml")
|
||||
existing := filepath.Join(directory, "pipeline.yml")
|
||||
if err := os.WriteFile(existing, []byte("workspace:\n root: ./tmp\n"), 0o644); err != nil {
|
||||
t.Fatalf("write default pipeline: %v", err)
|
||||
}
|
||||
|
||||
controlledErr := errors.New("controlled pipeline loader boundary")
|
||||
loadedPath := ""
|
||||
deps := commandDependenciesForPipelineCandidates(missing, existing)
|
||||
deps.loadPipelineConfig = func(path string, _ config.PipelineLoadOptions) (*config.PipelineConfig, error) {
|
||||
loadedPath = path
|
||||
return nil, controlledErr
|
||||
}
|
||||
deps.loadSecretsDir = func(string) (*configkit.SecretsReport, error) {
|
||||
t.Fatal("absent conventional secrets directory unexpectedly invoked bootstrap loader")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := executeWithDependencies([]string{"config", "validate"}, &stdout, &stderr, deps, func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
|
||||
t.Fatal("config validation unexpectedly invoked pipeline executor")
|
||||
return nil, nil
|
||||
})
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want controlled loader failure")
|
||||
}
|
||||
if loadedPath != existing {
|
||||
t.Fatalf("loader path = %q, want discovered default %q", loadedPath, existing)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), controlledErr.Error()) {
|
||||
t.Fatalf("stderr = %q, want controlled loader error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -119,6 +120,52 @@ func TestSecretsBootstrapPreservesExistingEnvironmentValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCredentialLoadsFromCommandSecretBootstrap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
explicit bool
|
||||
environment string
|
||||
fileValue string
|
||||
wantValue string
|
||||
}{
|
||||
{name: "conventional", fileValue: "conventional-value", wantValue: "conventional-value"},
|
||||
{name: "explicit", explicit: true, fileValue: "explicit-value", wantValue: "explicit-value"},
|
||||
{name: "environment precedence", explicit: true, environment: "environment-value", fileValue: "file-value", wantValue: "environment-value"},
|
||||
}
|
||||
for index, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
envName := fmt.Sprintf("NARRATIO_NOTARIUS_BOOTSTRAP_%d", index)
|
||||
restoreEnvAfterTest(t, envName)
|
||||
root := t.TempDir()
|
||||
pipelinePath := filepath.Join(root, "pipeline.yml")
|
||||
if err := os.WriteFile(pipelinePath, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secretsDir := filepath.Join(root, "secrets")
|
||||
var selection *string
|
||||
if test.explicit {
|
||||
secretsDir = filepath.Join(t.TempDir(), "selected-secrets")
|
||||
selection = &secretsDir
|
||||
}
|
||||
mustWriteSecretFile(t, filepath.Join(secretsDir, envName), test.fileValue+"\n")
|
||||
if test.environment != "" {
|
||||
if err := os.Setenv(envName, test.environment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
_, pipeline, err := loadPipelineConfigWithDependencies(pipelinePath, config.PipelineLoadOptions{}, selection, commandDependencies{diagnostics: io.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("load pipeline: %v", err)
|
||||
}
|
||||
if pipeline.Notarius == nil || pipeline.Notarius.LLMAPIKey == nil || !pipeline.Notarius.LLMAPIKey.IsResolved() ||
|
||||
pipeline.Notarius.LLMAPIKey.EnvName() != envName || pipeline.Notarius.LLMAPIKey.Value() != test.wantValue {
|
||||
t.Fatal("Notarius credential did not resolve from the selected command bootstrap source")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretsBootstrapPreservesSafePartialReportOnFailure(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mustWriteSecretFile(t, filepath.Join(directory, "INVALID-NAME"), "must-not-appear\n")
|
||||
@@ -173,7 +220,7 @@ func TestExecuteConfigValidateReportsSkippedEmptyBeforeTypedLoadFailure(t *testi
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pipelineYAML = []byte(strings.Replace(string(pipelineYAML), "audita:\n binary:", "audita:\n llm_api_key: "+name+"\n binary:", 1))
|
||||
pipelineYAML = append(pipelineYAML, []byte("notarius:\n enabled: false\n llm_api_key: "+name+"\n")...)
|
||||
if err := os.WriteFile(pipelinePath, pipelineYAML, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
// restoreEnvAfterTest clears names for a test and restores both their prior
|
||||
@@ -47,3 +49,29 @@ func mustWriteSecretFile(t *testing.T, path, value string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func commandDependenciesForPipelineCandidates(candidates ...string) commandDependencies {
|
||||
ownedCandidates := append([]string(nil), candidates...)
|
||||
return commandDependencies{resolvePipelineConfigPath: func(flagValue string) (string, error) {
|
||||
return resolvePipelineConfigPathWithCandidates(flagValue, ownedCandidates)
|
||||
}}
|
||||
}
|
||||
|
||||
func guardedMissingPipelineDiscovery(t *testing.T) (commandDependencies, string, pipelineExecutor) {
|
||||
t.Helper()
|
||||
candidate := filepath.Join(t.TempDir(), "missing-pipeline.yml")
|
||||
deps := commandDependenciesForPipelineCandidates(candidate)
|
||||
deps.loadSecretsDir = func(string) (*configkit.SecretsReport, error) {
|
||||
t.Fatal("secret bootstrap continued after pipeline discovery failed")
|
||||
return nil, nil
|
||||
}
|
||||
deps.loadPipelineConfig = func(string, config.PipelineLoadOptions) (*config.PipelineConfig, error) {
|
||||
t.Fatal("typed pipeline loading continued after pipeline discovery failed")
|
||||
return nil, nil
|
||||
}
|
||||
executor := func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
|
||||
t.Fatal("pipeline execution continued after pipeline discovery failed")
|
||||
return nil, nil
|
||||
}
|
||||
return deps, candidate, executor
|
||||
}
|
||||
|
||||
@@ -309,6 +309,7 @@ type ScriptoriumInputConfig struct {
|
||||
type NotariusConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Binary string `yaml:"binary" json:"binary"`
|
||||
LLMAPIKey *configkit.Secret `yaml:"llm_api_key,omitempty" json:"llm_api_key,omitempty"`
|
||||
ConfigPath string `yaml:"config_path" json:"config_path"`
|
||||
PipelineID string `yaml:"pipeline_id" json:"pipeline_id"`
|
||||
Timeout string `yaml:"timeout" json:"timeout"`
|
||||
|
||||
@@ -50,6 +50,26 @@ func (cfg AuditaConfig) MarshalYAML() (any, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MarshalYAML exposes only the public Notarius credential reference name.
|
||||
func (cfg NotariusConfig) MarshalYAML() (any, error) {
|
||||
type safeNotarius struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Binary string `yaml:"binary"`
|
||||
LLMAPIKey *string `yaml:"llm_api_key,omitempty"`
|
||||
ConfigPath string `yaml:"config_path"`
|
||||
PipelineID string `yaml:"pipeline_id"`
|
||||
Timeout string `yaml:"timeout"`
|
||||
WorkingDirectory string `yaml:"working_directory"`
|
||||
References map[string]string `yaml:"references"`
|
||||
Outputs map[string]NotariusOutputConfig `yaml:"outputs"`
|
||||
}
|
||||
return safeNotarius{
|
||||
Enabled: cfg.Enabled, Binary: cfg.Binary, LLMAPIKey: secretReferenceName(cfg.LLMAPIKey),
|
||||
ConfigPath: cfg.ConfigPath, PipelineID: cfg.PipelineID, Timeout: cfg.Timeout,
|
||||
WorkingDirectory: cfg.WorkingDirectory, References: cfg.References, Outputs: cfg.Outputs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func secretReferenceName(secret *configkit.Secret) *string {
|
||||
if secret == nil {
|
||||
return nil
|
||||
|
||||
@@ -92,6 +92,7 @@ func TestCredentialValuesNeverEnterEffectiveProjectionOrDigest(t *testing.T) {
|
||||
t.Setenv("NARRATIO_TEST_ACCESS_KEY", firstCanary)
|
||||
t.Setenv("NARRATIO_TEST_SECRET_KEY", firstCanary)
|
||||
t.Setenv("NARRATIO_TEST_AUDITA_KEY", firstCanary)
|
||||
t.Setenv("NARRATIO_TEST_NOTARIUS_KEY", firstCanary)
|
||||
pipelineYAML := strings.Replace(testPipelineBaseYAML, "audita:\n binary: audita\n", "audita:\n binary: audita\n llm_api_key: NARRATIO_TEST_AUDITA_KEY\n", 1) + `
|
||||
storage:
|
||||
backend: s3
|
||||
@@ -99,6 +100,9 @@ storage:
|
||||
bucket: test-bucket
|
||||
access_key_id: NARRATIO_TEST_ACCESS_KEY
|
||||
secret_access_key: NARRATIO_TEST_SECRET_KEY
|
||||
notarius:
|
||||
enabled: false
|
||||
llm_api_key: NARRATIO_TEST_NOTARIUS_KEY
|
||||
`
|
||||
pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
|
||||
|
||||
@@ -130,7 +134,7 @@ storage:
|
||||
if strings.Contains(output, firstCanary) {
|
||||
t.Fatalf("effective output exposed credential value: %q", output)
|
||||
}
|
||||
for _, reference := range []string{"NARRATIO_TEST_ACCESS_KEY", "NARRATIO_TEST_SECRET_KEY", "NARRATIO_TEST_AUDITA_KEY"} {
|
||||
for _, reference := range []string{"NARRATIO_TEST_ACCESS_KEY", "NARRATIO_TEST_SECRET_KEY", "NARRATIO_TEST_AUDITA_KEY", "NARRATIO_TEST_NOTARIUS_KEY"} {
|
||||
if !strings.Contains(output, reference) {
|
||||
t.Fatalf("effective output omitted safe reference %q: %q", reference, output)
|
||||
}
|
||||
@@ -140,6 +144,7 @@ storage:
|
||||
t.Setenv("NARRATIO_TEST_ACCESS_KEY", secondCanary)
|
||||
t.Setenv("NARRATIO_TEST_SECRET_KEY", secondCanary)
|
||||
t.Setenv("NARRATIO_TEST_AUDITA_KEY", secondCanary)
|
||||
t.Setenv("NARRATIO_TEST_NOTARIUS_KEY", secondCanary)
|
||||
second, err := LoadPipeline(pipelinePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -156,6 +161,15 @@ storage:
|
||||
if EffectivePipelineDigest(first) == EffectivePipelineDigest(alternate) {
|
||||
t.Fatal("credential reference-name change did not alter effective digest")
|
||||
}
|
||||
t.Setenv("NARRATIO_TEST_NOTARIUS_KEY_ALTERNATE", secondCanary)
|
||||
alternateNotariusPath, _ := writeConfigFiles(t, strings.Replace(pipelineYAML, "NARRATIO_TEST_NOTARIUS_KEY", "NARRATIO_TEST_NOTARIUS_KEY_ALTERNATE", 1), testSessionBaseYAML)
|
||||
alternateNotarius, err := LoadPipeline(alternateNotariusPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if EffectivePipelineDigest(first) == EffectivePipelineDigest(alternateNotarius) {
|
||||
t.Fatal("Notarius credential reference-name change did not alter effective digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectivePipelineValuesPreserveNilEmptyAndExactIntegers(t *testing.T) {
|
||||
@@ -252,6 +266,7 @@ func TestExamplesLoadAndValidate(t *testing.T) {
|
||||
t.Setenv("OBJECT_STORAGE_KEY_ID", "synthetic-example-access-key")
|
||||
t.Setenv("OBJECT_STORAGE_KEY", "synthetic-example-secret-key")
|
||||
t.Setenv("AUDITA_LLM_API_KEY", "synthetic-example-audita-key")
|
||||
t.Setenv("NOTARIUS_LLM_API_KEY", "synthetic-example-notarius-key")
|
||||
examplesDir := filepath.Join("..", "..", "examples")
|
||||
if got, want := maintainedPipelineRoots(t, examplesDir), []string{
|
||||
"pipeline.extraction-subset.yml",
|
||||
@@ -439,6 +454,7 @@ func TestMaintainedExtractionExamplesPreservePublishedContracts(t *testing.T) {
|
||||
t.Setenv("OBJECT_STORAGE_KEY_ID", "synthetic-example-access-key")
|
||||
t.Setenv("OBJECT_STORAGE_KEY", "synthetic-example-secret-key")
|
||||
t.Setenv("AUDITA_LLM_API_KEY", "synthetic-example-audita-key")
|
||||
t.Setenv("NOTARIUS_LLM_API_KEY", "synthetic-example-notarius-key")
|
||||
examplesDir := filepath.Join("..", "..", "examples")
|
||||
full, err := LoadPipeline(filepath.Join(examplesDir, "pipeline.full.annotated.yml"))
|
||||
if err != nil {
|
||||
|
||||
@@ -50,6 +50,72 @@ notarius:
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCredentialReferenceLoading(t *testing.T) {
|
||||
const envName = "NARRATIO_TEST_NOTARIUS_KEY"
|
||||
const canary = "notarius-credential-canary"
|
||||
t.Setenv(envName, canary)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
credential string
|
||||
wantNil bool
|
||||
}{
|
||||
{name: "omitted", wantNil: true},
|
||||
{name: "null", credential: " llm_api_key: null\n", wantNil: true},
|
||||
{name: "custom reference", credential: " llm_api_key: " + envName + "\n"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n" + test.credential
|
||||
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.wantNil {
|
||||
if cfg.Notarius.LLMAPIKey != nil {
|
||||
t.Fatal("credential reference should be nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != envName ||
|
||||
!cfg.Notarius.LLMAPIKey.IsResolved() || cfg.Notarius.LLMAPIKey.Value() != canary {
|
||||
t.Fatal("credential reference was not loaded and resolved")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCredentialReferenceFailures(t *testing.T) {
|
||||
const missing = "NARRATIO_TEST_NOTARIUS_MISSING"
|
||||
if err := os.Unsetenv(missing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
name, reference, want string
|
||||
}{
|
||||
{name: "missing", reference: missing, want: "secret.unset"},
|
||||
{name: "empty", reference: `""`, want: "secret.invalid_name"},
|
||||
{name: "invalid", reference: "123-NOT-AN-ENV-NAME", want: "secret.invalid_name"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n llm_api_key: " + test.reference + "\n"
|
||||
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := LoadPipeline(path)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("LoadPipeline() error = %v, want classification %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusEnabledDefaultsAndPathResolution(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pipelinePath := filepath.Join(dir, "deployment", "pipeline.yml")
|
||||
|
||||
@@ -60,7 +60,8 @@ characters:
|
||||
want string
|
||||
}{
|
||||
{name: "unsupported version", yaml: strings.Replace(valid, PartySchemaVersion, "narratio.party.v2", 1), want: "unsupported"},
|
||||
{name: "non-string version", yaml: strings.Replace(valid, PartySchemaVersion, "1", 1), want: "version.type"},
|
||||
{name: "unsupported integer version", yaml: strings.Replace(valid, PartySchemaVersion, "1", 1), want: "unsupported"},
|
||||
{name: "boolean version", yaml: strings.Replace(valid, PartySchemaVersion, "true", 1), want: "version.type"},
|
||||
{name: "unknown top field", yaml: valid + "unknown: true\n", want: "decode.unknown_field"},
|
||||
{name: "unknown nested field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n role: GM\n", 1), want: "decode.unknown_field"},
|
||||
{name: "trailing document", yaml: valid + "---\ncharacters: {}\n", want: "yaml.multiple_documents"},
|
||||
|
||||
@@ -117,6 +117,71 @@ func TestPipelineProfileDoesNotRequireUnusedBaseCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) {
|
||||
const baseKey = "NARRATIO_NOTARIUS_UNUSED_BASE_KEY"
|
||||
const profileKey = "NARRATIO_NOTARIUS_PROFILE_KEY"
|
||||
if err := os.Unsetenv(baseKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(profileKey, "synthetic-notarius-profile-key")
|
||||
|
||||
for _, credential := range []string{profileKey, "null"} {
|
||||
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")
|
||||
profile := "testing"
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credential == "null" {
|
||||
if cfg.Notarius.LLMAPIKey != nil {
|
||||
t.Fatal("profile did not remove the base credential")
|
||||
}
|
||||
return
|
||||
}
|
||||
if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != profileKey || !cfg.Notarius.LLMAPIKey.IsResolved() {
|
||||
t.Fatal("profile credential was not resolved")
|
||||
}
|
||||
sources, err := EffectivePipelineSources(cfg, ResolvedParty{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, source := range sources {
|
||||
if source.Path == "notarius.llm_api_key" {
|
||||
found = source.Source == overlay
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("Notarius credential provenance did not identify the selected profile")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCredentialRootProvenance(t *testing.T) {
|
||||
const envName = "NARRATIO_NOTARIUS_ROOT_KEY"
|
||||
t.Setenv(envName, "synthetic-notarius-root-key")
|
||||
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: "+envName+"\n")
|
||||
cfg, err := LoadPipeline(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sources, err := EffectivePipelineSources(cfg, ResolvedParty{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, source := range sources {
|
||||
if source.Path == "notarius.llm_api_key" && source.Source == root {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("Notarius credential provenance did not identify the root pipeline")
|
||||
}
|
||||
|
||||
func TestPipelineConfigKitAllowsOneBaseDeclarationWithProfileOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePipelineFixture(t, dir, "pipeline.yml", `configkit:
|
||||
|
||||
@@ -147,7 +147,7 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (re
|
||||
return nil, fmt.Errorf("extract: build configuration fingerprint: %w", err)
|
||||
}
|
||||
request := notarius.RunRequest{
|
||||
Binary: resolvedBinary, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
|
||||
Binary: resolvedBinary, LLMAPIKey: notariusConfig.LLMAPIKey, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
|
||||
InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory,
|
||||
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, References: referenceBindings,
|
||||
}
|
||||
|
||||
@@ -51,7 +51,25 @@ func TestExtractStageDisabledReturnsExplicitSkip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractStageResolvesManifestInputAndBuildsExactRequest(t *testing.T) {
|
||||
const credentialName = "NARRATIO_EXTRACT_NOTARIUS_KEY"
|
||||
const credentialCanary = "extract-notarius-credential-canary"
|
||||
t.Setenv(credentialName, credentialCanary)
|
||||
credentialPipelinePath := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
if err := os.WriteFile(credentialPipelinePath, []byte(`whisperx:
|
||||
transcribe_url: https://transcription.example.test/transcribe
|
||||
notarius:
|
||||
enabled: false
|
||||
llm_api_key: NARRATIO_EXTRACT_NOTARIUS_KEY
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loadedPipeline, err := config.LoadPipeline(credentialPipelinePath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPipeline() error = %v", err)
|
||||
}
|
||||
|
||||
env, m, fake := setupExtractEnv(t)
|
||||
env.Config.Pipeline.Notarius.LLMAPIKey = loadedPipeline.Notarius.LLMAPIKey
|
||||
result, err := (extractStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
@@ -78,9 +96,19 @@ func TestExtractStageResolvesManifestInputAndBuildsExactRequest(t *testing.T) {
|
||||
if len(req.References) != 0 {
|
||||
t.Fatalf("references = %#v, want none", req.References)
|
||||
}
|
||||
if req.LLMAPIKey != loadedPipeline.Notarius.LLMAPIKey || req.LLMAPIKey.EnvName() != credentialName || !req.LLMAPIKey.IsResolved() {
|
||||
t.Fatal("request did not retain the loaded Notarius credential reference")
|
||||
}
|
||||
if result.Metadata["reference_count"] != 0 || len(result.Metadata["references"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("reference metadata = count %#v references %#v", result.Metadata["reference_count"], result.Metadata["references"])
|
||||
}
|
||||
metadata, err := json.Marshal(result.Metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(metadata), credentialCanary) || strings.Contains(string(metadata), credentialName) {
|
||||
t.Fatal("result metadata contains the Notarius credential")
|
||||
}
|
||||
if len(result.Outputs) != 2 {
|
||||
t.Fatalf("outputs = %#v, want lane and index", result.Outputs)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
@@ -93,6 +95,55 @@ func TestExtractResumeValidationExcludesOperationalSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSemanticConfigExcludesLoadedCredentialChanges(t *testing.T) {
|
||||
const firstName = "NARRATIO_SEMANTIC_NOTARIUS_KEY"
|
||||
const secondName = "NARRATIO_SEMANTIC_NOTARIUS_KEY_TWO"
|
||||
first := loadNotariusCredentialForSemanticTest(t, firstName, "first-value")
|
||||
rotated := loadNotariusCredentialForSemanticTest(t, firstName, "rotated-value")
|
||||
renamed := loadNotariusCredentialForSemanticTest(t, secondName, "second-value")
|
||||
|
||||
baseline := downstreamSemanticEnv()
|
||||
baseline.Config.Pipeline.Notarius.LLMAPIKey = first
|
||||
want, err := (extractStage{}).SemanticConfigFingerprint(baseline)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
credential *configkit.Secret
|
||||
}{
|
||||
{name: "removed", credential: nil},
|
||||
{name: "renamed", credential: renamed},
|
||||
{name: "value rotated", credential: rotated},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
env := downstreamSemanticEnv()
|
||||
env.Config.Pipeline.Notarius.LLMAPIKey = test.credential
|
||||
got, err := (extractStage{}).SemanticConfigFingerprint(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !want.Equal(got) {
|
||||
t.Fatalf("credential-only change altered extract semantic fingerprint")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func loadNotariusCredentialForSemanticTest(t *testing.T, name, value string) *configkit.Secret {
|
||||
t.Helper()
|
||||
t.Setenv(name, value)
|
||||
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+name+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pipeline, err := config.LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pipeline.Notarius.LLMAPIKey
|
||||
}
|
||||
|
||||
func TestPublishSemanticConfigSensitivityAndOperationalExclusions(t *testing.T) {
|
||||
want, err := (publishStage{}).SemanticConfigFingerprint(downstreamSemanticEnv())
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user