Require explicit manifest schemas

This commit is contained in:
2026-09-02 11:45:40 +00:00
parent 9679117e94
commit 2cfca710ec
17 changed files with 307 additions and 123 deletions

View File

@@ -165,6 +165,12 @@ Validation by content type:
Artifacts package owns shared remote current-state loading mechanics used by Artifacts package owns shared remote current-state loading mechanics used by
restore, status and validation checks, and previous-cache planning. 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 For a new-protocol current state, the pointer-selected immutable commit is the
complete restore authority. Callers receive its declared object identities and complete restore authority. Callers receive its declared object identities and
must not supplement them by listing mutable session prefixes. The legacy reader must not supplement them by listing mutable session prefixes. The legacy reader

View File

@@ -10,6 +10,7 @@ Explain the session-progress and invocation-audit models implemented by
`manifest.Manifest` records: `manifest.Manifest` records:
- required `schema_version: narratio.session-manifest.v1`
- identity (`session_id`, `campaign`, `run_id`) - identity (`session_id`, `campaign`, `run_id`)
- local path metadata (`local_workdir`, `local_spool_dir`) - local path metadata (`local_workdir`, `local_spool_dir`)
- remote identity metadata (`s3_bucket`, `s3_session_prefix`, `s3_run_prefix`) - 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 guidance rather than being normalized into a different workspace or remote
namespace. 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 Prepare records independent `party` and `players` input checksums. In canonical
party mode, the party record retains its campaign source identity while the party mode, the party record retains its campaign source identity while the
players record uses `derived_from_party`; raw roster content is never embedded 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 ### 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 `analyze_artifacts` map keyed by normalized configured artifact key. The
version is the authority marker: version 1 with no entries is a valid evaluated 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 empty set. An absent or unsupported version is unsupported local state and is
no current configured-artifact evidence. rejected rather than being treated as aggregate-only authority.
Each analyze artifact record has one disposition: 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: `manifest.RunManifest` is created for each invocation and records:
- required `schema_version: narratio.run-manifest.v1`
- invocation identity and `force` flag - invocation identity and `force` flag
- the selected profile (when any) and secret-free effective configuration digest - the selected profile (when any) and secret-free effective configuration digest
- requested stages - requested stages
@@ -153,8 +161,10 @@ path is removable after migration and is never used to write new state.
`manifest.LocalStore`: `manifest.LocalStore`:
- validates loaded documents; - accepts only the exact supported schema version for each manifest kind;
- normalizes missing maps/stage records; - 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; - writes through a sibling temporary file and atomic replacement visibility;
- updates `updated_at` on save. - updates `updated_at` on save.

View File

@@ -480,6 +480,13 @@ Durable session paths:
- `config/**` - `config/**`
- `runs/**` - `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, Validated Notarius bundles live below `artifacts/notarius/{run_id}/`; receipt,
stderr, and pre-promotion output remain in the producing run's `extract` stderr, and pre-promotion output remain in the producing run's `extract`
directory as described in [Extraction Workflow](#extraction-workflow). directory as described in [Extraction Workflow](#extraction-workflow).

View File

@@ -107,7 +107,7 @@ During every stage:
## Stage 1 — Establish Explicit Current Manifest Schemas ## Stage 1 — Establish Explicit Current Manifest Schemas
**Status: Pending** **Status: Completed**
### Goal ### Goal

View File

@@ -56,7 +56,8 @@ Symptom:
Likely cause: 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: Diagnostics:
@@ -68,6 +69,10 @@ Safe fix:
- align config with [Configuration](./config.md) and the - align config with [Configuration](./config.md) and the
[maintained examples](../examples/README.md). [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). Relevant reference: [Configuration](./config.md).

View File

@@ -221,12 +221,13 @@ func restoreManifestJSON(t *testing.T, sessionID, campaign string) []byte {
t.Helper() t.Helper()
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
payload := map[string]any{ payload := map[string]any{
"session_id": sessionID, "schema_version": "narratio.session-manifest.v1",
"campaign": campaign, "session_id": sessionID,
"run_id": "20260519T010203Z-a1b2c3d4", "campaign": campaign,
"created_at": now, "run_id": "20260519T010203Z-a1b2c3d4",
"updated_at": now, "created_at": now,
"stages": map[string]any{}, "updated_at": now,
"stages": map[string]any{},
} }
data, err := json.Marshal(payload) data, err := json.Marshal(payload)
if err != nil { if err != nil {

View File

@@ -197,12 +197,13 @@ func testManifestJSON(t *testing.T, sessionID, campaign, runID string) []byte {
t.Helper() t.Helper()
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
payload := map[string]any{ payload := map[string]any{
"session_id": sessionID, "schema_version": "narratio.session-manifest.v1",
"campaign": campaign, "session_id": sessionID,
"run_id": runID, "campaign": campaign,
"created_at": now, "run_id": runID,
"updated_at": now, "created_at": now,
"stages": map[string]any{}, "updated_at": now,
"stages": map[string]any{},
} }
data, err := json.Marshal(payload) data, err := json.Marshal(payload)
if err != nil { if err != nil {

View File

@@ -112,12 +112,6 @@ func validateAnalyzeStageState(stageName string, version int, records map[string
} }
return nil 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) return ValidateAnalyzeArtifactCollection(version, records)
} }

View File

@@ -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) now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
store := &LocalStore{} store := &LocalStore{}
legacy := `{ aggregateOnly := `{
"schema_version": "narratio.session-manifest.v1",
"session_id": "session", "session_id": "session",
"created_at": "2026-05-03T10:00:00Z", "created_at": "2026-05-03T10:00:00Z",
"updated_at": "2026-05-03T10:01: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 := store.LoadReader(context.Background(), strings.NewReader(aggregateOnly)); err == nil || !strings.Contains(err.Error(), "unsupported analyze state version 0") {
if err != nil { t.Fatalf("LoadReader(aggregate-only) error = %v", err)
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 := New("session", now)
@@ -244,7 +230,7 @@ func TestAnalyzeStateOwnershipAndLegacyCompatibility(t *testing.T) {
Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{}, 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) t.Fatalf("missing version error = %v", err)
} }
} }

View File

@@ -12,7 +12,7 @@ import (
func TestLocalStoreRejectsUnsafeLegacyIdentity(t *testing.T) { func TestLocalStoreRejectsUnsafeLegacyIdentity(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
path := filepath.Join(t.TempDir(), "manifest.json") 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 { if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err) t.Fatalf("WriteFile() error = %v", err)
} }

View File

@@ -7,6 +7,15 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "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. // ErrorRecord captures structured error metadata at run or stage scope.
type ErrorRecord struct { type ErrorRecord struct {
Message string `json:"message"` Message string `json:"message"`
@@ -95,6 +104,7 @@ type SelectedProfileProvenance struct {
// Manifest is the durable run-state record for a session execution. // Manifest is the durable run-state record for a session execution.
type Manifest struct { type Manifest struct {
SchemaVersion string `json:"schema_version"`
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
Campaign string `json:"campaign,omitempty"` Campaign string `json:"campaign,omitempty"`
RunID string `json:"run_id,omitempty"` RunID string `json:"run_id,omitempty"`
@@ -117,10 +127,11 @@ type Manifest struct {
// New constructs a new manifest with deterministic timestamps. // New constructs a new manifest with deterministic timestamps.
func New(sessionID string, now time.Time) *Manifest { func New(sessionID string, now time.Time) *Manifest {
return &Manifest{ return &Manifest{
SessionID: strings.TrimSpace(sessionID), SchemaVersion: SessionManifestSchemaVersion,
CreatedAt: now, SessionID: strings.TrimSpace(sessionID),
UpdatedAt: now, CreatedAt: now,
Stages: map[string]*StageRecord{}, UpdatedAt: now,
Stages: map[string]*StageRecord{},
} }
} }
@@ -210,6 +221,10 @@ func (m *Manifest) ensureStage(name string, at time.Time) *StageRecord {
CreatedAt: at, CreatedAt: at,
UpdatedAt: at, UpdatedAt: at,
} }
if stageName == "analyze" {
s.AnalyzeStateVersion = AnalyzeStateContractVersion
s.AnalyzeArtifacts = map[string]AnalyzeArtifactRecord{}
}
m.Stages[stageName] = s m.Stages[stageName] = s
} }

View File

@@ -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) { func TestMarkStageRunningClearsPriorCompletionErrorAndResultDetails(t *testing.T) {
m := New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) m := New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))

View File

@@ -41,6 +41,7 @@ type RunStageRecord struct {
// RunManifest is the invocation-scoped execution record under runs/{run_id}/manifest.json. // RunManifest is the invocation-scoped execution record under runs/{run_id}/manifest.json.
type RunManifest struct { type RunManifest struct {
SchemaVersion string `json:"schema_version"`
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
Campaign string `json:"campaign,omitempty"` Campaign string `json:"campaign,omitempty"`
RunID string `json:"run_id"` RunID string `json:"run_id"`
@@ -66,6 +67,7 @@ type RunManifest struct {
// NewRun constructs a new run manifest with deterministic timestamps. // NewRun constructs a new run manifest with deterministic timestamps.
func NewRun(sessionID, campaign, runID string, force bool, requestedStages []string, now time.Time) *RunManifest { func NewRun(sessionID, campaign, runID string, force bool, requestedStages []string, now time.Time) *RunManifest {
return &RunManifest{ return &RunManifest{
SchemaVersion: RunManifestSchemaVersion,
SessionID: strings.TrimSpace(sessionID), SessionID: strings.TrimSpace(sessionID),
Campaign: strings.TrimSpace(campaign), Campaign: strings.TrimSpace(campaign),
RunID: strings.TrimSpace(runID), RunID: strings.TrimSpace(runID),
@@ -156,6 +158,10 @@ func (m *RunManifest) ensureStage(name string, at time.Time) *RunStageRecord {
CreatedAt: at, CreatedAt: at,
UpdatedAt: at, UpdatedAt: at,
} }
if stageName == "analyze" {
s.AnalyzeStateVersion = AnalyzeStateContractVersion
s.AnalyzeArtifacts = map[string]AnalyzeArtifactRecord{}
}
m.Stages[stageName] = s m.Stages[stageName] = s
} }
if s.Name == "" { if s.Name == "" {

View File

@@ -48,3 +48,14 @@ func TestRunManifestStageMarkHelpers(t *testing.T) {
t.Fatalf("status = %q, want %q", rm.Status, RunManifestStatusSucceeded) 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)
}
}

View File

@@ -11,7 +11,7 @@ import (
const testSemanticDigest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" const testSemanticDigest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
func TestSemanticConfigFingerprintRoundTripsAndLegacyRecordsRemainReadable(t *testing.T) { func TestSemanticConfigFingerprintRoundTripsAndRemainsOptional(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := &LocalStore{} store := &LocalStore{}
dir := t.TempDir() dir := t.TempDir()
@@ -47,17 +47,17 @@ func TestSemanticConfigFingerprintRoundTripsAndLegacyRecordsRemainReadable(t *te
t.Fatalf("run fingerprint = %#v", loadedRun.Stages["prepare"].SemanticConfig) t.Fatalf("run fingerprint = %#v", loadedRun.Stages["prepare"].SemanticConfig)
} }
legacyPath := filepath.Join(dir, "legacy.json") path := filepath.Join(dir, "without-fingerprint.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"}}}` 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(legacyPath, []byte(legacy), 0o644); err != nil { if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
legacyManifest, err := store.Load(ctx, legacyPath) loadedWithoutFingerprint, err := store.Load(ctx, path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if legacyManifest.Stages["prepare"].SemanticConfig != nil { if loadedWithoutFingerprint.Stages["prepare"].SemanticConfig != nil {
t.Fatalf("legacy fingerprint = %#v, want nil", legacyManifest.Stages["prepare"].SemanticConfig) t.Fatalf("fingerprint = %#v, want nil", loadedWithoutFingerprint.Stages["prepare"].SemanticConfig)
} }
} }

View File

@@ -98,6 +98,9 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
if m == nil { if m == nil {
return fmt.Errorf("save manifest: manifest is 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) == "" { if strings.TrimSpace(m.SessionID) == "" {
return fmt.Errorf("save manifest: session_id is required") 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 { if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
return fmt.Errorf("save manifest: %w", err) 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 { for name, stage := range m.Stages {
if stage == nil {
continue
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts) normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil { if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("save manifest: stages.%s: %w", name, err) 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() m.UpdatedAt = time.Now().UTC()
if m.Stages == nil {
m.Stages = map[string]*StageRecord{}
}
data, err := json.MarshalIndent(m, "", " ") data, err := json.MarshalIndent(m, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("save manifest: marshal: %w", err) 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 { if m == nil {
return fmt.Errorf("save run manifest: manifest is 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) == "" { if strings.TrimSpace(m.SessionID) == "" {
return fmt.Errorf("save run manifest: session_id is required") 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 { if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
return fmt.Errorf("save run manifest: %w", err) 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 { for name, stage := range m.Stages {
if stage == nil {
continue
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts) normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil { if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("save run manifest: stages.%s: %w", name, err) 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() m.UpdatedAt = time.Now().UTC()
if m.Stages == nil {
m.Stages = map[string]*RunStageRecord{}
}
data, err := json.MarshalIndent(m, "", " ") data, err := json.MarshalIndent(m, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("save run manifest: marshal: %w", err) return fmt.Errorf("save run manifest: marshal: %w", err)
@@ -253,6 +251,9 @@ func validateLoadedManifest(m *Manifest) error {
if m == nil { if m == nil {
return fmt.Errorf("manifest is nil") return fmt.Errorf("manifest is nil")
} }
if err := validateSessionManifestSchemaVersion(m.SchemaVersion); err != nil {
return err
}
if strings.TrimSpace(m.SessionID) == "" { if strings.TrimSpace(m.SessionID) == "" {
return fmt.Errorf("session_id is required") return fmt.Errorf("session_id is required")
} }
@@ -268,10 +269,10 @@ func validateLoadedManifest(m *Manifest) error {
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil { if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
return err return err
} }
if err := validateSessionStages(m.Stages); err != nil {
return err
}
for name, stage := range m.Stages { for name, stage := range m.Stages {
if stage == nil {
continue
}
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil { if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("stages.%s: %w", name, err) return fmt.Errorf("stages.%s: %w", name, err)
} }
@@ -286,17 +287,7 @@ func validateLoadedManifest(m *Manifest) error {
} }
func normalizeManifest(m *Manifest) { func normalizeManifest(m *Manifest) {
if m.Stages == nil { for _, stage := range m.Stages {
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
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts) normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
} }
} }
@@ -326,6 +317,9 @@ func validateLoadedRunManifest(m *RunManifest) error {
if m == nil { if m == nil {
return fmt.Errorf("manifest is nil") return fmt.Errorf("manifest is nil")
} }
if err := validateRunManifestSchemaVersion(m.SchemaVersion); err != nil {
return err
}
if strings.TrimSpace(m.SessionID) == "" { if strings.TrimSpace(m.SessionID) == "" {
return fmt.Errorf("session_id is required") 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 { if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
return err return err
} }
if err := validateRunStages(m.Stages); err != nil {
return err
}
for name, stage := range m.Stages { for name, stage := range m.Stages {
if stage == nil {
continue
}
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil { if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("stages.%s: %w", name, err) return fmt.Errorf("stages.%s: %w", name, err)
} }
@@ -386,27 +380,65 @@ func validateManifestIdentities(sessionID, campaign, runID string) error {
} }
func normalizeRunManifest(m *RunManifest) { func normalizeRunManifest(m *RunManifest) {
if m.Stages == nil { for _, stage := range m.Stages {
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
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts) 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 { func writeJSONAtomically(ctx context.Context, path string, data []byte) error {
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil { if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {

View File

@@ -74,18 +74,19 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
} }
} }
func TestLocalStoreArtifactMetadataCompatibility(t *testing.T) { func TestLocalStorePreservesOptionalArtifactMetadata(t *testing.T) {
store := &LocalStore{} store := &LocalStore{}
ctx := context.Background() ctx := context.Background()
dir := t.TempDir() dir := t.TempDir()
oldPath := filepath.Join(dir, "old-manifest.json") path := filepath.Join(dir, "manifest.json")
oldJSON := `{ data := `{
"schema_version": "narratio.session-manifest.v1",
"session_id": "2026-05-03", "session_id": "2026-05-03",
"created_at": "2026-05-03T10:00:00Z", "created_at": "2026-05-03T10:00:00Z",
"updated_at": "2026-05-03T10:01:00Z", "updated_at": "2026-05-03T10:01:00Z",
"stages": { "stages": {
"analyze": { "render": {
"name": "analyze", "name": "render",
"status": "succeeded", "status": "succeeded",
"created_at": "2026-05-03T10:00:00Z", "created_at": "2026-05-03T10:00:00Z",
"updated_at": "2026-05-03T10:01: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 { if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatalf("WriteFile() old manifest error = %v", err) t.Fatalf("WriteFile() manifest error = %v", err)
} }
loaded, err := store.Load(ctx, oldPath) loaded, err := store.Load(ctx, path)
if err != nil { 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 { if oldOutput.Contract != nil || oldOutput.ExternalProvenance != nil {
t.Fatalf("old output metadata = %#v, %#v; want nil", oldOutput.Contract, oldOutput.ExternalProvenance) t.Fatalf("old output metadata = %#v, %#v; want nil", oldOutput.Contract, oldOutput.ExternalProvenance)
} }
if err := store.Save(ctx, oldPath, loaded); err != nil { if err := store.Save(ctx, path, loaded); err != nil {
t.Fatalf("Save() old manifest error = %v", err) t.Fatalf("Save() manifest error = %v", err)
} }
roundTripped, err := os.ReadFile(oldPath) roundTripped, err := os.ReadFile(path)
if err != nil { if err != nil {
t.Fatalf("ReadFile() round-tripped old manifest error = %v", err) 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) 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", MediaType: "application/json",
SchemaID: "example.recap", SchemaID: "example.recap",
SchemaVersion: "v1", SchemaVersion: "v1",
} }
loaded.Stages["analyze"].Outputs[0].ExternalProvenance = &artifactmodel.ExternalProvenance{ loaded.Stages["render"].Outputs[0].ExternalProvenance = &artifactmodel.ExternalProvenance{
System: "example", System: "example",
RunID: "external-run", RunID: "external-run",
PipelineID: "pipeline", PipelineID: "pipeline",
@@ -135,7 +136,7 @@ func TestLocalStoreArtifactMetadataCompatibility(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Load() metadata manifest error = %v", err) 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" { if got.Contract == nil || got.Contract.SchemaID != "example.recap" {
t.Fatalf("contract = %#v, want persisted contract", got.Contract) t.Fatalf("contract = %#v, want persisted contract", got.Contract)
} }
@@ -242,7 +243,7 @@ func TestLoadRejectsInvalidManifest(t *testing.T) {
ctx := context.Background() ctx := context.Background()
path := filepath.Join(t.TempDir(), "manifest.json") 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) t.Fatalf("WriteFile() error = %v", err)
} }
@@ -319,7 +320,7 @@ func TestLoadRunRejectsInvalidManifest(t *testing.T) {
ctx := context.Background() ctx := context.Background()
path := filepath.Join(t.TempDir(), "run-manifest.json") 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) 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()) 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)
}
})
}
}