From 57719b7d81bfab1819947e5e267cfd1f87ddaaf2 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 2 Sep 2026 14:09:32 +0000 Subject: [PATCH] Require manifest-backed local artifacts --- docs/internal/artifacts.md | 13 +- docs/internal/stage-publish.md | 7 +- docs/operations.md | 3 +- docs/roadmap/implementation.md | 2 +- docs/troubleshooting.md | 19 +++ internal/app/artifact_evidence_test.go | 31 ++++ internal/app/commands_test.go | 9 +- internal/app/extract_lifecycle_test.go | 37 ++++- internal/app/operator_helpers_test.go | 33 +++- internal/app/post_publish_cleanup_test.go | 1 + internal/app/restore_workflow_test.go | 8 + internal/app/run_stage_test.go | 35 ++++- internal/artifacts/artifact_resolver.go | 148 +++++++++--------- internal/artifacts/artifact_resolver_test.go | 65 ++++---- internal/artifacts/extraction_catalog_test.go | 12 +- .../analyze_evidence_test_helpers_test.go | 12 ++ internal/stage/analyze_input_identity_test.go | 6 + internal/stage/analyze_plan_test.go | 7 +- internal/stage/analyze_reconciliation.go | 5 +- internal/stage/analyze_reconciliation_test.go | 2 +- internal/stage/analyze_test.go | 32 +++- internal/stage/extract_resume.go | 2 +- internal/stage/extract_test.go | 6 +- internal/stage/normalize_test.go | 27 ++-- internal/stage/polish_test.go | 23 +-- internal/stage/publish_test.go | 1 + internal/stage/render_test.go | 9 +- internal/stage/trim_test.go | 16 +- 28 files changed, 392 insertions(+), 179 deletions(-) create mode 100644 internal/app/artifact_evidence_test.go diff --git a/docs/internal/artifacts.md b/docs/internal/artifacts.md index 12b48ae..a383dd7 100644 --- a/docs/internal/artifacts.md +++ b/docs/internal/artifacts.md @@ -20,8 +20,8 @@ The internal registry recognizes these stable built-in source IDs: - `narratio.transcript.final_trimmed_markdown` - `narratio.bounds.session` -Registry entries bind each ID to its producer, output kind, canonical fallback, -and content validator. The focused stage documents own their input/output flow; +Registry entries bind each ID to its producer, output kind, and content +validator. The focused stage documents own their input/output flow; [Configuration](../config.md) owns where operators may select these IDs. ## Configured, Extraction, And Previous-Session Sources @@ -91,8 +91,13 @@ Current provenance values: Built-ins: -1. manifest producer outputs (when present) -2. canonical session-path fallback +- resolve only from the current manifest's succeeded registered producer; +- require exactly one output with the registered source ID and output kind, a + safe session-rooted path, producer run ID, canonical checksum, and valid + content; and +- verify the registered checksum against the durable bytes. Incidental files, + canonical filenames, duplicate outputs, failed/stale producer records, and + malformed evidence are unavailable. Configured sources (`narratio.artifact.*`): diff --git a/docs/internal/stage-publish.md b/docs/internal/stage-publish.md index 4acad18..53d4f80 100644 --- a/docs/internal/stage-publish.md +++ b/docs/internal/stage-publish.md @@ -41,10 +41,9 @@ Exact remote placement and the operator workflow belong in - resolves publish output sources through runtime artifact catalog and manifest-aware resolution. Configured Scriptorium outputs are publishable only from validated `current` per-artifact analyze evidence; an incidental - canonical file, legacy aggregate output, stale/failed/unselected record, or - mismatched path, size, or checksum remains unavailable. This does not change - the explicit compatibility policies owned by built-in, extraction, or - previous-session sources. + canonical file, aggregate output, stale/failed/unselected record, or + mismatched path, size, or checksum remains unavailable. Built-in and + extraction sources likewise require their own current manifest evidence. - publishes extraction lanes only through explicit configured output rules; neither run-local nor durable Notarius bundles are scanned or uploaded wholesale. - selected artifact filter applies to configured artifact sources only. diff --git a/docs/operations.md b/docs/operations.md index e3982b1..e9e8bdf 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -102,8 +102,7 @@ Canonical stage order: Execution rules: - succeeded stages are skipped unless `--force` is set; stages with semantic - configuration contracts additionally require matching versioned evidence, - and missing legacy evidence causes a safe one-time rerun; + configuration contracts additionally require matching versioned evidence; - `run` continues interrupted or partially completed sessions by running non-succeeded stages; - forcing a stage marks succeeded transitive dependents as `stale` before the replacement runs; render and extract are independent siblings; and diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 1825709..cf94b6b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -317,7 +317,7 @@ path candidates or remote probing. ## Stage 6 — Make Local Artifact Evidence Manifest-Only -**Status: Pending** +**Status: Completed** ### Goal diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ebc03e3..47b339f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -428,6 +428,25 @@ narratio analyze 2026-04-04 --artifacts session_recap Relevant references: [Operations: Artifact Selection](./operations.md#artifact-selection) and [Artifact Internals](./internal/artifacts.md#resolution-rules). +## Built-in transcript or bounds source is unavailable + +Symptom: + +- analyze, extract, render, or publish says a built-in source is unavailable + even though a file exists at its usual session path. + +Likely cause: + +- the producing stage has not succeeded, its output lacks the exact registered + source, run ID, or checksum, its evidence is duplicated or stale, or the + durable bytes no longer match the manifest checksum. + +Safe fix: + +- do not copy or rename the file to make it discoverable. Rerun the producing + stage to regenerate both the bytes and current manifest evidence, then retry + the consumer. + ## Legacy aggregate analysis requires regeneration Symptom: diff --git a/internal/app/artifact_evidence_test.go b/internal/app/artifact_evidence_test.go new file mode 100644 index 0000000..e052310 --- /dev/null +++ b/internal/app/artifact_evidence_test.go @@ -0,0 +1,31 @@ +package app + +import ( + "context" + "testing" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func setCurrentBuiltInEvidence(t *testing.T, m *manifest.Manifest, stage, kind, sourceID, path string) { + t.Helper() + checksum, err := artifacts.SHA256File(path) + if err != nil { + t.Fatal(err) + } + m.MarkStageSucceeded(stage, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), []manifest.ArtifactRecord{{ + Kind: kind, SourceID: sourceID, LocalPath: path, ProducerRunID: stage + "-run", Checksum: checksum, + }}) +} + +func seedCurrentBuiltInEvidence(t *testing.T, manifestPath, sessionID, campaign, stage, kind, sourceID, path string) { + t.Helper() + m := manifest.New(sessionID, time.Now().UTC()) + m.Campaign = campaign + setCurrentBuiltInEvidence(t, m, stage, kind, sourceID, path) + if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPath, m); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 8720715..8ee322f 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) @@ -126,7 +127,9 @@ func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") - mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "polished.json"), `{"segments":[{"id":1}]}`) + polishedPath := filepath.Join(workRoot, "transcripts", "polished.json") + mustWriteTestFile(t, polishedPath, `{"segments":[{"id":1}]}`) + seedCurrentBuiltInEvidence(t, filepath.Join(workRoot, "manifest.json"), "2026-05-03", "sample-campaign", "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, polishedPath) var stdout bytes.Buffer var stderr bytes.Buffer @@ -247,7 +250,9 @@ inputs: }) workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", sessionID) - mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"schema":"seriatim-intermediate","segments":[]}`) + basePath := filepath.Join(workRoot, "transcripts", "base.json") + mustWriteTestFile(t, basePath, `{"schema":"seriatim-intermediate","segments":[]}`) + seedCurrentBuiltInEvidence(t, filepath.Join(workRoot, "manifest.json"), sessionID, "sample-campaign", "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath) mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "[]\n") var stdout bytes.Buffer diff --git a/internal/app/extract_lifecycle_test.go b/internal/app/extract_lifecycle_test.go index aaef2bb..2e7707b 100644 --- a/internal/app/extract_lifecycle_test.go +++ b/internal/app/extract_lifecycle_test.go @@ -156,7 +156,7 @@ func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) { if err != nil { t.Fatalf("executeStages() error = %v", err) } - if len(summary.Executed) != 2 || len(runner.requests) != 1 { + if len(summary.Executed) != 3 || len(runner.requests) != 1 { t.Fatalf("summary = %#v requests=%d", summary, len(runner.requests)) } paths, err := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID) @@ -510,12 +510,12 @@ func TestExtractLifecycleRerunsAfterDirectTranscriptChange(t *testing.T) { t.Fatalf("Save(mutated) error = %v", err) } - rerun, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) - if err != nil { - t.Fatalf("rerun executeStages() error = %v", err) + _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) + if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("rerun executeStages() error = %v, want stale transcript evidence rejection", err) } - if len(rerun.Executed) != 2 || len(rerun.Skipped) != 0 || len(runner.requests) != 2 || analyzeRuns != 2 { - t.Fatalf("rerun summary = %#v requests=%d analyze=%d", rerun, len(runner.requests), analyzeRuns) + if len(runner.requests) != 1 || analyzeRuns != 1 { + t.Fatalf("stale transcript should prevent extraction, requests=%d analyze=%d", len(runner.requests), analyzeRuns) } } @@ -645,7 +645,24 @@ func prepareExtractLifecyclePlan(t *testing.T) []stage.Stage { if err != nil { t.Fatalf("BuildSingleStagePlan(extract) error = %v", err) } - return append(prepare, extract...) + return append(append(prepare, lifecycleTrimEvidenceStage{}), extract...) +} + +type lifecycleTrimEvidenceStage struct{} + +func (lifecycleTrimEvidenceStage) Name() string { return "trim" } + +func (lifecycleTrimEvidenceStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) { + paths := artifacts.NewLocalStore(env.Config.Pipeline.Workspace.Root).SessionPathsFor(m.Campaign, m.SessionID) + path := filepath.Join(paths.ArtifactsDir, "trimmed.from-manifest.json") + checksum, err := artifacts.SHA256File(path) + if err != nil { + return nil, err + } + return &stage.StageResult{Outputs: []artifacts.Ref{{ + Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed, + AbsolutePath: path, Checksum: checksum, + }}}, nil } func configureLifecycleReferences(t *testing.T, cfg *config.Config) map[string]string { @@ -738,9 +755,13 @@ func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *st } m := manifest.New(cfg.Session.SessionID, time.Now().UTC()) m.Campaign = cfg.Session.Campaign + inputChecksum, err := artifacts.SHA256File(inputPath) + if err != nil { + t.Fatalf("SHA256File(input) error = %v", err) + } m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{ Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed, - LocalPath: inputPath, + LocalPath: inputPath, ProducerRunID: "trim-run", Checksum: inputChecksum, }}) if err := (&manifest.LocalStore{}).Save(context.Background(), paths.ManifestPath, m); err != nil { t.Fatalf("Save(seed) error = %v", err) diff --git a/internal/app/operator_helpers_test.go b/internal/app/operator_helpers_test.go index 92f7fe9..56d8e77 100644 --- a/internal/app/operator_helpers_test.go +++ b/internal/app/operator_helpers_test.go @@ -1276,13 +1276,32 @@ func TestExecutePublishLoadsRemoteLocks(t *testing.T) { restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") - for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} { - // The publish stage only checks the manifest statuses and source files. - _ = stageName - } mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.md"), "# final\n") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.md"), "# final trimmed\n") + m := manifest.New("2026-05-03", time.Now().UTC()) + m.Campaign = "sample-campaign" + for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "extract", "analyze"} { + m.MarkStageSucceeded(stageName, time.Now().UTC(), nil) + } + setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(workRoot, "transcripts", "final.trimmed.json")) + finalMarkdown := filepath.Join(workRoot, "transcripts", "final.md") + trimmedMarkdown := filepath.Join(workRoot, "transcripts", "final.trimmed.md") + finalChecksum, err := artifacts.SHA256File(finalMarkdown) + if err != nil { + t.Fatal(err) + } + trimmedChecksum, err := artifacts.SHA256File(trimmedMarkdown) + if err != nil { + t.Fatal(err) + } + m.MarkStageSucceeded("render", time.Now().UTC(), []manifest.ArtifactRecord{ + {Kind: artifacts.TranscriptOutputKindFinalMarkdown, SourceID: artifacts.ArtifactTranscriptFinalMarkdown, LocalPath: finalMarkdown, ProducerRunID: "render-run", Checksum: finalChecksum}, + {Kind: artifacts.TranscriptOutputKindFinalTrimmedMarkdown, SourceID: artifacts.ArtifactTranscriptFinalTrimmedMarkdown, LocalPath: trimmedMarkdown, ProducerRunID: "render-run", Checksum: trimmedChecksum}, + }) + if err := (&manifest.LocalStore{}).Save(context.Background(), filepath.Join(workRoot, "manifest.json"), m); err != nil { + t.Fatal(err) + } var stdout bytes.Buffer var stderr bytes.Buffer @@ -1357,10 +1376,14 @@ func writeOperatorExtractionManifest(t *testing.T, workspaceRoot string) string } m := manifest.New("2026-05-03", time.Now().UTC()) m.Campaign = "sample-campaign" + trimmedChecksum, err := artifacts.SHA256File(trimmedPath) + if err != nil { + t.Fatal(err) + } m.Stages["trim"] = &manifest.StageRecord{ Name: "trim", Status: manifest.StatusSucceeded, Outputs: []manifest.ArtifactRecord{{ - Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, LocalPath: trimmedPath, ProducerRunID: "trim-run-1", + Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed, LocalPath: trimmedPath, ProducerRunID: "trim-run-1", Checksum: trimmedChecksum, }}, } input, err := artifacts.ResolveExtractionInputIdentity(paths, m) diff --git a/internal/app/post_publish_cleanup_test.go b/internal/app/post_publish_cleanup_test.go index d24d955..09c2abb 100644 --- a/internal/app/post_publish_cleanup_test.go +++ b/internal/app/post_publish_cleanup_test.go @@ -542,6 +542,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} { seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil) } + setCurrentBuiltInEvidence(t, seedManifest, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID), "transcripts", "final.trimmed.json")) setAppAnalyzeEvidence(seedManifest, "session_recap", "artifacts/session_recap.md", []byte("# recap\n")) seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID) diff --git a/internal/app/restore_workflow_test.go b/internal/app/restore_workflow_test.go index f5fac4b..8f335d4 100644 --- a/internal/app/restore_workflow_test.go +++ b/internal/app/restore_workflow_test.go @@ -206,6 +206,14 @@ previous_session_id: 2026-04-26 sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`+"\n") + localManifest, err := (&manifest.LocalStore{}).Load(context.Background(), filepath.Join(sessionRoot, "manifest.json")) + if err != nil { + t.Fatalf("load restored manifest: %v", err) + } + setCurrentBuiltInEvidence(t, localManifest, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json")) + if err := (&manifest.LocalStore{}).Save(context.Background(), filepath.Join(sessionRoot, "manifest.json"), localManifest); err != nil { + t.Fatalf("save manifest evidence: %v", err) + } previousManifestBytes, err := os.ReadFile(filepath.Join(sessionRoot, "previous", "manifest.json")) if err != nil { t.Fatalf("read restored previous manifest: %v", err) diff --git a/internal/app/run_stage_test.go b/internal/app/run_stage_test.go index 90f45ca..721a20b 100644 --- a/internal/app/run_stage_test.go +++ b/internal/app/run_stage_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) @@ -121,7 +122,9 @@ func TestRunStageExecutesOnlySelectedStage(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") - mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`) + basePath := filepath.Join(workRoot, "transcripts", "base.json") + mustWriteTestFile(t, basePath, `{"segments":[]}`) + seedCurrentBuiltInEvidence(t, manifestPath, "2026-05-03", "sample-campaign", "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath) mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") var out bytes.Buffer @@ -172,11 +175,13 @@ func TestRunStageSkipAndForce(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") - mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`) + basePath := filepath.Join(workRoot, "transcripts", "base.json") + mustWriteTestFile(t, basePath, `{"segments":[]}`) mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") store := &manifest.LocalStore{} m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) + setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath) m.MarkStageSucceeded("polish", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "polish") if err := store.Save(context.Background(), manifestPath, m); err != nil { @@ -207,7 +212,9 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") - mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`) + basePath := filepath.Join(workRoot, "transcripts", "base.json") + mustWriteTestFile(t, basePath, `{"segments":[]}`) + seedCurrentBuiltInEvidence(t, manifestPath, "2026-05-03", "sample-campaign", "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath) store := &manifest.LocalStore{} materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath) @@ -218,6 +225,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T) for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} { seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) } + setCurrentBuiltInEvidence(t, seed, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath) seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge") if err := store.Save(context.Background(), manifestPath, seed); err != nil { t.Fatalf("save manifest: %v", err) @@ -257,7 +265,9 @@ func TestRunStageTrimExecutes(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") - mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1},{"id":2}]}`) + finalPath := filepath.Join(workRoot, "transcripts", "final.json") + mustWriteTestFile(t, finalPath, `{"segments":[{"id":1},{"id":2}]}`) + seedCurrentBuiltInEvidence(t, manifestPath, "2026-05-03", "sample-campaign", "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, finalPath) var out bytes.Buffer err := RunStage(context.Background(), []string{"trim", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) @@ -286,7 +296,9 @@ func TestRunStageNormalizeExecutes(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") - mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "polished.json"), `{"segments":[{"id":1},{"id":2}]}`) + polishedPath := filepath.Join(workRoot, "transcripts", "polished.json") + mustWriteTestFile(t, polishedPath, `{"segments":[{"id":1},{"id":2}]}`) + seedCurrentBuiltInEvidence(t, manifestPath, "2026-05-03", "sample-campaign", "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, polishedPath) var out bytes.Buffer err := RunStage(context.Background(), []string{"normalize", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) @@ -312,8 +324,17 @@ func TestRunStageRenderExecutes(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") - mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1}]}`) - mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[{"id":2}]}`) + finalPath := filepath.Join(workRoot, "transcripts", "final.json") + trimmedPath := filepath.Join(workRoot, "transcripts", "final.trimmed.json") + mustWriteTestFile(t, finalPath, `{"segments":[{"id":1}]}`) + mustWriteTestFile(t, trimmedPath, `{"segments":[{"id":2}]}`) + seed := manifest.New("2026-05-03", time.Now().UTC()) + seed.Campaign = "sample-campaign" + setCurrentBuiltInEvidence(t, seed, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, finalPath) + setCurrentBuiltInEvidence(t, seed, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, trimmedPath) + if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPath, seed); err != nil { + t.Fatal(err) + } var out bytes.Buffer err := RunStage(context.Background(), []string{"render", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) diff --git a/internal/artifacts/artifact_resolver.go b/internal/artifacts/artifact_resolver.go index be7d9a8..a8e01fe 100644 --- a/internal/artifacts/artifact_resolver.go +++ b/internal/artifacts/artifact_resolver.go @@ -1,6 +1,7 @@ package artifacts import ( + "encoding/hex" "encoding/json" "errors" "fmt" @@ -12,6 +13,7 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/manifest" + "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) // MaxResolvedArtifactBytes bounds externally generated artifact content before validation. @@ -178,8 +180,8 @@ func PreviousSessionArtifactName(source string) (string, bool) { return artifactpolicy.ParsePreviousSessionSource(source) } -// ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path. -// Resolution order is manifest producer outputs first, then canonical session path fallback. +// ResolveSessionArtifact resolves a symbolic source from its unique current +// producer output declaration in the session manifest. func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) { id, err := NormalizeSessionArtifactSource(source) if err != nil { @@ -187,43 +189,21 @@ func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source str } spec := artifactRegistry[id] - for _, candidate := range manifestArtifactCandidates(paths, m, spec) { - exists, isDir, statErr := pathExists(candidate.Path) - if statErr != nil { - return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", candidate.Path, statErr) - } - if !exists || isDir { - continue - } - resolved := candidate - resolved.ID = spec.ID - resolved.ProducerStage = spec.ProducerStage - resolved.OutputKind = spec.OutputKind - if err := validateResolvedContent(resolved.Path, spec.ContentKind); err != nil { - return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", resolved.ID, err) - } - return resolved, nil + candidate, err := manifestArtifactCandidate(paths, m, spec) + if err != nil { + return ResolvedSessionArtifact{}, err } - - fallbackPath := filepath.Join(paths.Root, filepath.FromSlash(spec.CanonicalRelPath)) - exists, isDir, statErr := pathExists(fallbackPath) - if statErr != nil { - return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", fallbackPath, statErr) + if err := validateResolvedContent(candidate.Path, spec.ContentKind); err != nil { + return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", spec.ID, err) } - if exists && !isDir { - if err := validateResolvedContent(fallbackPath, spec.ContentKind); err != nil { - return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", spec.ID, err) - } - return ResolvedSessionArtifact{ - ID: spec.ID, - Path: filepath.Clean(fallbackPath), - ProducerStage: spec.ProducerStage, - OutputKind: spec.OutputKind, - Provenance: "fallback.canonical_path", - }, nil + actualChecksum, err := SHA256File(candidate.Path) + if err != nil { + return ResolvedSessionArtifact{}, fmt.Errorf("checksum %q: %w", spec.ID, err) } - - return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID} + if actualChecksum != candidate.Checksum { + return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: checksum mismatch", spec.ID) + } + return candidate, nil } // ResolveSessionArtifactWithCatalog resolves built-in sources using existing rules and resolves @@ -334,52 +314,74 @@ func ResolvePreviousSessionArtifactWithCatalog( return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source} } -func manifestArtifactCandidates(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) []ResolvedSessionArtifact { - if m == nil || len(m.Stages) == 0 || spec.ProducerStage == "" || spec.OutputKind == "" { - return nil +func manifestArtifactCandidate(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) (ResolvedSessionArtifact, error) { + if m == nil || spec.ProducerStage == "" || spec.OutputKind == "" { + return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID} } sr := m.Stages[spec.ProducerStage] - if sr == nil { - return nil + if sr == nil || sr.Name != spec.ProducerStage || sr.Status != manifest.StatusSucceeded { + return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID} } - - candidates := make([]ResolvedSessionArtifact, 0, len(sr.Outputs)) + var selected manifest.ArtifactRecord + count := 0 for _, out := range sr.Outputs { - if strings.TrimSpace(out.Kind) != spec.OutputKind { - continue + if strings.TrimSpace(out.Kind) == spec.OutputKind && strings.TrimSpace(out.SourceID) == spec.ID { + selected, count = out, count+1 } - if strings.TrimSpace(out.LocalPath) == "" { - continue - } - resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, out.LocalPath)) - if resolved == "" { - continue - } - candidates = append(candidates, ResolvedSessionArtifact{ - Path: resolved, - ProducerRunID: strings.TrimSpace(out.ProducerRunID), - Provenance: "manifest." + spec.ProducerStage + ".outputs", - }) } - return dedupeResolvedArtifacts(candidates) + if count == 0 { + return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID} + } + if count != 1 { + return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q is ambiguous", spec.ProducerStage, spec.ID) + } + if err := ValidateRunIdentity(strings.TrimSpace(selected.ProducerRunID)); err != nil { + return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid producer run ID: %w", spec.ProducerStage, spec.ID, err) + } + checksum := strings.TrimSpace(selected.Checksum) + if len(checksum) != 64 || strings.ToLower(checksum) != checksum { + return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid checksum", spec.ProducerStage, spec.ID) + } + if _, err := hex.DecodeString(checksum); err != nil { + return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid checksum", spec.ProducerStage, spec.ID) + } + resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, selected.LocalPath)) + if resolved == "" { + return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has an empty path", spec.ProducerStage, spec.ID) + } + if _, err := pathsafe.SlashRelativeFromRoot(paths.Root, resolved); err != nil { + return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has unsafe path: %w", spec.ProducerStage, spec.ID, err) + } + if err := requireSafeSessionRegularFile(paths.Root, resolved); err != nil { + return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has unsafe path: %w", spec.ProducerStage, spec.ID, err) + } + return ResolvedSessionArtifact{ID: spec.ID, Path: resolved, ProducerStage: spec.ProducerStage, OutputKind: spec.OutputKind, ProducerRunID: selected.ProducerRunID, Provenance: "manifest." + spec.ProducerStage + ".outputs", Checksum: checksum}, nil } -func dedupeResolvedArtifacts(values []ResolvedSessionArtifact) []ResolvedSessionArtifact { - seen := map[string]struct{}{} - out := make([]ResolvedSessionArtifact, 0, len(values)) - for _, value := range values { - key := filepath.Clean(strings.TrimSpace(value.Path)) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - value.Path = key - out = append(out, value) +func requireSafeSessionRegularFile(root, path string) error { + relative, err := pathsafe.SlashRelativeFromRoot(root, path) + if err != nil { + return err } - return out + current := filepath.Clean(root) + for _, component := range strings.Split(relative, "/") { + current = filepath.Join(current, filepath.FromSlash(component)) + info, err := os.Lstat(current) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("path contains symlink %q", current) + } + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("path is not a regular file") + } + return nil } func previousSessionCacheCandidatePaths(paths SessionPaths, canonicalRelPath string) []string { diff --git a/internal/artifacts/artifact_resolver_test.go b/internal/artifacts/artifact_resolver_test.go index d2149a0..f2b64d7 100644 --- a/internal/artifacts/artifact_resolver_test.go +++ b/internal/artifacts/artifact_resolver_test.go @@ -187,7 +187,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) { m := manifest.New("session", time.Now().UTC()) m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ - {Kind: "transcript_final", LocalPath: manifestPath, ProducerRunID: "run-123"}, + {Kind: "transcript_final", SourceID: ArtifactTranscriptFinal, LocalPath: manifestPath, ProducerRunID: "run-123", Checksum: resolverTestChecksum(t, manifestPath)}, }) resolved, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFinal) @@ -205,7 +205,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) { } } -func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) { +func TestResolveSessionArtifactRejectsIncidentalCanonicalPath(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json") @@ -216,19 +216,13 @@ func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed) - if err != nil { - t.Fatalf("ResolveSessionArtifact() error = %v", err) - } - if resolved.Path != canonicalPath { - t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath) - } - if resolved.Provenance != "fallback.canonical_path" { - t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance) + _, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed) + if !errors.Is(err, ErrSessionArtifactNotFound) { + t.Fatalf("ResolveSessionArtifact() error = %v, want typed unavailable", err) } } -func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) { +func TestResolveSessionArtifactRejectsIncidentalCanonicalMarkdownPath(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.md") @@ -239,15 +233,9 @@ func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown) - if err != nil { - t.Fatalf("ResolveSessionArtifact() error = %v", err) - } - if resolved.Path != canonicalPath { - t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath) - } - if resolved.Provenance != "fallback.canonical_path" { - t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance) + _, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown) + if !errors.Is(err, ErrSessionArtifactNotFound) { + t.Fatalf("ResolveSessionArtifact() error = %v, want typed unavailable", err) } } @@ -275,7 +263,8 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - _, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptPolished) + m := resolverTestManifest(t, paths, ArtifactTranscriptPolished, canonicalPath) + _, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptPolished) if err == nil { t.Fatal("expected error, got nil") } @@ -295,7 +284,8 @@ func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - _, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmedMarkdown) + m := resolverTestManifest(t, paths, ArtifactTranscriptFinalTrimmedMarkdown, canonicalPath) + _, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFinalTrimmedMarkdown) if err == nil { t.Fatal("expected error, got nil") } @@ -304,7 +294,7 @@ func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) { } } -func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) { +func TestResolveSessionArtifactWithCatalogBuiltInUsesManifestEvidence(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json") @@ -315,18 +305,39 @@ func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) t.Fatalf("WriteFile() error = %v", err) } - resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, ArtifactTranscriptFinalTrimmed, NewArtifactCatalog()) + m := resolverTestManifest(t, paths, ArtifactTranscriptFinalTrimmed, canonicalPath) + resolved, err := ResolveSessionArtifactWithCatalog(paths, m, ArtifactTranscriptFinalTrimmed, NewArtifactCatalog()) if err != nil { t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err) } if resolved.Path != canonicalPath { t.Fatalf("resolved.Path = %q, want %q", resolved.Path, canonicalPath) } - if resolved.Provenance != "fallback.canonical_path" { - t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance) + if resolved.Provenance != "manifest.trim.outputs" { + t.Fatalf("provenance = %q, want manifest.trim.outputs", resolved.Provenance) } } +func resolverTestManifest(t *testing.T, paths SessionPaths, source, localPath string) *manifest.Manifest { + t.Helper() + spec := artifactRegistry[source] + m := manifest.New(paths.SessionID, time.Now().UTC()) + m.MarkStageSucceeded(spec.ProducerStage, time.Now().UTC(), []manifest.ArtifactRecord{{ + Kind: spec.OutputKind, SourceID: source, LocalPath: localPath, + ProducerRunID: "resolver-run", Checksum: resolverTestChecksum(t, localPath), + }}) + return m +} + +func resolverTestChecksum(t *testing.T, path string) string { + t.Helper() + checksum, err := SHA256File(path) + if err != nil { + t.Fatal(err) + } + return checksum +} + func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") diff --git a/internal/artifacts/extraction_catalog_test.go b/internal/artifacts/extraction_catalog_test.go index 2d3f0b4..503fa97 100644 --- a/internal/artifacts/extraction_catalog_test.go +++ b/internal/artifacts/extraction_catalog_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -93,6 +94,7 @@ func TestInspectExtractionEvidenceClassifiesBundleStates(t *testing.T) { name string state ExtractionEvidenceState recomputeInputIdentity bool + wantInputUnavailable bool mutate func(*testing.T, *SessionPaths, *manifest.Manifest) }{ {name: "valid", state: ExtractionEvidenceValid, mutate: func(_ *testing.T, _ *SessionPaths, _ *manifest.Manifest) {}}, @@ -102,7 +104,7 @@ func TestInspectExtractionEvidenceClassifiesBundleStates(t *testing.T) { {name: "obsolete version", state: ExtractionEvidenceObsolete, mutate: func(_ *testing.T, _ *SessionPaths, m *manifest.Manifest) { m.Stages["extract"].Outputs[0].Contract.SchemaVersion = "99" }}, - {name: "changed direct transcript", state: ExtractionEvidenceObsolete, recomputeInputIdentity: true, mutate: func(t *testing.T, _ *SessionPaths, m *manifest.Manifest) { + {name: "changed direct transcript", wantInputUnavailable: true, recomputeInputIdentity: true, mutate: func(t *testing.T, _ *SessionPaths, m *manifest.Manifest) { writeExtractionFixtureFile(t, m.Stages["trim"].Outputs[0].LocalPath, `{"segments":[{"id":"changed"}]}`) }}, {name: "changed direct producer", state: ExtractionEvidenceObsolete, recomputeInputIdentity: true, mutate: func(_ *testing.T, _ *SessionPaths, m *manifest.Manifest) { @@ -140,6 +142,12 @@ func TestInspectExtractionEvidenceClassifiesBundleStates(t *testing.T) { test.mutate(t, &paths, currentManifest) if test.recomputeInputIdentity { input, err = ResolveExtractionInputIdentity(paths, currentManifest) + if test.wantInputUnavailable { + if !errors.Is(err, ErrSessionArtifactNotFound) && (err == nil || !strings.Contains(err.Error(), "checksum mismatch")) { + t.Fatalf("ResolveExtractionInputIdentity() error = %v, want unavailable", err) + } + return + } if err != nil { t.Fatal(err) } @@ -271,7 +279,7 @@ func validExtractionCatalogFixture(t *testing.T) (SessionPaths, *manifest.Manife m.Stages["trim"] = &manifest.StageRecord{ Name: "trim", Status: manifest.StatusSucceeded, Outputs: []manifest.ArtifactRecord{{ - Kind: TranscriptOutputKindFinalTrimmed, LocalPath: trimmedPath, ProducerRunID: "trim-run-1", + Kind: TranscriptOutputKindFinalTrimmed, SourceID: ArtifactTranscriptFinalTrimmed, LocalPath: trimmedPath, ProducerRunID: "trim-run-1", Checksum: extractionFixtureChecksum(t, trimmedPath), }}, } input, err := ResolveExtractionInputIdentity(paths, m) diff --git a/internal/stage/analyze_evidence_test_helpers_test.go b/internal/stage/analyze_evidence_test_helpers_test.go index 28725f5..ba424e1 100644 --- a/internal/stage/analyze_evidence_test_helpers_test.go +++ b/internal/stage/analyze_evidence_test_helpers_test.go @@ -43,3 +43,15 @@ func setCurrentAnalyzeEvidence(t *testing.T, m *manifest.Manifest, key, relative OutputSize: info.Size(), ProducerRunID: "run-1", UpdatedAt: now, } } + +func setCurrentBuiltInEvidence(t *testing.T, m *manifest.Manifest, stage, kind, sourceID, absolutePath string) { + t.Helper() + checksum, err := artifacts.SHA256File(absolutePath) + if err != nil { + t.Fatal(err) + } + m.MarkStageSucceeded(stage, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), []manifest.ArtifactRecord{{ + Kind: kind, SourceID: sourceID, LocalPath: absolutePath, + ProducerRunID: stage + "-run", Checksum: checksum, + }}) +} diff --git a/internal/stage/analyze_input_identity_test.go b/internal/stage/analyze_input_identity_test.go index 6615ddc..3f8142e 100644 --- a/internal/stage/analyze_input_identity_test.go +++ b/internal/stage/analyze_input_identity_test.go @@ -29,6 +29,7 @@ func TestResolveAnalyzeInputIdentitiesSupportsEverySourceKind(t *testing.T) { setup: func(t *testing.T, env *Env, m *manifest.Manifest) string { path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json") writeAnalyzeFile(t, path, `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, path) return path }, wantOutputKind: "transcript_final_trimmed", @@ -47,6 +48,7 @@ func TestResolveAnalyzeInputIdentitiesSupportsEverySourceKind(t *testing.T) { setup: func(t *testing.T, env *Env, m *manifest.Manifest) string { paths := sessionPathsForEnv(env, m.SessionID) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(paths.TranscriptsDir, "final.trimmed.json")) return configureAnalyzeExtractionFixture(t, env, m)["encounters"] }, wantOutputKind: "notarius_lane", @@ -177,6 +179,9 @@ func TestResolveAnalyzeInputIdentitiesRejectsUnsafePathsAndFileTypes(t *testing. setup: func(t *testing.T, env *Env, m *manifest.Manifest) { t.Helper() path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } if err := os.MkdirAll(path, 0o755); err != nil { t.Fatal(err) } @@ -213,6 +218,7 @@ func TestAnalyzeInputIdentityIsStableAcrossWorkspaceRelocationAndChangesWithByte env, m, _ := setupAnalyzeEnv(t) path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json") writeAnalyzeFile(t, path, body) + setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, path) resolved, err := resolveAnalyzeInputIdentities(map[string]config.ScriptoriumInputConfig{ "transcript": {Source: "narratio.transcript.final_trimmed", Required: true}, }, newAnalyzeIdentityExecution(t, env, m)) diff --git a/internal/stage/analyze_plan_test.go b/internal/stage/analyze_plan_test.go index 10ad1d9..c0c0e79 100644 --- a/internal/stage/analyze_plan_test.go +++ b/internal/stage/analyze_plan_test.go @@ -151,7 +151,6 @@ func TestPlanAnalyzeWorkSchedulesEveryNonCurrentReason(t *testing.T) { analyzeReconciliationStale, analyzeReconciliationMissing, analyzeReconciliationFailed, - analyzeReconciliationLegacy, analyzeReconciliationNonResumable, } for _, reason := range reasons { @@ -245,12 +244,12 @@ func TestPlanAnalyzeWorkProjectionPreservesOnlyValidConfiguredAuthority(t *testi } } -func TestPlanAnalyzeWorkLegacyPartialSelectionDoesNotPromoteUnselectedOutput(t *testing.T) { +func TestPlanAnalyzeWorkNonResumablePartialSelectionDoesNotPromoteUnselectedOutput(t *testing.T) { configured := map[string]config.ScriptoriumArtifactConfig{ "target": {Enabled: true}, "legacy_unselected": {Enabled: true}, } reconciliation := analyzePlanningReconciliation(configured, map[string]analyzeReconciliationReason{ - "target": analyzeReconciliationLegacy, "legacy_unselected": analyzeReconciliationLegacy, + "target": analyzeReconciliationNonResumable, "legacy_unselected": analyzeReconciliationNonResumable, }, nil) plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, []string{"target"}, false, reconciliation) if err != nil { @@ -258,7 +257,7 @@ func TestPlanAnalyzeWorkLegacyPartialSelectionDoesNotPromoteUnselectedOutput(t * } assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, []string{"target"}) if len(plan.ProjectedRecords) != 0 { - t.Fatalf("legacy projection = %#v, want no current records", plan.ProjectedRecords) + t.Fatalf("non-resumable projection = %#v, want no current records", plan.ProjectedRecords) } } diff --git a/internal/stage/analyze_reconciliation.go b/internal/stage/analyze_reconciliation.go index 4d53cf1..ac4211b 100644 --- a/internal/stage/analyze_reconciliation.go +++ b/internal/stage/analyze_reconciliation.go @@ -18,7 +18,6 @@ const ( analyzeReconciliationStale analyzeReconciliationReason = "stale" analyzeReconciliationMissing analyzeReconciliationReason = "missing" analyzeReconciliationFailed analyzeReconciliationReason = "failed" - analyzeReconciliationLegacy analyzeReconciliationReason = "legacy" analyzeReconciliationRemoved analyzeReconciliationReason = "removed" analyzeReconciliationNonResumable analyzeReconciliationReason = "non_resumable" ) @@ -109,8 +108,8 @@ func reconcileAnalyzeArtifact( return result } if stageRecord.AnalyzeStateVersion == 0 { - result.Reason = analyzeReconciliationLegacy - result.Detail = "analyze manifest uses aggregate-only legacy state" + result.Reason = analyzeReconciliationNonResumable + result.Detail = "analyze manifest state contract is absent" return result } if stageRecord.AnalyzeStateVersion != manifest.AnalyzeStateContractVersion { diff --git a/internal/stage/analyze_reconciliation_test.go b/internal/stage/analyze_reconciliation_test.go index 65b850f..7aa174e 100644 --- a/internal/stage/analyze_reconciliation_test.go +++ b/internal/stage/analyze_reconciliation_test.go @@ -32,7 +32,7 @@ func TestReconcileAnalyzeArtifactsClassifiesStoredState(t *testing.T) { }, }, { - name: "legacy record", want: analyzeReconciliationLegacy, + name: "missing state contract", want: analyzeReconciliationNonResumable, mutate: func(_ *testing.T, _ *Env, m *manifest.Manifest) { m.Stages["analyze"].AnalyzeStateVersion = 0 m.Stages["analyze"].AnalyzeArtifacts = nil diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index aa9d868..0045e68 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -264,6 +264,7 @@ func TestAnalyzeOmitsOptionalCanonicalPreviousRecapWhenUnavailable(t *testing.T) env, m, fake := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{ Enabled: true, @@ -303,6 +304,7 @@ func TestAnalyzeUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) { m.RunID = "20260518T010203Z-abcdef12" paths := sessionPathsForEnv(env, m.SessionID) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(paths.TranscriptsDir, "final.trimmed.json")) result, err := (analyzeStage{}).Run(context.Background(), env, m) if err != nil { @@ -386,6 +388,7 @@ func TestAnalyzeIncludesCanonicalPreviousRecapWhenPreparedCacheExists(t *testing env, m, fake := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) previousRecapPath := mustPreviousArtifactPath(t, paths, "artifacts/session_recap.md") writeAnalyzeFile(t, previousRecapPath, "previous recap\n") @@ -938,6 +941,7 @@ func TestAnalyzeFailsWhenOutputPathMissing(t *testing.T) { func TestAnalyzeFailsWhenTrimmedTranscriptMissing(t *testing.T) { env, m, _ := setupAnalyzeEnv(t) + delete(m.Stages, "trim") _, err := (analyzeStage{}).Run(context.Background(), env, m) if err == nil { t.Fatal("expected error, got nil") @@ -964,6 +968,9 @@ func TestAnalyzeMissingOptionalBuiltInInputsAreOmitted(t *testing.T) { "optional": {Source: source, Required: false}, } env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact + if source == artifacts.ArtifactTranscriptFinalTrimmed { + delete(m.Stages, "trim") + } if _, err := (analyzeStage{}).Run(context.Background(), env, m); err != nil { t.Fatalf("Run() error = %v", err) @@ -1000,6 +1007,9 @@ func TestAnalyzeMissingRequiredBuiltInInputsProvideExecutableGuidance(t *testing "required": {Source: tt.source, Required: true}, } env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact + if tt.source == artifacts.ArtifactTranscriptFinalTrimmed { + delete(m.Stages, "trim") + } _, err := (analyzeStage{}).Run(context.Background(), env, m) if err == nil { @@ -1020,6 +1030,7 @@ func TestAnalyzeSupportsProcessedTranscriptSourceWhenConfigured(t *testing.T) { env, m, fake := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ @@ -1069,6 +1080,7 @@ func TestAnalyzeSupportsNormalizedTranscriptSourceWhenConfigured(t *testing.T) { paths := sessionPathsForEnv(env, m.SessionID) normalizedPath := filepath.Join(paths.TranscriptsDir, "final.json") writeAnalyzeFile(t, normalizedPath, `{"segments":[{"id":1}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, normalizedPath) artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ @@ -1094,6 +1106,7 @@ func TestAnalyzeSupportsRenderedMarkdownTranscriptSourceWhenConfigured(t *testin paths := sessionPathsForEnv(env, m.SessionID) markdownPath := filepath.Join(paths.TranscriptsDir, "final.md") writeAnalyzeFile(t, markdownPath, "# Session Transcript\n") + setCurrentBuiltInEvidence(t, m, "render", artifacts.TranscriptOutputKindFinalMarkdown, artifacts.ArtifactTranscriptFinalMarkdown, markdownPath) artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ @@ -1243,9 +1256,7 @@ func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json") writeAnalyzeFile(t, fallbackPath, `{"segments":[{"id":999}]}`) writeAnalyzeFile(t, manifestPath, `{"segments":[{"id":10}]}`) - m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ - {Kind: "transcript_final", LocalPath: manifestPath}, - }) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, manifestPath) artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ @@ -1273,9 +1284,7 @@ func TestAnalyzeSupportsNormalizedTranscriptSourceFromManifestOutput(t *testing. manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json") writeAnalyzeFile(t, fallbackPath, `{"segments":[{"id":999}]}`) writeAnalyzeFile(t, manifestPath, `{"segments":[{"id":10}]}`) - m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ - {Kind: "transcript_final", LocalPath: manifestPath}, - }) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, manifestPath) artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ @@ -1689,6 +1698,17 @@ func setupAnalyzeEnv(t *testing.T) (*Env, *manifest.Manifest, *scriptorium.FakeR Scriptorium: fake, } m := manifest.New("2026-05-03", time.Now().UTC()) + paths := store.SessionPathsFor("icewind-dale", "2026-05-03") + trimmedPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json") + writeAnalyzeFile(t, trimmedPath, `{"segments":[]}`) + checksum, err := artifacts.SHA256File(trimmedPath) + if err != nil { + t.Fatalf("SHA256File(%q): %v", trimmedPath, err) + } + m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{ + Kind: artifacts.TranscriptOutputKindFinalTrimmed, SourceID: artifacts.ArtifactTranscriptFinalTrimmed, + LocalPath: trimmedPath, ProducerRunID: "trim-run", Checksum: checksum, + }}) return env, m, fake } diff --git a/internal/stage/extract_resume.go b/internal/stage/extract_resume.go index f55217b..5345f6c 100644 --- a/internal/stage/extract_resume.go +++ b/internal/stage/extract_resume.go @@ -30,7 +30,7 @@ func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani paths := sessionPathsForEnv(env, sessionID) input, err := artifacts.ResolveExtractionInputIdentity(paths, m) if err != nil { - return ResumeValidation{}, fmt.Errorf("extract resume: resolve final-trimmed transcript identity: %w", err) + return NonResumable("final-trimmed transcript evidence is unavailable or changed"), nil } references, err := resolveExtractReferences(paths, m, cfg, sessionID) if err != nil { diff --git a/internal/stage/extract_test.go b/internal/stage/extract_test.go index 1951178..25b451b 100644 --- a/internal/stage/extract_test.go +++ b/internal/stage/extract_test.go @@ -1012,9 +1012,13 @@ func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunn m := manifest.New(sessionID, time.Now().UTC()) m.Campaign = campaign m.RunID = runID + inputChecksum, err := artifacts.SHA256File(inputPath) + if err != nil { + t.Fatalf("SHA256File(input) error = %v", err) + } m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{ Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed, - LocalPath: inputPath, + LocalPath: inputPath, ProducerRunID: "trim-run", Checksum: inputChecksum, }}) outputRoot := artifacts.SessionRunNotariusOutputRootForCampaign(workspace, campaign, sessionID, runID) diff --git a/internal/stage/normalize_test.go b/internal/stage/normalize_test.go index 900de49..55e946e 100644 --- a/internal/stage/normalize_test.go +++ b/internal/stage/normalize_test.go @@ -20,10 +20,7 @@ func TestNormalizeStageConsumesProcessedTranscriptFromManifest(t *testing.T) { manifestProcessed := filepath.Join(paths.ArtifactsDir, "processed.from-manifest.json") writeFile(t, manifestProcessed, `{"segments":[{"id":10}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":99}]}`) - m.MarkStageSucceeded("polish", time.Now().UTC(), []manifest.ArtifactRecord{{ - Kind: "transcript_polished", - LocalPath: manifestProcessed, - }}) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, manifestProcessed) result, err := (normalizeStage{}).Run(context.Background(), env, m) if err != nil { @@ -41,21 +38,18 @@ func TestNormalizeStageConsumesProcessedTranscriptFromManifest(t *testing.T) { } } -func TestNormalizeStageFallsBackToProcessedTranscriptPath(t *testing.T) { +func TestNormalizeStageRejectsIncidentalProcessedTranscriptPath(t *testing.T) { env, m, ser := setupNormalizeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) fallback := filepath.Join(paths.TranscriptsDir, "polished.json") writeFile(t, fallback, `{"segments":[{"id":1}]}`) _, err := (normalizeStage{}).Run(context.Background(), env, m) - if err != nil { - t.Fatalf("normalize.Run() error = %v", err) + if err == nil || !strings.Contains(err.Error(), "processed transcript input is required") { + t.Fatalf("normalize.Run() error = %v, want unavailable manifest input", err) } - if len(ser.NormalizeRequests) != 1 { - t.Fatalf("normalize requests = %d, want 1", len(ser.NormalizeRequests)) - } - if ser.NormalizeRequests[0].InputTranscriptPath != fallback { - t.Fatalf("input transcript = %q, want %q", ser.NormalizeRequests[0].InputTranscriptPath, fallback) + if len(ser.NormalizeRequests) != 0 { + t.Fatalf("normalize requests = %d, want 0", len(ser.NormalizeRequests)) } } @@ -74,6 +68,7 @@ func TestNormalizeStageFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) { env, m, _ := setupNormalizeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), "not-json") + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) _, err := (normalizeStage{}).Run(context.Background(), env, m) if err == nil { @@ -88,6 +83,7 @@ func TestNormalizeStageFailsWhenProcessedTranscriptMissingSegments(t *testing.T) env, m, _ := setupNormalizeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"schema":"audita.processed.v1"}`) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) _, err := (normalizeStage{}).Run(context.Background(), env, m) if err == nil { @@ -102,6 +98,7 @@ func TestNormalizeStagePassesConfiguredOutputSchemaToAdapter(t *testing.T) { env, m, ser := setupNormalizeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) env.Config.Pipeline.Normalize.OutputSchema = "seriatim-full" result, err := (normalizeStage{}).Run(context.Background(), env, m) @@ -123,6 +120,7 @@ func TestNormalizeStageRecordsNormalizedTranscriptOutputKind(t *testing.T) { env, m, _ := setupNormalizeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) result, err := (normalizeStage{}).Run(context.Background(), env, m) if err != nil { @@ -140,6 +138,7 @@ func TestNormalizeStageRecordsReportLogAndGeneratedConfigRefs(t *testing.T) { env, m, _ := setupNormalizeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) report := true env.Config.Pipeline.Normalize.Report = &report @@ -166,6 +165,7 @@ func TestNormalizeStageFailsWhenAdapterReturnsError(t *testing.T) { env, m, ser := setupNormalizeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) ser.NormalizeErr = errors.New("normalize failed") _, err := (normalizeStage{}).Run(context.Background(), env, m) @@ -181,6 +181,7 @@ func TestNormalizeStageFailsWhenNormalizedOutputInvalid(t *testing.T) { env, m, ser := setupNormalizeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) badOutput := filepath.Join(paths.TranscriptsDir, "normalized.bad.json") writeFile(t, badOutput, "not-json") ser.NormalizeResult = seriatim.NormalizeResult{OutputNormalizedPath: badOutput} @@ -198,6 +199,7 @@ func TestNormalizeStageReportEnabledFailsWhenReportMissing(t *testing.T) { env, m, ser := setupNormalizeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) report := true env.Config.Pipeline.Normalize.Report = &report ser.NormalizeResult = seriatim.NormalizeResult{ReportPath: filepath.Join(paths.ArtifactsDir, "missing-report.json")} @@ -218,6 +220,7 @@ func TestNormalizeStageUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) { m.RunID = "20260518T010203Z-abcdef12" paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) + setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json")) result, err := (normalizeStage{}).Run(context.Background(), env, m) if err != nil { diff --git a/internal/stage/polish_test.go b/internal/stage/polish_test.go index ff32717..aa7cf69 100644 --- a/internal/stage/polish_test.go +++ b/internal/stage/polish_test.go @@ -21,9 +21,7 @@ func TestPolishStagePolishesMergedTranscriptAndRecordsMetadata(t *testing.T) { mergedPath := filepath.Join(paths.TranscriptsDir, "base.json") writeFile(t, mergedPath, `{"segments":[]}`) writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") - m.MarkStageSucceeded("merge", time.Now().UTC(), []manifest.ArtifactRecord{ - {Kind: "transcript_base", LocalPath: mergedPath}, - }) + setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, mergedPath) fake := &audita.FakeRunner{} env.Audita = fake @@ -84,7 +82,7 @@ func TestPolishStagePolishesMergedTranscriptAndRecordsMetadata(t *testing.T) { } } -func TestPolishStageFallsBackToMergedTranscriptPath(t *testing.T) { +func TestPolishStageRejectsIncidentalMergedTranscriptPath(t *testing.T) { env, m := setupPolishEnv(t) paths := sessionPathsForEnv(env, m.SessionID) mergedPath := filepath.Join(paths.TranscriptsDir, "base.json") @@ -95,14 +93,11 @@ func TestPolishStageFallsBackToMergedTranscriptPath(t *testing.T) { env.Audita = fake _, err := (polishStage{}).Run(context.Background(), env, m) - if err != nil { - t.Fatalf("polish.Run() error = %v", err) + if err == nil || !strings.Contains(err.Error(), "merged transcript input is required") { + t.Fatalf("polish.Run() error = %v, want unavailable manifest input", err) } - if len(fake.Requests) != 1 { - t.Fatalf("requests = %d, want 1", len(fake.Requests)) - } - if fake.Requests[0].MergedTranscriptPath != mergedPath { - t.Fatalf("merged path = %q, want %q", fake.Requests[0].MergedTranscriptPath, mergedPath) + if len(fake.Requests) != 0 { + t.Fatalf("requests = %d, want 0", len(fake.Requests)) } } @@ -125,6 +120,7 @@ func TestPolishStageFailsWhenMergedTranscriptInvalidJSON(t *testing.T) { env, m := setupPolishEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), "not-json") + setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json")) writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") env.Audita = &audita.FakeRunner{} @@ -141,6 +137,7 @@ func TestPolishStageFailsWhenGlossaryMissing(t *testing.T) { env, m := setupPolishEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json")) env.Audita = &audita.FakeRunner{} _, err := (polishStage{}).Run(context.Background(), env, m) @@ -156,6 +153,7 @@ func TestPolishStageFailsWhenAdapterFails(t *testing.T) { env, m := setupPolishEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json")) writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") env.Audita = &audita.FakeRunner{Err: errors.New("audita failed")} @@ -172,6 +170,7 @@ func TestPolishStageFailsWhenProcessedOutputInvalid(t *testing.T) { env, m := setupPolishEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json")) writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") badPath := filepath.Join(paths.TranscriptsDir, "processed.invalid.json") writeFile(t, badPath, `{"schema":"audita.processed.v1","segments":"wrong-type"}`) @@ -190,6 +189,7 @@ func TestPolishStageFailsWhenReportInvalid(t *testing.T) { env, m := setupPolishEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json")) writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") badReport := filepath.Join(paths.ArtifactsDir, "bad.report.json") writeFile(t, badReport, "not-json") @@ -211,6 +211,7 @@ func TestPolishStageUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) { m.RunID = "20260518T010203Z-abcdef12" paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json")) writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") fake := &audita.FakeRunner{} diff --git a/internal/stage/publish_test.go b/internal/stage/publish_test.go index cce8fc4..ac74af8 100644 --- a/internal/stage/publish_test.go +++ b/internal/stage/publish_test.go @@ -1071,6 +1071,7 @@ func publishFixture(t *testing.T) (*Env, *manifest.Manifest, string) { for _, name := range publishPrerequisiteStages { m.MarkStageSucceeded(name, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), nil) } + setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json")) setCurrentAnalyzeEvidence(t, m, "session_recap", "artifacts/session_recap.md", filepath.Join(sessionRoot, "artifacts", "session_recap.md")) env := &Env{ diff --git a/internal/stage/render_test.go b/internal/stage/render_test.go index 57d87c3..3835153 100644 --- a/internal/stage/render_test.go +++ b/internal/stage/render_test.go @@ -44,8 +44,8 @@ func TestRenderStageResolvesManifestInputsFirst(t *testing.T) { writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`) - m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final", LocalPath: manifestFinal}}) - m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final_trimmed", LocalPath: manifestTrimmed}}) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, manifestFinal) + setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, manifestTrimmed) result, err := (renderStage{}).Run(context.Background(), env, m) if err != nil { @@ -82,6 +82,8 @@ func TestRenderStageTitleFallsBackToSessionTitle(t *testing.T) { paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) + setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(paths.TranscriptsDir, "final.trimmed.json")) env.Config.Pipeline.Render.Title = "" env.Config.Session.Title = "Session Title" @@ -112,6 +114,7 @@ func TestRenderStageFailsWhenFinalTrimmedInputMissing(t *testing.T) { env, m, _ := setupRenderEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) _, err := (renderStage{}).Run(context.Background(), env, m) if err == nil { @@ -130,6 +133,8 @@ func TestRenderStageUsesRunLocalOutputsAndMaterializesCanonical(t *testing.T) { paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) + setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(paths.TranscriptsDir, "final.trimmed.json")) result, err := (renderStage{}).Run(context.Background(), env, m) if err != nil { diff --git a/internal/stage/trim_test.go b/internal/stage/trim_test.go index 6304d6a..3969ea8 100644 --- a/internal/stage/trim_test.go +++ b/internal/stage/trim_test.go @@ -21,9 +21,7 @@ func TestTrimStageConsumesNormalizedAndProducesTrimmedTranscript(t *testing.T) { paths := sessionPathsForEnv(env, m.SessionID) normalized := filepath.Join(paths.TranscriptsDir, "final.json") writeFile(t, normalized, `{"segments":[{"id":10},{"id":868}]}`) - m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ - {Kind: "transcript_final", LocalPath: normalized}, - }) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, normalized) scr.BoundsBody = `{"confidence":"high","trim_action":"trim","start_segment_id":10,"end_segment_id":868,"warnings":[]}` result, err := (trimStage{}).Run(context.Background(), env, m) @@ -76,6 +74,7 @@ func TestTrimStageUsesConfiguredScriptoriumInputName(t *testing.T) { paths := sessionPathsForEnv(env, m.SessionID) normalized := filepath.Join(paths.TranscriptsDir, "final.json") writeFile(t, normalized, `{"segments":[{"id":10},{"id":11}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, normalized) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":10,"end_segment_id":11}` cfg := env.Config.Pipeline.Trim @@ -99,6 +98,7 @@ func TestTrimStageRecordsLogAndGeneratedConfigRefs(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}` result, err := (trimStage{}).Run(context.Background(), env, m) @@ -119,6 +119,7 @@ func TestTrimStageRenderDebugDiagnosticsAreNotStageOutputs(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}` cfg := *env.Config.Pipeline.Trim @@ -183,6 +184,7 @@ func TestTrimStageFailsWhenNormalizedTranscriptInvalidJSON(t *testing.T) { env, m, _, _ := setupTrimEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), "not-json") + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { t.Fatal("expected error, got nil") @@ -196,6 +198,7 @@ func TestTrimStageFailsWhenNormalizedTranscriptMissingSegmentsArray(t *testing.T env, m, _, _ := setupTrimEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"schema":"audita.processed.v1"}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { t.Fatal("expected error, got nil") @@ -209,6 +212,7 @@ func TestTrimStageFailsWhenBoundsOutputInvalidJSON(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) scr.BoundsBody = "not-json" _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { @@ -223,6 +227,7 @@ func TestTrimStageFailsWhenBoundsRangeIsDescending(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":2,"end_segment_id":1}` _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { @@ -237,6 +242,7 @@ func TestTrimStageFailsWhenBoundsIDsMissingFromTranscript(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":20},{"id":21}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":10,"end_segment_id":21}` _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { @@ -251,6 +257,7 @@ func TestTrimStageFailsWhenScriptoriumAdapterFails(t *testing.T) { env, m, scr, _ := setupTrimEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) scr.RunErr = errors.New("bounds failed") _, err := (trimStage{}).Run(context.Background(), env, m) if err == nil { @@ -265,6 +272,7 @@ func TestTrimStageFailsWhenSeriatimTrimAdapterFails(t *testing.T) { env, m, scr, ser := setupTrimEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json")) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}` ser.TrimErr = errors.New("trim failed") _, err := (trimStage{}).Run(context.Background(), env, m) @@ -282,6 +290,7 @@ func TestTrimStageDisabledCopiesNormalizedTranscript(t *testing.T) { normalized := filepath.Join(paths.TranscriptsDir, "final.json") normalizedBody := `{"segments":[{"id":1,"text":"alpha"},{"id":2,"text":"beta"}]}` writeFile(t, normalized, normalizedBody) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, normalized) disabled := *env.Config.Pipeline.Trim enabled := false @@ -316,6 +325,7 @@ func TestTrimStageUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) { paths := sessionPathsForEnv(env, m.SessionID) normalized := filepath.Join(paths.TranscriptsDir, "final.json") writeFile(t, normalized, `{"segments":[{"id":10},{"id":11}]}`) + setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, normalized) scr.BoundsBody = `{"trim_action":"trim","start_segment_id":10,"end_segment_id":11,"warnings":[]}` result, err := (trimStage{}).Run(context.Background(), env, m)