Make configured artifacts manifest authoritative

This commit is contained in:
2026-08-29 18:46:40 +00:00
parent 903dc70682
commit 62de6abdbf
18 changed files with 627 additions and 110 deletions

View File

@@ -47,22 +47,26 @@ kind and prepared filename vocabulary.
- `planned`: source registered for run context;
- `executable`: included in the effective analyze artifact set;
- `available`: local file exists and validates;
- `available`: the source's canonical evidence owner validates its current
manifest record and durable bytes;
- `provenance`: availability source.
Configured definitions are always registered. Without an explicit selection,
the effective analyze set contains enabled definitions. With `--artifacts`, the
exact named configured definitions become the effective set for that invocation,
regardless of their `enabled` value; dependencies are not added implicitly.
Availability is separate from executability: a non-executable configured output
may be reused from a canonical non-empty file, while an executable definition
is generated by analyze. Extraction entries are registered from configuration
and become available only after compatible extraction evidence is hydrated.
Availability is separate from executability. Configured outputs, including
non-executable prerequisites, become available only when the versioned analyze
state identifies a current result whose source, contract, canonical configured
path, size, and checksum match a confined no-follow regular file. An incidental
canonical file and a legacy aggregate analyze output are unavailable.
Extraction entries are registered from configuration and become available only
after compatible extraction evidence is hydrated.
Current provenance values:
- `generated.current_analyze_run`
- `filesystem.disabled_artifact_output`
- `manifest.current_analyze_artifact`
- `manifest.inputs.previous_cache`
- `current_session.previous_cache`
@@ -75,7 +79,17 @@ Built-ins:
Configured sources (`narratio.artifact.*`):
- resolve only through runtime catalog availability.
- resolve only through runtime catalog availability;
- use the shared typed analyze-evidence inspection in
`analyze_evidence.go` for prior current-session results;
- require the supported analyze-state and fingerprint versions, a `current`
record for the exact configured key and source ID, a complete contract, the
configured canonical relative path, positive stored size, and stored
checksum matching bytes read from a confined no-follow regular file; and
- treat non-current statuses, legacy or malformed records, removed keys,
unsafe or missing files, and size/checksum mismatches as unavailable without
rewriting manifest state. Catalog construction iterates current
configuration, so removed or renamed records are not advertised.
Prepared stable sources (`narratio.input.*`):

View File

@@ -38,7 +38,13 @@ Exact remote placement and the operator workflow belong in
checks a declared checksum when present, then streams the opened descriptor.
- derives the durable previous-cache archive from its validated manifest using
the same confinement and regular-file checks.
- resolves publish output sources through runtime artifact catalog and manifest-aware resolution.
- 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.
- 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.

View File

@@ -409,6 +409,8 @@ history through the existing application-owned runner transaction.
## Stage 8 — Manifest-Authoritative Configured Artifact Evidence
**Status: Completed**
### Goal
Make configured analysis outputs available to analyze and publish only through

View File

@@ -0,0 +1,37 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func setAppAnalyzeEvidence(m *manifest.Manifest, key, relativePath string, body []byte) {
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC)
record := m.Stages["analyze"]
if record == nil {
record = &manifest.StageRecord{Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now}
m.Stages["analyze"] = record
}
if record.AnalyzeArtifacts == nil {
record.AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{}
}
digest := sha256.Sum256(body)
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
record.AnalyzeArtifacts[key] = manifest.AnalyzeArtifactRecord{
Key: key, Status: manifest.AnalyzeArtifactCurrent,
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
Fingerprint: strings.Repeat("1", 64),
Output: &manifest.ArtifactRecord{
Kind: "scriptorium_artifact", SourceID: artifacts.ConfiguredArtifactSourceID(key), LocalPath: relativePath,
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
ProducerRunID: "run-1", Checksum: hex.EncodeToString(digest[:]),
},
OutputSize: int64(len(body)), ProducerRunID: "run-1", UpdatedAt: now,
}
}

View File

@@ -31,6 +31,7 @@ func buildHelperArtifactCatalog(cfg *config.Config, m *manifest.Manifest) (*arti
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
}
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
return catalog, nil
}
@@ -43,7 +44,11 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
fmt.Fprintln(out, "Configured:")
for _, entry := range catalog.ListConfigured() {
writeArtifactLine(out, entry.SourceID, lockSet)
state := "unavailable"
if entry.Available {
state = "available"
}
writeExtractionArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet)
}
fmt.Fprintln(out, "Extraction:")
for _, entry := range catalog.ListExtraction() {

View File

@@ -0,0 +1,54 @@
package app
import (
"os"
"path/filepath"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestBuildHelperArtifactCatalogUsesAnalyzeManifestEvidence(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{
Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: root},
Scriptorium: &config.ScriptoriumConfig{Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
}},
},
Session: &config.SessionConfig{Campaign: "campaign", SessionID: "session"},
}
paths := artifacts.NewLocalStore(root).SessionPathsFor("campaign", "session")
outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md")
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
t.Fatal(err)
}
body := []byte("# recap\n")
if err := os.WriteFile(outputPath, body, 0o644); err != nil {
t.Fatal(err)
}
m := manifest.New("session", time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC))
incidental, err := buildHelperArtifactCatalog(cfg, m)
if err != nil {
t.Fatal(err)
}
entry, _ := incidental.Lookup(artifacts.ConfiguredArtifactSourceID("session_recap"))
if entry.Available {
t.Fatal("operator catalog advertised incidental configured artifact")
}
setAppAnalyzeEvidence(m, "session_recap", "artifacts/session_recap.md", body)
current, err := buildHelperArtifactCatalog(cfg, m)
if err != nil {
t.Fatal(err)
}
entry, _ = current.Lookup(artifacts.ConfiguredArtifactSourceID("session_recap"))
if !entry.Available || entry.Provenance != artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
t.Fatalf("operator catalog entry = %#v", entry)
}
}

View File

@@ -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)
}
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)
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {

View File

@@ -282,6 +282,7 @@ func restoreWorkflowManifestJSON(t *testing.T, sessionID, campaign string) []byt
for i, stageName := range stages {
m.MarkStageSucceeded(stageName, now.Add(time.Duration(i+1)*time.Minute), nil)
}
setAppAnalyzeEvidence(m, "session_recap", "artifacts/session_recap.md", []byte("# restored recap\n"))
path := filepath.Join(t.TempDir(), "manifest.json")
if err := store.Save(context.Background(), path, m); err != nil {
t.Fatalf("save workflow manifest fixture: %v", err)

View File

@@ -0,0 +1,134 @@
package artifacts
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
// AnalyzeEvidenceState distinguishes a verified current configured artifact
// from every form of unavailable evidence.
type AnalyzeEvidenceState string
const (
AnalyzeEvidenceCurrent AnalyzeEvidenceState = "current"
AnalyzeEvidenceNonCurrent AnalyzeEvidenceState = "non_current"
)
// AnalyzeEvidence is the read-only result of inspecting one configured
// artifact's manifest record and durable output.
type AnalyzeEvidence struct {
State AnalyzeEvidenceState
Reason string
SourceID string
Path string
ProducerRunID string
}
// InspectAnalyzeEvidence verifies that one configured artifact has supported,
// current manifest evidence for the exact canonical bytes on disk.
func InspectAnalyzeEvidence(
paths SessionPaths,
m *manifest.Manifest,
key string,
configured ConfiguredArtifactDefinition,
) AnalyzeEvidence {
normalizedKey := strings.TrimSpace(key)
sourceID := ConfiguredArtifactSourceID(normalizedKey)
nonCurrent := func(reason string) AnalyzeEvidence {
return AnalyzeEvidence{State: AnalyzeEvidenceNonCurrent, Reason: reason, SourceID: sourceID}
}
if m == nil {
return nonCurrent("analyze manifest evidence is absent; regenerate the artifact")
}
stageRecord := m.Stages["analyze"]
if stageRecord == nil || stageRecord.Name != "analyze" {
return nonCurrent("analyze manifest evidence is absent; regenerate the artifact")
}
if !stageRecord.HasVersionedAnalyzeState() {
return nonCurrent("analyze manifest evidence is legacy or unsupported; regenerate the artifact")
}
record, ok := stageRecord.AnalyzeArtifacts[normalizedKey]
if !ok {
return nonCurrent("configured artifact has no analyze manifest record; regenerate the artifact")
}
if record.Status != manifest.AnalyzeArtifactCurrent {
return nonCurrent(fmt.Sprintf("configured artifact manifest status is %q; regenerate the artifact", record.Status))
}
if err := manifest.ValidateAnalyzeArtifactCollection(
stageRecord.AnalyzeStateVersion,
map[string]manifest.AnalyzeArtifactRecord{normalizedKey: record},
); err != nil {
return nonCurrent("configured artifact manifest evidence is malformed; regenerate the artifact")
}
configuredPath, err := pathsafe.NormalizeRelativeDestination(strings.TrimSpace(configured.OutputPath))
if err != nil {
return nonCurrent("configured artifact output path is unsafe; correct the configuration")
}
if record.Output.LocalPath != configuredPath {
return nonCurrent("configured artifact manifest path differs from current configuration; regenerate the artifact")
}
if record.Output.SourceID != sourceID || record.Output.Kind != "scriptorium_artifact" {
return nonCurrent("configured artifact manifest identity is incompatible; regenerate the artifact")
}
file, err := fileops.OpenConfinedRegularFile(paths.Root, configuredPath)
if err != nil {
return nonCurrent("configured artifact output is missing or unsafe; regenerate the artifact")
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
return nonCurrent("configured artifact output is not a safe regular file; regenerate the artifact")
}
hash := sha256.New()
size, err := io.Copy(hash, file)
if err != nil {
return nonCurrent("configured artifact output could not be verified; regenerate the artifact")
}
if size != info.Size() || size != record.OutputSize {
return nonCurrent("configured artifact output size differs from manifest evidence; regenerate the artifact")
}
if hex.EncodeToString(hash.Sum(nil)) != record.Output.Checksum {
return nonCurrent("configured artifact output checksum differs from manifest evidence; regenerate the artifact")
}
return AnalyzeEvidence{
State: AnalyzeEvidenceCurrent,
SourceID: sourceID,
Path: filepath.Join(paths.Root, filepath.FromSlash(configuredPath)),
ProducerRunID: record.ProducerRunID,
}
}
// HydrateAnalyzeArtifacts makes configured sources available only from
// validated current manifest evidence. It never mutates manifest state.
func (c *ArtifactCatalog) HydrateAnalyzeArtifacts(
paths SessionPaths,
m *manifest.Manifest,
configured map[string]ConfiguredArtifactDefinition,
) {
if c == nil || len(configured) == 0 {
return
}
for _, entry := range c.ListConfigured() {
definition, ok := configured[entry.ConfiguredKey]
if !ok {
continue
}
evidence := InspectAnalyzeEvidence(paths, m, entry.ConfiguredKey, definition)
if evidence.State != AnalyzeEvidenceCurrent {
continue
}
_ = c.markAvailableFromAnalyzeManifest(evidence.SourceID, evidence.Path, evidence.ProducerRunID)
}
}

View File

@@ -0,0 +1,222 @@
package artifacts
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestInspectAnalyzeEvidenceAcceptsCurrentCanonicalOutput(t *testing.T) {
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
body := []byte("recap\n")
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", body)
got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"})
if got.State != AnalyzeEvidenceCurrent {
t.Fatalf("State = %q, reason = %q", got.State, got.Reason)
}
if got.SourceID != ConfiguredArtifactSourceID("session_recap") || got.Path != filepath.Join(paths.ArtifactsDir, "session_recap.md") || got.ProducerRunID != "run-1" {
t.Fatalf("evidence = %#v", got)
}
}
func TestInspectAnalyzeEvidenceRejectsNonCurrentAndInvalidEvidence(t *testing.T) {
tests := []struct {
name string
mutate func(*manifest.Manifest)
config ConfiguredArtifactDefinition
reason string
}{
{name: "absent manifest", mutate: func(m *manifest.Manifest) { *m = manifest.Manifest{} }, reason: "absent"},
{name: "legacy", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeStateVersion = 0
m.Stages["analyze"].AnalyzeArtifacts = nil
}, reason: "legacy"},
{name: "unsupported version", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeStateVersion++ }, reason: "legacy or unsupported"},
{name: "missing record", mutate: func(m *manifest.Manifest) { delete(m.Stages["analyze"].AnalyzeArtifacts, "session_recap") }, reason: "no analyze manifest record"},
{name: "stale", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactStale), reason: `status is "stale"`},
{name: "missing", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactMissing), reason: `status is "missing"`},
{name: "failed", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactFailed), reason: `status is "failed"`},
{name: "unselected", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactUnselected), reason: `status is "unselected"`},
{name: "fingerprint version", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.FingerprintVersion++ })
}, reason: "malformed"},
{name: "key mismatch", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Key = "other" })
}, reason: "malformed"},
{name: "source mismatch", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.SourceID = ConfiguredArtifactSourceID("other") })
}, reason: "malformed"},
{name: "missing contract", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Contract = nil })
}, reason: "malformed"},
{name: "configured path mismatch", config: ConfiguredArtifactDefinition{OutputPath: "artifacts/renamed.md"}, reason: "differs from current configuration"},
{name: "record path mismatch", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.LocalPath = "artifacts/other.md" })
}, reason: "differs from current configuration"},
{name: "size mismatch", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.OutputSize++ })
}, reason: "size differs"},
{name: "checksum mismatch", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Checksum = strings.Repeat("0", 64) })
}, reason: "checksum differs"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
if test.mutate != nil {
test.mutate(m)
}
definition := test.config
if definition.OutputPath == "" {
definition.OutputPath = "artifacts/session_recap.md"
}
got := InspectAnalyzeEvidence(paths, m, "session_recap", definition)
if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, test.reason) {
t.Fatalf("evidence = %#v, want non-current reason containing %q", got, test.reason)
}
})
}
}
func TestInspectAnalyzeEvidenceRejectsMissingAndUnsafeFiles(t *testing.T) {
tests := []struct {
name string
alter func(t *testing.T, paths SessionPaths, outputPath string)
}{
{name: "missing", alter: func(t *testing.T, _ SessionPaths, outputPath string) {
if err := os.Remove(outputPath); err != nil {
t.Fatal(err)
}
}},
{name: "directory", alter: func(t *testing.T, _ SessionPaths, outputPath string) {
if err := os.Remove(outputPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(outputPath, 0o755); err != nil {
t.Fatal(err)
}
}},
{name: "symlink leaf", alter: func(t *testing.T, paths SessionPaths, outputPath string) {
if err := os.Remove(outputPath); err != nil {
t.Fatal(err)
}
target := filepath.Join(paths.Root, "target.md")
if err := os.WriteFile(target, []byte("recap\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, outputPath); err != nil {
t.Fatal(err)
}
}},
{name: "symlink ancestor", alter: func(t *testing.T, paths SessionPaths, outputPath string) {
if err := os.RemoveAll(paths.ArtifactsDir); err != nil {
t.Fatal(err)
}
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "session_recap.md"), []byte("recap\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, paths.ArtifactsDir); err != nil {
t.Fatal(err)
}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md")
test.alter(t, paths, outputPath)
got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"})
if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, "missing or unsafe") {
t.Fatalf("evidence = %#v", got)
}
})
}
}
func TestHydrateAnalyzeArtifactsUsesOnlyCurrentConfiguredKeys(t *testing.T) {
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
m.Stages["analyze"].AnalyzeArtifacts["removed"] = analyzeEvidenceRecord(t, paths, "removed", "artifacts/removed.md", []byte("old\n"))
configured := map[string]ConfiguredArtifactDefinition{"session_recap": {OutputPath: "artifacts/session_recap.md"}}
catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(configured, nil); err != nil {
t.Fatal(err)
}
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
entry, _ := catalog.Lookup(ConfiguredArtifactSourceID("session_recap"))
if !entry.Available || entry.Provenance != ArtifactProvenanceCurrentAnalyzeManifest || entry.ProducerRunID != "run-1" {
t.Fatalf("entry = %#v", entry)
}
if _, ok := catalog.Lookup(ConfiguredArtifactSourceID("removed")); ok {
t.Fatal("removed manifest record was advertised in current catalog")
}
}
func analyzeEvidenceFixture(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) *manifest.Manifest {
t.Helper()
record := analyzeEvidenceRecord(t, paths, key, relativePath, body)
now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
m := manifest.New(paths.SessionID, now)
m.Stages["analyze"] = &manifest.StageRecord{
Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now,
AnalyzeStateVersion: manifest.AnalyzeStateContractVersion,
AnalyzeArtifacts: map[string]manifest.AnalyzeArtifactRecord{key: record},
}
return m
}
func analyzeEvidenceRecord(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) manifest.AnalyzeArtifactRecord {
t.Helper()
outputPath := filepath.Join(paths.Root, filepath.FromSlash(relativePath))
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(outputPath, body, 0o644); err != nil {
t.Fatal(err)
}
checksum, err := SHA256File(outputPath)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
return manifest.AnalyzeArtifactRecord{
Key: key, Status: manifest.AnalyzeArtifactCurrent,
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
Fingerprint: strings.Repeat("1", 64),
Output: &manifest.ArtifactRecord{
Kind: "scriptorium_artifact", SourceID: ConfiguredArtifactSourceID(key), LocalPath: relativePath,
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
ProducerRunID: "run-1", Checksum: checksum,
},
OutputSize: int64(len(body)), ProducerRunID: "run-1", UpdatedAt: now,
}
}
func analyzeEvidenceStatus(status manifest.AnalyzeArtifactStatus) func(*manifest.Manifest) {
return func(m *manifest.Manifest) {
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
record.Status = status
record.Output = nil
record.OutputSize = 0
if status == manifest.AnalyzeArtifactFailed {
record.Error = "generation failed"
}
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = record
}
}
func mutateAnalyzeEvidenceRecord(m *manifest.Manifest, mutate func(*manifest.AnalyzeArtifactRecord)) manifest.AnalyzeArtifactRecord {
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
mutate(&record)
return record
}

View File

@@ -364,7 +364,7 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testin
}
}
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing.T) {
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromManifest(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
@@ -385,16 +385,17 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
}
sourceID := ConfiguredArtifactSourceID("player_handout")
if err := catalog.MarkAvailableFromDisk(sourceID, outputPath); err != nil {
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
}
m := analyzeEvidenceFixture(t, paths, "player_handout", "artifacts/player_handout.md", []byte("handout\n"))
catalog.HydrateAnalyzeArtifacts(paths, m, map[string]ConfiguredArtifactDefinition{
"player_handout": {Enabled: false, OutputPath: "artifacts/player_handout.md"},
})
resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, sourceID, catalog)
resolved, err := ResolveSessionArtifactWithCatalog(paths, m, sourceID, catalog)
if err != nil {
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
}
if resolved.Provenance != ArtifactProvenanceDisabledFromDisk {
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceDisabledFromDisk)
if resolved.Provenance != ArtifactProvenanceCurrentAnalyzeManifest {
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceCurrentAnalyzeManifest)
}
}

View File

@@ -11,7 +11,7 @@ import (
const (
ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run"
ArtifactProvenanceDisabledFromDisk = "filesystem.disabled_artifact_output"
ArtifactProvenanceCurrentAnalyzeManifest = "manifest.current_analyze_artifact"
ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run"
)
@@ -282,11 +282,6 @@ func (c *ArtifactCatalog) MarkAvailableGenerated(sourceID, path string) error {
return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun)
}
// MarkAvailableFromDisk marks one source as available from disabled artifact on disk.
func (c *ArtifactCatalog) MarkAvailableFromDisk(sourceID, path string) error {
return c.markAvailable(sourceID, path, ArtifactProvenanceDisabledFromDisk)
}
func (c *ArtifactCatalog) markAvailableFromExtractManifest(sourceID, path, producerRunID string) error {
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil {
return err
@@ -297,6 +292,16 @@ func (c *ArtifactCatalog) markAvailableFromExtractManifest(sourceID, path, produ
return nil
}
func (c *ArtifactCatalog) markAvailableFromAnalyzeManifest(sourceID, path, producerRunID string) error {
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentAnalyzeManifest); err != nil {
return err
}
entry := c.entries[strings.TrimSpace(sourceID)]
entry.ProducerRunID = strings.TrimSpace(producerRunID)
c.entries[entry.SourceID] = entry
return nil
}
func (c *ArtifactCatalog) markAvailable(sourceID, path, provenance string) error {
if c == nil {
return fmt.Errorf("artifact catalog is nil")

View File

@@ -179,30 +179,6 @@ func TestArtifactCatalogMarkAvailableGenerated(t *testing.T) {
}
}
func TestArtifactCatalogMarkAvailableFromDisk(t *testing.T) {
catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(
map[string]ConfiguredArtifactDefinition{
"session_recap": {Enabled: false, OutputPath: "artifacts/session_recap.md"},
},
nil,
); err != nil {
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
}
sourceID, _ := catalog.SourceIDForConfiguredKey("session_recap")
if err := catalog.MarkAvailableFromDisk(sourceID, "/tmp/session_recap.md"); err != nil {
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
}
entry, _ := catalog.Lookup(sourceID)
if !entry.Available {
t.Fatalf("entry.Available = false, want true")
}
if entry.Provenance != ArtifactProvenanceDisabledFromDisk {
t.Fatalf("entry.Provenance = %q, want %q", entry.Provenance, ArtifactProvenanceDisabledFromDisk)
}
}
func TestArtifactCatalogLookupPlannedButUnavailable(t *testing.T) {
catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(

View File

@@ -357,7 +357,7 @@ func executeAnalyzeArtifact(
default:
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: invalid resolution state", inputName, artifactName)
}
if resolution.Artifact != nil && resolution.Artifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk {
if resolution.Artifact != nil && resolution.Artifact.Provenance == artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
reusedArtifacts = append(reusedArtifacts, map[string]any{
"name": configuredArtifactNameFromSourceID(resolution.Artifact.ID),
"source_id": resolution.Artifact.ID,
@@ -738,25 +738,7 @@ func buildAnalyzeRuntimeArtifactCatalog(
if notariusCfg != nil && notariusCfg.Enabled {
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
}
for _, entry := range catalog.ListConfigured() {
if entry.Executable {
continue
}
if strings.TrimSpace(entry.CanonicalRelPath) == "" {
continue
}
resolvedPath, err := resolveScriptoriumOutputPath(paths, entry.CanonicalRelPath)
if err != nil {
continue
}
if err := requireNonEmptyFile(resolvedPath, "configured artifact "+entry.SourceID); err != nil {
continue
}
if err := catalog.MarkAvailableFromDisk(entry.SourceID, resolvedPath); err != nil {
return nil, err
}
}
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
return catalog, nil
}

View File

@@ -0,0 +1,45 @@
package stage
import (
"os"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func setCurrentAnalyzeEvidence(t *testing.T, m *manifest.Manifest, key, relativePath, absolutePath string) {
t.Helper()
checksum, err := artifacts.SHA256File(absolutePath)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(absolutePath)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC)
record := m.Stages["analyze"]
if record == nil {
record = &manifest.StageRecord{Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now}
m.Stages["analyze"] = record
}
if record.AnalyzeArtifacts == nil {
record.AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{}
}
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
record.AnalyzeArtifacts[key] = manifest.AnalyzeArtifactRecord{
Key: key, Status: manifest.AnalyzeArtifactCurrent,
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
Fingerprint: strings.Repeat("1", 64),
Output: &manifest.ArtifactRecord{
Kind: "scriptorium_artifact", SourceID: artifacts.ConfiguredArtifactSourceID(key), LocalPath: relativePath,
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
ProducerRunID: "run-1", Checksum: checksum,
},
OutputSize: info.Size(), ProducerRunID: "run-1", UpdatedAt: now,
}
}

View File

@@ -468,6 +468,7 @@ func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *tes
Enabled: false,
OutputPath: "artifacts/player_handout.md",
}
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", playerHandoutPath)
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
@@ -481,6 +482,25 @@ func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *tes
}
}
func TestAnalyzeDoesNotResolveIncidentalConfiguredArtifactFile(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
writeAnalyzeFile(t, filepath.Join(paths.ArtifactsDir, "player_handout.md"), "handout\n")
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{Source: "narratio.artifact.player_handout", Required: true}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: false, OutputPath: "artifacts/player_handout.md",
}
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), `"narratio.artifact.player_handout" is unavailable`) {
t.Fatalf("Run() error = %v, want incidental artifact unavailable", err)
}
}
func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
@@ -498,6 +518,7 @@ func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
Enabled: false,
OutputPath: "artifacts/player_handout.md",
}
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", playerHandoutPath)
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
@@ -545,8 +566,8 @@ func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
if r0["path"] != playerHandoutPath {
t.Fatalf("reused[0].path = %#v, want %q", r0["path"], playerHandoutPath)
}
if r0["provenance"] != artifacts.ArtifactProvenanceDisabledFromDisk {
t.Fatalf("reused[0].provenance = %#v, want %q", r0["provenance"], artifacts.ArtifactProvenanceDisabledFromDisk)
if r0["provenance"] != artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
t.Fatalf("reused[0].provenance = %#v, want %q", r0["provenance"], artifacts.ArtifactProvenanceCurrentAnalyzeManifest)
}
}

View File

@@ -839,51 +839,11 @@ func buildPublishRuntimeArtifactCatalog(
if notariusCfg != nil && notariusCfg.Enabled {
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
}
for _, entry := range catalog.ListConfigured() {
if strings.TrimSpace(entry.CanonicalRelPath) == "" {
continue
}
localPath, err := resolveConfiguredArtifactLocalPath(paths, entry.CanonicalRelPath)
if err != nil {
continue
}
info, statErr := os.Stat(localPath)
if statErr != nil {
if os.IsNotExist(statErr) {
continue
}
return nil, fmt.Errorf("stat configured artifact %q: %w", entry.SourceID, statErr)
}
if info.IsDir() {
continue
}
if err := catalog.MarkAvailableFromDisk(entry.SourceID, localPath); err != nil {
return nil, err
}
}
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
return catalog, nil
}
func resolveConfiguredArtifactLocalPath(paths artifacts.SessionPaths, configured string) (string, error) {
outputPath := strings.TrimSpace(configured)
if outputPath == "" {
return "", fmt.Errorf("configured artifact output path is required")
}
if filepath.IsAbs(outputPath) {
return filepath.Clean(outputPath), nil
}
rel := filepath.Clean(outputPath)
if rel == "." || rel == "" {
return "", fmt.Errorf("relative output path is required")
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("relative output path escapes session root: %q", configured)
}
return filepath.Join(paths.Root, rel), nil
}
func collectPublishRunFiles(runRoot string, runManifest *manifest.RunManifest) ([]publishUploadFile, error) {
if runManifest == nil {
return nil, fmt.Errorf("run manifest is required")

View File

@@ -607,6 +607,54 @@ func TestPublishSelectedConfiguredOutputStillFailsWhenMissing(t *testing.T) {
}
}
func TestPublishDoesNotSelectIncidentalConfiguredArtifactFile(t *testing.T) {
env, m, _ := publishFixture(t)
m.Stages["analyze"].AnalyzeStateVersion = 0
m.Stages["analyze"].AnalyzeArtifacts = nil
env.SelectedArtifactKeys = []string{"session_recap"}
_, err := publishStage{}.Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), `required output source unavailable: "narratio.artifact.session_recap"`) {
t.Fatalf("Run() error = %v, want incidental configured artifact unavailable", err)
}
}
func TestPublishOmitsStaleConfiguredArtifactAndPublishesUnrelatedCurrentArtifact(t *testing.T) {
env, m, _ := publishFixture(t)
paths := publishSessionPaths(env, m)
handoutPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
writeStageTestFile(t, handoutPath, "# handout\n")
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: true, OutputPath: "artifacts/player_handout.md",
}
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", handoutPath)
recap := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
recap.Status = manifest.AnalyzeArtifactStale
recap.Output = nil
recap.OutputSize = 0
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = recap
env.SelectedArtifactKeys = []string{"session_recap", "player_handout"}
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(false)},
{Source: "narratio.artifact.player_handout", Dest: "artifacts/player_handout.md", Required: boolPtr(true)},
}
result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
fake := env.ObjectStore.(*storage.FakeBackend)
if _, ok := fake.Objects[publishOutputRemoteKey(m.S3RunPrefix, "artifacts/session_recap.md")]; ok {
t.Fatal("stale configured artifact was uploaded")
}
if _, ok := fake.Objects[publishOutputRemoteKey(m.S3RunPrefix, "artifacts/player_handout.md")]; !ok {
t.Fatal("unrelated current configured artifact was not uploaded")
}
if got := result.Metadata["skipped_optional_outputs"].([]string); !reflect.DeepEqual(got, []string{"artifacts/session_recap.md"}) {
t.Fatalf("skipped_optional_outputs = %#v", got)
}
}
func TestPublishLockedSelectedOutputSkipsAsLocked(t *testing.T) {
env, m, _ := publishFixture(t)
env.SelectedArtifactKeys = []string{"session_recap"}
@@ -946,10 +994,12 @@ func TestPublishRejectsAmbiguousOrCollidingOutputMappings(t *testing.T) {
func TestPublishKeepsSameBasenameSourcesDistinct(t *testing.T) {
env, m, _ := publishFixture(t)
writeStageTestFile(t, filepath.Join(env.Config.Pipeline.Workspace.Root, "work", m.Campaign, m.SessionID, "reports", "session_recap.md"), "# other recap\n")
otherPath := filepath.Join(env.Config.Pipeline.Workspace.Root, "work", m.Campaign, m.SessionID, "reports", "session_recap.md")
writeStageTestFile(t, otherPath, "# other recap\n")
env.Config.Pipeline.Scriptorium.Artifacts["other_recap"] = config.ScriptoriumArtifactConfig{
Enabled: true, PromptID: "dnd.other_recap", OutputPath: "reports/session_recap.md",
}
setCurrentAnalyzeEvidence(t, m, "other_recap", "reports/session_recap.md", otherPath)
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: "narratio.artifact.session_recap", Dest: "published/first/session_recap.md", Required: boolPtr(true)},
{Source: "narratio.artifact.other_recap", Dest: "published/second/session_recap.md", Required: boolPtr(true)},
@@ -1024,6 +1074,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)
}
setCurrentAnalyzeEvidence(t, m, "session_recap", "artifacts/session_recap.md", filepath.Join(sessionRoot, "artifacts", "session_recap.md"))
env := &Env{
Config: &config.Config{