From 2cfca710ec03e9a57389103f95a25fe4c6bc50c9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 2 Sep 2026 11:45:40 +0000 Subject: [PATCH] Require explicit manifest schemas --- docs/internal/artifacts.md | 6 + docs/internal/manifest.md | 20 ++- docs/operations.md | 7 + docs/roadmap/implementation.md | 2 +- docs/troubleshooting.md | 7 +- internal/app/restore_discovery_test.go | 13 +- internal/artifacts/current_state_test.go | 13 +- internal/manifest/analyze_state.go | 6 - internal/manifest/analyze_state_test.go | 26 +--- internal/manifest/identity_validation_test.go | 2 +- internal/manifest/manifest.go | 23 ++- internal/manifest/manifest_test.go | 11 ++ internal/manifest/run_manifest.go | 6 + internal/manifest/run_manifest_test.go | 11 ++ .../manifest/semantic_fingerprint_test.go | 14 +- internal/manifest/store.go | 128 ++++++++++------- internal/manifest/store_test.go | 135 +++++++++++++++--- 17 files changed, 307 insertions(+), 123 deletions(-) diff --git a/docs/internal/artifacts.md b/docs/internal/artifacts.md index ca1a117..87885fb 100644 --- a/docs/internal/artifacts.md +++ b/docs/internal/artifacts.md @@ -165,6 +165,12 @@ Validation by content type: Artifacts package owns shared remote current-state loading mechanics used by restore, status and validation checks, and previous-cache planning. +The selected session manifest is accepted only with +`schema_version: narratio.session-manifest.v1` and complete stage identity. +An unsupported local or downloaded manifest must be replaced by regeneration, +verified restore, or republication; artifact resolution never fills in missing +authority markers. + For a new-protocol current state, the pointer-selected immutable commit is the complete restore authority. Callers receive its declared object identities and must not supplement them by listing mutable session prefixes. The legacy reader diff --git a/docs/internal/manifest.md b/docs/internal/manifest.md index 0c1ffe8..a602b3c 100644 --- a/docs/internal/manifest.md +++ b/docs/internal/manifest.md @@ -10,6 +10,7 @@ Explain the session-progress and invocation-audit models implemented by `manifest.Manifest` records: +- required `schema_version: narratio.session-manifest.v1` - identity (`session_id`, `campaign`, `run_id`) - local path metadata (`local_workdir`, `local_spool_dir`) - remote identity metadata (`s3_bucket`, `s3_session_prefix`, `s3_run_prefix`) @@ -25,6 +26,12 @@ portable opaque segments. Unsafe legacy identities are rejected with migration guidance rather than being normalized into a different workspace or remote namespace. +Every persisted session manifest has a non-nil `stages` map. Each entry is +non-nil and its embedded `name` exactly matches its map key; loading never +invents either marker. A missing or unsupported schema version, or incomplete +stage identity, is unsupported local state: move it aside and regenerate it, +or restore a verified current committed snapshot when that is authoritative. + Prepare records independent `party` and `players` input checksums. In canonical party mode, the party record retains its campaign source identity while the players record uses `derived_from_party`; raw roster content is never embedded @@ -43,11 +50,11 @@ The model admits these stage states: ### Analyze-owned artifact state -The `analyze` stage record may carry `analyze_state_version: 1` and an +Every `analyze` stage record carries `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. +empty set. An absent or unsupported version is unsupported local state and is +rejected rather than being treated as aggregate-only authority. Each analyze artifact record has one disposition: @@ -123,6 +130,7 @@ does not turn incidental canonical bytes into manifest authority. `manifest.RunManifest` is created for each invocation and records: +- required `schema_version: narratio.run-manifest.v1` - invocation identity and `force` flag - the selected profile (when any) and secret-free effective configuration digest - requested stages @@ -153,8 +161,10 @@ path is removable after migration and is never used to write new state. `manifest.LocalStore`: -- validates loaded documents; -- normalizes missing maps/stage records; +- accepts only the exact supported schema version for each manifest kind; +- validates stage-map identity without synthesizing missing maps, records, or + names; +- retains only representation-preserving collection normalization; - writes through a sibling temporary file and atomic replacement visibility; - updates `updated_at` on save. diff --git a/docs/operations.md b/docs/operations.md index 2c86a6e..253ed71 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -480,6 +480,13 @@ Durable session paths: - `config/**` - `runs/**` +`manifest.json` is a versioned local-state document. Narratio accepts only +`narratio.session-manifest.v1`; each invocation record under `runs/**` accepts +only `narratio.run-manifest.v1`. Do not edit an older manifest to add missing +markers. Move unsupported state aside and regenerate it, restore a verified +current committed snapshot, or republish healthy regenerated state as the +relevant workflow requires. + Validated Notarius bundles live below `artifacts/notarius/{run_id}/`; receipt, stderr, and pre-promotion output remain in the producing run's `extract` directory as described in [Extraction Workflow](#extraction-workflow). diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 09e9de9..b56808b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -107,7 +107,7 @@ During every stage: ## Stage 1 — Establish Explicit Current Manifest Schemas -**Status: Pending** +**Status: Completed** ### Goal diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index be6d63a..85bf6dc 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -56,7 +56,8 @@ Symptom: Likely cause: -- stale field name, typo, invalid enum, or invalid duration/path format. +- stale field name, typo, invalid enum, invalid duration/path format, or an + unsupported local manifest schema/state marker. Diagnostics: @@ -68,6 +69,10 @@ Safe fix: - align config with [Configuration](./config.md) and the [maintained examples](../examples/README.md). +- for a manifest schema error, move the unsupported local state aside and + regenerate it; restore a verified current committed snapshot or republish + healthy regenerated state when appropriate. Do not add version or stage + markers by hand. Relevant reference: [Configuration](./config.md). diff --git a/internal/app/restore_discovery_test.go b/internal/app/restore_discovery_test.go index fb01a62..55e5b0b 100644 --- a/internal/app/restore_discovery_test.go +++ b/internal/app/restore_discovery_test.go @@ -221,12 +221,13 @@ func restoreManifestJSON(t *testing.T, sessionID, campaign string) []byte { t.Helper() now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) payload := map[string]any{ - "session_id": sessionID, - "campaign": campaign, - "run_id": "20260519T010203Z-a1b2c3d4", - "created_at": now, - "updated_at": now, - "stages": map[string]any{}, + "schema_version": "narratio.session-manifest.v1", + "session_id": sessionID, + "campaign": campaign, + "run_id": "20260519T010203Z-a1b2c3d4", + "created_at": now, + "updated_at": now, + "stages": map[string]any{}, } data, err := json.Marshal(payload) if err != nil { diff --git a/internal/artifacts/current_state_test.go b/internal/artifacts/current_state_test.go index 173b828..0a272d9 100644 --- a/internal/artifacts/current_state_test.go +++ b/internal/artifacts/current_state_test.go @@ -197,12 +197,13 @@ func testManifestJSON(t *testing.T, sessionID, campaign, runID string) []byte { t.Helper() now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) payload := map[string]any{ - "session_id": sessionID, - "campaign": campaign, - "run_id": runID, - "created_at": now, - "updated_at": now, - "stages": map[string]any{}, + "schema_version": "narratio.session-manifest.v1", + "session_id": sessionID, + "campaign": campaign, + "run_id": runID, + "created_at": now, + "updated_at": now, + "stages": map[string]any{}, } data, err := json.Marshal(payload) if err != nil { diff --git a/internal/manifest/analyze_state.go b/internal/manifest/analyze_state.go index b1fadbc..1e234dc 100644 --- a/internal/manifest/analyze_state.go +++ b/internal/manifest/analyze_state.go @@ -112,12 +112,6 @@ func validateAnalyzeStageState(stageName string, version int, records map[string } 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) } diff --git a/internal/manifest/analyze_state_test.go b/internal/manifest/analyze_state_test.go index 992d3a1..1fd2d72 100644 --- a/internal/manifest/analyze_state_test.go +++ b/internal/manifest/analyze_state_test.go @@ -178,10 +178,11 @@ func TestValidateAnalyzeArtifactCollectionRejectsMalformedState(t *testing.T) { } } -func TestAnalyzeStateOwnershipAndLegacyCompatibility(t *testing.T) { +func TestAnalyzeStageRequiresCurrentPerArtifactAuthority(t *testing.T) { now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) store := &LocalStore{} - legacy := `{ + aggregateOnly := `{ + "schema_version": "narratio.session-manifest.v1", "session_id": "session", "created_at": "2026-05-03T10:00:00Z", "updated_at": "2026-05-03T10:01:00Z", @@ -195,23 +196,8 @@ func TestAnalyzeStateOwnershipAndLegacyCompatibility(t *testing.T) { } } }` - 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) + if _, err := store.LoadReader(context.Background(), strings.NewReader(aggregateOnly)); err == nil || !strings.Contains(err.Error(), "unsupported analyze state version 0") { + t.Fatalf("LoadReader(aggregate-only) error = %v", err) } empty := New("session", now) @@ -244,7 +230,7 @@ func TestAnalyzeStateOwnershipAndLegacyCompatibility(t *testing.T) { 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") { + if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "missing-version.json"), m); err == nil || !strings.Contains(err.Error(), "unsupported analyze state version 0") { t.Fatalf("missing version error = %v", err) } } diff --git a/internal/manifest/identity_validation_test.go b/internal/manifest/identity_validation_test.go index 6440d1e..cab44d7 100644 --- a/internal/manifest/identity_validation_test.go +++ b/internal/manifest/identity_validation_test.go @@ -12,7 +12,7 @@ import ( func TestLocalStoreRejectsUnsafeLegacyIdentity(t *testing.T) { now := time.Now().UTC() path := filepath.Join(t.TempDir(), "manifest.json") - data := []byte(`{"session_id":"../escape","campaign":"campaign","created_at":"` + now.Format(time.RFC3339Nano) + `","updated_at":"` + now.Format(time.RFC3339Nano) + `","stages":{}}`) + data := []byte(`{"schema_version":"narratio.session-manifest.v1","session_id":"../escape","campaign":"campaign","created_at":"` + now.Format(time.RFC3339Nano) + `","updated_at":"` + now.Format(time.RFC3339Nano) + `","stages":{}}`) if err := os.WriteFile(path, data, 0o600); err != nil { t.Fatalf("WriteFile() error = %v", err) } diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 97fbb53..9949575 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -7,6 +7,15 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" ) +const ( + // SessionManifestSchemaVersion identifies the only supported durable session + // manifest representation. + SessionManifestSchemaVersion = "narratio.session-manifest.v1" + // RunManifestSchemaVersion identifies the only supported invocation manifest + // representation. + RunManifestSchemaVersion = "narratio.run-manifest.v1" +) + // ErrorRecord captures structured error metadata at run or stage scope. type ErrorRecord struct { Message string `json:"message"` @@ -95,6 +104,7 @@ type SelectedProfileProvenance struct { // Manifest is the durable run-state record for a session execution. type Manifest struct { + SchemaVersion string `json:"schema_version"` SessionID string `json:"session_id"` Campaign string `json:"campaign,omitempty"` RunID string `json:"run_id,omitempty"` @@ -117,10 +127,11 @@ type Manifest struct { // New constructs a new manifest with deterministic timestamps. func New(sessionID string, now time.Time) *Manifest { return &Manifest{ - SessionID: strings.TrimSpace(sessionID), - CreatedAt: now, - UpdatedAt: now, - Stages: map[string]*StageRecord{}, + SchemaVersion: SessionManifestSchemaVersion, + SessionID: strings.TrimSpace(sessionID), + CreatedAt: now, + UpdatedAt: now, + Stages: map[string]*StageRecord{}, } } @@ -210,6 +221,10 @@ func (m *Manifest) ensureStage(name string, at time.Time) *StageRecord { CreatedAt: at, UpdatedAt: at, } + if stageName == "analyze" { + s.AnalyzeStateVersion = AnalyzeStateContractVersion + s.AnalyzeArtifacts = map[string]AnalyzeArtifactRecord{} + } m.Stages[stageName] = s } diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go index b5ef364..021b50d 100644 --- a/internal/manifest/manifest_test.go +++ b/internal/manifest/manifest_test.go @@ -69,6 +69,17 @@ func TestStageMarkHelpers(t *testing.T) { } } +func TestEnsureAnalyzeStageStartsWithCurrentAuthority(t *testing.T) { + now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) + m := New("2026-05-03", now) + m.MarkStageRunning("analyze", now) + + stage := m.Stages["analyze"] + if stage == nil || stage.AnalyzeStateVersion != AnalyzeStateContractVersion || stage.AnalyzeArtifacts == nil { + t.Fatalf("analyze stage = %#v, want initialized current authority", stage) + } +} + func TestMarkStageRunningClearsPriorCompletionErrorAndResultDetails(t *testing.T) { m := New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) diff --git a/internal/manifest/run_manifest.go b/internal/manifest/run_manifest.go index 58ae6ee..0d4fb98 100644 --- a/internal/manifest/run_manifest.go +++ b/internal/manifest/run_manifest.go @@ -41,6 +41,7 @@ type RunStageRecord struct { // RunManifest is the invocation-scoped execution record under runs/{run_id}/manifest.json. type RunManifest struct { + SchemaVersion string `json:"schema_version"` SessionID string `json:"session_id"` Campaign string `json:"campaign,omitempty"` RunID string `json:"run_id"` @@ -66,6 +67,7 @@ type RunManifest struct { // NewRun constructs a new run manifest with deterministic timestamps. func NewRun(sessionID, campaign, runID string, force bool, requestedStages []string, now time.Time) *RunManifest { return &RunManifest{ + SchemaVersion: RunManifestSchemaVersion, SessionID: strings.TrimSpace(sessionID), Campaign: strings.TrimSpace(campaign), RunID: strings.TrimSpace(runID), @@ -156,6 +158,10 @@ func (m *RunManifest) ensureStage(name string, at time.Time) *RunStageRecord { CreatedAt: at, UpdatedAt: at, } + if stageName == "analyze" { + s.AnalyzeStateVersion = AnalyzeStateContractVersion + s.AnalyzeArtifacts = map[string]AnalyzeArtifactRecord{} + } m.Stages[stageName] = s } if s.Name == "" { diff --git a/internal/manifest/run_manifest_test.go b/internal/manifest/run_manifest_test.go index 34f2baf..f923ca4 100644 --- a/internal/manifest/run_manifest_test.go +++ b/internal/manifest/run_manifest_test.go @@ -48,3 +48,14 @@ func TestRunManifestStageMarkHelpers(t *testing.T) { t.Fatalf("status = %q, want %q", rm.Status, RunManifestStatusSucceeded) } } + +func TestRunEnsureAnalyzeStageStartsWithCurrentAuthority(t *testing.T) { + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + run := NewRun("2026-05-03", "forsaken", "run-123", false, nil, now) + run.MarkStageRunning("analyze", now) + + stage := run.Stages["analyze"] + if stage == nil || stage.AnalyzeStateVersion != AnalyzeStateContractVersion || stage.AnalyzeArtifacts == nil { + t.Fatalf("analyze stage = %#v, want initialized current authority", stage) + } +} diff --git a/internal/manifest/semantic_fingerprint_test.go b/internal/manifest/semantic_fingerprint_test.go index 3fcfd38..a50b718 100644 --- a/internal/manifest/semantic_fingerprint_test.go +++ b/internal/manifest/semantic_fingerprint_test.go @@ -11,7 +11,7 @@ import ( const testSemanticDigest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" -func TestSemanticConfigFingerprintRoundTripsAndLegacyRecordsRemainReadable(t *testing.T) { +func TestSemanticConfigFingerprintRoundTripsAndRemainsOptional(t *testing.T) { ctx := context.Background() store := &LocalStore{} dir := t.TempDir() @@ -47,17 +47,17 @@ func TestSemanticConfigFingerprintRoundTripsAndLegacyRecordsRemainReadable(t *te t.Fatalf("run fingerprint = %#v", loadedRun.Stages["prepare"].SemanticConfig) } - legacyPath := filepath.Join(dir, "legacy.json") - legacy := `{"session_id":"session","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","stages":{"prepare":{"name":"prepare","status":"succeeded","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}}}` - if err := os.WriteFile(legacyPath, []byte(legacy), 0o644); err != nil { + path := filepath.Join(dir, "without-fingerprint.json") + data := `{"schema_version":"narratio.session-manifest.v1","session_id":"session","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","stages":{"prepare":{"name":"prepare","status":"succeeded","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}}}` + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { t.Fatal(err) } - legacyManifest, err := store.Load(ctx, legacyPath) + loadedWithoutFingerprint, err := store.Load(ctx, path) if err != nil { t.Fatal(err) } - if legacyManifest.Stages["prepare"].SemanticConfig != nil { - t.Fatalf("legacy fingerprint = %#v, want nil", legacyManifest.Stages["prepare"].SemanticConfig) + if loadedWithoutFingerprint.Stages["prepare"].SemanticConfig != nil { + t.Fatalf("fingerprint = %#v, want nil", loadedWithoutFingerprint.Stages["prepare"].SemanticConfig) } } diff --git a/internal/manifest/store.go b/internal/manifest/store.go index b6aa58f..f9a237e 100644 --- a/internal/manifest/store.go +++ b/internal/manifest/store.go @@ -98,6 +98,9 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error { if m == nil { return fmt.Errorf("save manifest: manifest is nil") } + if err := validateSessionManifestSchemaVersion(m.SchemaVersion); err != nil { + return fmt.Errorf("save manifest: %w", err) + } if strings.TrimSpace(m.SessionID) == "" { return fmt.Errorf("save manifest: session_id is required") } @@ -110,10 +113,10 @@ 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) } + if err := validateSessionStages(m.Stages); 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) @@ -121,10 +124,6 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error { } m.UpdatedAt = time.Now().UTC() - if m.Stages == nil { - m.Stages = map[string]*StageRecord{} - } - data, err := json.MarshalIndent(m, "", " ") if err != nil { return fmt.Errorf("save manifest: marshal: %w", err) @@ -213,6 +212,9 @@ func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) e if m == nil { return fmt.Errorf("save run manifest: manifest is nil") } + if err := validateRunManifestSchemaVersion(m.SchemaVersion); err != nil { + return fmt.Errorf("save run manifest: %w", err) + } if strings.TrimSpace(m.SessionID) == "" { return fmt.Errorf("save run manifest: session_id is required") } @@ -225,10 +227,10 @@ 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) } + if err := validateRunStages(m.Stages); 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) @@ -236,10 +238,6 @@ func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) e } m.UpdatedAt = time.Now().UTC() - if m.Stages == nil { - m.Stages = map[string]*RunStageRecord{} - } - data, err := json.MarshalIndent(m, "", " ") if err != nil { return fmt.Errorf("save run manifest: marshal: %w", err) @@ -253,6 +251,9 @@ func validateLoadedManifest(m *Manifest) error { if m == nil { return fmt.Errorf("manifest is nil") } + if err := validateSessionManifestSchemaVersion(m.SchemaVersion); err != nil { + return err + } if strings.TrimSpace(m.SessionID) == "" { return fmt.Errorf("session_id is required") } @@ -268,10 +269,10 @@ func validateLoadedManifest(m *Manifest) error { if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil { return err } + if err := validateSessionStages(m.Stages); 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) } @@ -286,17 +287,7 @@ func validateLoadedManifest(m *Manifest) error { } func normalizeManifest(m *Manifest) { - if m.Stages == nil { - m.Stages = map[string]*StageRecord{} - } - for name, stage := range m.Stages { - if stage == nil { - stage = &StageRecord{Name: name, Status: StatusPending, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt} - m.Stages[name] = stage - } - if stage.Name == "" { - stage.Name = name - } + for _, stage := range m.Stages { normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts) } } @@ -326,6 +317,9 @@ func validateLoadedRunManifest(m *RunManifest) error { if m == nil { return fmt.Errorf("manifest is nil") } + if err := validateRunManifestSchemaVersion(m.SchemaVersion); err != nil { + return err + } if strings.TrimSpace(m.SessionID) == "" { return fmt.Errorf("session_id is required") } @@ -341,10 +335,10 @@ func validateLoadedRunManifest(m *RunManifest) error { if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil { return err } + if err := validateRunStages(m.Stages); 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) } @@ -386,27 +380,65 @@ func validateManifestIdentities(sessionID, campaign, runID string) error { } func normalizeRunManifest(m *RunManifest) { - if m.Stages == nil { - m.Stages = map[string]*RunStageRecord{} - } - for name, stage := range m.Stages { - if stage == nil { - stage = &RunStageRecord{ - Name: name, - Action: RunStageActionRun, - Status: StatusPending, - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, - } - m.Stages[name] = stage - } - if stage.Name == "" { - stage.Name = name - } + for _, stage := range m.Stages { normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts) } } +func validateSessionManifestSchemaVersion(version string) error { + return validateManifestSchemaVersion("session manifest", version, SessionManifestSchemaVersion) +} + +func validateRunManifestSchemaVersion(version string) error { + return validateManifestSchemaVersion("run manifest", version, RunManifestSchemaVersion) +} + +func validateManifestSchemaVersion(kind, version, supported string) error { + if version == supported { + return nil + } + if version == "" { + return fmt.Errorf("%s schema_version is required; move aside and regenerate this unsupported local state (supported: %s)", kind, supported) + } + return fmt.Errorf("unsupported %s schema_version %q; move aside and regenerate this unsupported local state (supported: %s)", kind, version, supported) +} + +func validateSessionStages(stages map[string]*StageRecord) error { + if stages == nil { + return fmt.Errorf("stages is required") + } + for name, stage := range stages { + if stage == nil { + return fmt.Errorf("stages.%s is nil", name) + } + if stage.Name == "" { + return fmt.Errorf("stages.%s.name is required", name) + } + if stage.Name != name { + return fmt.Errorf("stages.%s.name %q does not match map key", name, stage.Name) + } + } + return nil +} + +func validateRunStages(stages map[string]*RunStageRecord) error { + if stages == nil { + return fmt.Errorf("stages is required") + } + for name, stage := range stages { + if stage == nil { + return fmt.Errorf("stages.%s is nil", name) + } + if stage.Name == "" { + return fmt.Errorf("stages.%s.name is required", name) + } + if stage.Name != name { + return fmt.Errorf("stages.%s.name %q does not match map key", name, stage.Name) + } + } + return nil +} + func writeJSONAtomically(ctx context.Context, path string, data []byte) error { dir := filepath.Dir(path) if err := fileops.EnsureWorkspaceDirectory(dir); err != nil { diff --git a/internal/manifest/store_test.go b/internal/manifest/store_test.go index 01c6036..88b7e49 100644 --- a/internal/manifest/store_test.go +++ b/internal/manifest/store_test.go @@ -74,18 +74,19 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) { } } -func TestLocalStoreArtifactMetadataCompatibility(t *testing.T) { +func TestLocalStorePreservesOptionalArtifactMetadata(t *testing.T) { store := &LocalStore{} ctx := context.Background() dir := t.TempDir() - oldPath := filepath.Join(dir, "old-manifest.json") - oldJSON := `{ + path := filepath.Join(dir, "manifest.json") + data := `{ + "schema_version": "narratio.session-manifest.v1", "session_id": "2026-05-03", "created_at": "2026-05-03T10:00:00Z", "updated_at": "2026-05-03T10:01:00Z", "stages": { - "analyze": { - "name": "analyze", + "render": { + "name": "render", "status": "succeeded", "created_at": "2026-05-03T10:00:00Z", "updated_at": "2026-05-03T10:01:00Z", @@ -93,22 +94,22 @@ func TestLocalStoreArtifactMetadataCompatibility(t *testing.T) { } } }` - if err := os.WriteFile(oldPath, []byte(oldJSON), 0o644); err != nil { - t.Fatalf("WriteFile() old manifest error = %v", err) + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatalf("WriteFile() manifest error = %v", err) } - loaded, err := store.Load(ctx, oldPath) + loaded, err := store.Load(ctx, path) if err != nil { - t.Fatalf("Load() old manifest error = %v", err) + t.Fatalf("Load() manifest error = %v", err) } - oldOutput := loaded.Stages["analyze"].Outputs[0] + oldOutput := loaded.Stages["render"].Outputs[0] if oldOutput.Contract != nil || oldOutput.ExternalProvenance != nil { t.Fatalf("old output metadata = %#v, %#v; want nil", oldOutput.Contract, oldOutput.ExternalProvenance) } - if err := store.Save(ctx, oldPath, loaded); err != nil { - t.Fatalf("Save() old manifest error = %v", err) + if err := store.Save(ctx, path, loaded); err != nil { + t.Fatalf("Save() manifest error = %v", err) } - roundTripped, err := os.ReadFile(oldPath) + roundTripped, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile() round-tripped old manifest error = %v", err) } @@ -116,12 +117,12 @@ func TestLocalStoreArtifactMetadataCompatibility(t *testing.T) { t.Fatalf("old manifest gained fabricated metadata:\n%s", roundTripped) } - loaded.Stages["analyze"].Outputs[0].Contract = &artifactmodel.ContractMetadata{ + loaded.Stages["render"].Outputs[0].Contract = &artifactmodel.ContractMetadata{ MediaType: "application/json", SchemaID: "example.recap", SchemaVersion: "v1", } - loaded.Stages["analyze"].Outputs[0].ExternalProvenance = &artifactmodel.ExternalProvenance{ + loaded.Stages["render"].Outputs[0].ExternalProvenance = &artifactmodel.ExternalProvenance{ System: "example", RunID: "external-run", PipelineID: "pipeline", @@ -135,7 +136,7 @@ func TestLocalStoreArtifactMetadataCompatibility(t *testing.T) { if err != nil { t.Fatalf("Load() metadata manifest error = %v", err) } - got := withMetadata.Stages["analyze"].Outputs[0] + got := withMetadata.Stages["render"].Outputs[0] if got.Contract == nil || got.Contract.SchemaID != "example.recap" { t.Fatalf("contract = %#v, want persisted contract", got.Contract) } @@ -242,7 +243,7 @@ func TestLoadRejectsInvalidManifest(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "manifest.json") - if err := os.WriteFile(path, []byte(`{"updated_at":"2026-05-03T10:00:00Z"}`), 0o644); err != nil { + if err := os.WriteFile(path, []byte(`{"schema_version":"narratio.session-manifest.v1","updated_at":"2026-05-03T10:00:00Z","stages":{}}`), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } @@ -319,7 +320,7 @@ func TestLoadRunRejectsInvalidManifest(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "run-manifest.json") - if err := os.WriteFile(path, []byte(`{"session_id":"2026-05-03"}`), 0o644); err != nil { + if err := os.WriteFile(path, []byte(`{"schema_version":"narratio.run-manifest.v1","session_id":"2026-05-03","stages":{}}`), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } @@ -331,3 +332,101 @@ func TestLoadRunRejectsInvalidManifest(t *testing.T) { t.Fatalf("error = %q, want run_id validation", err.Error()) } } + +func TestLocalStoreRejectsUnsupportedSchemaVersions(t *testing.T) { + store := &LocalStore{} + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + + for _, test := range []struct { + name string + schema string + load func(*strings.Reader) error + save func() error + want string + }{ + { + name: "missing session schema", + schema: `{"session_id":"session","created_at":"2026-05-03T10:00:00Z","updated_at":"2026-05-03T10:00:00Z","stages":{}}`, + load: func(source *strings.Reader) error { + _, err := store.LoadReader(context.Background(), source) + return err + }, + save: func() error { + return store.Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), &Manifest{SessionID: "session", CreatedAt: now, UpdatedAt: now, Stages: map[string]*StageRecord{}}) + }, + want: "session manifest schema_version is required", + }, + { + name: "unknown session schema", + schema: `{"schema_version":"narratio.session-manifest.v99","session_id":"session","created_at":"2026-05-03T10:00:00Z","updated_at":"2026-05-03T10:00:00Z","stages":{}}`, + load: func(source *strings.Reader) error { + _, err := store.LoadReader(context.Background(), source) + return err + }, + save: func() error { + m := New("session", now) + m.SchemaVersion = "narratio.session-manifest.v99" + return store.Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), m) + }, + want: "unsupported session manifest schema_version", + }, + { + name: "missing run schema", + schema: `{"session_id":"session","run_id":"run","created_at":"2026-05-03T10:00:00Z","updated_at":"2026-05-03T10:00:00Z","stages":{}}`, + load: func(source *strings.Reader) error { + _, err := store.LoadRunReader(context.Background(), source) + return err + }, + save: func() error { + return store.SaveRun(context.Background(), filepath.Join(t.TempDir(), "run.json"), &RunManifest{SessionID: "session", RunID: "run", CreatedAt: now, UpdatedAt: now, Stages: map[string]*RunStageRecord{}}) + }, + want: "run manifest schema_version is required", + }, + { + name: "unknown run schema", + schema: `{"schema_version":"narratio.run-manifest.v99","session_id":"session","run_id":"run","created_at":"2026-05-03T10:00:00Z","updated_at":"2026-05-03T10:00:00Z","stages":{}}`, + load: func(source *strings.Reader) error { + _, err := store.LoadRunReader(context.Background(), source) + return err + }, + save: func() error { + m := NewRun("session", "", "run", false, nil, now) + m.SchemaVersion = "narratio.run-manifest.v99" + return store.SaveRun(context.Background(), filepath.Join(t.TempDir(), "run.json"), m) + }, + want: "unsupported run manifest schema_version", + }, + } { + t.Run(test.name, func(t *testing.T) { + if err := test.load(strings.NewReader(test.schema)); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("load error = %v, want %q", err, test.want) + } + if err := test.save(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("save error = %v, want %q", err, test.want) + } + }) + } +} + +func TestLocalStoreRejectsIncompleteStageIdentity(t *testing.T) { + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + store := &LocalStore{} + for _, test := range []struct { + name string + stages map[string]*StageRecord + want string + }{ + {name: "nil map", want: "stages is required"}, + {name: "nil entry", stages: map[string]*StageRecord{"prepare": nil}, want: "stages.prepare is nil"}, + {name: "missing name", stages: map[string]*StageRecord{"prepare": {CreatedAt: now, UpdatedAt: now}}, want: "stages.prepare.name is required"}, + {name: "mismatched name", stages: map[string]*StageRecord{"prepare": {Name: "render", CreatedAt: now, UpdatedAt: now}}, want: "does not match map key"}, + } { + t.Run(test.name, func(t *testing.T) { + m := New("session", now) + m.Stages = test.stages + if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), m); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Save() error = %v, want %q", err, test.want) + } + }) + } +}