Migrate Notarius to credential lists

This commit is contained in:
2026-09-25 15:33:46 +00:00
parent 49948852d6
commit 7e0151ef93
21 changed files with 161 additions and 112 deletions

View File

@@ -98,7 +98,7 @@ 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
Typed credential references, including `notarius.credentials[]`, must therefore
resolve even though these inspection commands never construct an adapter.
Campaign selection is optional for a pipeline without party-driven artifact
@@ -178,7 +178,7 @@ Behavior:
- 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
load, including Notarius credentials 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;

View File

@@ -230,7 +230,7 @@ inputs:
- Do not place raw secrets in YAML.
- Credential fields contain environment-variable names, represented internally
as ConfigKit typed secret references (for example
`pipeline.notarius.llm_api_key: NOTARIUS_LLM_API_KEY`). A configured reference
`pipeline.notarius.credentials: [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
@@ -362,7 +362,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.credentials[]` | list[secret reference] | No | empty; exact environment-variable names forwarded to Notarius |
| `pipeline.notarius.config_profile` | string | No | unset; exact Notarius ConfigKit profile selector |
| `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 |
@@ -406,35 +406,36 @@ does not restrict profile names, resolve profile files, or rewrite the value.
This selection is independent of Narratio's own `--overlay` selection. A
Narratio overlay may explicitly select a differently named Notarius overlay,
but Narratio never forwards its own overlay name automatically. It is also
independent of `pipeline.notarius.llm_api_key`: an overlay may change either or
independent of `pipeline.notarius.credentials`: an overlay may change either or
both fields according to the deployed Notarius configuration. See the
[Notarius integration contract](./integrations/notarius.md) for invocation and
failure behavior.
### Notarius Credential
### Notarius Credentials
`pipeline.notarius.llm_api_key` optionally names the one environment variable
`pipeline.notarius.credentials` optionally lists environment-variable names
to forward to the Notarius child process:
```yaml
notarius:
llm_api_key: OPENROUTER_API_KEY
credentials: [OPENROUTER_API_KEY, ANTHROPIC_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
The list contains variable names, not credential values. Those names must
match the credential references in the selected Notarius/PromptKit configuration;
Narratio does not infer or remap provider names. Omission, `null`, or `[]`
forwards nothing. Each 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.
these exact selected names and loaded values. Other credentials are not forwarded.
Changing a value, name, presence, or absence does not invalidate extract's
semantic resume evidence. The public names remain part of safe effective
inspection, so renaming them may appear in `config diff` and change the effective
configuration digest; rotating only their values does neither. An overlay
replaces the whole list, including with `[]` to clear it.
See the [Notarius integration contract](./integrations/notarius.md) for the
delivery and redaction boundary.

View File

@@ -105,15 +105,16 @@ causes a cold miss. Chunk refresh bypasses lookup and publishes the accepted
replacement for the active source; it does not delete unrelated plans or
checkpoint state.
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.
When `pipeline.notarius.credentials` is absent, null, or empty, Narratio adds no
credentials to that environment. When configured, each list entry names an
environment variable whose already-loaded nonempty value is forwarded under
that exact name. Operators must use the names referenced by the selected
Notarius/PromptKit providers. Narratio does not inspect the Notarius configuration
to infer providers or destination names, and it does not forward unrelated
credentials from its parent environment or secrets directory. A Narratio
overlay replaces the whole credential list; `[]` clears it.
The selected name is registered with Narratio's shared streaming redactor.
Every 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

View File

@@ -241,7 +241,10 @@ a resolver, provider policy, or ambient-secret inheritance.
## Stage 3: Migrate Notarius to credential lists
Status: Not started.
Status: Complete (2026-09-25).
Verification: `go test ./internal/config ./internal/adapters/notarius ./internal/stage ./internal/app`,
`go test -race ./internal/adapters/notarius`, and `git diff --check` passed.
Preparation: `internal/config/config.go`, `credential_projection.go`, loading/
inspection tests; `internal/adapters/notarius/runner.go`, `subprocess.go`;

View File

@@ -312,7 +312,7 @@ and [Transcribe internals](./internal/stage-transcribe.md).
Symptom:
- configuration loading reports `secret.unset`, `secret.empty`, or an invalid
name for `pipeline.notarius.llm_api_key`, including during inspection or a
name in `pipeline.notarius.credentials`, including during inspection or a
run that would not execute extraction.
Likely causes:

View File

@@ -131,9 +131,9 @@ notarius:
# Optional structured extraction after render and before analyze.
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
# Optional environment-variable references. Match these names to the
# selected Notarius/PromptKit providers; never put credential values here.
credentials: [NOTARIUS_LLM_API_KEY]
# Optionally select a Notarius ConfigKit overlay when extraction runs. This
# is independent of Narratio's selected overlay and may use a different name.
# See docs/config.md and docs/integrations/notarius.md for the contracts.

View File

@@ -1,6 +1,10 @@
package notarius
import "context"
import (
"context"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// FakeRunner is a configurable in-memory runner for stage tests.
type FakeRunner struct {
@@ -15,6 +19,7 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error)
return RunResult{}, err
}
copyRequest := req
copyRequest.Credentials = append([]configkit.Secret(nil), req.Credentials...)
if req.ConfigProfile != nil {
profile := *req.ConfigProfile
copyRequest.ConfigProfile = &profile

View File

@@ -25,8 +25,8 @@ type ReferenceBinding struct {
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
type RunRequest struct {
Binary string
// LLMAPIKey is an optional already-resolved credential reference.
LLMAPIKey *configkit.Secret
// Credentials are already-resolved environment references selected for Notarius.
Credentials []configkit.Secret
ConfigProfile *string
ConfigPath string
PipelineID string

View File

@@ -52,19 +52,9 @@ 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}
envOverrides, sensitiveEnvNames, err := subprocess.CredentialEnvironment(req.Credentials)
if err != nil {
return RunResult{}, fmt.Errorf("notarius credentials: %w", err)
}
args := []string{

View File

@@ -188,12 +188,16 @@ func TestSubprocessRunnerRejectsInvalidChunkCacheBeforeLaunch(t *testing.T) {
func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t *testing.T) {
const envName = "NARRATIO_NOTARIUS_CUSTOM_LLM_KEY"
const otherName = "NARRATIO_NOTARIUS_CUSTOM_OTHER_KEY"
const loadedValue = "loaded-notarius-credential"
const otherValue = "loaded-notarius-other-credential"
credential := loadTestCredential(t, envName, loadedValue)
otherCredential := loadTestCredential(t, otherName, otherValue)
t.Setenv(envName, "changed-parent-credential")
t.Setenv(otherName, "changed-parent-other-credential")
req := validRunRequest(t)
req.LLMAPIKey = credential
req.Credentials = []configkit.Secret{*credential, *otherCredential}
var captured sharedsubprocess.RunRequest
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
captured = processReq
@@ -203,10 +207,10 @@ func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t *
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 len(captured.EnvOverrides) != 2 || captured.EnvOverrides[envName] != loadedValue || captured.EnvOverrides[otherName] != otherValue {
t.Fatal("subprocess did not receive exactly the loaded credential overrides")
}
if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{envName}) {
if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{envName, otherName}) {
t.Fatalf("sensitive environment names = %#v, want configured reference name", captured.SensitiveEnvNames)
}
wantArgs := []string{
@@ -228,7 +232,7 @@ func TestSubprocessRunnerRejectsInvalidProgrammaticCredentialBeforeLaunch(t *tes
} {
t.Run(test.name, func(t *testing.T) {
req := validRunRequest(t)
req.LLMAPIKey = &test.reference
req.Credentials = []configkit.Secret{test.reference}
started := false
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
started = true
@@ -342,10 +346,12 @@ func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) {
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
const envName = "NARRATIO_NOTARIUS_CUSTOM_CAPTURE_KEY"
const otherName = "NARRATIO_NOTARIUS_CUSTOM_CAPTURE_OTHER_KEY"
const canary = "notarius-captured-stream-canary"
const otherCanary = "notarius-captured-stream-other-canary"
const unrelated = "NARRATIO_NOTARIUS_UNRELATED_PARENT_KEY"
req := validRunRequest(t)
req.LLMAPIKey = loadTestCredential(t, envName, canary)
req.Credentials = []configkit.Secret{*loadTestCredential(t, envName, canary), *loadTestCredential(t, otherName, otherCanary)}
writeValidBundleAndReceipt(t, req, false)
receiptFixture := req.ReceiptPath + ".fixture"
data, err := os.ReadFile(req.ReceiptPath)
@@ -356,7 +362,7 @@ func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T)
if err := json.Unmarshal(data, &receipt); err != nil {
t.Fatalf("decode receipt fixture: %v", err)
}
receipt["credential_echo"] = canary
receipt["credential_echo"] = canary + " " + otherCanary
writeJSONFile(t, receiptFixture, receipt)
if err := os.Remove(req.ReceiptPath); err != nil {
t.Fatalf("Remove(receipt) error = %v", err)
@@ -373,11 +379,16 @@ if [ "$`+envName+`" != '`+canary+`' ]; then
printf 'selected credential mismatch\n' >&2
exit 8
fi
if [ "$`+otherName+`" != '`+otherCanary+`' ]; then
printf 'other selected credential mismatch\n' >&2
exit 10
fi
if [ -n "$`+unrelated+`" ]; then
printf 'unrelated credential inherited\n' >&2
exit 9
fi
printf '%s\n' "$`+envName+`" >&2
printf '%s\n' "$`+otherName+`" >&2
cat "$NOTARIUS_RECEIPT_FIXTURE"
`)
req.Binary = script
@@ -392,32 +403,39 @@ cat "$NOTARIUS_RECEIPT_FIXTURE"
assertTextFile(t, filepath.Join(captureDir, "working-directory"), req.WorkingDirectory+"\n")
assertTextFile(t, filepath.Join(captureDir, "environment"), "")
assertRedactedCredentialFile(t, req.LogPath, canary)
assertRedactedCredentialFile(t, req.LogPath, otherCanary)
receiptBytes, err := os.ReadFile(req.ReceiptPath)
if err != nil {
t.Fatalf("ReadFile(receipt) error = %v", err)
}
assertRedactedCredentialBytes(t, receiptBytes, canary)
assertRedactedCredentialBytes(t, receiptBytes, otherCanary)
}
func TestSubprocessRunnerRedactsCredentialOnProcessFailure(t *testing.T) {
const envName = "NARRATIO_NOTARIUS_CUSTOM_FAILURE_KEY"
const otherName = "NARRATIO_NOTARIUS_CUSTOM_FAILURE_OTHER_KEY"
const canary = "notarius-failure-stream-canary"
const otherCanary = "notarius-failure-stream-other-canary"
req := validRunRequest(t)
req.LLMAPIKey = loadTestCredential(t, envName, canary)
req.Credentials = []configkit.Secret{*loadTestCredential(t, envName, canary), *loadTestCredential(t, otherName, otherCanary)}
req.Binary = writeShellScript(t, `#!/bin/sh
printf '%s\n' "$`+envName+`"
printf '%s\n' "$`+envName+`" >&2
printf '%s\n' "$`+otherName+`" >&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>") {
if strings.Contains(err.Error(), canary) || strings.Contains(err.Error(), otherCanary) || !strings.Contains(err.Error(), "<redacted>") {
t.Fatal("process failure did not return redacted diagnostics")
}
assertRedactedCredentialFile(t, req.ReceiptPath, canary)
assertRedactedCredentialFile(t, req.ReceiptPath, otherCanary)
assertRedactedCredentialFile(t, req.LogPath, canary)
assertRedactedCredentialFile(t, req.LogPath, otherCanary)
}
func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T) {

View File

@@ -389,16 +389,16 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
`, 1)
pipelineYAML += `notarius:
enabled: false
llm_api_key: NARRATIO_INSPECT_NOTARIUS_ONE
credentials: [NARRATIO_INSPECT_NOTARIUS_ONE]
`
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatal(err)
}
writeInspectionOverlays(t, pipelinePath,
"audita:\n model: production\n llm_api_key: NARRATIO_INSPECT_LLM_ONE\n",
"audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\nnotarius:\n llm_api_key: NARRATIO_INSPECT_NOTARIUS_TWO\n",
"audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\nnotarius:\n credentials: [NARRATIO_INSPECT_NOTARIUS_TWO]\n",
)
mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "overlays", "without-notarius-key.yml"), "notarius:\n llm_api_key: null\n")
mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "overlays", "without-notarius-key.yml"), "notarius:\n credentials: []\n")
var validate bytes.Buffer
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &validate); err != nil {
@@ -439,11 +439,11 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
!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") {
if !strings.Contains(sources.String(), "notarius.credentials") {
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("overlay null did not remove Notarius credential: %q", removed.String())
if strings.Contains(removed.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") {
t.Fatalf("empty overlay did not remove Notarius credential: %q", removed.String())
}
}
@@ -451,7 +451,7 @@ 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")
mustWriteTestFile(t, path, "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+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)

View File

@@ -665,16 +665,16 @@ func TestExtractLifecycleCredentialChangesRemainResumable(t *testing.T) {
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)
cfg.Pipeline.Notarius.Credentials = []configkit.Secret{*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
cfg.Pipeline.Notarius.Credentials = nil
if test.after != nil {
cfg.Pipeline.Notarius.LLMAPIKey = test.after(t)
cfg.Pipeline.Notarius.Credentials = []configkit.Secret{*test.after(t)}
}
resumed, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
if err != nil {
@@ -876,7 +876,7 @@ 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)
cfg.Pipeline.Notarius.Credentials = []configkit.Secret{*loadLifecycleNotariusCredential(t, name, canary)}
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
t.Fatalf("executeStages() error = %v", err)
}
@@ -1181,12 +1181,12 @@ func loadLifecycleNotariusCredential(t *testing.T, name, value string) *configki
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 {
if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+name+"]\n"), 0o644); err != nil {
t.Fatal(err)
}
pipeline, err := config.LoadPipeline(path)
if err != nil {
t.Fatal(err)
}
return pipeline.Notarius.LLMAPIKey
return &pipeline.Notarius.Credentials[0]
}

View File

@@ -138,7 +138,7 @@ func TestNotariusCredentialLoadsFromCommandSecretBootstrap(t *testing.T) {
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 {
if err := os.WriteFile(pipelinePath, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+envName+"]\n"), 0o644); err != nil {
t.Fatal(err)
}
secretsDir := filepath.Join(root, "secrets")
@@ -158,8 +158,8 @@ func TestNotariusCredentialLoadsFromCommandSecretBootstrap(t *testing.T) {
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 {
if pipeline.Notarius == nil || len(pipeline.Notarius.Credentials) != 1 || !pipeline.Notarius.Credentials[0].IsResolved() ||
pipeline.Notarius.Credentials[0].EnvName() != envName || pipeline.Notarius.Credentials[0].Value() != test.wantValue {
t.Fatal("Notarius credential did not resolve from the selected command bootstrap source")
}
})
@@ -220,7 +220,7 @@ func TestExecuteConfigValidateReportsSkippedEmptyBeforeTypedLoadFailure(t *testi
if err != nil {
t.Fatal(err)
}
pipelineYAML = append(pipelineYAML, []byte("notarius:\n enabled: false\n llm_api_key: "+name+"\n")...)
pipelineYAML = append(pipelineYAML, []byte("notarius:\n enabled: false\n credentials: ["+name+"]\n")...)
if err := os.WriteFile(pipelinePath, pipelineYAML, 0o644); err != nil {
t.Fatal(err)
}

View File

@@ -309,7 +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"`
Credentials []configkit.Secret `yaml:"credentials,omitempty" json:"credentials,omitempty"`
ConfigProfile *string `yaml:"config_profile,omitempty" json:"config_profile,omitempty"`
Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"`
ChunkCache string `yaml:"chunk_cache,omitempty" json:"chunk_cache,omitempty"`

View File

@@ -50,12 +50,12 @@ func (cfg AuditaConfig) MarshalYAML() (any, error) {
}, nil
}
// MarshalYAML exposes only the public Notarius credential reference name.
// MarshalYAML exposes only public Notarius credential reference names.
func (cfg NotariusConfig) MarshalYAML() (any, error) {
type safeNotarius struct {
Enabled bool `yaml:"enabled"`
Binary string `yaml:"binary"`
LLMAPIKey *string `yaml:"llm_api_key,omitempty"`
Credentials []string `yaml:"credentials,omitempty"`
ConfigProfile *string `yaml:"config_profile,omitempty"`
Resume bool `yaml:"resume,omitempty"`
ChunkCache string `yaml:"chunk_cache,omitempty"`
@@ -67,7 +67,7 @@ func (cfg NotariusConfig) MarshalYAML() (any, error) {
Outputs map[string]NotariusOutputConfig `yaml:"outputs"`
}
return safeNotarius{
Enabled: cfg.Enabled, Binary: cfg.Binary, LLMAPIKey: secretReferenceName(cfg.LLMAPIKey),
Enabled: cfg.Enabled, Binary: cfg.Binary, Credentials: secretReferenceNames(cfg.Credentials),
ConfigProfile: cfg.ConfigProfile,
Resume: cfg.Resume, ChunkCache: cfg.ChunkCache,
ConfigPath: cfg.ConfigPath, PipelineID: cfg.PipelineID, Timeout: cfg.Timeout,
@@ -75,6 +75,17 @@ func (cfg NotariusConfig) MarshalYAML() (any, error) {
}, nil
}
func secretReferenceNames(secrets []configkit.Secret) []string {
if secrets == nil {
return nil
}
names := make([]string, len(secrets))
for index, secret := range secrets {
names[index] = secret.EnvName()
}
return names
}
func secretReferenceName(secret *configkit.Secret) *string {
if secret == nil {
return nil

View File

@@ -102,7 +102,7 @@ storage:
secret_access_key: NARRATIO_TEST_SECRET_KEY
notarius:
enabled: false
llm_api_key: NARRATIO_TEST_NOTARIUS_KEY
credentials: [NARRATIO_TEST_NOTARIUS_KEY]
config_profile: ""
`
pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
@@ -146,8 +146,8 @@ notarius:
}
var roundTrip struct {
Notarius *struct {
LLMAPIKey *string `yaml:"llm_api_key"`
ConfigProfile *string `yaml:"config_profile"`
Credentials []string `yaml:"credentials"`
ConfigProfile *string `yaml:"config_profile"`
} `yaml:"notarius"`
}
if err := yaml.Unmarshal(firstYAML, &roundTrip); err != nil {
@@ -156,8 +156,8 @@ notarius:
if roundTrip.Notarius == nil || roundTrip.Notarius.ConfigProfile == nil || *roundTrip.Notarius.ConfigProfile != "" {
t.Fatalf("round-trip config profile = %#v, want explicit empty", roundTrip.Notarius)
}
if roundTrip.Notarius.LLMAPIKey == nil || *roundTrip.Notarius.LLMAPIKey != "NARRATIO_TEST_NOTARIUS_KEY" {
t.Fatalf("round-trip credential reference = %#v, want safe public name", roundTrip.Notarius.LLMAPIKey)
if len(roundTrip.Notarius.Credentials) != 1 || roundTrip.Notarius.Credentials[0] != "NARRATIO_TEST_NOTARIUS_KEY" {
t.Fatalf("round-trip credential reference = %#v, want safe public name", roundTrip.Notarius.Credentials)
}
t.Setenv("NARRATIO_TEST_ACCESS_KEY", secondCanary)

View File

@@ -58,8 +58,10 @@ notarius:
func TestNotariusCredentialReferenceLoading(t *testing.T) {
const envName = "NARRATIO_TEST_NOTARIUS_KEY"
const secondName = "NARRATIO_TEST_NOTARIUS_SECOND_KEY"
const canary = "notarius-credential-canary"
t.Setenv(envName, canary)
t.Setenv(secondName, "second-canary")
tests := []struct {
name string
@@ -67,8 +69,9 @@ func TestNotariusCredentialReferenceLoading(t *testing.T) {
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"},
{name: "null", credential: " credentials: null\n", wantNil: true},
{name: "empty", credential: " credentials: []\n"},
{name: "custom references", credential: " credentials: [" + envName + ", " + secondName + "]\n"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -81,15 +84,19 @@ func TestNotariusCredentialReferenceLoading(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if test.wantNil {
if cfg.Notarius.LLMAPIKey != nil {
t.Fatal("credential reference should be nil")
}
if test.wantNil && cfg.Notarius.Credentials != nil {
t.Fatal("credential list should be nil")
}
if test.name == "empty" && len(cfg.Notarius.Credentials) == 0 {
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")
if test.wantNil {
return
}
if len(cfg.Notarius.Credentials) != 2 ||
cfg.Notarius.Credentials[0].EnvName() != envName || cfg.Notarius.Credentials[0].Value() != canary ||
cfg.Notarius.Credentials[1].EnvName() != secondName || cfg.Notarius.Credentials[1].Value() != "second-canary" {
t.Fatal("credential references were not loaded and resolved")
}
})
}
@@ -110,7 +117,7 @@ func TestNotariusCredentialReferenceFailures(t *testing.T) {
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"
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n credentials: [" + test.reference + "]\n"
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
t.Fatal(err)
}
@@ -122,6 +129,17 @@ func TestNotariusCredentialReferenceFailures(t *testing.T) {
}
}
func TestNotariusRejectsRetiredCredentialField(t *testing.T) {
path := filepath.Join(t.TempDir(), "pipeline.yml")
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n llm_api_key: NARRATIO_TEST_NOTARIUS_KEY\n"
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadPipeline(path); err == nil || !strings.Contains(err.Error(), "decode.unknown_field") {
t.Fatalf("LoadPipeline() error = %v, want unknown field", err)
}
}
func TestNotariusEnabledDefaultsAndPathResolution(t *testing.T) {
dir := t.TempDir()
pipelinePath := filepath.Join(dir, "deployment", "pipeline.yml")

View File

@@ -147,7 +147,7 @@ func TestPipelineOverlayDoesNotRequireUnusedBaseCredentials(t *testing.T) {
}
}
func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) {
func TestNotariusCredentialOverlayReplacementAndProvenance(t *testing.T) {
const baseKey = "NARRATIO_NOTARIUS_UNUSED_BASE_KEY"
const profileKey = "NARRATIO_NOTARIUS_PROFILE_KEY"
if err := os.Unsetenv(baseKey); err != nil {
@@ -155,24 +155,24 @@ func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) {
}
t.Setenv(profileKey, "synthetic-notarius-profile-key")
for _, credential := range []string{profileKey, "null"} {
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, "overlays/testing.yml", "notarius:\n llm_api_key: "+credential+"\n")
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+baseKey+"]\n")
overlay := writePipelineFixture(t, dir, "overlays/testing.yml", "notarius:\n credentials: "+credential+"\n")
profile := "testing"
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile})
if err != nil {
t.Fatal(err)
}
if credential == "null" {
if cfg.Notarius.LLMAPIKey != nil {
t.Fatal("profile did not remove the base credential")
if credential == "null" || credential == "[]" {
if len(cfg.Notarius.Credentials) != 0 {
t.Fatal("overlay did not clear base credentials")
}
return
}
if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != profileKey || !cfg.Notarius.LLMAPIKey.IsResolved() {
t.Fatal("profile credential was not resolved")
if len(cfg.Notarius.Credentials) != 1 || cfg.Notarius.Credentials[0].EnvName() != profileKey || !cfg.Notarius.Credentials[0].IsResolved() {
t.Fatal("overlay credential was not resolved")
}
sources, err := EffectivePipelineSources(cfg, ResolvedParty{})
if err != nil {
@@ -180,12 +180,12 @@ func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) {
}
found := false
for _, source := range sources {
if source.Path == "notarius.llm_api_key" {
if source.Path == "notarius.credentials" {
found = source.Source == overlay
}
}
if !found {
t.Fatal("Notarius credential provenance did not identify the selected profile")
t.Fatal("Notarius credential provenance did not identify the selected overlay")
}
})
}
@@ -234,7 +234,7 @@ 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")
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+envName+"]\n")
cfg, err := LoadPipeline(root)
if err != nil {
t.Fatal(err)
@@ -244,7 +244,7 @@ func TestNotariusCredentialRootProvenance(t *testing.T) {
t.Fatal(err)
}
for _, source := range sources {
if source.Path == "notarius.llm_api_key" && source.Source == root {
if source.Path == "notarius.credentials" && source.Source == root {
return
}
}

View File

@@ -146,7 +146,7 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (re
chunkCache = "refresh"
}
request := notarius.RunRequest{
Binary: resolvedBinary, LLMAPIKey: notariusConfig.LLMAPIKey, ConfigProfile: notariusConfig.ConfigProfile,
Binary: resolvedBinary, Credentials: notariusConfig.Credentials, ConfigProfile: notariusConfig.ConfigProfile,
ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory,
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, Resume: resume, ChunkCache: chunkCache, References: referenceBindings,

View File

@@ -61,7 +61,7 @@ func TestExtractStageResolvesManifestInputAndBuildsExactRequest(t *testing.T) {
transcribe_url: https://transcription.example.test/transcribe
notarius:
enabled: false
llm_api_key: NARRATIO_EXTRACT_NOTARIUS_KEY
credentials: [NARRATIO_EXTRACT_NOTARIUS_KEY]
`), 0o644); err != nil {
t.Fatal(err)
}
@@ -71,7 +71,7 @@ notarius:
}
env, m, fake := setupExtractEnv(t)
env.Config.Pipeline.Notarius.LLMAPIKey = loadedPipeline.Notarius.LLMAPIKey
env.Config.Pipeline.Notarius.Credentials = loadedPipeline.Notarius.Credentials
configProfile := "inexpensive"
env.Config.Pipeline.Notarius.ConfigProfile = &configProfile
result, err := (extractStage{}).Run(context.Background(), env, m)
@@ -103,7 +103,7 @@ notarius:
if req.Resume || req.ChunkCache != "" {
t.Fatalf("default cache controls = resume %v, mode %q", req.Resume, req.ChunkCache)
}
if req.LLMAPIKey != loadedPipeline.Notarius.LLMAPIKey || req.LLMAPIKey.EnvName() != credentialName || !req.LLMAPIKey.IsResolved() {
if len(req.Credentials) != 1 || req.Credentials[0].EnvName() != credentialName || !req.Credentials[0].IsResolved() {
t.Fatal("request did not retain the loaded Notarius credential reference")
}
if req.ConfigProfile == nil || *req.ConfigProfile != configProfile {

View File

@@ -194,7 +194,7 @@ func TestExtractSemanticConfigExcludesLoadedCredentialChanges(t *testing.T) {
renamed := loadNotariusCredentialForSemanticTest(t, secondName, "second-value")
baseline := downstreamSemanticEnv()
baseline.Config.Pipeline.Notarius.LLMAPIKey = first
baseline.Config.Pipeline.Notarius.Credentials = []configkit.Secret{*first}
want, err := (extractStage{}).SemanticConfigFingerprint(baseline)
if err != nil {
t.Fatal(err)
@@ -209,7 +209,9 @@ func TestExtractSemanticConfigExcludesLoadedCredentialChanges(t *testing.T) {
} {
t.Run(test.name, func(t *testing.T) {
env := downstreamSemanticEnv()
env.Config.Pipeline.Notarius.LLMAPIKey = test.credential
if test.credential != nil {
env.Config.Pipeline.Notarius.Credentials = []configkit.Secret{*test.credential}
}
got, err := (extractStage{}).SemanticConfigFingerprint(env)
if err != nil {
t.Fatal(err)
@@ -225,14 +227,14 @@ func loadNotariusCredentialForSemanticTest(t *testing.T, name, value string) *co
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 {
if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+name+"]\n"), 0o644); err != nil {
t.Fatal(err)
}
pipeline, err := config.LoadPipeline(path)
if err != nil {
t.Fatal(err)
}
return pipeline.Notarius.LLMAPIKey
return &pipeline.Notarius.Credentials[0]
}
func TestPublishSemanticConfigSensitivityAndOperationalExclusions(t *testing.T) {