diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 9bc6082..6fcf6d8 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -37,7 +37,7 @@ failed stage or required manifest save prevents later stages from starting. | --- | --- | --- | | Executable | `cmd/narratio` | Process entry, standard stream wiring, argument handoff, and exit status. | | Application orchestration | `internal/app` | Command dispatch, configuration selection, secret-file environment loading, production composition, session locking, planning, execution, restore, and user-facing reporting. | -| Configuration | [`internal/config`](configuration.md) | ConfigKit-backed pipeline imports and profiles, canonical party and family expansion, strict YAML loading, defaults, normalization, session templating, and validation. | +| Configuration | [`internal/config`](configuration.md) | ConfigKit-backed pipeline imports and overlays, canonical party and family expansion, strict YAML loading, defaults, normalization, session templating, and validation. | | Pipeline stages | `internal/stage` | Canonical stage registry, shared stage contract, execution dependencies, and implemented stage behavior. | | External boundaries | `internal/adapters`, `internal/audio` | WhisperX HTTP, downstream subprocesses, notification, object storage, and S3 audio materialization behind Narratio contracts. | | Manifests | `internal/manifest` | Durable session progress, stage transitions, validation, and atomic persistence. | @@ -106,7 +106,7 @@ so any earlier replacement can stale a prior cleanup result. ## Focused Documentation -- [Configuration Internals](configuration.md): pipeline imports and profiles, +- [Configuration Internals](configuration.md): pipeline imports and overlays, field ownership, decoding, and root-relative path semantics. - [Adapter Internals](adapters.md): external adapter boundaries, composition, failure behavior, and test surfaces. diff --git a/docs/roadmap/audita.md b/docs/roadmap/audita.md index c042230..45ea4c5 100644 --- a/docs/roadmap/audita.md +++ b/docs/roadmap/audita.md @@ -1,6 +1,6 @@ # Adopt Audita v2 and ConfigKit v0.4.0 -Status: agreed scope; implementation has not started. +Status: implemented (2026-09-25); release pending. ## Goal diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 284f436..560c850 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implementation Plan: Audita v2 and Shared Provider Credentials -Status: In progress. +Status: Complete (2026-09-25). ## Scope and working instructions @@ -421,7 +421,18 @@ implicit credential aliases, or retired CLI/report expectations. ## Stage 8: Verify assembled behavior and audit scope -Status: Not started. +Status: Complete (2026-09-25). + +Verification: the offline app-level shared/Audita-only credential scenario +passed with config bootstrap, both subprocess fixtures, safe inspection/logging, +and ordinary polish reports. The tagged Audita v2.0.0 CLI validated its minimal +configuration and produced a transcript plus all three reports with an empty +module sequence and no provider request. Active-reference and scope audits found +no removed production fields or compatibility path; remaining old names occur in +migration guidance, explicit-rejection checks, or historical fixtures. `go test +./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`, `go test +./internal/doccheck`, `go test ./internal/config -run +'^TestExamplesLoadAndValidate$'`, and `git diff --check` passed. Preparation: cumulative diff, this plan, feature roadmap, app configuration/ subprocess integration tests, and all policies. diff --git a/internal/app/provider_credentials_integration_test.go b/internal/app/provider_credentials_integration_test.go new file mode 100644 index 0000000..e78434b --- /dev/null +++ b/internal/app/provider_credentials_integration_test.go @@ -0,0 +1,244 @@ +package app + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +const ( + sharedProviderName = "NARRATIO_SHARED_PROVIDER_KEY" + auditaOnlyName = "NARRATIO_AUDITA_ONLY_KEY" + sharedProviderKey = "synthetic-shared-provider-value" + auditaOnlyKey = "synthetic-audita-only-value" +) + +func TestCommandsShareProviderCredentialAcrossAdapters(t *testing.T) { + configDir := t.TempDir() + workspace := t.TempDir() + secretsDir := filepath.Join(configDir, "secrets") + for name, value := range map[string]string{ + sharedProviderName: sharedProviderKey, + auditaOnlyName: auditaOnlyKey, + } { + restoreEnvAfterTest(t, name) + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } + mustWriteSecretFile(t, filepath.Join(secretsDir, name), value+"\n") + } + fixture := writeProviderProcessWrapper(t) + seriatim := writeSeriatimAppTestWrapper(t) + configPath := filepath.Join(configDir, "pipeline.yml") + campaignPath := writeAppTestCampaignConfig(t, configDir) + sessionPath := filepath.Join(configDir, "session.yml") + notariusConfig := filepath.Join(configDir, "notarius.yml") + mustWriteTestFile(t, notariusConfig, "pipelines: {}\n") + mustWriteTestFile(t, configPath, `workspace: + root: `+workspace+` +storage: + backend: local +whisperx: + transcribe_url: https://example.com/transcribe +seriatim: + binary: `+seriatim+` +audita: + binary: `+fixture+` + credentials: [`+sharedProviderName+`, `+auditaOnlyName+`] +notarius: + enabled: true + binary: `+fixture+` + config_path: `+notariusConfig+` + pipeline_id: dnd-session + credentials: [`+sharedProviderName+`] + outputs: + npc_registry: + lane_id: npc-registry + media_type: application/json + schema_id: notarius.dnd.npc_registry + schema_version: v1 + module_key: dnd/npc-registry +notification: + mode: noop +`) + mustWriteTestFile(t, sessionPath, `session_id: 2026-05-03 +campaign: sample-campaign +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`) + + var stdout, stderr bytes.Buffer + if code := Execute([]string{"config", "show", "--config", configPath}, &stdout, &stderr); code != 0 { + t.Fatalf("config show exit = %d; stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), sharedProviderName) || !strings.Contains(stdout.String(), auditaOnlyName) { + t.Fatalf("config show omitted reference names: %q", stdout.String()) + } + assertNoProviderValues(t, stdout.Bytes(), stderr.Bytes()) + + workRoot := filepath.Join(workspace, "work", "sample-campaign", "2026-05-03") + manifestPath := filepath.Join(workRoot, "manifest.json") + basePath := filepath.Join(workRoot, "transcripts", "base.json") + mustWriteTestFile(t, basePath, `{"schema":"seriatim-intermediate","segments":[]}`) + seedCurrentBuiltInEvidence(t, manifestPath, "2026-05-03", "sample-campaign", "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath) + mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "[]\n") + args := []string{"--config", configPath, "--campaign-file", campaignPath, "--session", sessionPath} + stdout.Reset() + stderr.Reset() + if code := Execute(append([]string{"run-stage", "polish", "2026-05-03", "--force"}, args...), &stdout, &stderr); code != 0 { + t.Fatalf("polish exit = %d; stderr=%q", code, stderr.String()) + } + assertNoProviderValues(t, stdout.Bytes(), stderr.Bytes()) + m, err := (&manifest.LocalStore{}).Load(context.Background(), manifestPath) + if err != nil { + t.Fatal(err) + } + if polish := m.Stages["polish"]; polish == nil || len(polish.Outputs) != 1 || len(polish.Logs) != 5 { + t.Fatalf("polish did not retain canonical transcript and ordinary reports: %#v", polish) + } + for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} { + found := false + for _, path := range m.Stages["polish"].Logs { + found = found || filepath.Base(path) == name + } + if !found { + t.Fatalf("missing polish report %q", name) + } + } + + trimmedPath := filepath.Join(workRoot, "transcripts", "final.trimmed.json") + mustWriteTestFile(t, trimmedPath, `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, trimmedPath) + if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPath, m); err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + if code := Execute(append([]string{"run-stage", "extract", "2026-05-03", "--force"}, args...), &stdout, &stderr); code != 0 { + t.Fatalf("extract exit = %d; stderr=%q", code, stderr.String()) + } + assertNoProviderValues(t, stdout.Bytes(), stderr.Bytes()) + if !strings.Contains(stdout.String(), "stage=extract executed=1") { + t.Fatalf("extract did not execute: %q", stdout.String()) + } + redactedLogs := 0 + if err := filepath.WalkDir(workRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil || entry.IsDir() { + return walkErr + } + data, err := os.ReadFile(path) + if err == nil { + assertNoProviderValues(t, data) + if strings.HasSuffix(path, ".stderr.log") && bytes.Contains(data, []byte("")) { + redactedLogs++ + } + } + return err + }); err != nil { + t.Fatal(err) + } + if redactedLogs < 2 { + t.Fatalf("redacted subprocess logs = %d, want Audita and Notarius", redactedLogs) + } +} + +func assertNoProviderValues(t *testing.T, content ...[]byte) { + t.Helper() + for _, data := range content { + if bytes.Contains(data, []byte(sharedProviderKey)) || bytes.Contains(data, []byte(auditaOnlyKey)) { + t.Fatal("credential value appeared in inspection or workspace output") + } + } +} + +func writeProviderProcessWrapper(t *testing.T) string { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "provider-process.sh") + if err := os.WriteFile(path, []byte("#!/bin/sh\nexec \""+exe+"\" -test.run=TestProviderCredentialProcessFixture -- \"$@\"\n"), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func TestProviderCredentialProcessFixture(t *testing.T) { + if !appHelperInvocation() { + return + } + start := -1 + for i, arg := range os.Args { + if arg == "--" { + start = i + 1 + break + } + } + if start < 0 || start >= len(os.Args) { + os.Exit(2) + } + args := os.Args[start:] + if os.Getenv(sharedProviderName) != sharedProviderKey { + _, _ = os.Stderr.WriteString("shared credential not forwarded\n") + os.Exit(2) + } + switch args[0] { + case "process": + if os.Getenv(auditaOnlyName) != auditaOnlyKey { + _, _ = os.Stderr.WriteString("Audita-only credential not forwarded\n") + os.Exit(2) + } + output := appSeriatimFlagValue(args, "--output") + if output == "" { + os.Exit(2) + } + if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil { + os.Exit(2) + } + if err := os.WriteFile(output, []byte(`{"schema":"audita.processed.v1","segments":[]}`), 0o644); err != nil { + os.Exit(2) + } + for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} { + if err := os.WriteFile(filepath.Join(filepath.Dir(output), name), []byte("status: completed\n"), 0o644); err != nil { + os.Exit(2) + } + } + case "run": + if _, present := os.LookupEnv(auditaOnlyName); present { + _, _ = os.Stderr.WriteString("Audita-only credential reached Notarius\n") + os.Exit(2) + } + root := appSeriatimFlagValue(args, "--output-dir") + bundle := filepath.Join(root, "fixture-run") + if err := os.MkdirAll(filepath.Join(bundle, "lanes"), 0o755); err != nil { + os.Exit(2) + } + for name, value := range map[string]string{ + "manifest.json": "{}", "rejected.json": `{"rejected":[]}`, + "warnings.json": `{"schema_version":"notarius.warnings.v2","group_count":0,"occurrence_count":0,"groups":[]}`, + "diagnostics.json": `{"schema_version":"notarius.diagnostics.v1","group_count":0,"occurrence_count":0,"truncated":false,"unrepresented_occurrence_count":0,"groups":[]}`, + "lanes/npc.json": `{"records":[]}`, + "index.json": `{"manifest_file":"manifest.json","output_files":[{"lane_id":"npc-registry","file":"lanes/npc.json","media_type":"application/json","module_key":"dnd/npc-registry","schema_id":"notarius.dnd.npc_registry","schema_version":"v1"}],"rejected_file":"rejected.json","warnings_file":"warnings.json","diagnostics_file":"diagnostics.json"}`, + } { + if err := os.WriteFile(filepath.Join(bundle, filepath.FromSlash(name)), []byte(value), 0o644); err != nil { + os.Exit(2) + } + } + _, _ = os.Stdout.WriteString(fmt.Sprintf(`{"schema_version":"notarius.run-result.v2","run_id":"fixture-run","pipeline_id":"dnd-session","output_directory":%q,"index_file":"index.json","normalized_output_count":1,"rejected_output_count":0,"warning_group_count":0,"warning_occurrence_count":0,"diagnostic_group_count":0,"diagnostic_occurrence_count":0,"diagnostics_truncated":false,"validation_status":"approved"}`, bundle)) + default: + os.Exit(2) + } + _, _ = os.Stderr.WriteString(sharedProviderKey + "\n") + os.Exit(0) +}