Add versioned analysis artifact state

This commit is contained in:
2026-08-29 18:27:44 +00:00
parent 6639775d7d
commit 23c714da66
7 changed files with 766 additions and 23 deletions

View File

@@ -35,6 +35,37 @@ The model admits these stage states:
- `stale`
- `interrupted`
### Analyze-owned artifact state
The `analyze` stage record may carry `analyze_state_version: 1` and an
`analyze_artifacts` map keyed by normalized configured artifact key. The
version is the authority marker: version 1 with no entries is a valid evaluated
empty set, while an absent version is legacy aggregate-only state and provides
no current configured-artifact evidence.
Each analyze artifact record has one disposition:
- `current`: the configured artifact is available and carries a versioned
fingerprint plus a complete output record and separate output size;
- `stale`: the recorded semantic identity is no longer current;
- `missing`: no validated current result exists;
- `failed`: the attempted work failed and carries a bounded diagnostic; or
- `unselected`: the artifact was intentionally outside the evaluated set.
Records bind their normalized key and dependencies, fingerprint contract when
evaluated, canonical session-relative output identity when current, producing
Narratio run, update time, and bounded non-secret Scriptorium provenance and
diagnostic paths. A current output includes its configured source ID, contract,
checksum, and positive byte size. Non-current records cannot carry an output,
so an older file is not advertised through stale, missing, failed, or
unselected state.
The session-stage collection is the reconciled authority across invocations.
The corresponding collection on an invocation's `analyze` stage record is an
audit of only the artifacts evaluated or attempted by that run. These records
remain analyze-owned data inside the fixed stage; they are not dynamic stages
or generic subtasks.
## Run Manifest
`manifest.RunManifest` is created for each invocation and records:
@@ -99,6 +130,12 @@ those details because resume validation and diagnosis may still require them
before execution begins. Invocation run manifests remain immutable audit
records of their own outcomes.
Aggregate lifecycle clearing deliberately preserves the analyze-owned
per-artifact collection. This lets later reconciliation replace only evaluated
entries without erasing unrelated current results. Other stages retain their
existing aggregate-only lifecycle behavior and are forbidden from carrying the
analyze-specific fields.
A stage may explicitly return a skipped disposition and stable reason. The
runner persists that outcome in both manifests, clears older outputs for the
session-stage record along with older logs, generated configuration references,

View File

@@ -305,6 +305,8 @@ path.
## Stage 6 — Versioned Analyze-Artifact Manifest State
**Status: Completed**
### Goal
Introduce a backward-compatible, analyze-specific session-manifest model that

View File

@@ -0,0 +1,355 @@
package manifest
import (
"encoding/hex"
"fmt"
"sort"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
const (
// AnalyzeStateContractVersion identifies the supported per-artifact state
// representation owned by the analyze stage.
AnalyzeStateContractVersion = 1
// AnalyzeFingerprintContractVersion identifies the fingerprint representation
// stored by the supported analyze state contract.
AnalyzeFingerprintContractVersion = 1
)
const (
maxAnalyzeArtifactErrorLength = 512
maxAnalyzeArtifactTextLength = 4096
maxAnalyzeArtifactListEntries = 128
)
// AnalyzeArtifactStatus describes whether one configured analysis artifact is
// currently available or why it is not.
type AnalyzeArtifactStatus string
const (
AnalyzeArtifactCurrent AnalyzeArtifactStatus = "current"
AnalyzeArtifactStale AnalyzeArtifactStatus = "stale"
AnalyzeArtifactMissing AnalyzeArtifactStatus = "missing"
AnalyzeArtifactFailed AnalyzeArtifactStatus = "failed"
AnalyzeArtifactUnselected AnalyzeArtifactStatus = "unselected"
)
// AnalyzeArtifactProvenance records useful non-secret Scriptorium invocation
// identity without making adapter diagnostics part of the generic artifact schema.
type AnalyzeArtifactProvenance struct {
PromptID string `json:"prompt_id,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
CommandMode string `json:"command_mode,omitempty"`
}
// AnalyzeArtifactRecord is analyze-owned state for one configured artifact.
// Output is present only while the record is current.
type AnalyzeArtifactRecord struct {
Key string `json:"key"`
Status AnalyzeArtifactStatus `json:"status"`
FingerprintVersion int `json:"fingerprint_version,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
Output *ArtifactRecord `json:"output,omitempty"`
OutputSize int64 `json:"output_size,omitempty"`
ProducerRunID string `json:"producer_run_id"`
UpdatedAt time.Time `json:"updated_at"`
Error string `json:"error,omitempty"`
Scriptorium *AnalyzeArtifactProvenance `json:"scriptorium,omitempty"`
Logs []string `json:"logs,omitempty"`
GeneratedConfigs []string `json:"generated_configs,omitempty"`
}
// HasVersionedAnalyzeState reports whether an analyze stage record carries the
// supported per-artifact authority. A legacy aggregate-only record returns false.
func (s *StageRecord) HasVersionedAnalyzeState() bool {
return s != nil && s.Name == "analyze" && s.AnalyzeStateVersion == AnalyzeStateContractVersion
}
// ValidateAnalyzeArtifactCollection validates one complete session or
// invocation collection independently of its containing manifest.
func ValidateAnalyzeArtifactCollection(version int, records map[string]AnalyzeArtifactRecord) error {
if version != AnalyzeStateContractVersion {
return fmt.Errorf("unsupported analyze state version %d", version)
}
for key, record := range records {
if err := validateAnalyzeArtifactRecord(key, record); err != nil {
return fmt.Errorf("analyze artifact %q: %w", key, err)
}
}
return nil
}
func validateAnalyzeStageState(stageName string, version int, records map[string]AnalyzeArtifactRecord) error {
if stageName != "analyze" {
if version != 0 || records != nil {
return fmt.Errorf("stage %q cannot contain analyze-owned state", stageName)
}
return nil
}
if version == 0 {
if records != nil {
return fmt.Errorf("legacy analyze stage without a state version cannot contain analyze_artifacts")
}
return nil
}
return ValidateAnalyzeArtifactCollection(version, records)
}
func validateAnalyzeArtifactRecord(mapKey string, record AnalyzeArtifactRecord) error {
if !isNormalizedAnalyzeArtifactKey(mapKey) {
return fmt.Errorf("map key must match ^[a-z][a-z0-9_]*$ without normalization")
}
if record.Key != mapKey {
return fmt.Errorf("record key %q does not match map key", record.Key)
}
switch record.Status {
case AnalyzeArtifactCurrent, AnalyzeArtifactStale, AnalyzeArtifactMissing, AnalyzeArtifactFailed, AnalyzeArtifactUnselected:
default:
return fmt.Errorf("unsupported status %q", record.Status)
}
if err := validateAnalyzeDependencies(record.Dependencies); err != nil {
return err
}
if err := validateAnalyzeFingerprint(record.FingerprintVersion, record.Fingerprint, record.Status == AnalyzeArtifactCurrent); err != nil {
return err
}
if err := pathsafe.ValidateOpaqueSegment(record.ProducerRunID); err != nil {
return fmt.Errorf("producer_run_id is invalid: %w", err)
}
if record.UpdatedAt.IsZero() {
return fmt.Errorf("updated_at is required")
}
if len(record.Error) > maxAnalyzeArtifactErrorLength {
return fmt.Errorf("error exceeds %d bytes", maxAnalyzeArtifactErrorLength)
}
if strings.TrimSpace(record.Error) != record.Error {
return fmt.Errorf("error must be trimmed")
}
if record.Status == AnalyzeArtifactFailed {
if record.Error == "" {
return fmt.Errorf("failed status requires error")
}
} else if record.Error != "" {
return fmt.Errorf("status %q forbids error", record.Status)
}
if record.Status == AnalyzeArtifactCurrent {
if err := validateCurrentAnalyzeOutput(record); err != nil {
return err
}
} else if record.Output != nil || record.OutputSize != 0 {
return fmt.Errorf("status %q forbids output and output_size", record.Status)
}
if err := validateAnalyzeProvenance(record.Scriptorium); err != nil {
return err
}
if err := validateAnalyzeTextList("logs", record.Logs); err != nil {
return err
}
if err := validateAnalyzeTextList("generated_configs", record.GeneratedConfigs); err != nil {
return err
}
return nil
}
func validateCurrentAnalyzeOutput(record AnalyzeArtifactRecord) error {
if record.Output == nil {
return fmt.Errorf("current status requires output")
}
if record.OutputSize <= 0 {
return fmt.Errorf("current status requires positive output_size")
}
output := record.Output
if strings.TrimSpace(output.Kind) == "" {
return fmt.Errorf("current output kind is required")
}
if strings.TrimSpace(output.Kind) != output.Kind {
return fmt.Errorf("current output kind must be trimmed")
}
wantSource := artifactpolicy.ConfiguredSourceID(record.Key)
if output.SourceID != wantSource {
return fmt.Errorf("current output source_id %q must equal %q", output.SourceID, wantSource)
}
normalizedPath, err := pathsafe.NormalizeRelativeDestination(output.LocalPath)
if err != nil {
return fmt.Errorf("current output local_path is unsafe: %w", err)
}
if normalizedPath != output.LocalPath {
return fmt.Errorf("current output local_path %q is not canonical %q", output.LocalPath, normalizedPath)
}
if output.Contract == nil || strings.TrimSpace(output.Contract.MediaType) == "" || strings.TrimSpace(output.Contract.SchemaID) == "" || strings.TrimSpace(output.Contract.SchemaVersion) == "" {
return fmt.Errorf("current output contract media_type, schema_id, and schema_version are required")
}
for field, value := range map[string]string{
"media_type": output.Contract.MediaType, "schema_id": output.Contract.SchemaID,
"schema_version": output.Contract.SchemaVersion, "module_key": output.Contract.ModuleKey,
} {
if strings.TrimSpace(value) != value {
return fmt.Errorf("current output contract %s must be trimmed", field)
}
}
if err := validateSHA256("current output checksum", output.Checksum); err != nil {
return err
}
if output.ProducerRunID != "" && output.ProducerRunID != record.ProducerRunID {
return fmt.Errorf("current output producer_run_id %q does not match record", output.ProducerRunID)
}
for field, value := range map[string]string{
"output kind": output.Kind,
"output source_id": output.SourceID,
"output local_path": output.LocalPath,
"output contract media_type": output.Contract.MediaType,
"output contract schema_id": output.Contract.SchemaID,
"output contract schema_version": output.Contract.SchemaVersion,
"output contract module_key": output.Contract.ModuleKey,
} {
if err := validateAnalyzeText(field, value); err != nil {
return err
}
}
if output.ExternalProvenance != nil {
if strings.TrimSpace(output.ExternalProvenance.System) == "" {
return fmt.Errorf("output provenance system is required when provenance is present")
}
for field, value := range map[string]string{
"output provenance system": output.ExternalProvenance.System,
"output provenance run_id": output.ExternalProvenance.RunID,
"output provenance pipeline_id": output.ExternalProvenance.PipelineID,
"output provenance artifact_id": output.ExternalProvenance.ArtifactID,
} {
if err := validateAnalyzeText(field, value); err != nil {
return err
}
}
}
return nil
}
func validateAnalyzeDependencies(dependencies []string) error {
seen := make(map[string]struct{}, len(dependencies))
for index, dependency := range dependencies {
if !isNormalizedAnalyzeArtifactKey(dependency) {
return fmt.Errorf("dependencies[%d] must match ^[a-z][a-z0-9_]*$ without normalization", index)
}
if _, duplicate := seen[dependency]; duplicate {
return fmt.Errorf("duplicate dependency %q", dependency)
}
seen[dependency] = struct{}{}
}
return nil
}
func validateAnalyzeFingerprint(version int, fingerprint string, required bool) error {
if version == 0 && fingerprint == "" {
if required {
return fmt.Errorf("current status requires fingerprint version and fingerprint")
}
return nil
}
if version != AnalyzeFingerprintContractVersion {
return fmt.Errorf("unsupported fingerprint version %d", version)
}
return validateSHA256("fingerprint", fingerprint)
}
func validateSHA256(field, value string) error {
if len(value) != 64 || strings.ToLower(value) != value {
return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field)
}
decoded, err := hex.DecodeString(value)
if err != nil || len(decoded) != 32 {
return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field)
}
return nil
}
func validateAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) error {
if provenance == nil {
return nil
}
if provenance.PromptID == "" && provenance.ProfileID == "" && provenance.CommandMode == "" {
return fmt.Errorf("scriptorium provenance must contain at least one identifier")
}
for field, value := range map[string]string{
"scriptorium prompt_id": provenance.PromptID,
"scriptorium profile_id": provenance.ProfileID,
"scriptorium command_mode": provenance.CommandMode,
} {
if err := validateAnalyzeText(field, value); err != nil {
return err
}
}
return nil
}
func validateAnalyzeTextList(field string, values []string) error {
if len(values) > maxAnalyzeArtifactListEntries {
return fmt.Errorf("%s exceeds %d entries", field, maxAnalyzeArtifactListEntries)
}
for index, value := range values {
if err := validateAnalyzeText(fmt.Sprintf("%s[%d]", field, index), value); err != nil {
return err
}
}
return nil
}
func validateAnalyzeText(field, value string) error {
if len(value) > maxAnalyzeArtifactTextLength {
return fmt.Errorf("%s exceeds %d bytes", field, maxAnalyzeArtifactTextLength)
}
if strings.ContainsRune(value, '\x00') {
return fmt.Errorf("%s contains a NUL byte", field)
}
return nil
}
func isNormalizedAnalyzeArtifactKey(key string) bool {
return strings.TrimSpace(key) == key && artifactpolicy.IsConfiguredKey(key)
}
func normalizeAnalyzeArtifactCollection(records map[string]AnalyzeArtifactRecord) {
for key, record := range records {
if len(record.Dependencies) > 1 {
record.Dependencies = append([]string(nil), record.Dependencies...)
sort.Strings(record.Dependencies)
}
record.Logs = cloneStrings(record.Logs)
record.GeneratedConfigs = cloneStrings(record.GeneratedConfigs)
record.Output = cloneArtifactRecord(record.Output)
record.Scriptorium = cloneAnalyzeProvenance(record.Scriptorium)
records[key] = record
}
}
func cloneStrings(values []string) []string {
return append([]string(nil), values...)
}
func cloneArtifactRecord(record *ArtifactRecord) *ArtifactRecord {
if record == nil {
return nil
}
clone := *record
if record.Contract != nil {
contract := *record.Contract
clone.Contract = &contract
}
if record.ExternalProvenance != nil {
provenance := *record.ExternalProvenance
clone.ExternalProvenance = &provenance
}
return &clone
}
func cloneAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) *AnalyzeArtifactProvenance {
if provenance == nil {
return nil
}
clone := *provenance
return &clone
}

View File

@@ -0,0 +1,309 @@
package manifest
import (
"bytes"
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
)
func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
records := map[string]AnalyzeArtifactRecord{
"session_recap": currentAnalyzeArtifactRecord("session_recap", now),
"quest_log": {
Key: "quest_log", Status: AnalyzeArtifactStale,
FingerprintVersion: AnalyzeFingerprintContractVersion,
Fingerprint: strings.Repeat("2", 64),
Dependencies: []string{"session_recap"}, ProducerRunID: "run-stale", UpdatedAt: now,
},
"player_handout": {
Key: "player_handout", Status: AnalyzeArtifactMissing,
Dependencies: []string{"session_recap"}, ProducerRunID: "run-missing", UpdatedAt: now,
},
"npc_digest": {
Key: "npc_digest", Status: AnalyzeArtifactFailed,
ProducerRunID: "run-failed", UpdatedAt: now, Error: "scriptorium validation failed",
Logs: []string{"runs/run-failed/logs/npc-digest.stderr.log"},
},
"gm_notes": {
Key: "gm_notes", Status: AnalyzeArtifactUnselected,
ProducerRunID: "run-unselected", UpdatedAt: now,
},
}
records["session_recap"] = func() AnalyzeArtifactRecord {
record := records["session_recap"]
record.Dependencies = []string{"quest_log", "gm_notes"}
return record
}()
m := New("session", now)
m.Stages["analyze"] = &StageRecord{
Name: "analyze",
Status: StatusSucceeded,
CreatedAt: now,
UpdatedAt: now,
AnalyzeStateVersion: AnalyzeStateContractVersion,
AnalyzeArtifacts: records,
}
path := filepath.Join(t.TempDir(), "manifest.json")
store := &LocalStore{}
if err := store.Save(context.Background(), path, m); err != nil {
t.Fatalf("Save() error = %v", err)
}
loaded, err := store.Load(context.Background(), path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
analyze := loaded.Stages["analyze"]
if !analyze.HasVersionedAnalyzeState() {
t.Fatal("round-tripped analyze record lacks versioned state")
}
for key, want := range records {
got, ok := analyze.AnalyzeArtifacts[key]
if !ok || got.Status != want.Status {
t.Fatalf("artifact %q = %#v, want status %q", key, got, want.Status)
}
}
if got := analyze.AnalyzeArtifacts["session_recap"].Dependencies; !reflect.DeepEqual(got, []string{"gm_notes", "quest_log"}) {
t.Fatalf("canonical dependencies = %#v", got)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
orderedKeys := []string{"gm_notes", "npc_digest", "player_handout", "quest_log", "session_recap"}
previous := -1
for _, key := range orderedKeys {
position := bytes.Index(data, []byte(`"`+key+`": {`))
if position <= previous {
t.Fatalf("map key %q position = %d after %d; JSON is not canonical:\n%s", key, position, previous, data)
}
previous = position
}
}
func TestRunManifestAnalyzeArtifactStateRoundTrip(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
run := NewRun("session", "campaign", "run-123", true, []string{"analyze"}, now)
run.Stages["analyze"] = &RunStageRecord{
Name: "analyze",
Action: RunStageActionRun,
Status: StatusSucceeded,
CreatedAt: now,
UpdatedAt: now,
AnalyzeStateVersion: AnalyzeStateContractVersion,
AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{
"session_recap": currentAnalyzeArtifactRecord("session_recap", now),
},
}
path := filepath.Join(t.TempDir(), "run.json")
store := &LocalStore{}
if err := store.SaveRun(context.Background(), path, run); err != nil {
t.Fatalf("SaveRun() error = %v", err)
}
loaded, err := store.LoadRun(context.Background(), path)
if err != nil {
t.Fatalf("LoadRun() error = %v", err)
}
got := loaded.Stages["analyze"]
if got.AnalyzeStateVersion != AnalyzeStateContractVersion || got.AnalyzeArtifacts["session_recap"].Status != AnalyzeArtifactCurrent {
t.Fatalf("run analyze state = %#v", got)
}
}
func TestValidateAnalyzeArtifactCollectionRejectsMalformedState(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
valid := currentAnalyzeArtifactRecord("session_recap", now)
tests := []struct {
name string
key string
mutate func(*AnalyzeArtifactRecord)
want string
}{
{name: "malformed map key", key: "Session Recap", want: "map key must match"},
{name: "record key mismatch", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Key = "quest_log" }, want: "does not match map key"},
{name: "unsupported status", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = "ready" }, want: "unsupported status"},
{name: "unsupported fingerprint version", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.FingerprintVersion = 99 }, want: "unsupported fingerprint version"},
{name: "bad fingerprint", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Fingerprint = "not-a-digest" }, want: "fingerprint must be"},
{name: "duplicate dependency", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Dependencies = []string{"quest_log", "quest_log"} }, want: "duplicate dependency"},
{name: "malformed dependency", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Dependencies = []string{"Quest Log"} }, want: "dependencies[0]"},
{name: "missing current output", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output = nil }, want: "requires output"},
{name: "bad current size", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.OutputSize = 0 }, want: "positive output_size"},
{name: "bad current source", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.SourceID = "narratio.artifact.other" }, want: "source_id"},
{name: "unsafe current path", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.LocalPath = "../recap.md" }, want: "local_path is unsafe"},
{name: "missing current contract", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.Contract = nil }, want: "output contract"},
{name: "bad current checksum", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.Checksum = strings.Repeat("G", 64) }, want: "checksum must be"},
{name: "producer mismatch", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.ProducerRunID = "other-run" }, want: "does not match record"},
{name: "non-current output", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = AnalyzeArtifactStale }, want: "forbids output"},
{name: "failed without error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = AnalyzeArtifactFailed; r.Output = nil; r.OutputSize = 0 }, want: "requires error"},
{name: "current with error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Error = "unexpected" }, want: "forbids error"},
{name: "oversized error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) {
r.Status = AnalyzeArtifactFailed
r.Output = nil
r.OutputSize = 0
r.Error = strings.Repeat("x", maxAnalyzeArtifactErrorLength+1)
}, want: "error exceeds"},
{name: "bad producer run", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.ProducerRunID = "bad/run" }, want: "producer_run_id"},
{name: "missing update time", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.UpdatedAt = time.Time{} }, want: "updated_at"},
{name: "oversized log collection", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Logs = make([]string, maxAnalyzeArtifactListEntries+1) }, want: "logs exceeds"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
record := valid
record.Output = cloneArtifactRecord(valid.Output)
if test.mutate != nil {
test.mutate(&record)
}
err := ValidateAnalyzeArtifactCollection(AnalyzeStateContractVersion, map[string]AnalyzeArtifactRecord{test.key: record})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
})
}
if err := ValidateAnalyzeArtifactCollection(99, nil); err == nil || !strings.Contains(err.Error(), "unsupported analyze state version") {
t.Fatalf("unsupported version error = %v", err)
}
}
func TestAnalyzeStateOwnershipAndLegacyCompatibility(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
store := &LocalStore{}
legacy := `{
"session_id": "session",
"created_at": "2026-05-03T10:00:00Z",
"updated_at": "2026-05-03T10:01:00Z",
"stages": {
"analyze": {
"name": "analyze",
"status": "succeeded",
"created_at": "2026-05-03T10:00:00Z",
"updated_at": "2026-05-03T10:01:00Z",
"outputs": [{"kind":"session_recap","local_path":"artifacts/session_recap.md"}]
}
}
}`
loaded, err := store.LoadReader(context.Background(), strings.NewReader(legacy))
if err != nil {
t.Fatalf("LoadReader(legacy) error = %v", err)
}
if loaded.Stages["analyze"].HasVersionedAnalyzeState() {
t.Fatal("legacy aggregate outputs became current per-artifact evidence")
}
path := filepath.Join(t.TempDir(), "legacy.json")
if err := store.Save(context.Background(), path, loaded); err != nil {
t.Fatalf("Save(legacy) error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(legacy) error = %v", err)
}
if bytes.Contains(data, []byte("analyze_state_version")) || bytes.Contains(data, []byte("analyze_artifacts")) || bytes.Contains(data, []byte("fingerprint")) {
t.Fatalf("legacy state gained fabricated evidence:\n%s", data)
}
empty := New("session", now)
empty.Stages["analyze"] = &StageRecord{
Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
AnalyzeStateVersion: AnalyzeStateContractVersion,
}
emptyPath := filepath.Join(t.TempDir(), "empty.json")
if err := store.Save(context.Background(), emptyPath, empty); err != nil {
t.Fatalf("Save(versioned empty state) error = %v", err)
}
emptyLoaded, err := store.Load(context.Background(), emptyPath)
if err != nil {
t.Fatalf("Load(versioned empty state) error = %v", err)
}
if !emptyLoaded.Stages["analyze"].HasVersionedAnalyzeState() || len(emptyLoaded.Stages["analyze"].AnalyzeArtifacts) != 0 {
t.Fatalf("versioned empty state = %#v", emptyLoaded.Stages["analyze"])
}
m := New("session", now)
m.Stages["render"] = &StageRecord{
Name: "render", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
AnalyzeStateVersion: AnalyzeStateContractVersion,
}
if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "bad-owner.json"), m); err == nil || !strings.Contains(err.Error(), "cannot contain analyze-owned state") {
t.Fatalf("non-analyze ownership error = %v", err)
}
m.Stages = map[string]*StageRecord{"analyze": {
Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{},
}}
if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "missing-version.json"), m); err == nil || !strings.Contains(err.Error(), "without a state version") {
t.Fatalf("missing version error = %v", err)
}
}
func TestAnalyzeArtifactStateSurvivesAggregateLifecycleClearing(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
for _, transition := range []struct {
name string
apply func(*Manifest)
}{
{name: "running", apply: func(m *Manifest) { m.MarkStageRunning("analyze", now.Add(time.Minute)) }},
{name: "failed", apply: func(m *Manifest) { m.MarkStageFailed("analyze", now.Add(time.Minute), "aggregate failed") }},
{name: "skipped", apply: func(m *Manifest) { m.MarkStageSkipped("analyze", now.Add(time.Minute), "disabled") }},
} {
t.Run(transition.name, func(t *testing.T) {
m := New("session", now)
m.Stages["analyze"] = &StageRecord{
Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
Outputs: []ArtifactRecord{{Kind: "legacy", LocalPath: "artifacts/legacy.md"}},
Logs: []string{"aggregate.log"}, GeneratedConfigs: []string{"aggregate.yml"}, Metadata: map[string]any{"aggregate": true},
AnalyzeStateVersion: AnalyzeStateContractVersion,
AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{
"session_recap": currentAnalyzeArtifactRecord("session_recap", now),
},
}
transition.apply(m)
stage := m.Stages["analyze"]
if len(stage.Outputs) != 0 || len(stage.Logs) != 0 || len(stage.GeneratedConfigs) != 0 || len(stage.Metadata) != 0 {
t.Fatalf("aggregate details survived %s: %#v", transition.name, stage)
}
if !stage.HasVersionedAnalyzeState() || stage.AnalyzeArtifacts["session_recap"].Status != AnalyzeArtifactCurrent {
t.Fatalf("per-artifact state was cleared by %s: %#v", transition.name, stage)
}
})
}
}
func currentAnalyzeArtifactRecord(key string, now time.Time) AnalyzeArtifactRecord {
runID := "run-" + strings.ReplaceAll(key, "_", "-")
return AnalyzeArtifactRecord{
Key: key,
Status: AnalyzeArtifactCurrent,
FingerprintVersion: AnalyzeFingerprintContractVersion,
Fingerprint: strings.Repeat("1", 64),
Output: &ArtifactRecord{
Kind: key,
SourceID: "narratio.artifact." + key,
LocalPath: "artifacts/" + strings.ReplaceAll(key, "_", "-") + ".md",
ProducerRunID: runID,
Checksum: strings.Repeat("a", 64),
Contract: &artifactmodel.ContractMetadata{
MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1",
},
ExternalProvenance: &artifactmodel.ExternalProvenance{
System: "scriptorium", PipelineID: "campaign", ArtifactID: key,
},
},
OutputSize: 42,
ProducerRunID: runID,
UpdatedAt: now,
Scriptorium: &AnalyzeArtifactProvenance{
PromptID: key, ProfileID: "default", CommandMode: "artifact",
},
Logs: []string{"runs/" + runID + "/logs/" + key + ".log"},
GeneratedConfigs: []string{"runs/" + runID + "/config/" + key + ".yml"},
}
}

View File

@@ -55,6 +55,8 @@ type StageRecord struct {
GeneratedConfigs []string `json:"generated_configs,omitempty"`
Error *ErrorRecord `json:"error,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
}
// CleanupTarget records one root-confined local deletion requested by a

View File

@@ -34,6 +34,8 @@ type RunStageRecord struct {
GeneratedConfigs []string `json:"generated_configs,omitempty"`
Error *ErrorRecord `json:"error,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
}
// RunManifest is the invocation-scoped execution record under runs/{run_id}/manifest.json.

View File

@@ -110,6 +110,15 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
return fmt.Errorf("save manifest: %w", err)
}
for name, stage := range m.Stages {
if stage == nil {
continue
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("save manifest: stages.%s: %w", name, err)
}
}
m.UpdatedAt = time.Now().UTC()
if m.Stages == nil {
@@ -216,6 +225,15 @@ func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) e
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
return fmt.Errorf("save run manifest: %w", err)
}
for name, stage := range m.Stages {
if stage == nil {
continue
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("save run manifest: stages.%s: %w", name, err)
}
}
m.UpdatedAt = time.Now().UTC()
if m.Stages == nil {
@@ -250,6 +268,14 @@ func validateLoadedManifest(m *Manifest) error {
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
return err
}
for name, stage := range m.Stages {
if stage == nil {
continue
}
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("stages.%s: %w", name, err)
}
}
return nil
}
@@ -266,6 +292,7 @@ func normalizeManifest(m *Manifest) {
if stage.Name == "" {
stage.Name = name
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
}
}
@@ -309,6 +336,14 @@ func validateLoadedRunManifest(m *RunManifest) error {
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
return err
}
for name, stage := range m.Stages {
if stage == nil {
continue
}
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("stages.%s: %w", name, err)
}
}
return nil
}
@@ -358,6 +393,7 @@ func normalizeRunManifest(m *RunManifest) {
if stage.Name == "" {
stage.Name = name
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
}
}