Require explicit manifest schemas
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -107,7 +107,7 @@ During every stage:
|
||||
|
||||
## Stage 1 — Establish Explicit Current Manifest Schemas
|
||||
|
||||
**Status: Pending**
|
||||
**Status: Completed**
|
||||
|
||||
### Goal
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user