From 8ff1b4fa66e4a0c974a6040061f0bef9de0cf03e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 10 Aug 2026 21:57:54 +0000 Subject: [PATCH] Enforce requested adapter output paths --- docs/internal/stage-transcribe.md | 2 ++ docs/roadmap/implementation.md | 2 ++ internal/stage/analyze.go | 44 ++++++----------------- internal/stage/merge.go | 18 +++++----- internal/stage/merge_test.go | 2 +- internal/stage/normalize.go | 40 +++++---------------- internal/stage/normalize_test.go | 2 +- internal/stage/output_path.go | 27 +++++++++++++++ internal/stage/output_path_test.go | 30 ++++++++++++++++ internal/stage/polish.go | 46 ++++--------------------- internal/stage/polish_test.go | 2 +- internal/stage/render.go | 10 ++++-- internal/stage/transcribe.go | 7 ++-- internal/stage/transcript_resolution.go | 19 ++++++++++ internal/stage/trim.go | 12 +++++-- 15 files changed, 137 insertions(+), 126 deletions(-) create mode 100644 internal/stage/output_path.go create mode 100644 internal/stage/output_path_test.go create mode 100644 internal/stage/transcript_resolution.go diff --git a/docs/internal/stage-transcribe.md b/docs/internal/stage-transcribe.md index 0539cdd..15f98e1 100644 --- a/docs/internal/stage-transcribe.md +++ b/docs/internal/stage-transcribe.md @@ -26,6 +26,8 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX. - prepared audio identities must be unique; prepare disambiguates distinct source paths that share a basename. - output path returned by adapter must match requested output path. +- an empty adapter result path means the requested path; adapters cannot select + an alternate destination. - each successful output is validated before stage success, and cancellation or incomplete dispatch cannot be reported as a successful result. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 11c3f32..36fd7f8 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -869,6 +869,8 @@ transcripts through one artifact-owned contract. no output, multiple transcripts, unsafe paths, adapter conformance, and plural raw sources. No adapter-selected alternate path may become authoritative. +**Status:** Completed. + ## Stage 26 — Centralize typed extraction-bundle evidence **Read first:** `audit-findings.md` lines 3538–3568 (DUP-007) and 3907–3932 diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index a29445a..4744c85 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -438,7 +438,10 @@ func executeAnalyzeArtifact( if renderRes.ValidationFailed { return nil, fmt.Errorf("analyze: scriptorium render returned validation_failed=true for artifact %q", artifactName) } - finalRenderOutputPath := coalesceString(renderRes.OutputPath, renderReq.OutputPath) + finalRenderOutputPath, err := authoritativeOutputPath(renderReq.OutputPath, renderRes.OutputPath) + if err != nil { + return nil, fmt.Errorf("analyze: %w", err) + } if err := requireNonEmptyFile(finalRenderOutputPath, artifactName+" render output"); err != nil { return nil, fmt.Errorf("analyze: %w", err) } @@ -487,7 +490,7 @@ func executeAnalyzeArtifact( "analyze: scriptorium validation failed (artifact=%q, prompt_id=%q, output_path=%q, exit_code=%d, stdout_log=%q, stderr_log=%q): %w", artifactName, req.PromptID, - coalesceString(res.OutputPath, req.OutputPath), + req.OutputPath, res.ExitCode, coalesceString(res.StdoutLogPath, req.StdoutLogPath), coalesceString(res.StderrLogPath, req.StderrLogPath), @@ -500,7 +503,10 @@ func executeAnalyzeArtifact( return nil, fmt.Errorf("analyze: scriptorium run returned validation_failed=true for artifact %q", artifactName) } - finalOutputPath := coalesceString(res.OutputPath, req.OutputPath) + finalOutputPath, err := authoritativeOutputPath(req.OutputPath, res.OutputPath) + if err != nil { + return nil, fmt.Errorf("analyze: %w", err) + } if err := requireNonEmptyFile(finalOutputPath, artifactName+" output"); err != nil { return nil, fmt.Errorf("analyze: %w", err) } @@ -562,37 +568,7 @@ func configuredArtifactNameFromSourceID(sourceID string) string { } func discoverProcessedTranscript(m *manifest.Manifest, paths artifacts.SessionPaths) (string, string, error) { - candidates := []string{} - if m != nil && m.Stages != nil { - if sr := m.Stages["polish"]; sr != nil { - for _, out := range sr.Outputs { - if out.Kind != "transcript_polished" { - continue - } - p := strings.TrimSpace(out.LocalPath) - if p == "" { - continue - } - resolved := artifacts.ResolveSessionLocalPathForRead(paths, p) - candidates = append(candidates, filepath.Clean(resolved)) - } - } - } - deduped := dedupeAndSortPaths(candidates) - for _, p := range deduped { - if info, err := os.Stat(p); err == nil && !info.IsDir() { - return p, "manifest.polish.outputs", nil - } - } - - fallback := filepath.Join(paths.TranscriptsDir, "polished.json") - if info, err := os.Stat(fallback); err == nil && !info.IsDir() { - return filepath.Clean(fallback), "fallback.transcripts_dir", nil - } - if len(deduped) > 0 { - return deduped[0], "manifest.polish.outputs", nil - } - return "", "", nil + return resolveSingletonTranscript(m, paths, artifacts.ArtifactTranscriptPolished) } type analyzeTranscriptInputs struct { diff --git a/internal/stage/merge.go b/internal/stage/merge.go index 5dca190..1314113 100644 --- a/internal/stage/merge.go +++ b/internal/stage/merge.go @@ -133,17 +133,17 @@ func (mergeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Sta return nil, fmt.Errorf("merge: seriatim merge failed: %w", err) } - finalMergedPath := mergedPath - if strings.TrimSpace(res.MergedTranscriptPath) != "" { - finalMergedPath = res.MergedTranscriptPath + finalMergedPath, err := authoritativeOutputPath(mergedPath, res.MergedTranscriptPath) + if err != nil { + return nil, fmt.Errorf("merge: %w", err) } if err := validateTranscriptJSONFile(finalMergedPath); err != nil { return nil, fmt.Errorf("merge: merged transcript %q invalid: %w", finalMergedPath, err) } - finalReportPath := req.ReportPath - if strings.TrimSpace(res.ReportPath) != "" { - finalReportPath = res.ReportPath + finalReportPath, err := authoritativeOutputPath(req.ReportPath, res.ReportPath) + if err != nil { + return nil, fmt.Errorf("merge: %w", err) } if reportEnabled { if err := validateTranscriptJSONFile(finalReportPath); err != nil { @@ -290,9 +290,9 @@ func normalizeMergeInputs( return nil, nil, nil, nil, fmt.Errorf("merge: normalize input %q failed: %w", input, err) } - finalOutputPath := outPath - if strings.TrimSpace(res.OutputNormalizedPath) != "" { - finalOutputPath = strings.TrimSpace(res.OutputNormalizedPath) + finalOutputPath, err := authoritativeOutputPath(outPath, res.OutputNormalizedPath) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("merge: normalize input %q: %w", input, err) } if err := validateTranscriptJSONFile(finalOutputPath); err != nil { return nil, nil, nil, nil, fmt.Errorf("merge: normalized transcript %q invalid (input %q): %w", finalOutputPath, input, err) diff --git a/internal/stage/merge_test.go b/internal/stage/merge_test.go index e19e1f5..b011f81 100644 --- a/internal/stage/merge_test.go +++ b/internal/stage/merge_test.go @@ -284,7 +284,7 @@ func TestMergeStageFailsWhenNormalizedOutputInvalid(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "normalized transcript") { + if !strings.Contains(err.Error(), "output path") { t.Fatalf("error = %q", err.Error()) } } diff --git a/internal/stage/normalize.go b/internal/stage/normalize.go index d4ddcc6..a451a13 100644 --- a/internal/stage/normalize.go +++ b/internal/stage/normalize.go @@ -3,7 +3,6 @@ package stage import ( "context" "fmt" - "os" "path/filepath" "strings" @@ -121,12 +120,18 @@ func (normalizeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) ( return nil, fmt.Errorf("normalize: seriatim normalize failed: %w", err) } - finalNormalizedPath := coalesceString(res.OutputNormalizedPath, req.OutputNormalizedPath) + finalNormalizedPath, err := authoritativeOutputPath(req.OutputNormalizedPath, res.OutputNormalizedPath) + if err != nil { + return nil, fmt.Errorf("normalize: %w", err) + } if err := validateProcessedTranscriptOutput(finalNormalizedPath); err != nil { return nil, fmt.Errorf("normalize: normalized transcript %q invalid: %w", finalNormalizedPath, err) } - finalReportPath := coalesceString(res.ReportPath, req.ReportPath) + finalReportPath, err := authoritativeOutputPath(req.ReportPath, res.ReportPath) + if err != nil { + return nil, fmt.Errorf("normalize: %w", err) + } if reportEnabled { if err := validateJSONFile(finalReportPath); err != nil { return nil, fmt.Errorf("normalize: report %q invalid: %w", finalReportPath, err) @@ -209,32 +214,5 @@ func normalizeConfigOrDefault(cfg *config.NormalizeConfig) *config.NormalizeConf } func discoverNormalizedTranscript(m *manifest.Manifest, paths artifacts.SessionPaths) (string, string, error) { - candidates := []string{} - if m != nil && m.Stages != nil { - if sr := m.Stages["normalize"]; sr != nil { - for _, out := range sr.Outputs { - if out.Kind != "transcript_final" { - continue - } - p := strings.TrimSpace(out.LocalPath) - if p == "" { - continue - } - resolved := artifacts.ResolveSessionLocalPathForRead(paths, p) - candidates = append(candidates, filepath.Clean(resolved)) - } - } - } - deduped := dedupeAndSortPaths(candidates) - for _, p := range deduped { - if info, err := os.Stat(p); err == nil && !info.IsDir() { - return p, "manifest.normalize.outputs", nil - } - } - - fallback := filepath.Join(paths.TranscriptsDir, "final.json") - if info, err := os.Stat(fallback); err == nil && !info.IsDir() { - return filepath.Clean(fallback), "fallback.transcripts_dir", nil - } - return "", "", nil + return resolveSingletonTranscript(m, paths, artifacts.ArtifactTranscriptFinal) } diff --git a/internal/stage/normalize_test.go b/internal/stage/normalize_test.go index 7f50f29..900de49 100644 --- a/internal/stage/normalize_test.go +++ b/internal/stage/normalize_test.go @@ -189,7 +189,7 @@ func TestNormalizeStageFailsWhenNormalizedOutputInvalid(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "normalized transcript") { + if !strings.Contains(err.Error(), "output path") { t.Fatalf("error = %q", err.Error()) } } diff --git a/internal/stage/output_path.go b/internal/stage/output_path.go new file mode 100644 index 0000000..6d1bd26 --- /dev/null +++ b/internal/stage/output_path.go @@ -0,0 +1,27 @@ +package stage + +import ( + "fmt" + "path/filepath" + "strings" +) + +// authoritativeOutputPath accepts an omitted adapter result path as the requested +// path and rejects any adapter attempt to redirect the stage-owned destination. +func authoritativeOutputPath(requested, returned string) (string, error) { + requested = filepath.Clean(strings.TrimSpace(requested)) + if requested == "" { + if strings.TrimSpace(returned) == "" { + return "", nil + } + return "", fmt.Errorf("adapter returned output path %q without a requested destination", returned) + } + if strings.TrimSpace(returned) == "" { + return requested, nil + } + returned = filepath.Clean(strings.TrimSpace(returned)) + if returned != requested { + return "", fmt.Errorf("adapter output path %q did not match requested path %q", returned, requested) + } + return requested, nil +} diff --git a/internal/stage/output_path_test.go b/internal/stage/output_path_test.go new file mode 100644 index 0000000..8ae29fb --- /dev/null +++ b/internal/stage/output_path_test.go @@ -0,0 +1,30 @@ +package stage + +import "testing" + +func TestAuthoritativeOutputPath(t *testing.T) { + tests := []struct { + name string + requested string + returned string + want string + wantErr bool + }{ + {name: "empty result", requested: "outputs/result.json", want: "outputs/result.json"}, + {name: "exact result", requested: "outputs/result.json", returned: "outputs/result.json", want: "outputs/result.json"}, + {name: "clean equivalent", requested: "outputs/result.json", returned: "outputs/next/../result.json", want: "outputs/result.json"}, + {name: "different result", requested: "outputs/result.json", returned: "other/result.json", wantErr: true}, + {name: "unexpected result without destination", returned: "other/result.json", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := authoritativeOutputPath(tt.requested, tt.returned) + if (err != nil) != tt.wantErr { + t.Fatalf("authoritativeOutputPath(%q, %q) error = %v", tt.requested, tt.returned, err) + } + if got != tt.want { + t.Fatalf("authoritativeOutputPath(%q, %q) = %q, want %q", tt.requested, tt.returned, got, tt.want) + } + }) + } +} diff --git a/internal/stage/polish.go b/internal/stage/polish.go index af1566b..2aeeff0 100644 --- a/internal/stage/polish.go +++ b/internal/stage/polish.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "path/filepath" "strings" @@ -131,17 +130,17 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St return nil, fmt.Errorf("polish: audita polish failed: %w", err) } - finalProcessedPath := processedPath - if strings.TrimSpace(res.ProcessedTranscriptPath) != "" { - finalProcessedPath = res.ProcessedTranscriptPath + finalProcessedPath, err := authoritativeOutputPath(processedPath, res.ProcessedTranscriptPath) + if err != nil { + return nil, fmt.Errorf("polish: %w", err) } if err := validateProcessedTranscriptOutput(finalProcessedPath); err != nil { return nil, fmt.Errorf("polish: processed transcript %q invalid: %w", finalProcessedPath, err) } - finalReportPath := req.ReportPath - if strings.TrimSpace(res.ReportPath) != "" { - finalReportPath = res.ReportPath + finalReportPath, err := authoritativeOutputPath(req.ReportPath, res.ReportPath) + if err != nil { + return nil, fmt.Errorf("polish: %w", err) } if reportEnabled { if err := validateTranscriptJSONFile(finalReportPath); err != nil { @@ -246,38 +245,7 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St } func discoverMergedTranscript(m *manifest.Manifest, paths artifacts.SessionPaths) (string, string, error) { - candidates := make([]string, 0) - if m != nil && m.Stages != nil { - if sr := m.Stages["merge"]; sr != nil { - for _, out := range sr.Outputs { - if out.Kind != "transcript_base" { - continue - } - p := strings.TrimSpace(out.LocalPath) - if p == "" { - continue - } - resolved := artifacts.ResolveSessionLocalPathForRead(paths, p) - candidates = append(candidates, filepath.Clean(resolved)) - } - } - } - - deduped := dedupeAndSortPaths(candidates) - for _, p := range deduped { - if info, err := os.Stat(p); err == nil && !info.IsDir() { - return p, "manifest.merge.outputs", nil - } - } - - fallback := filepath.Join(paths.TranscriptsDir, "base.json") - if info, err := os.Stat(fallback); err == nil && !info.IsDir() { - return filepath.Clean(fallback), "fallback.transcripts_dir", nil - } - if len(deduped) > 0 { - return deduped[0], "manifest.merge.outputs", nil - } - return "", "", nil + return resolveSingletonTranscript(m, paths, artifacts.ArtifactTranscriptBase) } func validateProcessedTranscriptOutput(path string) error { diff --git a/internal/stage/polish_test.go b/internal/stage/polish_test.go index a447b2f..b6e212a 100644 --- a/internal/stage/polish_test.go +++ b/internal/stage/polish_test.go @@ -215,7 +215,7 @@ func TestPolishStageFailsWhenProcessedOutputInvalid(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "processed transcript") { + if !strings.Contains(err.Error(), "output path") { t.Fatalf("error = %q", err.Error()) } } diff --git a/internal/stage/render.go b/internal/stage/render.go index 639f02a..8809eaa 100644 --- a/internal/stage/render.go +++ b/internal/stage/render.go @@ -157,7 +157,10 @@ func (renderStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St if err != nil { return nil, fmt.Errorf("render: seriatim render failed for %q: %w", artifacts.ArtifactTranscriptFinal, err) } - finalRenderedPath := coalesceString(finalRes.OutputRenderedPath, finalReq.OutputRenderedPath) + finalRenderedPath, err := authoritativeOutputPath(finalReq.OutputRenderedPath, finalRes.OutputRenderedPath) + if err != nil { + return nil, fmt.Errorf("render: %w", err) + } if err := requireNonEmptyFile(finalRenderedPath, "final transcript markdown output"); err != nil { return nil, fmt.Errorf("render: %w", err) } @@ -180,7 +183,10 @@ func (renderStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St if err != nil { return nil, fmt.Errorf("render: seriatim render failed for %q: %w", artifacts.ArtifactTranscriptFinalTrimmed, err) } - finalTrimmedRenderedPath := coalesceString(finalTrimmedRes.OutputRenderedPath, finalTrimmedReq.OutputRenderedPath) + finalTrimmedRenderedPath, err := authoritativeOutputPath(finalTrimmedReq.OutputRenderedPath, finalTrimmedRes.OutputRenderedPath) + if err != nil { + return nil, fmt.Errorf("render: %w", err) + } if err := requireNonEmptyFile(finalTrimmedRenderedPath, "final trimmed transcript markdown output"); err != nil { return nil, fmt.Errorf("render: %w", err) } diff --git a/internal/stage/transcribe.go b/internal/stage/transcribe.go index 01702ba..c96cbfe 100644 --- a/internal/stage/transcribe.go +++ b/internal/stage/transcribe.go @@ -139,13 +139,10 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) continue } - if strings.TrimSpace(res.OutputRawTranscriptPath) == "" { - res.OutputRawTranscriptPath = j.outPath - } - if filepath.Clean(res.OutputRawTranscriptPath) != filepath.Clean(j.outPath) { + if _, err := authoritativeOutputPath(j.outPath, res.OutputRawTranscriptPath); err != nil { mu.Lock() if firstErr == nil { - firstErr = fmt.Errorf("speaker %q: adapter output path %q did not match expected %q", j.speakerID, res.OutputRawTranscriptPath, j.outPath) + firstErr = fmt.Errorf("speaker %q: %w", j.speakerID, err) cancel() } mu.Unlock() diff --git a/internal/stage/transcript_resolution.go b/internal/stage/transcript_resolution.go new file mode 100644 index 0000000..a2aebb4 --- /dev/null +++ b/internal/stage/transcript_resolution.go @@ -0,0 +1,19 @@ +package stage + +import ( + "errors" + + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func resolveSingletonTranscript(m *manifest.Manifest, paths artifacts.SessionPaths, source string) (string, string, error) { + resolved, err := artifacts.ResolveSessionArtifact(paths, m, source) + if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { + return "", "", nil + } + if err != nil { + return "", "", err + } + return resolved.Path, resolved.Provenance, nil +} diff --git a/internal/stage/trim.go b/internal/stage/trim.go index 233bb57..40c0e17 100644 --- a/internal/stage/trim.go +++ b/internal/stage/trim.go @@ -200,7 +200,10 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag if renderRes.ValidationFailed { return nil, fmt.Errorf("trim: scriptorium bounds render returned validation_failed=true") } - finalRenderOutputPath := coalesceString(renderRes.OutputPath, renderReq.OutputPath) + finalRenderOutputPath, err := authoritativeOutputPath(renderReq.OutputPath, renderRes.OutputPath) + if err != nil { + return nil, fmt.Errorf("trim: %w", err) + } if err := requireNonEmptyFile(finalRenderOutputPath, "bounds render output"); err != nil { return nil, fmt.Errorf("trim: %w", err) } @@ -242,7 +245,7 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag return nil, fmt.Errorf( "trim: scriptorium bounds validation failed (prompt_id=%q, output_path=%q, exit_code=%d, stdout_log=%q, stderr_log=%q): %w", boundsReq.PromptID, - coalesceString(boundsRes.OutputPath, boundsReq.OutputPath), + boundsReq.OutputPath, boundsRes.ExitCode, coalesceString(boundsRes.StdoutLogPath, boundsReq.StdoutLogPath), coalesceString(boundsRes.StderrLogPath, boundsReq.StderrLogPath), @@ -255,7 +258,10 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag return nil, fmt.Errorf("trim: scriptorium bounds run returned validation_failed=true") } - finalBoundsOutputPath := coalesceString(boundsRes.OutputPath, boundsReq.OutputPath) + finalBoundsOutputPath, err := authoritativeOutputPath(boundsReq.OutputPath, boundsRes.OutputPath) + if err != nil { + return nil, fmt.Errorf("trim: %w", err) + } if err := requireNonEmptyFile(finalBoundsOutputPath, "session bounds output"); err != nil { return nil, fmt.Errorf("trim: %w", err) }