diff --git a/docs/integrations/json-output.md b/docs/integrations/json-output.md index 7a354cf..a1bcfdf 100644 --- a/docs/integrations/json-output.md +++ b/docs/integrations/json-output.md @@ -98,6 +98,30 @@ approved. Fields with empty values may be omitted by JSON encoding. +`source_digests` contains source document digests only. Bound references are +recorded separately under `references`, which contains provenance only: +lane ID, slot name, origin type and URI, digest, media type, byte size, and +binding source. Reference content is not written to durable output. + +When references are bound, the manifest section has this shape: + +```json +{ + "references": [ + { + "lane_id": "events", + "slot_name": "roster", + "origin_type": "file", + "origin_uri": "file:///absolute/path/roster.txt", + "digest": "sha256:...", + "media_type": "text/plain; charset=utf-8", + "size_bytes": 123, + "binding_source": "config" + } + ] +} +``` + `module_metadata` is omitted when no singleton module provides metadata. `validation_status` is `approved` when no candidates were rejected and diff --git a/docs/internal/diagnostics.md b/docs/internal/diagnostics.md index 0771a05..476b889 100644 --- a/docs/internal/diagnostics.md +++ b/docs/internal/diagnostics.md @@ -33,6 +33,7 @@ Implemented artifact names: - `invocation.json` - `effective-config.json` - `resolved-pipeline.json` +- `resolved-references.json` - `source-document.json` - `run-manifest.json` - `run-report.json` diff --git a/docs/internal/pipeline.md b/docs/internal/pipeline.md index a0c629e..8339253 100644 --- a/docs/internal/pipeline.md +++ b/docs/internal/pipeline.md @@ -42,7 +42,9 @@ During run preparation, resolved file references are materialized before any LLM-backed pipeline work. Config bindings resolve relative to the config file, CLI bindings resolve relative to the current working directory, and materialized reference content is passed to extractors through `ExtractionRequest`. -Reference content is omitted from diagnostics and manifests. +Reference content is omitted from diagnostics and manifests. The CLI writes +provenance-only resolved reference diagnostics, and the run manifest records +lane-scoped reference provenance separately from source digests. Prompt bundles can declare reference slots and use `reference` and `hasreference` template functions. Bundle loading validates string-literal slot @@ -176,7 +178,7 @@ On successful execution, the manifest validation status is: The manifest records run ID, pipeline ID, pipeline digest, module keys, top-level module metadata, artifact lanes, LLM profile metadata, source digest, -validation status, and timing. +reference provenance, validation status, and timing. Singleton pipeline modules may add non-secret metadata by implementing `contracts.ManifestMetadataProvider`. The runner records that metadata under diff --git a/docs/operations.md b/docs/operations.md index 689004c..6a51400 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -62,6 +62,9 @@ Implemented diagnostics artifacts: path, selected lanes, run ID, and pipeline digest when available. - `effective-config.json`: resolved config with API keys redacted. - `resolved-pipeline.json`: resolved module bindings and pipeline digest. +- `resolved-references.json`: lane-scoped resolved reference provenance, + including origin, digest, media type, byte size, and binding source, without + reference content. - `run-manifest.json`: the same run manifest written to durable output when it is available, including top-level module metadata when present. - `warnings.json`: warning list. diff --git a/internal/cli/run.go b/internal/cli/run.go index 9996b23..599a13c 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -206,6 +206,9 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i if err := runDir.WriteResolvedPipeline(effective.ResolvedPipeline); err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics resolved pipeline: %w", err)) } + if err := runDir.WriteResolvedReferences(pipeline.ReferenceProvenance(effective.ResolvedPipeline)); err != nil { + return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics resolved references: %w", err)) + } profileIDs := effectiveLLMProfileIDs(effective.ResolvedPipeline) if len(profileIDs) != 1 { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 4bb405a..e7f5066 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -15,6 +15,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/dnd/scenes" @@ -984,6 +985,74 @@ func TestRunPipelineWritesDurableOutputFiles(t *testing.T) { assertNoTemporaryFiles(t, runOutputDir) } +func TestRunPipelineReferenceBytesProduceDistinctManifests(t *testing.T) { + run := func(t *testing.T, referenceText string) artifacts.RunManifest { + t.Helper() + + diagnosticsDir := t.TempDir() + outputDir := t.TempDir() + configDir := t.TempDir() + referencePath := filepath.Join(configDir, "roster.txt") + if err := os.WriteFile(referencePath, []byte(referenceText), 0o644); err != nil { + t.Fatalf("write reference: %v", err) + } + configPath := filepath.Join(configDir, "config.yml") + if err := os.WriteFile(configPath, []byte(testConfigYAMLWithReferencesAndDiagnostics("example", "events", diagnosticsDir, map[string]string{"roster": "roster.txt"})), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + inputPath := writeFile(t, "source.txt", "source text") + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"run", "example", "--config", configPath, "--input", inputPath, "--output-dir", outputDir}, &stdout, &stderr, Options{ + Catalog: fakeCatalog(t, pipeline.ModuleSpec{ + Key: "fake/extract", + Stage: pipeline.StageExtract, + Requires: []string{"chunks"}, + Provides: []string{"artifact"}, + ReferenceSlots: []contracts.ReferenceSlot{ + {Name: "roster"}, + }, + }), + Registries: fakeExecutionRegistries(t), + LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), + }) + if code != 0 { + t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) + } + + var manifest artifacts.RunManifest + readJSONFile(t, filepath.Join(onlyChildDir(t, outputDir), "manifest.json"), &manifest) + if len(manifest.References) != 1 { + t.Fatalf("manifest references = %#v, want one entry", manifest.References) + } + if !reflect.DeepEqual(manifest.SourceDigests, []string{"sha256:source"}) { + t.Fatalf("source digests = %#v, want source-only digest", manifest.SourceDigests) + } + + var resolvedReferences []artifacts.ReferenceProvenance + readJSONFile(t, filepath.Join(onlyChildDir(t, diagnosticsDir), diagnostics.ArtifactResolvedReferences), &resolvedReferences) + if !reflect.DeepEqual(resolvedReferences, manifest.References) { + t.Fatalf("resolved references = %#v, want manifest references %#v", resolvedReferences, manifest.References) + } + resolvedReferenceJSON := string(readFile(t, filepath.Join(onlyChildDir(t, diagnosticsDir), diagnostics.ArtifactResolvedReferences))) + if strings.Contains(resolvedReferenceJSON, referenceText) || strings.Contains(resolvedReferenceJSON, "content") { + t.Fatalf("resolved references diagnostics contains content: %s", resolvedReferenceJSON) + } + return manifest + } + + first := run(t, "first roster") + second := run(t, "second roster") + + if first.References[0].Digest == second.References[0].Digest { + t.Fatalf("reference digests match for different bytes: %q", first.References[0].Digest) + } + if first.PipelineDigest != second.PipelineDigest { + t.Fatalf("pipeline digests differ = %q vs %q, want reference bytes outside pipeline identity", first.PipelineDigest, second.PipelineDigest) + } +} + func TestRunPipelineRejectsUnsafeOutputFileName(t *testing.T) { diagnosticsDir := t.TempDir() outputDir := t.TempDir() @@ -1540,6 +1609,30 @@ func testConfigYAMLWithReferences(pipelineID string, laneID string, references m return b.String() } +func testConfigYAMLWithReferencesAndDiagnostics(pipelineID string, laneID string, diagnosticsDir string, references map[string]string) string { + var b strings.Builder + b.WriteString("version: 1\n") + b.WriteString("diagnostics:\n") + b.WriteString(" work_dir: " + diagnosticsDir + "\n") + b.WriteString(" retention: always\n") + b.WriteString("pipelines:\n") + b.WriteString(" " + pipelineID + ":\n") + b.WriteString(" input: fake/input\n") + b.WriteString(" artifacts:\n") + b.WriteString(" " + laneID + ":\n") + b.WriteString(" extract: fake/extract\n") + b.WriteString(" references:\n") + keys := make([]string, 0, len(references)) + for key := range references { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + b.WriteString(" " + key + ": " + references[key] + "\n") + } + return b.String() +} + func mvpConfigYAML(pipelineID string, extractor string) string { return `version: 1 pipelines: @@ -1788,6 +1881,153 @@ func registriesWithOutput(t *testing.T, encoder contracts.OutputEncoder) pipelin return registries } +func fakeExecutionRegistries(t *testing.T) pipeline.Registries { + t.Helper() + inputs := pipeline.NewInputAdapterRegistry() + chunkers := pipeline.NewChunkerRegistry() + extractors := pipeline.NewExtractorRegistry() + mergers := pipeline.NewMergerRegistry() + normalizers := pipeline.NewNormalizerRegistry() + outputs := pipeline.NewOutputEncoderRegistry() + + if err := inputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "fake/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func() (contracts.InputAdapter, error) { + return fakeRunInputAdapter{}, nil + }); err != nil { + t.Fatalf("register fake input: %v", err) + } + if err := chunkers.RegisterWithSpec(pipeline.ModuleSpec{Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}}, func() (contracts.Chunker, error) { + return fakeRunChunker{}, nil + }); err != nil { + t.Fatalf("register fake chunker: %v", err) + } + if err := extractors.RegisterWithSpec(pipeline.ModuleSpec{ + Key: "fake/extract", + Stage: pipeline.StageExtract, + Requires: []string{"chunks"}, + Provides: []string{"artifact"}, + ReferenceSlots: []contracts.ReferenceSlot{ + {Name: "roster"}, + }, + }, func() (contracts.Extractor, error) { + return fakeRunExtractor{}, nil + }); err != nil { + t.Fatalf("register fake extractor: %v", err) + } + if err := mergers.RegisterWithSpec(pipeline.ModuleSpec{Key: "appendorder", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}}, func() (contracts.Merger, error) { + return fakeRunMerger{}, nil + }); err != nil { + t.Fatalf("register fake merger: %v", err) + } + if err := normalizers.RegisterWithSpec(pipeline.ModuleSpec{Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}}, func() (contracts.Normalizer, error) { + return fakeRunNormalizer{}, nil + }); err != nil { + t.Fatalf("register fake normalizer: %v", err) + } + if err := jsonoutput.Register(outputs); err != nil { + t.Fatalf("register json output: %v", err) + } + + return pipeline.Registries{ + Inputs: inputs, + Chunkers: chunkers, + Extractors: extractors, + Mergers: mergers, + Normalizers: normalizers, + Outputs: outputs, + } +} + +type fakeRunInputAdapter struct{} + +func (fakeRunInputAdapter) Key() string { + return "fake/input" +} + +func (fakeRunInputAdapter) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) { + return &source.SourceDocument{ + ID: "source", + Kind: "text", + Format: "test", + Digest: "sha256:source", + Units: []source.SourceUnit{ + {ID: "unit-1", Kind: "text", Text: string(req.Raw)}, + }, + }, nil +} + +type fakeRunChunker struct{} + +func (fakeRunChunker) Key() string { + return "generic" +} + +func (fakeRunChunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contracts.ChunkResult, error) { + return contracts.ChunkResult{ + Chunks: []contracts.SourceChunk{ + {ID: "chunk-1", SourceID: req.Source.ID, Index: 0, Units: req.Source.Units}, + }, + }, nil +} + +type fakeRunExtractor struct{} + +func (fakeRunExtractor) Key() string { + return "fake/extract" +} + +func (fakeRunExtractor) ArtifactType() string { + return "fake.artifact" +} + +func (fakeRunExtractor) SchemaVersion() string { + return "v1" +} + +func (fakeRunExtractor) ReferenceSlots() []contracts.ReferenceSlot { + return []contracts.ReferenceSlot{{Name: "roster"}} +} + +func (fakeRunExtractor) Validators() []contracts.Validator { + return nil +} + +func (fakeRunExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { + return contracts.ExtractionResult{ + Candidates: []artifacts.ArtifactCandidate{ + { + Payload: []byte(`{"value":true}`), + SourceRefs: []source.SourceRef{ + {SourceID: "source", StartUnitID: "unit-1", EndUnitID: "unit-1"}, + }, + }, + }, + }, nil +} + +type fakeRunMerger struct{} + +func (fakeRunMerger) Key() string { + return "appendorder" +} + +func (fakeRunMerger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) { + var candidates []artifacts.ArtifactCandidate + for _, chunkArtifacts := range req.ChunkArtifacts { + candidates = append(candidates, chunkArtifacts.Candidates...) + } + return contracts.MergeResult{Candidates: candidates}, nil +} + +type fakeRunNormalizer struct{} + +func (fakeRunNormalizer) Key() string { + return "noop" +} + +func (fakeRunNormalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) { + return contracts.NormalizeResult{Candidates: append([]artifacts.ArtifactCandidate(nil), req.Candidates...)}, nil +} + func onlyChildDir(t *testing.T, root string) string { t.Helper() children := childDirs(t, root) diff --git a/internal/core/artifacts/artifacts.go b/internal/core/artifacts/artifacts.go index 6724776..5e0737d 100644 --- a/internal/core/artifacts/artifacts.go +++ b/internal/core/artifacts/artifacts.go @@ -48,6 +48,17 @@ type LLMProfileManifest struct { Model string `json:"model,omitempty"` } +type ReferenceProvenance struct { + LaneID string `json:"lane_id"` + SlotName string `json:"slot_name"` + OriginType string `json:"origin_type"` + OriginURI string `json:"origin_uri,omitempty"` + Digest string `json:"digest,omitempty"` + MediaType string `json:"media_type,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + BindingSource string `json:"binding_source,omitempty"` +} + type RunManifest struct { RunID string `json:"run_id,omitempty"` PipelineID string `json:"pipeline_id,omitempty"` @@ -61,6 +72,7 @@ type RunManifest struct { OutputEncoder string `json:"output_encoder,omitempty"` ModuleMetadata map[string]map[string]any `json:"module_metadata,omitempty"` ArtifactLanes []ArtifactLaneManifest `json:"artifact_lanes,omitempty"` + References []ReferenceProvenance `json:"references,omitempty"` LLMProfiles []LLMProfileManifest `json:"llm_profiles,omitempty"` SchemaVersion string `json:"schema_version,omitempty"` ValidationStatus string `json:"validation_status,omitempty"` diff --git a/internal/core/artifacts/artifacts_test.go b/internal/core/artifacts/artifacts_test.go index a8825ea..67530f6 100644 --- a/internal/core/artifacts/artifacts_test.go +++ b/internal/core/artifacts/artifacts_test.go @@ -183,6 +183,43 @@ func TestRunManifestIncludesPipelineAndArtifactLaneFields(t *testing.T) { assertHasKeys(t, lane, "id", "extractor", "merger", "normalizer", "validators", "metadata") } +func TestRunManifestIncludesReferenceProvenance(t *testing.T) { + manifest := RunManifest{ + References: []ReferenceProvenance{ + { + LaneID: "events", + SlotName: "roster", + OriginType: "file", + OriginURI: "file:///tmp/roster.txt", + Digest: "sha256:reference", + MediaType: "text/plain; charset=utf-8", + SizeBytes: 12, + BindingSource: "config", + }, + }, + } + + gotJSON, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var got RunManifest + if err := json.Unmarshal(gotJSON, &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if len(got.References) != 1 { + t.Fatalf("len(References) = %d, want 1", len(got.References)) + } + reference := got.References[0] + if reference.LaneID != "events" || reference.SlotName != "roster" || reference.OriginType != "file" || reference.OriginURI != "file:///tmp/roster.txt" { + t.Fatalf("reference provenance = %#v, want lane-scoped origin details", reference) + } + if reference.Digest != "sha256:reference" || reference.MediaType != "text/plain; charset=utf-8" || reference.SizeBytes != 12 || reference.BindingSource != "config" { + t.Fatalf("reference provenance = %#v, want digest/media/size/source details", reference) + } +} + func TestRunManifestIncludesTopLevelModuleMetadata(t *testing.T) { manifest := RunManifest{ ModuleMetadata: map[string]map[string]any{ diff --git a/internal/core/diagnostics/artifacts.go b/internal/core/diagnostics/artifacts.go index a84aa7c..2154c41 100644 --- a/internal/core/diagnostics/artifacts.go +++ b/internal/core/diagnostics/artifacts.go @@ -4,6 +4,7 @@ const ( ArtifactInvocationMetadata = "invocation.json" ArtifactEffectiveConfig = "effective-config.json" ArtifactResolvedPipeline = "resolved-pipeline.json" + ArtifactResolvedReferences = "resolved-references.json" ArtifactSourceDocument = "source-document.json" ArtifactRunManifest = "run-manifest.json" ArtifactRunReport = "run-report.json" diff --git a/internal/core/diagnostics/artifacts_test.go b/internal/core/diagnostics/artifacts_test.go index a1b39b9..813924c 100644 --- a/internal/core/diagnostics/artifacts_test.go +++ b/internal/core/diagnostics/artifacts_test.go @@ -7,6 +7,7 @@ func TestArtifactNamesUseExtractionOrientedNames(t *testing.T) { ArtifactInvocationMetadata, ArtifactEffectiveConfig, ArtifactResolvedPipeline, + ArtifactResolvedReferences, ArtifactSourceDocument, ArtifactRunManifest, ArtifactRunReport, diff --git a/internal/core/diagnostics/run_dir.go b/internal/core/diagnostics/run_dir.go index 788d6d6..1fb58d4 100644 --- a/internal/core/diagnostics/run_dir.go +++ b/internal/core/diagnostics/run_dir.go @@ -149,6 +149,10 @@ func (r *RunDirectory) WriteResolvedPipeline(payload any) error { return r.WriteJSONArtifact(ArtifactResolvedPipeline, payload) } +func (r *RunDirectory) WriteResolvedReferences(payload any) error { + return r.WriteJSONArtifact(ArtifactResolvedReferences, payload) +} + func (r *RunDirectory) WriteSourceDocument(payload any) error { return r.WriteJSONArtifact(ArtifactSourceDocument, payload) } diff --git a/internal/core/diagnostics/run_dir_test.go b/internal/core/diagnostics/run_dir_test.go index 2d7a6e3..bdcb6f4 100644 --- a/internal/core/diagnostics/run_dir_test.go +++ b/internal/core/diagnostics/run_dir_test.go @@ -191,6 +191,9 @@ func TestWriteTypedArtifacts(t *testing.T) { if err := runDir.WriteResolvedPipeline(map[string]any{"pipeline": "test"}); err != nil { t.Fatalf("WriteResolvedPipeline: %v", err) } + if err := runDir.WriteResolvedReferences([]artifacts.ReferenceProvenance{{LaneID: "events", SlotName: "roster"}}); err != nil { + t.Fatalf("WriteResolvedReferences: %v", err) + } if err := runDir.WriteSourceDocument(map[string]any{"source_id": "source-1"}); err != nil { t.Fatalf("WriteSourceDocument: %v", err) } @@ -207,6 +210,7 @@ func TestWriteTypedArtifacts(t *testing.T) { for _, name := range []string{ ArtifactEffectiveConfig, ArtifactResolvedPipeline, + ArtifactResolvedReferences, ArtifactSourceDocument, ArtifactRunManifest, ArtifactRunReport, diff --git a/internal/framework/pipeline/references.go b/internal/framework/pipeline/references.go index 723c3c3..5ae8b8b 100644 --- a/internal/framework/pipeline/references.go +++ b/internal/framework/pipeline/references.go @@ -11,6 +11,7 @@ import ( "strings" "unicode/utf8" + "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) @@ -185,3 +186,33 @@ func CloneReferenceSet(in contracts.ReferenceSet) contracts.ReferenceSet { } return out } + +func ReferenceProvenance(resolved ResolvedPipeline) []artifacts.ReferenceProvenance { + provenance := []artifacts.ReferenceProvenance{} + for _, lane := range resolved.ArtifactLanes { + if len(lane.ReferenceSet.Slots) == 0 { + continue + } + slotNames := make([]string, 0, len(lane.ReferenceSet.Slots)) + for slotName := range lane.ReferenceSet.Slots { + slotNames = append(slotNames, slotName) + } + sort.Strings(slotNames) + for _, slotName := range slotNames { + slot := lane.ReferenceSet.Slots[slotName] + for _, item := range slot.Items { + provenance = append(provenance, artifacts.ReferenceProvenance{ + LaneID: lane.ID, + SlotName: item.SlotName, + OriginType: item.Origin.Type, + OriginURI: item.Origin.URI, + Digest: item.Digest, + MediaType: item.MediaType, + SizeBytes: item.SizeBytes, + BindingSource: item.BindingSource, + }) + } + } + } + return provenance +} diff --git a/internal/framework/pipeline/references_test.go b/internal/framework/pipeline/references_test.go index 35ec15c..f05dbe3 100644 --- a/internal/framework/pipeline/references_test.go +++ b/internal/framework/pipeline/references_test.go @@ -83,6 +83,17 @@ func TestMaterializeReferencesResolvesPathsAndDigestsContent(t *testing.T) { t.Fatalf("glossary origin URI = %q, want cli reference path", glossary.Origin.URI) } + provenance := ReferenceProvenance(first) + if len(provenance) != 2 { + t.Fatalf("ReferenceProvenance() = %#v, want two entries", provenance) + } + if provenance[0].LaneID != "events" || provenance[0].SlotName != "glossary" || provenance[0].Digest != glossary.Digest { + t.Fatalf("ReferenceProvenance()[0] = %#v, want sorted glossary provenance", provenance[0]) + } + if provenance[1].LaneID != "events" || provenance[1].SlotName != "roster" || provenance[1].Digest != roster.Digest { + t.Fatalf("ReferenceProvenance()[1] = %#v, want roster provenance", provenance[1]) + } + encoded, err := json.Marshal(first) if err != nil { t.Fatalf("json.Marshal(materialized) error = %v, want nil", err) diff --git a/internal/framework/pipeline/runner.go b/internal/framework/pipeline/runner.go index 8ba1d52..556bfa0 100644 --- a/internal/framework/pipeline/runner.go +++ b/internal/framework/pipeline/runner.go @@ -356,8 +356,12 @@ func manifestFromPipeline(input RunInput) artifacts.RunManifest { ArtifactLanes: make([]artifacts.ArtifactLaneManifest, 0, len(pipeline.ArtifactLanes)), RunID: runID, StartedAt: timePtr(startedAt), + References: ReferenceProvenance(pipeline), LLMProfiles: cloneLLMProfiles(input.LLMProfiles), } + // The runner does not currently maintain a cache or idempotency key. Reference + // digests are recorded in manifest provenance and intentionally kept separate + // from source_digests. for _, lane := range pipeline.ArtifactLanes { laneManifest := artifacts.ArtifactLaneManifest{ diff --git a/internal/framework/pipeline/runner_test.go b/internal/framework/pipeline/runner_test.go index a351686..1d0197f 100644 --- a/internal/framework/pipeline/runner_test.go +++ b/internal/framework/pipeline/runner_test.go @@ -960,7 +960,27 @@ func TestRunReturnsFailedManifestWhenOutputEncoderFails(t *testing.T) { } func TestRunManifestIncludesPipelineAndLaneDetails(t *testing.T) { - output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: resolvedPipelineWithValidators("configured")}) + resolved := resolvedPipelineWithValidators("configured") + resolved.ArtifactLanes[0].ReferenceSet = contracts.ReferenceSet{ + Slots: map[string]contracts.ResolvedReferenceSlot{ + "roster": { + Slot: contracts.ReferenceSlot{Name: "roster"}, + Items: []contracts.ReferenceItem{ + { + SlotName: "roster", + MediaType: "text/plain; charset=utf-8", + Content: []byte("reference content"), + Digest: "sha256:reference", + Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/roster.txt"}, + SizeBytes: int64(len("reference content")), + BindingSource: contracts.ReferenceBindingSourceConfig, + }, + }, + }, + }, + } + + output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: resolved}) if err != nil { t.Fatalf("Run() error = %v, want nil", err) } @@ -975,6 +995,16 @@ func TestRunManifestIncludesPipelineAndLaneDetails(t *testing.T) { if !reflect.DeepEqual(manifest.SourceDigests, []string{"sha256:source"}) { t.Fatalf("SourceDigests = %#v, want source digest", manifest.SourceDigests) } + if len(manifest.References) != 1 { + t.Fatalf("References = %#v, want one reference provenance entry", manifest.References) + } + reference := manifest.References[0] + if reference.LaneID != "alpha" || reference.SlotName != "roster" || reference.Digest != "sha256:reference" { + t.Fatalf("reference provenance = %#v, want lane slot digest", reference) + } + if reference.OriginType != "file" || reference.OriginURI != "file:///tmp/roster.txt" || reference.MediaType != "text/plain; charset=utf-8" || reference.SizeBytes != int64(len("reference content")) || reference.BindingSource != contracts.ReferenceBindingSourceConfig { + t.Fatalf("reference provenance = %#v, want origin/media/size/source", reference) + } if manifest.ValidationStatus != "approved" { t.Fatalf("ValidationStatus = %q, want approved", manifest.ValidationStatus) } diff --git a/internal/modules/output/json/encoder_test.go b/internal/modules/output/json/encoder_test.go index c0c9c83..f583b13 100644 --- a/internal/modules/output/json/encoder_test.go +++ b/internal/modules/output/json/encoder_test.go @@ -142,6 +142,42 @@ func TestEncodePrettyPrintsJSON(t *testing.T) { } } +func TestEncodeIncludesManifestReferences(t *testing.T) { + result, err := New().Encode(context.Background(), contracts.OutputRequest{ + Manifest: artifacts.RunManifest{ + RunID: "run-1", + References: []artifacts.ReferenceProvenance{ + { + LaneID: "events", + SlotName: "roster", + OriginType: "file", + OriginURI: "file:///tmp/roster.txt", + Digest: "sha256:reference", + MediaType: "text/plain; charset=utf-8", + SizeBytes: 12, + BindingSource: "config", + }, + }, + }, + }) + if err != nil { + t.Fatalf("Encode() error = %v, want nil", err) + } + + manifest := decodeObject(t, fileBytes(t, result.Files, "manifest.json")) + references := manifest["references"].([]any) + if len(references) != 1 { + t.Fatalf("references = %#v, want one entry", references) + } + reference := references[0].(map[string]any) + if reference["lane_id"] != "events" || reference["slot_name"] != "roster" || reference["digest"] != "sha256:reference" { + t.Fatalf("reference manifest = %#v, want lane slot digest", reference) + } + if _, ok := reference["content"]; ok { + t.Fatalf("reference manifest = %#v, want no content field", reference) + } +} + func TestEncodeRejectsArtifactTypeWithoutSafeFileName(t *testing.T) { _, err := New().Encode(context.Background(), contracts.OutputRequest{ Approved: []artifacts.Artifact{artifact("///", "unsafe")},