From c812fe3655d899f1840381787b064348dd1dc5ce Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 30 Aug 2026 18:51:20 +0000 Subject: [PATCH] Harden pipeline state and plan release upgrades --- docs/internal/configuration.md | 15 +- docs/internal/stage-prepare.md | 10 +- docs/operations.md | 6 +- docs/roadmap/implementation.md | 1589 +++++------------ docs/roadmap/release-procedure.md | 303 ++++ .../app/analyze_artifacts_commands_test.go | 14 +- internal/app/commands_test.go | 11 + internal/app/plan_test.go | 7 +- internal/app/run_stage_test.go | 29 +- internal/config/effective_digest.go | 36 +- internal/config/load.go | 1 + internal/config/notarius_test.go | 30 + internal/config/pipeline_composition.go | 3 + internal/stage/analyze.go | 4 + internal/stage/analyze_family_origin_test.go | 56 + internal/stage/prepare.go | 66 +- internal/stage/prepare_resume.go | 278 +++ internal/stage/prepare_resume_test.go | 160 ++ 18 files changed, 1381 insertions(+), 1237 deletions(-) create mode 100644 docs/roadmap/release-procedure.md create mode 100644 internal/stage/analyze_family_origin_test.go create mode 100644 internal/stage/prepare_resume.go create mode 100644 internal/stage/prepare_resume_test.go diff --git a/docs/internal/configuration.md b/docs/internal/configuration.md index bc6de6b..51a7265 100644 --- a/docs/internal/configuration.md +++ b/docs/internal/configuration.md @@ -129,12 +129,15 @@ metadata does not participate in YAML decoding or alter the public configuration model. The effective digest is SHA-256 over deterministic canonical YAML produced from -the defaulted and path-resolved `PipelineConfig`. Because composition and -resolution metadata are private, the digest excludes source layout, profile -name, and ownership. Configuration stores environment variable names rather -than resolving raw credentials, so raw secret values are neither loaded nor -hashed. `recomputePipelineEffectiveDigest` is the single package-owned refresh -point for later runtime expansion. +the defaulted `PipelineConfig`. Runtime Notarius paths remain absolute for +execution, but the digest substitutes their normalized logical values captured +before root-relative resolution, so relocating an equivalent configuration +bundle does not change provenance. Because composition and resolution metadata +are private, the digest excludes source layout, profile name, and ownership. +Configuration stores environment variable names rather than resolving raw +credentials, so raw secret values are neither loaded nor hashed. +`recomputePipelineEffectiveDigest` is the single package-owned refresh point +for later runtime expansion. ## Test Surfaces diff --git a/docs/internal/stage-prepare.md b/docs/internal/stage-prepare.md index 4ec4c78..7f9c5d9 100644 --- a/docs/internal/stage-prepare.md +++ b/docs/internal/stage-prepare.md @@ -73,8 +73,12 @@ evidence rerun once when selected. Workspace, spool, and cache placement and absolute source relocation are not semantic when logical selection, canonical names, and bytes are equivalent. The fingerprint deliberately does not read or rehash large audio. Prepared -input checksums remain the content provenance; force prepare after changing -source bytes that are not otherwise reflected by the semantic selection. +input checksums remain the content provenance. Before reusing success, prepare +validates every durable prepared copy and compares current stable-input bytes, +canonical party and derived-player bytes, local audio membership/checksums, or +S3 key/size/entity-tag identity with that provenance. Source relocation with +equivalent names and bytes remains reusable; changed or unavailable evidence +causes a normal prepare rerun. ## Related Contracts And Tests @@ -86,6 +90,8 @@ source bytes that are not otherwise reflected by the semantic selection. the internal collaborators. - Implementation and tests: `internal/stage/prepare.go`, `internal/stage/prepare_test.go`, + `internal/stage/prepare_resume.go`, + `internal/stage/prepare_resume_test.go`, `internal/stage/semantic_contracts_initial.go`, `internal/stage/semantic_contracts_initial_test.go`, `internal/audio/s3_audio_test.go`, diff --git a/docs/operations.md b/docs/operations.md index bb9d508..ef8cdde 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -115,7 +115,11 @@ Execution rules: Every aggregate stage except analyze currently provides semantic-configuration evidence; analyze retains its more precise per-artifact fingerprints and validator. -Changing prepare selection semantics reruns all fixed descendants; changing +Prepare additionally validates current stable/local/S3 source identity and the +checksums of its durable prepared copies before reuse. Changed bytes, audio +membership, S3 object identity, or missing/tampered copies rerun prepare and +its fixed descendants without requiring `--force`. Changing prepare selection +semantics likewise reruns all fixed descendants; changing WhisperX language/service identity reuses prepare; changing a Seriatim merge transformation reuses prepare and transcribe; and changing an Audita model reuses prepare, transcribe, and merge while rebuilding transcript refinement. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a2b6b43..db5c08c 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,1250 +1,471 @@ -# Pipeline Configuration Ergonomics Implementation Plan +# Release Procedure Upgrade Implementation Plan ## Purpose And Status This document is the executable implementation plan for -[`pipeline-configuration-ergonomics.md`](pipeline-configuration-ergonomics.md). -That feature roadmap owns the accepted user intent, policy choices, scope, and -target end state. This plan translates the roadmap into bounded stages suitable -for one `gpt-5.6-terra` implementation prompt apiece. +[`release-procedure.md`](release-procedure.md). The feature roadmap owns the +accepted user intent, policy choices, required outcome, and responsibility +boundary. This plan translates that target into bounded implementation stages +suitable for one `gpt-5.6-terra` coding prompt apiece. -All stages are pending and must be implemented in numeric order. Each stage is -intended to leave the repository correct, documented for the behavior that is -then usable, and independently reviewable. Later stages may rely on the tested -contracts established by earlier stages, but must not silently redesign them. +All stages are pending and must be implemented in numeric order. Each stage +must leave its owned behavior correct, tested at an appropriate stable +boundary, and ready for the next stage. This plan supersedes the completed +pipeline-configuration implementation plan that previously occupied this +path; that feature's implemented behavior is now owned by current canonical +documentation and code. ## Settled Implementation Decisions -The following choices make this plan decision-complete: +The following decisions are final for this implementation: -- Effective pipeline resolution has one order: parse the root and declared - sources with presence information; additively merge root fields and imports; - apply exactly one selected profile overlay when profiles exist; strict-decode - the assembled mapping; apply centralized defaults; resolve ordinary paths - relative to the root pipeline; resolve the selected campaign and its party; - expand party-driven artifacts, dependencies, member sources, and publish - rules; run ordinary concrete validation; and render/digest the normalized - effective pipeline. Defaults must not be applied to individual fragments. -- `composition.imports` is additive and has no winner. A duplicate final path - in two base sources is an error even if its values are equal. The selected - profile overlay is the only overwrite layer. Lists are atomic in the base and - replace completely in an overlay; maps merge recursively; incompatible YAML - kinds and YAML null-as-deletion are rejected. -- Profile names are case-sensitive, must be non-empty, trimmed, and free of - control characters. If profiles are declared, exactly one must be selected by - an explicitly supplied CLI value or `default_profile`; there is no implicit - first profile. A configuration with imports but no profiles remains valid. -- Pipeline load options retain whether `--profile` was explicitly present so - an explicitly empty value is distinguishable from omission and rejected. - `LoadPipeline` remains a compatibility entry point for callers that do not - make an explicit selection; the option-aware loader is authoritative. -- The normalized effective digest covers the secret-free, defaulted, expanded - runtime pipeline mapping after composition. It excludes composition - declarations and runtime-only provenance. It uses logical configuration - values rather than machine-specific serialization accidents. Party-derived - concrete values and publish rules therefore affect the digest. The digest is - provenance only and is never a blanket resume key. -- Source ownership is tracked by complete YAML-style field path. Base leaves - retain their declaring root/import source, overlaid leaves belong to the - selected overlay, centralized defaults are marked as defaults, and generated - fields identify both the family declaration and canonical party as sources. - Source paths may appear in diagnostics and inspection output; raw secret - values may not. -- Semantic resume evidence uses a versioned, typed stage contract. The runner - compares a stage's current semantic fingerprint before reusing terminal - state, then invokes any existing stage-specific resume validator. Missing or - mismatched evidence makes the stage non-resumable and uses the existing fixed - transitive invalidation relation. Each stage hashes only Narratio-observable, - result-affecting values. Timeouts, concurrency, workspace and diagnostic - paths, executable paths, profile names, and raw credentials are excluded. -- Existing analyze artifact fingerprints remain the granular authority for the - `analyze` stage. Do not add an aggregate analyze fingerprint that would stale - every configured artifact after one member or model changes. Instead, audit - and extend the existing per-artifact fingerprint inputs where necessary. -- The canonical party source is campaign-owned. In canonical - `narratio.party.v1` mode, a separate campaign or session `players_file` and a - session-level `party_file` override are rejected. The isolated legacy mode - preserves the current effective party/players override behavior, treats the - unversioned party payload as opaque reference material, and cannot drive - families. Compatibility code must be grouped and commented for later - removal. -- A party document containing `schema_version` is an attempted canonical - document: an unsupported or malformed version is an error, not a legacy - fallback. Canonical documents are strict single-document YAML. Unversioned - legacy documents are not decoded into the canonical schema. -- The canonical players projection is emitted with - `schema_version: narratio.players.v1`, one entry per character sorted by - stable character ID, and aliases in declaration order. An absent or empty - alias list is omitted from a projection entry. `prepare` copies the canonical - party bytes unchanged and generates players bytes deterministically. -- `scriptorium.artifact_families` is a resolution-only declaration. Every - family expands for every canonical party character, including a disabled - family so explicit selection can use existing disabled-artifact semantics. - Runtime stages and adapters receive only the resulting ordinary concrete - artifacts plus bounded origin metadata. -- `{character_id}` must occur exactly once in each configured family output - path pattern and each non-empty family publish destination pattern. No other - brace token is accepted. Substitution is followed by the existing relative, - confined output/destination validation; arbitrary interpolation is not - introduced. -- Ordinary family `depends_on` entries name shared concrete artifacts. - Corresponding family members use `member_dependencies`. A - `narratio.member_artifact.` input is legal only in a family, must name - one of its `member_dependencies`, and is rewritten to the concrete - `narratio.artifact._` source before ordinary validation. -- Family selection is normalized to concrete keys before the existing - effective-artifact resolver. Selecting a family selects all members; - selecting a generated key selects only that member; mixed selections are - sorted and deduplicated. Typed optional family/character origin fields are - retained in planning, listing, run/analyze state, and reconciliation without - creating dynamic pipeline stages. -- An enabled family publish declaration requires an existing top-level publish - configuration and expands to ordinary concrete output rules. Omitting its - destination pattern derives each destination from the generated artifact's - output path. Top-level publish enablement retains its existing meaning. -- `config validate`, `show`, and `sources` accept the existing `--config`, - `--campaign`, and `--campaign-file` selection mechanisms plus `--profile`. - They do not discover a session or create runtime state. Campaign selection is - optional only when full resolution does not need party data. `config diff` - takes exactly two positional profile names and the same pipeline/campaign - flags, but no separate `--profile` flag. -- `config show` emits deterministic secret-free effective YAML. - `config sources` emits deterministic path/role/source records. - `config diff` emits sorted semantic records of the form added, removed, or - changed at a normalized field path, with values represented in deterministic - compact form; it does not diff raw source text. +- The synchronous release boundary is a successful push of the exact selected + tag ref to `origin`. Neither `scripts/release.sh` nor any instruction that + defines successful release completion may poll Woodpecker, query build + status, wait for a Gitea release, download assets, or verify asynchronous + publication. +- The supported command surfaces are exactly: + `scripts/build-release-assets.sh VERSION OUTPUT_DIR`, + `scripts/check-release-candidate.sh VERSION`, and + `scripts/release.sh VERSION`. +- All scripts are POSIX `sh`, use `set -eu`, derive the repository root from + their own checked-in location, and use narrowly named variables. They do not + rely on the caller's current directory and do not repurpose `HOME`, + `CODEX_HOME`, or common system-option variables. +- A private source-only `scripts/release-lib.sh` owns the stable semantic + version parser and shared fatal-error/reporting helpers. It has no behavior + merely from being sourced. Public scripts source it relative to their own + location so version syntax and error conventions do not drift. +- Stable versions match exactly + `v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)`. Prerelease and build + suffixes are rejected. +- `scripts/build-release-assets.sh` accepts exactly two arguments. Its output + path must be absolute, must not be `/` or the repository root, must not be a + symlink or non-directory, and must either not exist or be an empty directory. + The script creates the directory when absent, writes only its six named + assets and any narrowly scoped temporary smoke binary, and never recursively + clears a caller-supplied path. +- Official targets are CGO-disabled Linux, Darwin, and Windows builds for both + `amd64` and `arm64`. Filenames are + `narratio-VERSION-GOOS-GOARCH`, with `.exe` only on Windows. Builds use + `-trimpath`, `-s -w`, and + `-X gitea.maximumdirect.net/eric/narratio/internal/buildinfo.Version=VERSION`. +- The asset builder verifies `narratio version` on the current host. When the + host is one of the six official targets it executes that official asset; + otherwise it builds a separate temporary host smoke binary, executes it, + and removes only that file. Expected output is exactly + `narratio VERSION`. +- `scripts/check-release-candidate.sh` is read-only with respect to tracked + source and Git refs. It accepts exactly one version, validates the matching + note, performs repository and Go-module hygiene checks, runs every required + test/static/build/documentation/example check uncached where applicable, + verifies formatting and whitespace, and calls the shared asset builder in a + directory created with `mktemp -d`. Every Go command it owns runs with + `GOWORK=off`. +- The candidate checker deliberately does not require `main`, a clean checkout, + synchronized remote refs, or an unused tag. This allows it to validate a + candidate before commit and to run in tag CI's detached checkout. Those + mutable publication guards belong only to `scripts/release.sh`. +- `scripts/release.sh` accepts exactly one version, operates only against the + remote named `origin` and branch named `main`, and never accepts bypass, + force, alternate-remote, skip-check, or CI-wait flags. +- The publication script uses a two-phase guard. It checks cheap local and + upstream conditions before the potentially long candidate checker, then + fetches and rechecks the clean checkout, exact `HEAD == origin/main`, and + local/remote tag absence immediately before creating the tag. This prevents + a long validation run from publishing after the candidate or upstream tip + changed. +- Release tags remain explicitly unsigned lightweight tags. The script uses + `git -c tag.gpgSign=false tag VERSION COMMIT`, verifies that the ref type is + `commit` and resolves to the recorded commit, and pushes only + `refs/tags/VERSION:refs/tags/VERSION`. It never pushes `main` or uses + `git push --tags`. +- A failed push leaves the unpublished local tag intact for inspection. The + script never silently deletes or retries it. A successful push prints the + version and exact commit, returns zero immediately, and performs no later + network or CI action. +- Release-script behavioral tests use real temporary Git repositories and a + local bare `origin`, with a fixture-local stub candidate checker where a + full repository validation would add no confidence. They never touch the + developer's actual refs or any network service. +- Tag CI uses `golang:1.25.5` and + `woodpeckerci/plugin-release:0.3.1`. Its steps are named + `validate-release`, `build-release-assets`, and `publish-release` in that + dependency order. +- The release plugin explicitly sets title + `Narratio ${CI_COMMIT_TAG}`, note + `docs/releases/${CI_COMMIT_TAG}.md`, flattened SHA-256 checksums, + `file-exists: skip`, `overwrite: false`, and `prerelease: false`. Skipping an + existing remote release preserves idempotence and immutability; repairing a + failed published tag remains a new-patch-release operation. +- Release documentation describes optional later inspection but never makes + CI success, Gitea release creation, checksum download, or host asset execution + a required step after a successful tag push. +- This feature does not create the next release note, tag a version, push + `main`, or publish a release. Those actions occur only after this + implementation sprint is complete and a separate release candidate is + selected. ## Instructions For Every Stage -Before changing code in each stage: +Before changing files in each stage: -1. Read `docs/development.md`, all documents in `docs/policy/`, this plan, and - the portions of the feature roadmap relevant to that stage. -2. Follow `docs/development.md` to the current canonical user, integration, and - internal documents for every subsystem the stage changes. -3. Inspect the current implementation and its focused tests before editing. - Prefer the codebase knowledge graph for discovery, then read the exact owning - files and symbols. -4. Confirm the worktree state and preserve unrelated user changes. +1. Read `docs/development.md`, all documents under `docs/policy/`, this plan, + and the relevant portions of + [`release-procedure.md`](release-procedure.md). +2. Inspect the current scripts, Woodpecker workflows, release notes, + `internal/buildinfo`, `internal/app/version.go`, and focused repository + checks relevant to the stage. Prefer the codebase knowledge graph for Go + discovery and use text search for shell, YAML, and Markdown. +3. Confirm the worktree state and preserve unrelated changes. +4. Re-read the responsibility boundary before editing any publication command: + successful upstream tag push ends the synchronous release process. -During each stage: +During every stage: -- Keep changes within that stage's scope and the accepted roadmap. Do not add a - general configuration language, workflow DAG, arbitrary templating, nested - imports, profile inheritance, or runtime family loop. -- Preserve strict YAML, centralized defaults, fixed application-owned stage - sequencing/invalidation, manifest authority, root-confined filesystem work, - indirect secret handling, and adapter ownership of private configuration. -- Use the one production loading/resolution path created by this plan. Tests - may call narrower package owners, but commands may not reimplement merging, - profile selection, party loading, or family expansion. -- Follow the testing policy: protect behavior at its narrowest stable owner, - use real filesystem behavior in `t.TempDir()`, fake only external or - nondeterministic boundaries, keep default tests offline, and avoid repeating - every parser case at CLI or assembled-workflow layers. -- Update each canonical current-behavior document in the same stage that makes - the behavior usable. Until then, leave unimplemented behavior only in the - roadmap and this plan. -- Preserve backward-compatible reads of existing manifests and monolithic - configurations. Emit only the new canonical representation after a new - writer is introduced. -- Run focused tests while iterating, format changed Go files, and finish with - `go test ./...`. Also run the applicable static, race, build, documentation, - and maintained-example checks listed in `docs/development.md`; the final stage - must run the complete repository validation set. +- Keep public shell interfaces and file ownership exactly as settled above. +- Use the Go standard library and ordinary POSIX tools already available in the + builder images; do not add a shell framework, Go dependency, release SaaS, + or package-manager tool. +- Keep tests deterministic, offline, and free of real credentials and remote + infrastructure. Use `t.TempDir()` and local bare Git repositories for + publication behavior. +- Test consequential behavior at the narrowest stable boundary. Avoid tests + that merely snapshot complete script text or duplicate every shell branch. +- Make failures identify the responsible script, invalid value, or failed + command without printing environment dumps or secret values. +- Do not document future behavior outside `docs/roadmap/` until the stage that + makes that behavior usable. Update canonical current-behavior documentation + in the same stage that completes the release procedure. +- Format changed Go files, run `sh -n` on changed shell files, use focused + tests while iterating, and finish each stage with `git diff --check` plus the + stage's exit criteria. +- Do not create, delete, move, or push a real Narratio tag while implementing + or testing this feature. -At the end of each stage, leave a cohesive change that can be reviewed and -committed independently. Do not begin a later stage while an earlier stage has -failing tests, stale documentation, or unmet exit criteria. +## Stage 1 — Shared Release Library And Asset Builder -## Stage 1 — Presence-Aware Composition Engine - -**Status: Completed** +**Status: Pending** ### Goal -Introduce the internal YAML representation and deterministic merge primitives -needed by imports and profiles without changing the public pipeline schema. +Create the single safe implementation of version validation and official +release-asset construction without changing Git refs, CI, or current release +documentation. ### Required Work -1. In `internal/config`, add a composition document model built from - `yaml.Node` or an equivalently presence-aware representation. Retain mapping - versus sequence versus scalar kind, explicit zero values, complete logical - field paths, source file identity, and declaration order for diagnostics. -2. Parse exactly one top-level mapping per source. Reject duplicate YAML keys, - aliases that produce ambiguous ownership, trailing documents, and malformed - YAML with source-qualified errors. Keep the existing strict decoder as the - final schema authority; this layer owns document mechanics and merge - presence, not a parallel field schema. -3. Implement separate operations for: - - additive base merge, which recursively joins disjoint maps and rejects any - duplicate final value/list/keyed entry; and - - overlay merge, which recursively merges maps, replaces same-kind scalar or - list values, and rejects incompatible kinds or deletion/null semantics. -4. Make conflicts report the complete configuration path and all claiming - sources. Preserve declared source order only for diagnostics, never as base - precedence. -5. Add deterministic traversal/rendering suitable for normalized YAML, - canonical digest input, semantic field comparison, and source reports. Map - keys must be sorted for effective output even when source declaration order - is retained separately. -6. Keep these APIs internal to the configuration owner. Do not accept a - `composition` field in `PipelineConfig` yet. +1. Create `scripts/release-lib.sh` as a source-only POSIX library. Give its + functions a Narratio-specific prefix. At minimum it must provide: + - a fatal-error helper that prefixes errors with the calling release tool; + - exact stable-semver validation; and + - a helper for locating the repository root from a public script's absolute + or relative path without depending on the caller's working directory. + Keep the library private to the scripts; it is not a supported user-facing + API. +2. Create executable `scripts/build-release-assets.sh` with the exact + `VERSION OUTPUT_DIR` interface and output-directory contract in the settled + decisions. Reject missing/extra arguments, malformed versions, relative or + dangerously broad output paths, symlink/non-directory output, and a + nonempty output directory before building anything. +3. Build the six official binaries in deterministic target order. Use the + current command package and build-info linker symbol; do not duplicate or + change application version semantics. +4. Verify exact host version output as settled above. Ensure a failed build or + version mismatch is fatal and leaves no false success message. Temporary + smoke output must be inside the validated staging directory and removed + narrowly. +5. Add focused tests under a new `internal/releasecheck` test package. Protect + the meaningful asset-builder risks without making the default Go suite + compile all six targets repeatedly: + - argument and semver rejection; + - refusal of unsafe, symlink, non-directory, and nonempty destinations; + - creation only beneath an accepted empty destination; and + - exact naming/version behavior through one controlled host build or a + command shim, whichever is simpler and remains behavior-focused. + The operational six-target build is exercised directly in the stage exit + criteria and later by candidate validation. -### Tests And Exit Criteria +### Exit Criteria -- Package-level table tests cover explicit `false`, zero, empty maps/lists, - disjoint recursive maps, every duplicate class, list atomicity/replacement, - kind conflicts, null deletion attempts, duplicate YAML keys, non-mapping - documents, and trailing documents. -- Diagnostics identify the full field path and both or all relevant sources. -- Deterministic rendering and digest input are stable across map insertion and - source traversal order where semantics are equal. -- Existing `LoadPipeline` behavior and all monolithic examples remain - unchanged. +- `sh -n scripts/release-lib.sh scripts/build-release-assets.sh` succeeds. +- Focused `internal/releasecheck` tests pass. +- Running the asset builder with a synthetic stable version and a fresh + temporary output directory produces exactly six official assets and the + host version check succeeds. +- Invalid and unsafe output examples fail before creating release assets. +- `go test ./...`, `go vet ./...`, `go build ./...`, and + `git diff --check` succeed. -## Stage 2 — Explicit Additive Imports +## Stage 2 — Central Release-Candidate Checker -**Status: Completed** +**Status: Pending** ### Goal -Make root-owned `composition.imports` usable while preserving monolithic -configuration behavior and one root-relative path base. +Create one read-only source-candidate validator that maintainers and tag CI can +run identically, eliminating drift between documented preparation and tagged +source validation. ### Required Work -1. Add the root-only composition envelope and ordered `imports` field. Strip - the envelope before strict `PipelineConfig` decoding. Root ordinary fields - participate as one additive base source alongside imported partial files. -2. Resolve import paths relative to the root pipeline directory. Accept only - non-empty relative `.yml` or `.yaml` paths that remain beneath that directory - and resolve through `Lstat` to regular files. Reject absolute paths, - traversal, symlinks/non-regular files, missing files, duplicate imports, and - an import of the root itself. -3. Parse every imported document through Stage 1. Reject `composition` in any - imported document, thereby preventing recursion, nested imports, profiles, - and cycles. -4. Additively merge root ordinary fields and imports, strict-decode the result, - apply existing defaults once, and resolve every ordinary relative pipeline - path against the root pipeline directory regardless of the declaring file. -5. Retain root/import paths and base field ownership on runtime-only resolution - metadata without exposing them as YAML fields or secrets. -6. Route `LoadPipeline` through the composed loader so monolithic and imported - roots cannot drift. Do not add an automatic `conf.d` scan. -7. Update `docs/config.md` and focused internal configuration documentation for - the implemented import schema, additive conflict contract, confinement, and - stable root-relative path semantics. +1. Create executable `scripts/check-release-candidate.sh` with the exact + one-version interface. Source `release-lib.sh`, resolve and enter the + repository root, and validate the version before using it in a path. +2. Require `docs/releases/VERSION.md` to be nonempty, begin with the exact + `# Narratio VERSION` heading, and contain exact + `## Summary`, `## Compatibility`, `## Upgrade`, and `## Changes` headings. + Do not parse prose or impose historical-note requirements on other tags. +3. Enforce repository/module hygiene: + - exact Narratio module declaration; + - no tracked `go.work` or `go.work.sum`; + - no `vendor` directory; + - no single-line or block `replace` declaration in `go.mod`; + - `GOWORK=off go mod tidy -diff` leaves module files unchanged; + - all tracked `*.go` files are `gofmt` clean; and + - both working-tree and cached whitespace checks pass. +4. Run the complete Narratio candidate checks with `GOWORK=off`: + - `go test -count=1 ./...`; + - `go test -race -count=1 ./...`; + - `go vet ./...`; + - `go build ./...`; + - `go test -count=1 ./internal/doccheck`; and + - `go test -count=1 ./internal/config -run '^TestExamplesLoadAndValidate$'`. +5. Create one temporary directory with `mktemp -d`, install a trap that removes + only that verified directory, and invoke `build-release-assets.sh` with the + selected version. The candidate checker owns no independent target matrix, + filenames, linker flags, or version smoke logic. +6. Extend `internal/releasecheck` with lean behavioral coverage for the + candidate checker's guards. Use isolated fixture repositories and command + shims where needed so default tests do not recursively run the complete + Narratio suite. Cover malformed version/note, wrong module identity, + disallowed workspace/vendor/replace state, propagation of a failing owned + validation command, and evidence that owned Go commands receive + `GOWORK=off`. +7. Ensure the checker itself does not inspect the active branch, fetch a + remote, require a clean status, check tag availability, mutate refs, or + contact Woodpecker/Gitea. Those are deliberately outside this script's + contract. -### Tests And Exit Criteria +### Exit Criteria -- Tests cover monolithic compatibility; root-plus-import fields; disjoint - contributions beneath `scriptorium.artifacts`; duplicate root/import and - import/import leaves; identical duplicate values; lists; type conflicts; - missing/absolute/traversing/symlink/non-regular/unsupported-extension paths; - repeated imports; imported composition; root self-import; source-aware - errors; and deterministic source ordering. -- Moving the same relative path field between root and import resolves to the - same absolute runtime value. -- `TestExamplesLoadAndValidate` and existing strict loader tests pass without - fixture-wide changes. +- `sh -n scripts/release-lib.sh scripts/build-release-assets.sh scripts/check-release-candidate.sh` + succeeds. +- Focused `internal/releasecheck` tests pass, including failure propagation. +- A disposable fixture with a compliant synthetic release note exercises the + candidate checker successfully; malformed fixtures fail with actionable + script-owned errors. +- The repository-wide test, race, vet, build, documentation, example, + formatting, module-tidiness, and whitespace checks all pass independently. +- No Git ref, tracked file, or external service is mutated by the checker. -## Stage 3 — Versioned Stage Semantic Resume Framework +## Stage 3 — Guarded Tag Publication Command -**Status: Completed** +**Status: Pending** ### Goal -Create one typed runner/manifest mechanism for result-affecting stage -configuration before selectable profiles can change those values. +Provide one safe, non-interactive command that publishes exactly one guarded +lightweight tag and stops immediately when the upstream Git push succeeds. ### Required Work -1. Add an optional typed semantic-configuration fingerprint record to session - `StageRecord` and invocation `RunStageRecord`, containing a positive schema - version and lowercase SHA-256 digest. Use `omitempty` and preserve reads of - existing manifests without the field. -2. Add a narrow optional stage interface that returns the current versioned - semantic fingerprint from resolved `stage.Env` configuration. Provide one - helper that hashes deterministic JSON from stage-owned typed structs; reject - invalid versions and never hash raw secret values, arbitrary `map[string]any` - iteration, or the complete effective configuration digest. -3. In both execution and read-only planning, compare current evidence whenever - a terminal stage record would otherwise be reused. Missing legacy evidence - or a version/digest mismatch makes the stage non-resumable with a bounded, - actionable reason and follows the existing stale/dependent invalidation - path. -4. If the semantic comparison succeeds, continue to call the existing - stage-specific `ResumeValidator`; both checks are required. Forced execution - retains its current behavior and does not need a comparison to decide to - run. -5. Compute the fingerprint immediately before the stage decision, persist the - same value only with a successful or intentional skipped result, and copy it - to the run-stage record. Never promote fingerprint evidence from a failed or - interrupted execution. -6. Ensure a run-manifest skip records the reused semantic fingerprint for - invocation provenance without rewriting the authoritative session stage - result. -7. Update focused manifest, runner, plan, and resume internals. Do not claim - stage coverage until Stages 4–6 add it. +1. Create executable `scripts/release.sh` with the exact one-version interface. + Source the shared library and reject invalid arguments before running Git + operations. +2. Implement the first cheap guard phase: + - require branch `main`; + - require an empty `git status --porcelain` result; + - fetch `origin main --tags`; + - record `HEAD^{commit}` as the immutable candidate for this attempt; + - require it to equal `origin/main^{commit}`; + - require the matching note to exist in that candidate; and + - reject an existing local or exact upstream tag. +3. Invoke `scripts/check-release-candidate.sh VERSION` without suppressing its + output or weakening any check. +4. Implement the second guard phase immediately after validation: + - refetch `origin main --tags`; + - require the checkout still to be clean and on `main`; + - require `HEAD` and `origin/main` still to equal the originally recorded + candidate commit; and + - recheck exact local and upstream tag absence. + Do not silently select a newer commit or restart validation. +5. Create the explicitly unsigned lightweight tag against the recorded commit, + verify its object type and resolved commit, show a concise local summary, + and push only its fully qualified tag ref to `origin`. +6. On push success, print one concise completion message containing the tag and + commit and exit zero immediately. There must be no commands after the push + that query CI, Gitea, a release API, assets, or checksums. Do not sleep or + retry waiting for asynchronous state. +7. On failure before tag creation, leave refs unchanged. On failure after local + tag creation, retain the local tag and return a clear instruction that it is + unpublished and requires inspection. Never automatically delete, move, + force-push, or reuse a tag. +8. Add behavioral tests in `internal/releasecheck` using temporary working + repositories and local bare `origin` repositories. Put a successful stub + candidate checker at the fixture's expected checked-in path so tests isolate + tag policy rather than rerun compilation. Cover at least: + - wrong branch and dirty checkout rejection; + - local `HEAD` not equal to `origin/main`; + - candidate-checker failure; + - existing local and upstream tag rejection; + - upstream `main` changing between the two guard phases; + - successful creation of a lightweight remote tag pointing to the exact + candidate commit; + - push failure retaining the local tag; and + - absence of any pushed branch or unrelated tag. + Tests must never use the real repository's `origin` or network. -### Tests And Exit Criteria +### Exit Criteria -- Manifest round-trip tests prove old records remain readable and new records - preserve version/digest in session and run state. -- Runner and plan tests cover matching, missing, mismatched, malformed, forced, - skipped, failed, and existing-resume-validator combinations. -- A mismatch marks the stage and only its fixed transitive dependents stale; - read-only plan predicts the same decision without mutation. -- Fingerprint tests use typed values and prove deterministic hashes without - asserting implementation-private serialization beyond the versioned - contract. +- All release scripts pass `sh -n`. +- Focused publication tests pass and prove the exact remote-tag outcome and + failure behavior. +- A success-path test demonstrates that the release command returns as soon as + the local bare-remote tag push succeeds; it performs no CI/Gitea follow-up. +- `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`, and + `git diff --check` succeed. +- No real Narratio tag or remote ref is created or changed. -## Stage 4 — Prepare, Transcribe, And Merge Semantic Contracts +## Stage 4 — Asynchronous Woodpecker Release Integration -**Status: Completed** +**Status: Pending** ### Goal -Protect reuse of the pipeline's input preparation and initial transcript work -with stage-specific semantic configuration evidence. +Make tag CI consume the shared candidate and asset contracts while preserving +strict validation-before-publication ordering and complete independence from +the synchronous tag command. ### Required Work -1. Implement Stage 3 fingerprint providers for `prepare`, `transcribe`, and - `merge`, each with an independently versioned private payload type. -2. The `prepare` payload must include resolved campaign/session input selection - semantics, local versus S3 audio selection, previous-session identity and - selected previous-artifact requirements, and any option that changes - prepared canonical bytes or names. Exclude absolute source/workspace paths - when their logical values and bytes are equivalent, cache/spool placement, - transfer tuning, and the complete profile/digest. Continue to rely on - prepared-input checksums and current source validation for content identity; - do not hash large audio a second time solely for configuration evidence. -3. The `transcribe` payload must include Narratio-visible recognition language - and service/model identity exposed by its configured adapter contract. It - must exclude retry, concurrency, timeout, credential, and diagnostic values. -4. The `merge` payload must include Seriatim operation/output schema and every - configured merge transformation that can change canonical transcript bytes. - Exclude executable, timeout, report, and retention settings. -5. Keep payload construction in the owning stage or a narrowly shared stage - helper; do not add a reflection-based whole-config hasher. -6. Update the focused prepare, transcribe, merge, operations, and resume - documentation for the evidence that is now observable and the external - private inputs that still require force. +1. Rewrite `.woodpecker/release.yml` around three explicit steps: + - `validate-release` in `golang:1.25.5`, invoking + `./scripts/check-release-candidate.sh "$CI_COMMIT_TAG"`; + - `build-release-assets` in `golang:1.25.5`, depending on + `validate-release`, requiring a fresh absent/empty absolute `$PWD/dist`, + and invoking the shared asset builder; and + - `publish-release`, depending on `build-release-assets` and using + `woodpeckerci/plugin-release:0.3.1`. + Remove the redundant inline target matrix, validation command list, and + separate cross-build step from the release workflow. +2. Configure publication exactly as settled: the existing release-token + secret, `dist/narratio-*` files, explicit title and tagged note, flattened + `SHA256SUMS`, `file-exists: skip`, no overwrite, and no prerelease. +3. Preserve the tag-only event trigger. Do not add a pipeline callback, status + endpoint, wait command, or any coupling from `scripts/release.sh` to this + workflow. +4. Update `internal/doccheck/doccheck_test.go` so the dependency assertion names + `validate-release` and still proves transitively that publication cannot run + after failed validation. Extend its minimal YAML model only as needed to + protect consequential release invariants: + - the validator calls the shared checker; + - asset publication depends on validated shared construction; + - the release plugin image is pinned; + - title and note use the selected tag; and + - overwrite/prerelease remain disabled. + Avoid snapshotting the whole workflow or duplicating every plugin setting. +5. Confirm `.woodpecker/verify.yml` and `.woodpecker/shuffle.yml` remain normal + push/PR and scheduled validation. Do not make either wait for or invoke a + release operation. -### Tests And Exit Criteria +### Exit Criteria -- Each stage has table-driven tests proving a representative semantic change - changes its fingerprint and representative operational/path-only changes do - not. -- Runner tests prove a changed prepare value stales its fixed descendants, a - changed transcribe value reuses prepare but stales transcribe descendants, - and a changed merge value reuses prepare/transcribe. -- Existing successful records without semantic evidence receive a safe one-time - rerun when the corresponding stage is selected. -- No test invokes live storage or transcription services. +- The Woodpecker YAML parses and the focused `internal/doccheck` workflow + dependency/invariant tests pass. +- The release workflow has one target matrix owner and one candidate-check + owner, both under `scripts/`. +- `publish-release` has a dependency path to `validate-release`; no failure in + validation or asset building can publish. +- Static inspection confirms `scripts/release.sh` has no reference to + Woodpecker, Gitea release status, or CI polling. +- `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`, + documentation/example checks, and `git diff --check` succeed. -## Stage 5 — Polish, Normalize, Trim, And Render Semantic Contracts +## Stage 5 — Canonical Release Documentation And Final Audit -**Status: Completed** +**Status: Pending** ### Goal -Make transcript refinement and rendering safe across profile-selected model and -prompt changes without invalidating unaffected upstream transcript work. +Document the now-implemented maintainer workflow in one canonical location, +make it discoverable, and perform a complete offline acceptance audit without +creating or publishing a real release. ### Required Work -1. Implement independently versioned semantic payloads for `polish`, - `normalize`, `trim`, and `render`. -2. The `polish` payload must include every Narratio-visible Audita value that - can change canonical output, including model, validation model, module set, - transcript description, output schema, and explicitly selected external - configuration identity. Include a configured service endpoint when it can - select different semantics. Exclude executable path, timeout, concurrency, - report/debug paths, work retention, and credential environment names. -3. The `normalize` payload must include the Seriatim normalization operation, - schema, and transformation settings, while excluding operational runner - settings. -4. The `trim` payload must include its enablement, prompt/profile identifiers, - canonical input/output identity, Scriptorium variables and render policy - that affect canonical output, and relevant Seriatim transformation settings. - Exclude diagnostic render output, timeout, and executable path. -5. The `render` payload must include final format, title, timestamp/segment ID/ - metadata inclusion, output identity, and any other canonical render choice. -6. Document that Narratio cannot observe private prompt/module/model/config file - contents behind a stable identifier; changes to those contents still require - force. - -### Tests And Exit Criteria - -- A production/testing Audita model change makes `polish` non-resumable while - leaving `prepare`, `transcribe`, and `merge` reusable. -- Each stage's semantic and operational exclusions are covered at its package - boundary, with one runner-level invalidation test per distinct dependency - branch rather than duplicated exhaustive cases. -- Render-only changes do not stale `extract`; trim changes stale both render and - extract through the existing fixed invalidation relation. -- Current stage, operations, and manifest documentation accurately describes - the implemented behavior. - -## Stage 6 — Extract, Analyze, Publish, And Notify Resume Audit - -**Status: Completed** - -### Goal - -Complete semantic resume coverage for downstream artifact work without -weakening extract validation or analyze's per-artifact granularity. - -### Required Work - -1. Give `extract` a versioned semantic payload containing enablement, Notarius - pipeline identity, declared output contracts, reference-slot/source mapping, - and canonical output identities. Combine it with the existing reference and - output resume validator; do not duplicate checksum logic in the payload. - Exclude binary, timeout, working directory, and private Notarius config file - contents. -2. Audit the existing analyze artifact fingerprint schema. Ensure it includes - all Narratio-visible effective prompt/profile identifiers, static and - generated variables, dependencies, input source identities/content - evidence, render-debug policy that affects canonical output, and output - identity. Bump its schema version only if its payload changes. Keep - reconciliation and partial selection artifact-granular. -3. Do not implement the Stage 3 aggregate fingerprint interface for `analyze`. - Adapt plan/runner integration only as necessary so its existing - `ResumeValidator` remains the authority and later family members participate - as ordinary artifacts. -4. Give `publish` semantic evidence for enabled behavior, normalized concrete - source/destination/required rules, upload-run policy, static lock policy, - and remote destination identity (backend, bucket, region/endpoint identity, - and root prefix). Exclude credentials, timeout/retry tuning, local workspace, - and run IDs. Retain immediate lock revalidation and commit safety. -5. Give `notify` semantic evidence for its configured delivery mode and any - Narratio-visible message-shaping option. Never hash credentials or remote - response data. -6. Publish a concise implemented coverage table in the focused resume/manifest - internals, linking to stage and integration owners rather than duplicating - their schemas. - -### Tests And Exit Criteria - -- Extract tests prove semantic changes and reference/output corruption are - independently non-resumable and operational changes are reusable. -- Analyze tests prove a model, variable, dependency, or input change affects - only the relevant artifact and its artifact dependencies, not every analysis - record or upstream transcript stage. -- Publish tests prove a destination/rule/target change reruns publication while - credential names and operational tuning do not; existing lock and commit - tests remain authoritative for destructive behavior. -- All canonical stages now have explicit semantic reuse coverage or the - documented analyze artifact-level equivalent before named profiles become - selectable. - -## Stage 7 — Named Profile Composition And Effective Digest - -**Status: Completed** - -### Goal - -Add strict single-profile overlay resolution and complete effective -configuration provenance within `internal/config`. - -### Required Work - -1. Extend the root-only composition envelope with `default_profile` and a - profile map whose only field is `overlay`. Reject unknown fields, empty - profile names, invalid defaults, nested composition, imports in overlays, - profile inheritance, and any attempt to stack profiles. -2. Add option-aware pipeline loading that distinguishes an explicitly supplied - profile from omission. Apply the selection rules in Settled Decisions and - return errors before stage or adapter composition. -3. Resolve every declared overlay path under the same confinement and regular - YAML-file rules as imports. Structurally parse every declared overlay to - catch missing files, malformed YAML, duplicate keys, trailing documents, or - forbidden composition; apply only the selected overlay to the base. -4. Use Stage 1 overlay semantics. Preserve explicit `false`, zero, empty-list, - and keyed-map additions; replace lists completely; reject kind changes and - null/deletion syntax. -5. Strict-decode and default once after overlay. Retain selected profile name - and source (`default` or `cli`), ordered root/import/overlay sources, and - leaf ownership as runtime-only provenance. -6. Generate a deterministic effective digest from the normalized secret-free - runtime pipeline mapping. Provide one recomputation hook for later - party/family expansion rather than inventing a second digest. -7. Keep `LoadPipeline` as the omission wrapper and update `LoadWithSession*` - option types so profile presence can flow through without API duplication. -8. Update `docs/config.md` and internal configuration documentation for the - implemented schema, exact selection/overlay rules, digest meaning, and - absence of inheritance, stacking, deletion, or environment selection. Do - not document `--profile` until Stage 9 exposes it. - -### Tests And Exit Criteria - -- Tests cover default and explicit selection; explicit-over-default; omitted - selection with profiles; profile-free imports; explicit profile against no - profiles; unknown/empty profiles; invalid defaults; malformed/unselected - overlay sources; map recursion; false/zero overrides; list replacement; - keyed artifact addition/disablement; kind/null conflicts; and nested - composition. -- Equal normalized results produce equal digests regardless of source split; - a semantic value change changes the digest; no raw secret value is loaded or - represented. -- Monolithic and import-only configurations keep their current behavior and - source-relative paths. - -## Stage 8 — One Production Configuration Loading Path - -**Status: Completed** - -### Goal - -Refactor application configuration loading so a composed pipeline/profile is -loaded once and carried unchanged through campaign, local/remote session, and -command-specific resolution before CLI profile selection is exposed. - -### Required Work - -1. Refactor `internal/app/config_loader.go` around one loaded pipeline/campaign - context that retains the option-aware pipeline result and provenance. The - subsequent session resolver must consume that loaded value rather than call - `LoadPipeline` or reread the root path. -2. Provide one configuration-package resolution entry point that can combine - an already loaded pipeline with campaign and optional session data. Keep - compatibility wrappers thin and route them through that owner. -3. Move pipeline and campaign discovery, explicit `--campaign-file` handling, - registry selection, and mutual-exclusion rules into shared application - helpers used by runtime and later inspection commands. Do not silently pick - a campaign when no existing command rule authorizes it. -4. Route local sessions, remote session loading, restore, plan, run, status, - helper commands, single-stage commands, session init/validate, locks, - artifact listing, session cleanup, and `clean --all` through the shared - pipeline load where applicable. Preserve their existing behavior and - mutation boundaries. -5. Ensure remote-session download changes only the session source and cannot - discard the already selected pipeline/profile. Closing temporary session - resources must not invalidate retained configuration/provenance. -6. Remove or make private any alternate production loader that could bypass - import/profile selection. Keep narrow pure test helpers only when they call - the same configuration package APIs. -7. Update focused application configuration-loader internals. This is a - behavior-preserving refactor; do not add public flags or user documentation. - -### Tests And Exit Criteria - -- Existing command, discovery, campaign registry, remote session, restore, - clean, and session-init tests pass unchanged except where fixtures must call - the new shared API. -- Add a counting/in-memory loader seam or equivalent behavioral evidence proving - one command invocation does not reread/reselect the root pipeline while - resolving its session. -- A test mutation of the pipeline file between base and session resolution - cannot create a mixed invocation; the initially loaded value is retained. -- No user-visible command behavior or runtime state format changes in this - stage. - -## Stage 9 — Profile CLI Plumbing, Reporting, And Manifest Provenance - -**Status: Completed** - -### Goal - -Expose profile selection consistently through every configuration-consuming -command and retain bounded invocation provenance without using profile identity -as a cache key. - -### Required Work - -1. Add an explicit-presence `--profile ` flag to the shared configuration - flags. Reject duplicates and explicitly empty values under the same - structural parsing standard as other singleton options. -2. Thread the profile selection through the Stage 8 loader for `run`, `session - plan`, `regenerate-artifacts`, `run-stage`, `analyze`, `publish`, status, - restore, session init/validate, artifacts, locks, cleanup, and every other - command that loads a pipeline. Convenience commands and aliases must not own - an independent profile rule. -3. Add typed optional selected-profile name/source and effective-config digest - fields to session and run manifests. Preserve old manifest reads. The run - manifest records the exact invocation; the session manifest records the most - recently resolved invocation provenance without changing stage reuse - decisions. -4. Apply current provenance before the first persistent run mutation and copy - it into terminal run state. A failed run still retains which effective - configuration was attempted. Read-only plan must report it without writing - any manifest. -5. Include concise profile (or `none`) and digest reporting in run/plan/status - output. When status also shows persisted provenance, label current resolved - versus last persisted values so a profile switch is not ambiguous. -6. Verify secret loading remains after configuration resolution and that raw - environment/file secret values never enter the digest, manifests, logs, or - output. -7. Update `docs/cli.md`, `docs/operations.md`, troubleshooting where useful, - and focused manifest/command internals for selection, reporting, resume - implications, and the force requirement for private external-tool changes. - -### Tests And Exit Criteria - -- Representative shared parser tests and command tests cover omitted/default, - explicit, unknown, duplicate, and explicitly empty profile values, including - the `regenerate-artifacts` alias path. -- Local, remote-session, restore, plan, and one helper command prove they retain - the same selected profile through final resolution; do not repeat the same - assertion for every wrapper. -- Manifest round trips preserve new provenance and old fixtures remain valid. -- An assembled runner test switches only the Audita model: prepare through - merge remain reusable, polish and its fixed dependents become stale, and the - profile name itself causes no unrelated invalidation. - -## Stage 10 — Canonical Party Domain And Players Projection - -**Status: Completed** - -### Goal - -Implement the strict versioned party contract and deterministic derived players -document as a pure configuration/domain boundary before wiring it into campaign -resolution. - -### Required Work - -1. Add typed canonical party structures for `narratio.party.v1`, keyed - characters, nested player/character values, optional singular `alias` list, - and required class entries with optional pointer levels. Retain stable - character IDs and declaration order where the public contract requires it. -2. Parse canonical mode as strict single-document YAML with known fields only. - A top-level `schema_version` selects canonical parsing; a wrong value, - malformed value, or otherwise malformed canonical document is an error. - Classify a document with no version as legacy without decoding it into the - canonical structures. -3. Validate non-empty characters; the existing configured-artifact key grammar - for IDs; exact trimmed, non-control display strings; required player and - character names; optional non-empty aliases; global case-insensitive - ambiguity across all character primary names and aliases; required non-empty - classes; case-insensitive duplicate classes per character; and positive - levels when present. Player display names may repeat. -4. Derive class summaries by preserving declared class order and joining - entries as `` or ` ` with ` / `. Derive alias summaries by - joining declared aliases with `, `. Use Unicode-aware case-insensitive - comparison for ambiguity while retaining original spelling in outputs. -5. Produce the exact `narratio.players.v1` projection from canonical data, one - entry per character sorted by stable ID, with player name, character ID, - character name, and an optional alias list in declared order. Serialize it - deterministically with one trailing newline. -6. Return raw canonical bytes separately from normalized domain values so - prepare can later copy `party.yml` unchanged. Do not expose a general - campaign metadata extension map. -7. Keep legacy classification in a small, clearly named compatibility file or - boundary with a removal comment. It must not grow canonical transformation - behavior. - -### Tests And Exit Criteria - -- Table tests cover every schema rule, alias/primary collisions across and - within characters, Unicode case folding, repeated player names, invalid IDs, - whitespace/control characters, duplicate classes, absent/zero/negative - levels, unknown fields, trailing documents, unsupported versions, and - unversioned legacy classification. -- Multiclass and alias summaries preserve declaration order. -- Projection tests prove stable-ID sorting, repeated-player behavior, optional - alias omission, deterministic bytes, and no class leakage into the - players-only contract. -- Tests target exported/package domain behavior rather than each private YAML - walk helper. - -## Stage 11 — Campaign Party Resolution And Isolated Legacy Mode - -**Status: Completed** - -### Goal - -Make combined pipeline/campaign resolution own canonical party loading and -enforce one unambiguous canonical versus legacy input mode. - -### Required Work - -1. Resolve the campaign-owned `inputs.party_file` relative to the selected - `campaign.yml`, require a regular readable file, load/classify it through - Stage 10, and retain mode, canonical domain data/raw bytes, source path, and - source identity on runtime-only resolved configuration. -2. Split campaign validation into syntax/path-independent validation and final - combined input-mode validation. Do not require `players_file` before party - mode is known. -3. In canonical mode, reject a campaign or session `players_file` and reject a - session `party_file` override. Create a virtual resolved players input whose - source is the canonical party projection; it has no external source path. -4. In legacy mode, require the existing effective `players_file`, preserve - current campaign/session stable-input override semantics for both opaque - party and players files, and retain the exact prepared-input behavior. Add - explicit comments and names marking this compatibility surface for removal - after migration. -5. Make `Resolve`, `LoadWithSessionOptions`, plan/run loading, and any - pipeline-plus-campaign resolution entry point share this logic. A caller - cannot obtain a fully resolved canonical configuration while bypassing party - validation. -6. Add typed party/campaign provenance to the resolution metadata for later - family and `config sources` use. Do not place raw party contents in - manifests or logs. -7. Update `docs/config.md` and add/extend the canonical party integration - document under `docs/integrations/`. Describe legacy mode only as a bounded - migration path and link rather than duplicate the full schema elsewhere. - -### Tests And Exit Criteria - -- Combined-resolution tests cover canonical success, canonical separate - players rejection from campaign and session, canonical session party - override rejection, legacy success, missing legacy players, campaign/session - legacy overrides, missing/non-regular party files, and source-relative paths. -- The same canonical and legacy decision is observed through direct config - resolution, local command loading, and remote session loading with only one - representative application-level test. -- Existing legacy fixtures continue to load; new canonical fixtures do not - carry a separate players file. -- No party-driven family behavior is added yet. - -## Stage 12 — Canonical Party And Derived Players Preparation - -**Status: Completed** - -### Goal - -Materialize one canonical campaign roster and its deterministic players -projection through the existing prepared-input contracts consumed by Notarius -and Scriptorium. - -### Required Work - -1. Refactor `prepare` input materialization by party mode. In canonical mode, - copy the validated campaign party source bytes unchanged to - `inputs/party.yml` and atomically write Stage 10's projection to - `inputs/players.yml`. In legacy mode, keep the current two-file copy path in - the isolated compatibility owner. -2. Record separate `party` and `players` input records with their own content - checksums. Mark the players record with a stable source identifier such as - `derived_from_party`; do not pretend it came from a user `players_file`. - Retain party source/config provenance without recording its full content. -3. Use existing confined atomic/copy-if-changed filesystem helpers and current - group-readable permission policy. Remove an obsolete prior players output - safely when switching modes only through the normal prepared overwrite path. -4. Preserve `narratio.input.party` and `narratio.input.players` as the two - runtime source IDs. Ensure extract reference composition passes the prepared - canonical party unchanged to Notarius's `party` slot and the derived - projection to its `players` slot; do not add adapter-specific roster logic. -5. Ensure Scriptorium configured inputs resolve the derived players record by - the existing manifest-authoritative prepared-input lookup. Missing or - checksum-invalid generated bytes must fail at that owner. -6. Include party mode and projection schema version in prepare semantic - evidence so a legacy-to-canonical migration cannot reuse old prepared - inputs. -7. Complete the durable players integration document and update prepare, - artifact, Notarius, manifest, and operations documentation without copying - full maintained examples into prose. - -### Tests And Exit Criteria - -- Prepare tests compare canonical party bytes exactly, compare deterministic - projection bytes, and verify separate manifest checksums/source identity. -- Legacy prepare tests prove current opaque party and explicit players bytes - remain unchanged through the compatibility path. -- Extract/reference and analyze-input tests prove both source IDs resolve from - prepared manifest authority; missing/corrupt projection evidence fails - clearly without invoking live tools. -- Re-running prepare with identical inputs is byte-stable and does not perform - an unsafe partial write. - -## Stage 13 — Basic Party-Driven Artifact Family Expansion - -**Status: Completed** - -### Goal - -Expand a shared character family declaration into deterministic ordinary -Scriptorium artifacts with member-specific identity, paths, and variables. - -### Required Work - -1. Add strict `scriptorium.artifact_families` structures supporting the shared - concrete-artifact fields named by the feature roadmap plus - `for_each`, `output_path_pattern`, `member_vars`, - `member_dependencies`, and the later typed publish block. Reject unknown - fields through the existing strict pipeline decode. -2. Require the exact `for_each: party.characters`, a canonical party, a valid - family key, and exactly one literal `{character_id}` token in - `output_path_pattern`. Reject all other brace syntax before substitution. -3. For every family and canonical character sorted by family key then stable - character ID, create concrete key `_` and copy shared - enablement, prompt/profile, timeout, render-debug, ordinary dependencies, - inputs, and static variables into an ordinary - `ScriptoriumArtifactConfig`. -4. Resolve member variables only from the closed selectors in the roadmap and - merge their string values into concrete `vars`. Reject invalid selectors, - invalid destination variable names under the existing Scriptorium variable - rules, and collisions with static variables. Preserve the existing reserved - sticky-session variable ownership after expansion. -5. Substitute the stable character ID into output paths and then run the - existing configured-key, safe relative path, duplicate output, selected - executable-field, and Scriptorium variable validation. Do not create a new - runtime artifact type or loop. -6. Reject collisions among family keys, explicit concrete keys, generated - keys, and generated output paths. Expand disabled families too; ordinary - default effective selection will still omit their disabled members. -7. Store a runtime-only family catalog mapping family to sorted members and - each generated member to family/character origins and source ownership. - Remove resolution-only family declarations from the concrete runtime - Scriptorium configuration before adapters receive it. -8. Recompute the normalized effective digest after expansion so party member - values and generated concrete configuration are represented. Update - configuration and Scriptorium internal documentation for the implemented - fields and exact substitution/member-variable limits. - -### Tests And Exit Criteria - -- Tests cover two families over multiple characters, stable ordering, all - member selectors, multiclass/alias summaries, static vars, disabled families, - invalid iteration, legacy/no party, token errors, variable conflicts, - key/output collisions, unsafe paths, and ordinary post-expansion validation. -- Reordering source maps without changing party order semantics produces the - same concrete map, catalog, digest, and normalized output. -- Adapters and analyze planning receive only ordinary concrete artifacts and - current `map[string]any` string/bool variables. -- Adding or removing a character changes the generated member set - deterministically; reconciliation behavior is deferred to Stage 15. - -## Stage 14 — Same-Member Dependencies And Member Artifact Sources - -**Status: Completed** - -### Goal - -Support several coordinated character artifact families while resolving all -family-specific dependency syntax before runtime validation. - -### Required Work - -1. Validate `member_dependencies` as unique family keys. Each referenced family - must exist, use the exact same canonical party iteration source, and produce - the same character IDs. Reject self-dependency early with family/member - context. -2. For each generated member, append dependencies on the corresponding - `_` artifacts. Preserve separately declared - ordinary dependencies on shared concrete artifacts, normalize duplicates, - and let the existing concrete dependency planner detect transitive cycles. -3. Recognize `narratio.member_artifact.` only while expanding family - inputs. Require the referenced family to appear in that declaration's - `member_dependencies`, then rewrite it to - `narratio.artifact._` before artifact-policy and - analyze validation. -4. Reject member-artifact syntax in explicit concrete artifacts, references to - missing/non-member families, malformed suffixes, and any unresolved member - source reaching the concrete pipeline. -5. Attribute generated dependency/input paths to both the declaring family and - relevant party member in provenance. Do not register - `narratio.member_artifact.*` as a runtime artifact-policy source. -6. Update configuration, analyze, and artifact internals for the implemented - resolution boundary and examples no larger than needed to show the syntax. - -### Tests And Exit Criteria - -- Tests cover valid meta-to-items corresponding dependencies for every member, - shared concrete dependencies, missing families/members, self and transitive - cycles, duplicate dependencies, missing `member_dependencies` declarations, - malformed/member syntax in concrete artifacts, and deterministic rewritten - sources. -- Existing concrete dependency ordering, failure propagation, fingerprints, - and source validation work unchanged after expansion. -- A repository search/test assertion confirms no unresolved - `narratio.member_artifact.` source can reach stage or adapter configuration. - -## Stage 15 — Family Selection, Origin Reporting, And Reconciliation - -**Status: Completed** - -### Goal - -Make family declarations ergonomic at command boundaries while preserving -concrete execution, artifact-granular fingerprints, and manifest authority. - -### Required Work - -1. Extend application artifact selection normalization with the Stage 13 family - catalog. Expand an exact family key to all sorted member keys; retain an - exact generated/concrete key as one target; reject unknown values; and sort/ - deduplicate mixed family/member/concrete selections before calling the - existing effective-artifact resolver. -2. Explicit family selection must pass every generated member through the - existing explicitly-selected-disabled validation. Default selection still - includes only enabled concrete artifacts. An empty canonical party is - already invalid and therefore never turns a family selection into a silent - no-op. -3. Extend effective-artifact metadata with optional typed `family` and - `character_id` origins while keeping `Keys()` and concrete lookup behavior - stable for existing callers. -4. Add backward-compatible optional family/character fields to the authoritative - per-artifact analyze session and run records. Populate them from resolved - configuration, not by splitting concrete names. Explicit non-family - artifacts leave them absent. -5. Include origin plus concrete identity in `session plan`, run/analyze - summaries, and `artifacts list` output. Family headings may summarize, but - every executable/reused/failed artifact must remain identifiable by concrete - key. -6. Ensure the existing analyze reconciliation treats a newly added party member - as one or more new configured artifacts and a removed member as removed - records. Preserve unrelated current members and archive/remove outputs only - through existing safe reconciliation policy. -7. Ensure member-specific resolved variables and dependency sources enter the - existing artifact fingerprint so changing a class or alias stales only - consumers of that value and their artifact dependents. -8. Update CLI, analyze, artifact, manifest, and operations documentation for - family versus member selection and concrete runtime identity. - -### Tests And Exit Criteria - -- Selection tests cover family, member, explicit concrete, mixed/duplicate, - unknown, enabled/disabled, and invalid executable member cases. -- Plan and runner tests prove family selection and its normalized concrete - selection are behaviorally equivalent; no adapter receives a family key. -- Reconciliation tests cover adding, changing, and removing a character while - preserving unrelated artifact records and respecting member dependencies. -- Manifest compatibility tests read old analyze records and round-trip new - optional origin fields without deriving origin from names. - -## Stage 16 — Family Publish Rule Expansion - -**Status: Completed** - -### Goal - -Turn one optional family publish declaration into existing concrete publish -rules before ordinary validation and publication. - -### Required Work - -1. Implement the strict family `publish` block with `enabled`, `required`, and - optional `dest_pattern`. An absent or disabled block emits no rules. An - enabled block requires a top-level publish configuration but does not change - top-level publish enablement or storage policy. -2. For each generated family member, create one ordinary publish rule whose - source is `narratio.artifact.`, whose required bit is copied, - and whose destination is either the exactly-once substituted destination - pattern or the generated artifact output path. -3. Reject unknown/missing/repeated brace tokens, unsafe/escaping destinations, - conflicts with explicit rules for the same source, duplicate normalized - destinations, and sources unavailable under existing artifact policy. -4. Merge generated and explicit rules in deterministic destination/source order - before existing publish and static-lock validation. Runtime publish code - continues to receive a concrete list and performs no wildcard/family match. -5. Include generated rules in normalized effective configuration, effective - digest, source provenance, publish semantic fingerprint, plan output, and - static/remote lock applicability. -6. Update configuration, publish, artifact-policy, operations, and manifest - internals for the implemented expansion boundary. - -### Tests And Exit Criteria - -- Tests cover explicit destination patterns, omitted destination derivation, - disabled policy, missing top-level publish config, explicit/generated source - conflicts, duplicate destinations, token/path failures, required propagation, - static locks, and deterministic order. -- Publish stage tests use existing fake object storage to prove generated rules - enter the same staging/commit/lock path as explicit rules. -- No runtime source matcher or adapter accepts family wildcards. - -## Stage 17 — Read-Only `config validate` And `config show` - -**Status: Completed** - -### Goal - -Let operators validate and inspect the complete selected effective pipeline -without creating a session, workspace, run, or external adapter. - -### Required Work - -1. Add the top-level `config` command dispatcher and `validate`/`show` - subcommands. Reuse the shared explicit-presence profile flag and existing - pipeline/campaign selection parsers; reject session-only, stage-range, - force, and artifact-execution flags. -2. Add a shared read-only inspection resolver that uses the Stage 8 production - pipeline/campaign path but does not discover/load a session. If the selected - pipeline contains any party-driven family, require an explicit/unambiguous - campaign through the existing mechanisms and perform canonical party and - all family/publish expansion. Otherwise permit pipeline-only resolution. -3. `config validate` must run complete strict composition, selection, defaults, - path resolution, campaign/party loading when needed, expansion, concrete - artifact/publish validation, and effective digesting. On success, print a - concise root/profile/digest summary; on failure, retain source/field context. -4. `config show` must perform the same validation and emit one deterministic - normalized effective YAML document. Remove composition metadata, - resolution-only families, runtime provenance, and raw secret values; include - defaulted fields, expanded concrete artifacts, and generated publish rules. -5. Define a stable serializer over the logical effective representation rather - than marshaling runtime-only fields or relying on nondeterministic map - traversal. Use documented YAML scalar types and one trailing newline. -6. Both commands must be side-effect free: no workspace layout, manifest, - session lock, secret read, subprocess, network, object storage, or cleanup. -7. Register help/usage and update `docs/cli.md`, `docs/config.md`, and focused - command/config internals. `show` owns effective output, not source tracing or - profile comparison yet. - -### Tests And Exit Criteria - -- CLI tests cover help, unknown subcommands, default/explicit profiles, - pipeline-only success, campaign registry/file selection, family-without- - campaign errors, canonical party errors, and imported source diagnostics. -- `show` golden/semantic tests cover deterministic ordering and expanded - concrete values without overcoupling to incidental whitespace. Golden updates - require the repository's explicit-review convention. -- Side-effect tests prove no workspace/manifest is created and external fake - adapters/object stores are not invoked. -- `validate` and `show` resolve the same digest as plan/run for an equivalent - fully resolved pipeline and campaign. - -## Stage 18 — Read-Only `config sources` - -**Status: Completed** - -### Goal - -Expose enough deterministic source ownership to explain effective values and -generated configuration without leaking secrets or internal YAML machinery. - -### Required Work - -1. Add `config sources` on the Stage 17 resolver and flags. It must validate the - same complete effective configuration before reporting ownership. -2. Complete ownership propagation through defaults, overlay replacements, - canonical campaign/party resolution, derived players, concrete family - expansion, member dependencies/sources, and generated publish rules. -3. Emit sorted records with at least effective field path, source role, and - source path/identifier. Use repeat records when a generated value has both a - family declaration and party source. Mark centralized defaults explicitly - rather than assigning them to whichever source happened to be traversed. -4. Report the root, ordered imports, selected profile/selection source/overlay, - selected campaign, canonical or legacy party mode, party source, and - effective digest in a concise header. Legacy players provenance remains - visible only as a legacy source; canonical derived players point to party. -5. Normalize paths consistently, but never print raw file contents, - environment-resolved credential values, secrets directory contents, or - private adapter configuration contents. -6. Keep source reporting as a projection over configuration provenance. Do not - reparse files in the CLI or add annotations to runtime YAML structs solely - for formatting. -7. Update the CLI/config internal documentation and troubleshooting guidance - for diagnosing duplicate ownership and unexpected profile values. - -### Tests And Exit Criteria - -- Tests cover root/import ownership, profile replacement versus inherited - leaves, list ownership, defaults, campaign/party, derived players, family - variables, member sources/dependencies, generated publish rules, and stable - sorting. -- A source-aware conflict diagnostic and successful `config sources` output use - the same logical path convention. -- Secret sentinel values placed in environment/files never appear in output; - only configured indirect identifiers may appear where part of effective - configuration. -- The command remains side-effect free under the Stage 17 test harness. - -## Stage 19 — Semantic `config diff` - -**Status: Completed** - -### Goal - -Compare two fully resolved profiles by effective meaning rather than raw file -layout or formatting. - -### Required Work - -1. Add `config diff ` with exactly two non-empty - positional profile names plus `--config`, `--campaign`, and - `--campaign-file`. Reject a separate `--profile`, duplicate singleton flags, - extra/missing profiles, and identical unknown selections through shared - profile rules. -2. Resolve the left and right independently from the same already parsed root - source set and the same explicitly selected campaign/party. Do not let one - profile's mutable structs or expansion metadata contaminate the other. -3. Flatten the two normalized secret-free effective mappings to logical field - paths. Emit sorted `added`, `removed`, and `changed` records with deterministic - compact representations of the relevant value(s). Treat an atomic list - replacement as one changed path unless semantic child paths are independently - addressable by the normalized model. -4. Compare expanded concrete artifacts and publish rules, not family source - text. Profile-only source movement with equal effective values produces no - semantic difference; differing complete digests with no emitted semantic - difference is an internal error. -5. Return success with an explicit `no differences` result when equal. Use - normal command errors for invalid resolution; do not use a non-zero exit - merely because differences exist unless Narratio already has a documented - CLI convention for that behavior. -6. Reuse Stage 17's side-effect-free resolver and Stage 1's canonical traversal. - Do not invoke an external `diff` process or add a raw-text diff dependency. -7. Update `docs/cli.md`, `docs/operations.md`, and focused config command - internals with the semantic output and migration/review use case. - -### Tests And Exit Criteria - -- Tests cover scalar changes, additions/removals, explicit false, list - replacement, keyed artifact additions, disabled artifacts, party-expanded - model/variable changes, generated publish differences, equal effective - profiles, invalid profile/campaign inputs, and deterministic order. -- Reformatting or moving an equal value between eligible base imports produces - no diff after successful composition. -- Profile resolution does not mutate cached/shared base nodes and remains - deterministic when left/right argument order is reversed. -- The command performs no runtime state or external side effects. - -## Stage 20 — Maintained Split Configuration Bundle - -**Status: Completed** - -### Goal - -Provide a copyable, validated production/testing bundle and move maintained -example data to the new party source of truth. - -### Required Work - -1. Add or migrate one maintained example bundle with root `pipeline.yml`, - explicit `conf.d` imports for stable concerns, production/testing overlays, - `production` as the default, and no auto-loaded fragment assumptions. -2. Use testing/production model selections that are obvious placeholders and - secret-free. Demonstrate at least one testing-only or testing-disabled - artifact without relying on deletion syntax or profile inheritance. -3. Migrate the maintained campaign to `narratio.party.v1` with stable IDs, - aliases, repeated-safe player semantics, and multiclass data. Remove its - separate `players_file` and provide at least two character families (meta - analysis and item tracking), a same-member dependency/source, and one family - publish policy. -4. Extend `TestExamplesLoadAndValidate` or its focused helpers to discover each - maintained root, validate every declared profile intentionally, select the - required campaign for families, and assert that examples remain offline and - secret-free. Retain at least one monolithic/explicit-concrete compatibility - fixture if the maintained set no longer naturally provides it. -5. Update `examples/README.md` and only the minimum canonical configuration or - integration links needed to make the new bundle discoverable. Do not defer - field/command behavior documentation from the stages that implemented it. - -### Tests And Exit Criteria - -- The maintained example test loads monolithic compatibility and every profile - of the split canonical bundle with its campaign, expands expected concrete - members/rules, and rejects no unknown fields. -- Documentation checks pass; complete examples exist only under `examples/`; - and no secret/private infrastructure values are added. -- A new operator can validate, show, source-trace, and diff the example profiles - using the documented commands without creating runtime state. - -## Stage 21 — Canonical Documentation And Migration Convergence - -**Status: Completed** - -### Goal - -Make current user, operator, integration, policy, and internal documentation -describe the completed feature once, at the correct canonical owners. - -### Required Work - -1. Review every document changed in earlier stages against the implementation - and maintained Stage 20 bundle. Correct commands, flags, fields, defaults, - schemas, paths, output conventions, compatibility limits, and resume claims; - do not repeat complete examples outside `examples/`. -2. Complete `docs/config.md`, `docs/cli.md`, `docs/operations.md`, and integration - links with a concise migration path: split additively, define/select - profiles, convert party, remove players, add families, inspect sources/show, - compare profiles, then run. Link to the maintained bundle rather than copy - it. -3. Revise `docs/policy/architecture.md` only for the durable implemented - invariants: root-owned deterministic composition, campaign-owned canonical - party, pre-runtime family expansion, one loading path, and semantic - stage/artifact resume evidence. Do not move field syntax or implementation - inventory into policy. -4. Update `docs/internal/overview.md` and focused configuration, manifest, - prepare, analyze, artifact, adapter, and publish documents so ownership and - cross-links describe the final implementation without parallel contracts. -5. Review troubleshooting and README orientation for only stable summaries and - links. Remove stale players-file guidance from canonical workflows while - retaining clearly labeled legacy migration documentation at its canonical - owner. -6. Check that documentation distinguishes Narratio-observable semantic - fingerprints from private external-tool content changes that still require - force, and that it never presents the profile name/effective digest as a - blanket resume key. - -### Tests And Exit Criteria - -- Documentation checks and maintained-example validation pass. -- Each contract has one canonical owner under the documentation policy; other - documents contain only a short stable summary and link. -- Current-behavior documents contain no pending implementation language, and - roadmap documents do not masquerade as current reference material. -- No commands, flags, schema fragments, secret values, private infrastructure, - or complete example bundles are duplicated into the wrong owner. - -## Stage 22 — Assembled Workflow Regression And Final Validation - -**Status: Completed** - -### Goal - -Prove the completed feature reaches existing pipeline boundaries correctly and -remove redundant implementation/test scaffolding before release review. - -### Required Work - -1. Add a small number of assembled offline workflow tests using real config, - filesystem, manifest, planner, and reconciliation owners plus fakes only for - external adapters/storage. Do not reproduce every lower-level parser case. -2. Prove one representative split bundle and canonical campaign can: - - select the production default and explicit testing profile; - - prepare unchanged party plus derived players; - - pass both prepared references to Notarius; - - expand two families with member variables, dependencies, and member input; - - select a full family or one concrete member; - - reconcile an added, changed, and removed party character; and - - expand and execute family publication through concrete rules. -3. Prove profile switching reuses semantically unchanged transcript stages, - stales an Audita polish result when its model changes, and limits analyze - regeneration to artifacts whose effective fingerprint changed. Verify - session/run provenance reports the selected profile/digest throughout. -4. Exercise `config validate/show/sources/diff`, `session plan`, and a bounded - run against the same fixtures and assert consistent effective digest, - concrete artifact identity, and family origin. Inspection remains - side-effect free. -5. Audit the final implementation for alternate production config loaders, - unresolved family/member syntax, reflection/whole-config cache keys, - compatibility code outside its marked boundary, duplicated merge or - projection helpers, stale documentation, and redundant tests. Consolidate - only where behavior remains unchanged. -6. Re-run formatting and the complete validation set from - `docs/development.md`: - - `go test ./...` - - `go test -race ./...` - - `go vet ./...` - - `go build ./...` - - `go test ./internal/doccheck` - - `go test ./internal/config -run '^TestExamplesLoadAndValidate$'` -7. Record any platform limitation exactly as current development policy does; - do not claim native macOS/Windows runtime evidence from cross-compilation. - -### Tests And Exit Criteria - -- The assembled tests protect cross-package wiring and meaningful state - transitions while lower-level tests retain ownership of detailed parse and - validation matrices. -- All repository validation commands pass offline and without real - credentials, paid APIs, mutable services, or live Notarius/Scriptorium calls. -- The implemented code, examples, manifests, CLI output, current documentation, - feature roadmap target state, and this plan agree on the final behavior. -- The worktree contains no generated test artifacts or obsolete compatibility - fixtures outside the intentionally retained legacy boundary. +1. Create `docs/release.md` as the canonical current-behavior procedure. It + must include: + - stable SemVer selection for a post-`v1.0.0` project; + - the exact future release-note structure; + - candidate preparation and ordinary `main` push expectations; + - direct use of `scripts/check-release-candidate.sh`; + - direct use and complete safety contract of `scripts/release.sh`; + - lightweight-tag immutability and correction through a new patch version; + - the explicit statement that successful upstream tag push completes the + release command; and + - a clearly optional asynchronous inspection section, separated from the + release steps and stating that a human may inspect CI/Gitea later but an + automated releaser must not wait for it. + Do not embed a second implementation of script guards as a long shell block; + the scripts own volatile mechanics and the document owns the maintainer + workflow and policy. +2. Update `docs/development.md` with a task-guide row pointing release work to + `docs/release.md`. Keep its validation summary concise and link to the + release procedure/checker rather than duplicating the candidate command + list. +3. Update `docs/policy/documentation.md` to assign canonical ownership for the + maintainer release procedure and version-matched historical notes. Preserve + the rule that release notes link to, rather than replace, current contract + documentation. +4. Update `docs/releases/README.md` to explain forward-looking note structure, + retain its existing `v1.5.0` entry, and identify the Gitea release collection + as the binary/checksum source when asynchronous publication succeeds. Do + not backfill or rewrite historical notes. +5. Review other current documentation for claims that release completion waits + on CI. Remove or link any conflicting duplication. Do not add release + mechanics to operations, CLI, architecture, or integration documents unless + they already own a directly affected current contract. +6. Mark `docs/roadmap/release-procedure.md` implemented only after scripts, + tests, CI, and canonical documentation match its target. Keep implementation + status in roadmap documents; current-behavior claims belong in + `docs/release.md` and the policy/developer routing documents. +7. Perform a final behavior and safety audit: + - inspect all scripts for unsafe path deletion, unresolved variables, + accidental secret output, broad ref pushes, force options, and commands + after the successful tag push; + - run all release-script behavioral tests against temporary local remotes; + - run the asset builder directly with a synthetic stable version and a + fresh temporary output directory, then verify the six filenames and host + version output; + - validate candidate-checker success in its isolated fixture and every + important failure class without creating a real note or tag; + - confirm the release workflow reads the note from the tagged tree and + cannot publish before validation; and + - confirm no test or documentation instruction requires live CI, Gitea, + credentials, or paid/external adapters. + +### Exit Criteria + +- All local Markdown links and Woodpecker dependency checks pass. +- `sh -n scripts/release-lib.sh scripts/build-release-assets.sh scripts/check-release-candidate.sh scripts/release.sh` + succeeds. +- Release-script tests and the direct six-target asset build pass without + touching real refs or external services. +- The full repository validation set from `docs/development.md` passes: + - `go test ./...`; + - `go test -race ./...`; + - `go vet ./...`; + - `go build ./...`; + - `go test ./internal/doccheck`; and + - `go test ./internal/config -run '^TestExamplesLoadAndValidate$'`. +- `go mod tidy -diff`, tracked-file `gofmt` inspection, + `git diff --check`, and `git diff --cached --check` succeed. +- The repository contains no synthetic release note, local test tag, build + asset, credential, temporary repository, or other generated test residue. +- No real release is tagged or pushed during this implementation plan. ## Open Questions -None. The accepted feature roadmap and the settled decisions above are -sufficient to implement every stage without another product or architecture -choice. If implementation reveals a genuinely new decision that would change -the accepted target state, stop that stage and revise the roadmap/plan with the -user rather than choosing a broader behavior implicitly. +None. The feature roadmap and settled decisions above are sufficient to +implement the release procedure without additional product or policy choices. diff --git a/docs/roadmap/release-procedure.md b/docs/roadmap/release-procedure.md new file mode 100644 index 0000000..4cfce5a --- /dev/null +++ b/docs/roadmap/release-procedure.md @@ -0,0 +1,303 @@ +# Release Procedure Upgrade + +## Status + +Accepted target state. This document owns the intended release-process scope, +maintainer policy, and target end state until the work is implemented. + +## Goal + +Give Narratio one reproducible, guarded minor/patch release procedure that +validates a source candidate before publication, creates the existing binary +release inputs consistently, and makes the exact upstream tag push the clear +completion boundary. + +The procedure should borrow WeatherReporter's binary-publication model and +Notarius's centralized source-candidate validation and immutable-tag guards, +while retaining Narratio's own repository-wide checks and six supported build +targets. + +## Responsibility Boundary + +A Narratio release is complete when the selected tag has been successfully +pushed to its exact tag ref in the upstream Git repository. The release command +must then return successfully without polling Woodpecker, waiting for a +pipeline, querying a Gitea release, downloading assets, or otherwise making its +result depend on CI availability. + +The tag event may asynchronously cause Woodpecker to validate the tagged tree, +build binaries and checksums, and create a Gitea release. That work is outside +the synchronous release procedure. CI failure or delay does not change whether +the Git tag was successfully published, and release tooling must never wait +indefinitely for infrastructure outside the upstream Git push. + +An operator may inspect CI or published assets later. Such inspection is an +optional asynchronous follow-up, not a release completion condition and not a +step that an automated coding agent must perform after pushing the tag. + +## Release Model + +- Releases use unused stable semantic-version tags in the form + `vMAJOR.MINOR.PATCH`; prereleases are outside this feature. +- Narratio is past `v1.0.0`, so a minor release adds backward-compatible + functionality, a patch release contains backward-compatible fixes, and an + intentional breaking change to a documented public contract requires a new + major version. +- Release tags remain lightweight, matching Narratio's established tag + history, and are bound explicitly to a guarded commit on `main`. +- Every new release has a nonempty checked-in note at + `docs/releases/.md` in the tagged tree. +- Published tags are immutable. They are never moved, reused, overwritten, or + deleted, even when asynchronous CI fails to create a complete binary + release. +- The Gitea release, when asynchronous publication succeeds, remains the + canonical source for downloadable binaries and checksums. The Git tag and + checked-in note remain valid source-release history independently of CI. +- Existing historical tags and notes are not retroactively rewritten or + backfilled to satisfy the new procedure. + +## Canonical Documentation + +Add `docs/release.md` as the canonical maintainer procedure. Update +`docs/development.md` to route release preparation, tagging, and publication to +it, and update the documentation policy so it assigns ownership as follows: + +- `docs/release.md` owns version selection, candidate preparation, validation, + tag guards, tag publication, the responsibility boundary, and failure + recovery; +- `docs/releases/.md` owns the immutable historical summary for one + release; +- `docs/releases/README.md` owns the release-note index and points to the Gitea + release collection for downloadable assets; and +- current CLI, configuration, operation, integration, and internal contracts + remain in their existing canonical documents rather than being restated in + release notes. + +The maintainer procedure must be written as a complete safe workflow suitable +for either a human maintainer or a coding agent. Commands must be explicit, +non-interactive, and scoped to the selected version and tag ref. + +## Release Notes + +Require each future note to use this minimum structure: + +```markdown +# Narratio vMAJOR.MINOR.PATCH + +This release ... + +## Summary + +## Compatibility + +## Upgrade + +## Changes +``` + +The compatibility section must identify changes to documented CLI, +configuration, durable artifact, integration, or operating contracts. The +upgrade section must state required operator actions or explicitly say that no +special action is required. When the Notarius consumer contract changes, the +note should identify the supported/tested contract version and any required +Notarius upgrade. + +Release notes summarize a historical candidate and link to current canonical +documentation for exact behavior. They must not substitute for updating that +documentation, include credentials or private infrastructure details, or make +claims that have not been validated against the candidate. + +## Shared Release Scripts + +### Source-candidate checker + +Add the POSIX-shell checker: + +```text +scripts/check-release-candidate.sh vMAJOR.MINOR.PATCH +``` + +It must be deterministic, non-interactive, safe to run repeatedly, and usable +both by maintainers before tagging and by tag CI in a detached checkout. It +must not create or push Git tags, modify tracked source, contact CI, or require +live WhisperX, LLM, Notarius, Scriptorium, object-storage, or notification +services. + +The checker must fail clearly unless all of the following hold: + +- the argument is a stable semantic version; +- `go.mod` declares Narratio's expected module path; +- the matching release note exists, has the exact Narratio/version heading, + and contains the required sections; +- Go workspace use is disabled for candidate commands; +- tracked `go.work`/`go.work.sum`, vendoring, and `go.mod` replacement + directives are absent; +- uncached repository tests, race tests, vet, build, documentation checks, and + maintained-example validation succeed; +- `go mod tidy -diff` reports no module-file change; +- every tracked Go source file is `gofmt` clean; +- working-tree and cached whitespace checks succeed; and +- all supported release binaries cross-compile with the selected version + embedded, with the host-platform binary reporting exactly + `narratio ` through `narratio version`. + +The checker must preserve the distinction already documented by Narratio: +cross-compilation is compilation evidence, not native macOS or Windows runtime +evidence. + +### Asset builder + +Add `scripts/build-release-assets.sh`, used by both the candidate checker and +release CI. It must build exactly these CGO-disabled targets: + +- Linux `amd64` and `arm64`; +- macOS `amd64` and `arm64`; and +- Windows `amd64` and `arm64`. + +Asset names must remain deterministic and include the Narratio version, +operating system, architecture, and `.exe` suffix for Windows. Build output +must use a caller-supplied, narrowly validated staging directory and must not +perform broad or ambiguous deletion. Official assets use `-trimpath` and the +established release version linker override. + +### Tag-publication command + +Add the narrowly scoped release command: + +```text +scripts/release.sh vMAJOR.MINOR.PATCH +``` + +The command owns local validation, publication guards, lightweight tag +creation, local tag verification, and pushing only the selected tag ref. It +must not commit candidate changes or push `main`; the candidate must already be +committed and present on upstream `main`. + +Before creating a tag, the command must: + +- run the shared candidate checker; +- require the current branch to be `main`; +- require a clean worktree and index; +- fetch upstream `main` and tags; +- record the exact candidate commit and require local `HEAD` to equal + `origin/main`; +- require the version-matched release note from that commit; and +- reject an existing local or upstream tag of the selected name. + +It must create an explicitly lightweight tag against the recorded commit, +verify that the local tag resolves directly to that commit, and push only: + +```text +refs/tags/:refs/tags/ +``` + +It must never use `git push --tags`. If the tag push succeeds, the command must +report the version and commit and exit successfully immediately. In +particular, it must not invoke a CI client, poll a build status, wait for a +Gitea release, or verify release assets. + +If a failure occurs before the upstream tag is published, no release has +completed. An unpublished local tag may be removed only after the maintainer +has inspected the failure. The script should not silently delete it. A +successful upstream push is final even if later asynchronous work fails. + +## Woodpecker Release Workflow + +Retain tag-triggered asynchronous release automation, but make it consume the +same checked-in contracts as local preparation: + +- validate the tag syntax and matching release note before asset work; +- call the shared candidate checker rather than maintaining an independent + drifting validation list; +- use the shared asset builder for the six official binaries; +- pin the Go builder image to `golang:1.25.5`, an explicit patch version + compatible with the current `go.mod` language version; +- pin the release plugin to the reviewed explicit version + `woodpeckerci/plugin-release:0.3.1` rather than a floating image; +- set the Gitea release title explicitly to `Narratio `; +- use `docs/releases/.md` from the tagged tree as the release body; +- generate a flattened `SHA256SUMS` file for all official binaries; +- keep prerelease publication disabled and overwrite disabled; and +- keep validation, asset building, and publication dependency-ordered so a + failed CI validation cannot publish assets. + +The workflow must not require a callback to, acknowledgement from, or status +update in the release command. The two sides have intentionally separate +responsibilities: + +```text +local release command asynchronous infrastructure +--------------------- --------------------------- +validate candidate +guard main and exact commit +create lightweight tag +push exact upstream tag ────────► validate tagged tree +return success immediately build assets and checksums + create Gitea release +``` + +## Failure And Correction Policy + +Failures before tag publication are corrected on `main`, followed by complete +candidate revalidation and fresh guards. + +After a tag has been pushed, neither CI failure nor a defect permits changing +that tag. Correct the cause on `main`, choose a new patch version, write a new +release note, and repeat the complete release procedure. Do not move or delete +the published tag, manually overwrite generated assets, or make the release +command wait for repaired infrastructure. + +A maintainer may later inspect the remote tag, Gitea note, assets, checksums, +and host binary. Documentation may provide these as optional asynchronous +inspection commands, clearly outside the release responsibility boundary. No +automated release instruction may require them after a successful tag push. + +## Security And Operational Constraints + +- Local scripts never read or print the Gitea release token; the token remains + a Woodpecker-owned secret supplied only to the release plugin. +- Candidate validation remains offline with respect to paid APIs, mutable + application services, and private user data. +- Temporary build output is confined to a validated temporary or staging + directory and cleaned safely. +- Scripts avoid environment dumps and never record credentials in release + notes, logs, repository files, or command output. +- The release command does not edit tracked files, create commits, or push + branches. Apart from fetching refs for its guards, its only release-state + mutations are creating the selected local tag and pushing that exact tag. + +## Target End State + +When this feature is complete: + +1. A maintainer or coding agent prepares and commits a standardized release + note plus any final canonical-documentation updates. +2. The candidate is pushed to `main` through the ordinary development + workflow. +3. One shared checker proves the exact tagged source is clean, self-contained, + formatted, tested, race-tested, statically checked, documented, module + tidy, cross-buildable, and version-correct. +4. One guarded release command refuses ambiguous or unpublished candidates, + creates the established lightweight tag, and pushes only that tag. +5. The command completes successfully at the upstream tag push and performs no + CI or Gitea status wait. +6. Woodpecker independently reuses the shared checks and asset builder and may + publish six versioned binaries, release notes, and SHA-256 checksums. +7. Published tags remain immutable, and failures after tag publication are + corrected only through a new version. +8. The release procedure is documented in one canonical location, linked from + contributor guidance, enforced by scripts and CI, and clear about the + boundary between guaranteed Git publication and best-effort asynchronous + binary publication. + +## Non-Goals + +This feature does not: + +- repair, replace, monitor, or improve Woodpecker reliability; +- wait for or synchronously verify CI or Gitea release completion; +- introduce prereleases, signed tags, containers, package-manager publication, + installers, auto-updaters, or native macOS/Windows test runners; +- publish `main` automatically or create release-note content automatically; +- run live external adapters or paid model calls during release validation; or +- rewrite historical tags or manufacture retrospective release notes. diff --git a/internal/app/analyze_artifacts_commands_test.go b/internal/app/analyze_artifacts_commands_test.go index 64eb61e..ecb71de 100644 --- a/internal/app/analyze_artifacts_commands_test.go +++ b/internal/app/analyze_artifacts_commands_test.go @@ -125,7 +125,17 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) { manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} - seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) + if err := RunStage( + context.Background(), + []string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, + &bytes.Buffer{}, + ); err != nil { + t.Fatalf("seed prepare stage: %v", err) + } + seed, err := store.Load(context.Background(), manifestPath) + if err != nil { + t.Fatalf("load prepared manifest: %v", err) + } for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} { seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) } @@ -136,7 +146,7 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) { } var out bytes.Buffer - err := Run( + err = Run( context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, &out, diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 4127857..f719b0a 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -549,6 +549,17 @@ inputs: return pipelinePath, campaignPath, sessionPath } +func materializePrepareResumeFixture(t *testing.T, pipelinePath, campaignPath, sessionPath string) { + t.Helper() + if err := RunStage( + context.Background(), + []string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, + &bytes.Buffer{}, + ); err != nil { + t.Fatalf("materialize prepare resume fixture: %v", err) + } +} + func writeAppTestCampaignConfig(t *testing.T, dir string) string { t.Helper() campaignPath := filepath.Join(dir, "campaign.yml") diff --git a/internal/app/plan_test.go b/internal/app/plan_test.go index 7bf150d..cdfe576 100644 --- a/internal/app/plan_test.go +++ b/internal/app/plan_test.go @@ -61,8 +61,11 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) { manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} - m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) - m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) + materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath) + m, err := store.Load(context.Background(), manifestPath) + if err != nil { + t.Fatalf("load prepared manifest: %v", err) + } m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil) seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe") if err := store.Save(context.Background(), manifestPath, m); err != nil { diff --git a/internal/app/run_stage_test.go b/internal/app/run_stage_test.go index eaa2469..90f45ca 100644 --- a/internal/app/run_stage_test.go +++ b/internal/app/run_stage_test.go @@ -19,8 +19,11 @@ func TestRunContinuesAfterCompletedStages(t *testing.T) { manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} - m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) - m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) + materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath) + m, err := store.Load(context.Background(), manifestPath) + if err != nil { + t.Fatalf("load prepared manifest: %v", err) + } m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil) seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe") if err := store.Save(context.Background(), manifestPath, m); err != nil { @@ -28,12 +31,9 @@ func TestRunContinuesAfterCompletedStages(t *testing.T) { } workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "raw", "alice.json"), `{"segments":[]}`) - mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") - mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "autocorrect.yml"), "[]\n") - mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") var out bytes.Buffer - err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) + err = Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("Run() error = %v", err) } @@ -56,7 +56,11 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) { manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} - m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) + materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath) + m, err := store.Load(context.Background(), manifestPath) + if err != nil { + t.Fatalf("load prepared manifest: %v", err) + } for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} { m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) } @@ -67,7 +71,7 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) { } var out bytes.Buffer - err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) + err = Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("Run() error = %v", err) } @@ -204,10 +208,13 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`) - mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") store := &manifest.LocalStore{} - seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) + materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath) + seed, err := store.Load(context.Background(), manifestPath) + if err != nil { + t.Fatalf("load prepared manifest: %v", err) + } for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} { seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) } @@ -217,7 +224,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T) } var out bytes.Buffer - err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out) + err = RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out) if err != nil { t.Fatalf("RunStage(force) error = %v", err) } diff --git a/internal/config/effective_digest.go b/internal/config/effective_digest.go index d8a0af8..cceef3b 100644 --- a/internal/config/effective_digest.go +++ b/internal/config/effective_digest.go @@ -4,6 +4,8 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "path/filepath" + "strings" "gopkg.in/yaml.v3" ) @@ -77,7 +79,14 @@ func recomputePipelineEffectiveDigest(cfg *PipelineConfig) error { if cfg == nil || cfg.resolution == nil { return fmt.Errorf("pipeline resolution metadata is required") } - data, err := yaml.Marshal(cfg) + digestConfig := *cfg + if cfg.Notarius != nil && cfg.resolution.logicalNotariusCaptured { + notarius := *cfg.Notarius + notarius.ConfigPath = cfg.resolution.logicalNotariusConfig + notarius.WorkingDirectory = cfg.resolution.logicalNotariusWorking + digestConfig.Notarius = ¬arius + } + data, err := yaml.Marshal(&digestConfig) if err != nil { return fmt.Errorf("serialize normalized effective pipeline: %w", err) } @@ -93,3 +102,28 @@ func recomputePipelineEffectiveDigest(cfg *PipelineConfig) error { cfg.resolution.effectiveDigest = hex.EncodeToString(digest[:]) return nil } + +// captureLogicalNotariusPaths retains normalized user-facing path semantics +// before runtime resolution makes relative paths depend on the checkout or +// installation directory. Runtime paths remain absolute; provenance does not. +func captureLogicalNotariusPaths(cfg *PipelineConfig) { + if cfg == nil || cfg.resolution == nil || cfg.Notarius == nil { + return + } + configPath := normalizeLogicalFilesystemPath(cfg.Notarius.ConfigPath) + workingDirectory := normalizeLogicalFilesystemPath(cfg.Notarius.WorkingDirectory) + if cfg.Notarius.Enabled && workingDirectory == "" && configPath != "" { + workingDirectory = normalizeLogicalFilesystemPath(filepath.Dir(configPath)) + } + cfg.resolution.logicalNotariusConfig = configPath + cfg.resolution.logicalNotariusWorking = workingDirectory + cfg.resolution.logicalNotariusCaptured = true +} + +func normalizeLogicalFilesystemPath(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return filepath.ToSlash(filepath.Clean(value)) +} diff --git a/internal/config/load.go b/internal/config/load.go index 66176be..1c77e98 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -95,6 +95,7 @@ func LoadPipelineProfilePair(path, leftProfile, rightProfile string) (*PipelineC func finalizeLoadedPipeline(path string, cfg *PipelineConfig) (*PipelineConfig, error) { cfg.resolution.publishDeclared = cfg.Publish != nil applyPipelineDefaults(cfg) + captureLogicalNotariusPaths(cfg) if err := resolveNotariusPaths(cfg, path); err != nil { return nil, fmt.Errorf("load pipeline config: %w", err) } diff --git a/internal/config/notarius_test.go b/internal/config/notarius_test.go index 2ebffa3..8f9fe53 100644 --- a/internal/config/notarius_test.go +++ b/internal/config/notarius_test.go @@ -100,6 +100,36 @@ notarius: } } +func TestNotariusRelativePathsDoNotMakeEffectiveDigestLocationDependent(t *testing.T) { + pipelineYAML := testPipelineBaseYAML + ` +notarius: + enabled: true + config_path: notarius/config.yml + pipeline_id: dnd-session +` + paths := make([]string, 2) + configs := make([]*PipelineConfig, 2) + for index := range paths { + dir := t.TempDir() + paths[index] = filepath.Join(dir, "pipeline.yml") + if err := os.WriteFile(paths[index], []byte(pipelineYAML), 0o644); err != nil { + t.Fatalf("write pipeline %d: %v", index, err) + } + loaded, err := LoadPipeline(paths[index]) + if err != nil { + t.Fatalf("LoadPipeline(%d) error = %v", index, err) + } + configs[index] = loaded + } + if configs[0].Notarius.ConfigPath == configs[1].Notarius.ConfigPath || + configs[0].Notarius.WorkingDirectory == configs[1].Notarius.WorkingDirectory { + t.Fatalf("runtime Notarius paths should remain location-specific: %#v / %#v", configs[0].Notarius, configs[1].Notarius) + } + if first, second := EffectivePipelineDigest(configs[0]), EffectivePipelineDigest(configs[1]); first == "" || first != second { + t.Fatalf("relocated logical configuration digests = %q / %q, want equal non-empty values", first, second) + } +} + func TestNotariusStrictYAML(t *testing.T) { tests := []struct { name string diff --git a/internal/config/pipeline_composition.go b/internal/config/pipeline_composition.go index c3e381a..c0c001f 100644 --- a/internal/config/pipeline_composition.go +++ b/internal/config/pipeline_composition.go @@ -18,6 +18,9 @@ type pipelineResolutionMetadata struct { sources []string selectedProfile *pipelineProfileSelection effectiveDigest string + logicalNotariusConfig string + logicalNotariusWorking string + logicalNotariusCaptured bool ownership []pipelineFieldOwnership artifactFamilies map[string]ScriptoriumArtifactFamilyConfig artifactFamiliesExpanded bool diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 81e2ef7..00d9866 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -406,6 +406,10 @@ func currentAnalyzeArtifactRecord( Logs: dedupeAndSortPaths(result.Logs), GeneratedConfigs: dedupeAndSortPaths(result.GeneratedConfigs), } + if origin, ok := analyzeArtifactOrigin(execution, plan.Name); ok { + record.Family = origin.Family + record.CharacterID = origin.CharacterID + } if err := manifest.ValidateAnalyzeArtifactCollection( manifest.AnalyzeStateContractVersion, map[string]manifest.AnalyzeArtifactRecord{plan.Name: record}, diff --git a/internal/stage/analyze_family_origin_test.go b/internal/stage/analyze_family_origin_test.go new file mode 100644 index 0000000..b4c3ea4 --- /dev/null +++ b/internal/stage/analyze_family_origin_test.go @@ -0,0 +1,56 @@ +package stage + +import ( + "path/filepath" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func TestCurrentAnalyzeArtifactRecordPreservesResolvedFamilyOrigin(t *testing.T) { + examples := filepath.Join("..", "..", "examples") + cfg, err := config.LoadWithSessionOptions( + filepath.Join(examples, "production-testing", "pipeline.yml"), + filepath.Join(examples, "campaigns", "sample-campaign", "campaign.yml"), + filepath.Join(examples, "session.local-audio.yml"), + config.SessionLoadOptions{}, + ) + if err != nil { + t.Fatalf("LoadWithSessionOptions() error = %v", err) + } + const key = "character_meta_arannis" + artifactConfig, ok := cfg.Pipeline.Scriptorium.Artifacts[key] + if !ok { + t.Fatalf("expanded artifact %q is unavailable", key) + } + execution := analyzeExecutionContext{ + Env: &Env{Config: cfg}, + Manifest: &manifest.Manifest{RunID: "run-family-origin"}, + } + result := &analyzeArtifactExecutionResult{ + Output: artifacts.Ref{ + Checksum: strings.Repeat("b", 64), + Contract: &artifactmodel.ContractMetadata{ + MediaType: "text/markdown", SchemaID: "narratio.character_meta", SchemaVersion: "1", + }, + }, + OutputSize: 12, + Scriptorium: manifest.AnalyzeArtifactProvenance{PromptID: artifactConfig.PromptID}, + } + record, err := currentAnalyzeArtifactRecord( + execution, + analyzeArtifactExecutionPlan{Name: key, Cfg: artifactConfig}, + strings.Repeat("a", 64), + result, + ) + if err != nil { + t.Fatalf("currentAnalyzeArtifactRecord() error = %v", err) + } + if record.Family != "character_meta" || record.CharacterID != "arannis" { + t.Fatalf("family origin = (%q, %q), want (character_meta, arannis)", record.Family, record.CharacterID) + } +} diff --git a/internal/stage/prepare.go b/internal/stage/prepare.go index 0ac7e41..ccd7258 100644 --- a/internal/stage/prepare.go +++ b/internal/stage/prepare.go @@ -437,29 +437,9 @@ func materializeS3AudioInputs(ctx context.Context, env *Env, m *manifest.Manifes return s3AudioMaterializationStats{}, fmt.Errorf("run id is required for s3 audio input") } - sessionPrefix := artifacts.S3SessionPrefix(env.Config.Pipeline.Storage.S3.RootPrefix, campaign, sessionID) - audioPrefix := artifacts.S3AudioPrefix(sessionPrefix, env.Config.Session.Inputs.AudioS3.Prefix) - objects, err := env.ObjectStore.List(ctx, audioPrefix) + audioObjects, err := listS3AudioObjects(ctx, env, campaign, sessionID) if err != nil { - return s3AudioMaterializationStats{}, fmt.Errorf("list s3 audio objects under %q: %w", audioPrefix, err) - } - - audioObjects := make([]storage.ObjectInfo, 0, len(objects)) - for _, obj := range objects { - key := strings.TrimSpace(obj.Key) - if key == "" || strings.HasSuffix(key, "/") { - continue - } - if !isFlac(key) { - continue - } - audioObjects = append(audioObjects, obj) - } - sort.Slice(audioObjects, func(i, j int) bool { - return audioObjects[i].Key < audioObjects[j].Key - }) - if len(audioObjects) == 0 { - return s3AudioMaterializationStats{}, fmt.Errorf("no .flac files found under s3 audio prefix %q", audioPrefix) + return s3AudioMaterializationStats{}, err } spoolAudioDir := strings.TrimSpace(m.LocalSpoolDir) @@ -475,16 +455,10 @@ func materializeS3AudioInputs(ctx context.Context, env *Env, m *manifest.Manifes return s3AudioMaterializationStats{}, fmt.Errorf("create work audio directory %q: %w", workAudioDir, err) } - seenBase := map[string]string{} stats := s3AudioMaterializationStats{} cacheEnabled := env.Config.Pipeline.Cache.S3Audio == nil || *env.Config.Pipeline.Cache.S3Audio for _, obj := range audioObjects { base := path.Base(obj.Key) - if prev, exists := seenBase[base]; exists && prev != obj.Key { - return s3AudioMaterializationStats{}, fmt.Errorf("duplicate s3 audio basename %q from %q and %q", base, prev, obj.Key) - } - seenBase[base] = obj.Key - spoolPath := filepath.Join(spoolAudioDir, base) workPath := filepath.Join(workAudioDir, base) result, err := audio.MaterializeS3Audio(ctx, audio.S3MaterializeRequest{ @@ -525,6 +499,42 @@ func materializeS3AudioInputs(ctx context.Context, env *Env, m *manifest.Manifes return stats, nil } +func listS3AudioObjects(ctx context.Context, env *Env, campaign, sessionID string) ([]storage.ObjectInfo, error) { + if env == nil || env.ObjectStore == nil || env.Config == nil || env.Config.Pipeline == nil || + env.Config.Session == nil || env.Config.Pipeline.Storage.S3 == nil || env.Config.Session.Inputs.AudioS3 == nil { + return nil, fmt.Errorf("s3 audio input requires object store and resolved storage configuration") + } + sessionPrefix := artifacts.S3SessionPrefix(env.Config.Pipeline.Storage.S3.RootPrefix, campaign, sessionID) + audioPrefix := artifacts.S3AudioPrefix(sessionPrefix, env.Config.Session.Inputs.AudioS3.Prefix) + objects, err := env.ObjectStore.List(ctx, audioPrefix) + if err != nil { + return nil, fmt.Errorf("list s3 audio objects under %q: %w", audioPrefix, err) + } + + audioObjects := make([]storage.ObjectInfo, 0, len(objects)) + seenBase := map[string]string{} + for _, obj := range objects { + key := strings.TrimSpace(obj.Key) + if key == "" || strings.HasSuffix(key, "/") || !isFlac(key) { + continue + } + base := path.Base(key) + if previous, exists := seenBase[base]; exists && previous != key { + return nil, fmt.Errorf("duplicate s3 audio basename %q from %q and %q", base, previous, key) + } + seenBase[base] = key + obj.Key = key + audioObjects = append(audioObjects, obj) + } + sort.Slice(audioObjects, func(i, j int) bool { + return audioObjects[i].Key < audioObjects[j].Key + }) + if len(audioObjects) == 0 { + return nil, fmt.Errorf("no .flac files found under s3 audio prefix %q", audioPrefix) + } + return audioObjects, nil +} + func countAudioInputs(inputs []manifest.InputRecord) int { count := 0 for _, in := range inputs { diff --git a/internal/stage/prepare_resume.go b/internal/stage/prepare_resume.go new file mode 100644 index 0000000..405151d --- /dev/null +++ b/internal/stage/prepare_resume.go @@ -0,0 +1,278 @@ +package stage + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "path" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +const prepareResumeChangedReason = "prepared inputs or their current sources changed; rerun prepare" + +type prepareExpectedInput struct { + kind string + destinationName string + checksum string + s3Bucket string + s3Key string + s3Size int64 + s3ETag string +} + +func (prepareStage) ValidateResume(ctx context.Context, env *Env, m *manifest.Manifest) (ResumeValidation, error) { + if err := ctx.Err(); err != nil { + return ResumeValidation{}, err + } + if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil { + return ResumeValidation{}, fmt.Errorf("prepare resume: resolved stage environment config is required") + } + if env.ArtifactStore == nil { + return ResumeValidation{}, fmt.Errorf("prepare resume: artifact store is required") + } + if m == nil || len(m.Inputs) == 0 { + return NonResumable(prepareResumeChangedReason), nil + } + + current, err := currentPrepareSourceInputs(ctx, env, m) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ResumeValidation{}, ctxErr + } + return NonResumable(prepareResumeChangedReason), nil + } + if !preparedInputRecordsCurrent(env, m.Inputs) || !prepareSourceInputsMatch(current, m.Inputs) { + return NonResumable(prepareResumeChangedReason), nil + } + return Resumable(), nil +} + +func preparedInputRecordsCurrent(env *Env, records []manifest.InputRecord) bool { + for _, record := range records { + path := strings.TrimSpace(record.Path) + expected := strings.TrimSpace(record.Checksum) + if path == "" || expected == "" { + return false + } + actual, err := env.ArtifactStore.Checksum(path) + if err != nil || !strings.EqualFold(actual, expected) { + return false + } + } + return true +} + +func currentPrepareSourceInputs(ctx context.Context, env *Env, m *manifest.Manifest) ([]prepareExpectedInput, error) { + cfg := env.Config + expected := make([]prepareExpectedInput, 0, 8) + appendFile := func(kind string, input config.ResolvedInputFile, fallback string) error { + resolved := stableInputSource(input, fallback, cfg.SessionPath) + sourcePath, err := resolveConfigRelativePath(resolved) + if err != nil { + return err + } + checksum, err := env.ArtifactStore.Checksum(sourcePath) + if err != nil { + return err + } + expected = append(expected, prepareExpectedInput{kind: kind, checksum: checksum}) + return nil + } + + if err := appendFile("speakers", cfg.StableInputs.SpeakersFile, cfg.Session.Inputs.SpeakersFile); err != nil { + return nil, err + } + if err := appendFile("autocorrect", cfg.StableInputs.AutocorrectFile, cfg.Session.Inputs.AutocorrectFile); err != nil { + return nil, err + } + glossary, ok := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputGlossary) + if !ok { + return nil, fmt.Errorf("glossary prepared-input descriptor is unavailable") + } + if err := appendFile(glossary.ManifestKind, cfg.StableInputs.GlossaryFile, cfg.Session.Inputs.GlossaryFile); err != nil { + return nil, err + } + spellCatalog, ok := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputSpellCatalog) + if !ok { + return nil, fmt.Errorf("spell catalog prepared-input descriptor is unavailable") + } + spellInput := stableInputSource(cfg.StableInputs.SpellCatalogFile, cfg.Session.Inputs.SpellCatalogFile, cfg.SessionPath) + if strings.TrimSpace(spellInput.Path) != "" { + if err := appendFile(spellCatalog.ManifestKind, cfg.StableInputs.SpellCatalogFile, cfg.Session.Inputs.SpellCatalogFile); err != nil { + return nil, err + } + } + + partyInputs, err := currentPreparePartySourceInputs(env) + if err != nil { + return nil, err + } + expected = append(expected, partyInputs...) + + audioInputs, err := currentPrepareAudioSourceInputs(ctx, env, m) + if err != nil { + return nil, err + } + expected = append(expected, audioInputs...) + return expected, nil +} + +func currentPreparePartySourceInputs(env *Env) ([]prepareExpectedInput, error) { + party, ok := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputParty) + if !ok { + return nil, fmt.Errorf("party prepared-input descriptor is unavailable") + } + players, ok := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputPlayers) + if !ok { + return nil, fmt.Errorf("players prepared-input descriptor is unavailable") + } + if env.Config.Party.Mode == config.PartyModeCanonical { + if env.Config.Party.Canonical == nil || len(env.Config.Party.Canonical.Raw) == 0 { + return nil, fmt.Errorf("canonical party data is unavailable") + } + playersBytes, err := env.Config.Party.Canonical.PlayersYAML() + if err != nil { + return nil, err + } + return []prepareExpectedInput{ + {kind: party.ManifestKind, checksum: checksumPrepareBytes(env.Config.Party.Canonical.Raw)}, + {kind: players.ManifestKind, checksum: checksumPrepareBytes(playersBytes)}, + }, nil + } + + cfg := env.Config + inputs := []struct { + kind string + resolved config.ResolvedInputFile + fallback string + }{ + {kind: party.ManifestKind, resolved: cfg.StableInputs.PartyFile, fallback: cfg.Session.Inputs.PartyFile}, + {kind: players.ManifestKind, resolved: cfg.StableInputs.PlayersFile, fallback: cfg.Session.Inputs.PlayersFile}, + } + expected := make([]prepareExpectedInput, 0, len(inputs)) + for _, input := range inputs { + resolved := stableInputSource(input.resolved, input.fallback, cfg.SessionPath) + path, err := resolveConfigRelativePath(resolved) + if err != nil { + return nil, err + } + checksum, err := env.ArtifactStore.Checksum(path) + if err != nil { + return nil, err + } + expected = append(expected, prepareExpectedInput{kind: input.kind, checksum: checksum}) + } + return expected, nil +} + +func currentPrepareAudioSourceInputs(ctx context.Context, env *Env, m *manifest.Manifest) ([]prepareExpectedInput, error) { + sessionID := strings.TrimSpace(m.SessionID) + if sessionID == "" { + sessionID = strings.TrimSpace(env.Config.Session.SessionID) + } + local, useS3, err := resolveAudioInputs(filepath.Dir(env.Config.SessionPath), env.Config.Session.Inputs) + if err != nil { + return nil, err + } + if useS3 { + campaign := strings.TrimSpace(env.Config.Session.Campaign) + objects, err := listS3AudioObjects(ctx, env, campaign, sessionID) + if err != nil { + return nil, err + } + bucket := strings.TrimSpace(env.Config.Pipeline.Storage.S3.Bucket) + expected := make([]prepareExpectedInput, 0, len(objects)) + for _, object := range objects { + if strings.TrimSpace(object.ETag) == "" { + return nil, fmt.Errorf("s3 audio object %q has no stable entity tag", object.Key) + } + expected = append(expected, prepareExpectedInput{ + kind: "audio", destinationName: path.Base(object.Key), + s3Bucket: bucket, s3Key: object.Key, s3Size: object.Size, s3ETag: strings.TrimSpace(object.ETag), + }) + } + return expected, nil + } + + destinations := localAudioDestinations(local) + expected := make([]prepareExpectedInput, 0, len(local)) + for _, source := range local { + checksum, err := env.ArtifactStore.Checksum(source) + if err != nil { + return nil, err + } + expected = append(expected, prepareExpectedInput{ + kind: "audio", destinationName: destinations[source], checksum: checksum, + }) + } + return expected, nil +} + +func prepareSourceInputsMatch(expected []prepareExpectedInput, records []manifest.InputRecord) bool { + if len(expected) == 0 { + return false + } + managedKinds := map[string]struct{}{ + "audio": {}, "speakers": {}, "autocorrect": {}, "glossary": {}, + "players": {}, "party": {}, "spell_catalog": {}, + } + expectedCounts := make(map[string]int, len(managedKinds)) + recordCounts := make(map[string]int, len(managedKinds)) + for _, input := range expected { + expectedCounts[input.kind]++ + } + for _, record := range records { + if _, managed := managedKinds[record.Kind]; managed { + recordCounts[record.Kind]++ + } + } + for kind := range managedKinds { + if expectedCounts[kind] != recordCounts[kind] { + return false + } + } + matched := make(map[int]struct{}, len(expected)) + for _, input := range expected { + found := -1 + for index, record := range records { + if _, used := matched[index]; used || !prepareInputRecordMatches(input, record) { + continue + } + if found != -1 { + return false + } + found = index + } + if found == -1 { + return false + } + matched[found] = struct{}{} + } + return true +} + +func prepareInputRecordMatches(expected prepareExpectedInput, record manifest.InputRecord) bool { + if record.Kind != expected.kind { + return false + } + if expected.destinationName != "" && filepath.Base(record.Path) != expected.destinationName { + return false + } + if expected.s3Key != "" { + return record.Source == "s3" && strings.TrimSpace(record.S3Bucket) == expected.s3Bucket && + strings.TrimSpace(record.S3Key) == expected.s3Key && record.S3Size == expected.s3Size && + strings.TrimSpace(record.S3ETag) == expected.s3ETag + } + return strings.EqualFold(strings.TrimSpace(record.Checksum), expected.checksum) +} + +func checksumPrepareBytes(data []byte) string { + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} diff --git a/internal/stage/prepare_resume_test.go b/internal/stage/prepare_resume_test.go new file mode 100644 index 0000000..30f3446 --- /dev/null +++ b/internal/stage/prepare_resume_test.go @@ -0,0 +1,160 @@ +package stage + +import ( + "context" + "os" + "path/filepath" + "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func TestPrepareResumeValidatesCurrentLocalSourcesAndPreparedCopies(t *testing.T) { + tests := []struct { + name string + mutate func(*testing.T, *Env, string) + }{ + { + name: "stable input bytes changed", + mutate: func(t *testing.T, env *Env, _ string) { + writeFile(t, filepath.Join(filepath.Dir(env.Config.CampaignPath), "party.yml"), "changed: true\n") + }, + }, + { + name: "audio bytes changed", + mutate: func(t *testing.T, _ *Env, audioPath string) { + writeFile(t, audioPath, "changed audio") + }, + }, + { + name: "audio directory membership changed", + mutate: func(t *testing.T, _ *Env, audioPath string) { + writeFile(t, filepath.Join(filepath.Dir(audioPath), "second.flac"), "second") + }, + }, + { + name: "prepared copy changed", + mutate: func(t *testing.T, env *Env, _ string) { + paths := env.ArtifactStore.SessionPathsFor(env.Config.Session.Campaign, env.Config.Session.SessionID) + writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "tampered: true\n") + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + env, m, audioPath := prepareResumeLocalFixture(t) + assertPrepareResume(t, env, m, true) + test.mutate(t, env, audioPath) + assertPrepareResume(t, env, m, false) + }) + } +} + +func TestPrepareResumeAcceptsRelocatedEquivalentLocalSource(t *testing.T) { + env, m, _ := prepareResumeLocalFixture(t) + cfgDir := filepath.Dir(env.Config.CampaignPath) + relocated := filepath.Join(cfgDir, "relocated-party.yml") + writeFile(t, relocated, "[]\n") + env.Config.StableInputs.PartyFile.Path = "./relocated-party.yml" + assertPrepareResume(t, env, m, true) +} + +func TestPrepareResumeDetectsCanonicalPartyChange(t *testing.T) { + env, m := setupPrepareEnv(t) + audioPath := filepath.Join(filepath.Dir(env.Config.SessionPath), "audio", "one.flac") + writeFile(t, audioPath, "audio") + first := []byte(`schema_version: narratio.party.v1 +characters: + arannis: + player: {name: Eric} + character: + name: Arannis + classes: [{name: wizard, level: 8}] +`) + setCanonicalPrepareParty(t, env, first) + if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("prepare.Run() error = %v", err) + } + assertPrepareResume(t, env, m, true) + + second := []byte(`schema_version: narratio.party.v1 +characters: + arannis: + player: {name: Eric} + character: + name: Arannis + alias: [The Red] + classes: [{name: wizard, level: 9}] +`) + setCanonicalPrepareParty(t, env, second) + assertPrepareResume(t, env, m, false) +} + +func TestPrepareResumeValidatesCurrentS3ObjectIdentity(t *testing.T) { + env, m := setupPrepareEnv(t) + env.Config.Session.Campaign = "forsaken" + env.Config.Session.Inputs.AudioDir = "" + env.Config.Session.Inputs.AudioS3 = &config.SessionAudioS3Input{Prefix: "audio/"} + env.Config.Pipeline.Spool = config.SpoolConfig{Root: filepath.Join(t.TempDir(), "spool")} + env.Config.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "archive", RootPrefix: "dnd"} + m.RunID = "20260515T031522Z-a1b2c3d4" + m.LocalWorkDir = artifacts.SessionRunRootForCampaign(env.Config.Pipeline.Workspace.Root, "forsaken", m.SessionID, m.RunID) + m.LocalSpoolDir = artifacts.SessionSpoolAudioDir(env.Config.Pipeline.Spool.Root, "forsaken", m.SessionID, m.RunID) + key := "dnd/campaigns/forsaken/sessions/2026-05-03/audio/alice.flac" + fake := &storage.FakeBackend{} + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("alice")}) + env.ObjectStore = fake + + if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("prepare.Run() error = %v", err) + } + assertPrepareResume(t, env, m, true) + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("changed")}) + assertPrepareResume(t, env, m, false) +} + +func prepareResumeLocalFixture(t *testing.T) (*Env, *manifest.Manifest, string) { + t.Helper() + env, m := setupPrepareEnv(t) + audioPath := filepath.Join(filepath.Dir(env.Config.SessionPath), "audio", "one.flac") + writeFile(t, audioPath, "audio") + if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("prepare.Run() error = %v", err) + } + return env, m, audioPath +} + +func setCanonicalPrepareParty(t *testing.T, env *Env, raw []byte) { + t.Helper() + document, err := config.ParseParty(raw) + if err != nil { + t.Fatalf("ParseParty() error = %v", err) + } + env.Config.Party = config.ResolvedParty{ + Mode: config.PartyModeCanonical, Source: config.PartySource{Source: "campaign_config"}, Canonical: document.Canonical, + } +} + +func assertPrepareResume(t *testing.T, env *Env, m *manifest.Manifest, want bool) { + t.Helper() + validation, err := (prepareStage{}).ValidateResume(context.Background(), env, m) + if err != nil { + t.Fatalf("ValidateResume() error = %v", err) + } + if validation.Resumable != want { + t.Fatalf("ValidateResume() = %#v, want resumable=%t", validation, want) + } +} + +func TestPrepareResumeMissingPreparedFileReruns(t *testing.T) { + env, m, _ := prepareResumeLocalFixture(t) + paths := env.ArtifactStore.SessionPathsFor(env.Config.Session.Campaign, env.Config.Session.SessionID) + if err := os.Remove(filepath.Join(paths.InputsDir, "players.yml")); err != nil { + t.Fatal(err) + } + assertPrepareResume(t, env, m, false) +}