Enforce requested adapter output paths

This commit is contained in:
2026-08-10 21:57:54 +00:00
parent 702f622e18
commit 8ff1b4fa66
15 changed files with 137 additions and 126 deletions

View File

@@ -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.

View File

@@ -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 35383568 (DUP-007) and 39073932

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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())
}
}

View File

@@ -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)
}

View File

@@ -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())
}
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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 {

View File

@@ -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())
}
}

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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)
}