204 Commits

Author SHA1 Message Date
d3ecd907ee Update ConfigKit and prepare Narratio v2.1.2
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-09-21 13:42:21 +00:00
442469b9f5 Fix derived publish destinations with explicit requiredness
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-09-21 03:06:13 +00:00
c45e042462 Prepare the v2.1.0 release
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-09-16 18:50:30 +00:00
207da1b6ef Remove completed Notarius cache roadmaps
Some checks failed
ci/woodpecker/push/verify Pipeline was canceled
2026-09-16 18:48:33 +00:00
9a1609435e Clarify Notarius compatibility requirements 2026-09-16 18:48:23 +00:00
13b83d7ee2 Fix Notarius snapshots in relative workspaces 2026-09-16 18:48:23 +00:00
a0312ed034 Document Notarius cache reuse workflow 2026-09-16 18:14:40 +00:00
8ff74e1251 Prove Notarius cache reuse end to end 2026-09-16 18:07:37 +00:00
1b480d4ed5 Verify Notarius cache lifecycle boundaries 2026-09-16 17:59:52 +00:00
e13d87a077 Use stable verified Notarius reference snapshots 2026-09-16 17:53:29 +00:00
5202f16972 Forward Notarius cache controls during extraction 2026-09-16 17:44:28 +00:00
5086596f94 Add typed Notarius cache controls 2026-09-16 17:42:16 +00:00
f7fc64db10 Plan Notarius cache reuse 2026-09-16 17:34:21 +00:00
6b0778b420 Clean up completed roadmap documentation
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
2026-09-16 16:39:37 +00:00
5b92b3e790 Document Notarius credential forwarding 2026-09-16 14:36:44 +00:00
cc43906ae9 Verify Notarius credential lifecycle 2026-09-16 14:31:54 +00:00
dcaa2ad9c6 Forward Notarius credentials securely 2026-09-16 14:27:06 +00:00
9d68cfcf2f Add Notarius credential configuration 2026-09-16 14:22:45 +00:00
2d346733d6 Plan Notarius credential forwarding 2026-09-16 14:14:56 +00:00
cd75bf6d5a Complete the ConfigKit v0.3.0 adoption 2026-09-16 04:33:19 +00:00
e08de98ae6 Upgrade ConfigKit to v0.3.0 2026-09-16 04:29:05 +00:00
df213bc2ba Isolate pipeline configuration discovery 2026-09-16 04:26:24 +00:00
869e68e834 Plan the ConfigKit upgrade and test isolation 2026-09-16 04:16:30 +00:00
ed4623bc41 Fix profile loading with ConfigKit history
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-09-07 13:10:29 +00:00
3ce198197d Report secrets bootstrap outcomes safely 2026-09-07 12:14:10 +00:00
0ac8072a9a Complete artifact provenance reporting 2026-09-07 12:08:19 +00:00
3ec54b8c72 Preserve null collections in effective YAML 2026-09-07 12:03:45 +00:00
47b3e5b55b Validate hidden base artifact ownership conflicts 2026-09-07 11:59:49 +00:00
99d1a0b926 Honor explicit Notarius working directories 2026-09-07 11:54:35 +00:00
884a62e8a6 Derive family member publish destinations 2026-09-07 11:52:57 +00:00
f7a40afc7a Complete the ConfigKit migration audit 2026-09-07 04:58:52 +00:00
a4f37ff427 Document the v2 operator migration 2026-09-07 04:55:13 +00:00
2b97ef3722 Add effective artifact catalog command 2026-09-07 04:46:20 +00:00
63a86c43f4 Infer artifact dependencies from configured inputs 2026-09-07 04:39:49 +00:00
92432232ef Resolve typed credentials safely 2026-09-07 04:29:00 +00:00
d31ba5a410 Bootstrap secrets before configuration loading 2026-09-07 04:14:04 +00:00
f0225e937c Migrate remaining configuration documents to ConfigKit 2026-09-07 04:02:21 +00:00
9e40c729e1 Adopt ConfigKit for pipeline loading 2026-09-07 03:54:41 +00:00
0e6ba414b3 Unify effective config projection 2026-09-07 03:35:46 +00:00
10a2e89965 Separate config normalization from validation 2026-09-07 03:32:50 +00:00
f8e3e651e6 Reduce supported platform matrix 2026-09-07 03:28:50 +00:00
1558c9ccae Plan ConfigKit adoption and platform simplification 2026-09-07 03:21:06 +00:00
b68435128b Document the v1.7.0 release
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-09-05 19:24:35 +00:00
5fb454a714 Remove completed roadmap and audit documents 2026-09-05 19:18:48 +00:00
79cf9f1139 Remove unused dependency wrappers 2026-09-05 18:49:16 +00:00
9bb203476d Carry dependencies through config commands 2026-09-05 18:48:25 +00:00
0726786016 Close quality and documentation workstream 2026-09-05 18:16:15 +00:00
204caf9d24 Improve structural test diagnostics 2026-09-05 18:08:49 +00:00
85663f5bc9 Replace restore phase globals 2026-09-05 18:05:18 +00:00
f51b9bb046 Carry dependencies through operator commands 2026-09-05 18:00:50 +00:00
dd905c4daa Carry dependencies through pipeline commands 2026-09-05 17:53:56 +00:00
c3307ab5fb Replace configuration and secret loading globals 2026-09-05 17:48:22 +00:00
1484f51f48 Plan final refactoring workstream 2026-09-05 17:38:57 +00:00
51d21676c1 Create run workspaces only when needed 2026-09-05 16:34:59 +00:00
c2806a1615 Complete cleanup stage workstream 2026-09-05 15:42:00 +00:00
078f661e3a Remove retired cleanup manifest state 2026-09-05 15:30:31 +00:00
adc60aa588 Integrate cleanup into pipeline execution 2026-09-05 15:27:08 +00:00
8d9eb5cc40 Implement idempotent cleanup behavior 2026-09-05 15:15:28 +00:00
563acce012 Define cleanup authorization and targets 2026-09-05 15:09:57 +00:00
49c359feb8 Plan cleanup stage refactor 2026-09-05 14:53:00 +00:00
38202d4472 Correct roadmap verification command 2026-09-05 14:17:43 +00:00
6d07dafc9f Remove unused lock dispatcher 2026-09-05 14:17:22 +00:00
6c66e18c4b Show carried output locations 2026-09-05 14:16:20 +00:00
4048996652 Validate published output declarations 2026-09-05 14:14:33 +00:00
470450e509 Complete remote publication workstream 2026-09-05 13:24:54 +00:00
7e78c22fec Remove retired lock authorities 2026-09-05 13:17:46 +00:00
610eaefb27 Migrate lock commands to independent objects 2026-09-05 13:11:11 +00:00
4bc33773fa Integrate artifact locks into publish 2026-09-05 13:04:08 +00:00
b74e31e1cc Model publish output outcomes 2026-09-05 12:52:55 +00:00
2f1e4407db Separate publish transfer and commit mechanics 2026-09-05 12:48:12 +00:00
4ccb4a73ca Separate publish source and output planning 2026-09-05 12:43:27 +00:00
145baac393 Permit safe cross-run output declarations 2026-09-05 12:37:58 +00:00
be59bff690 Define independent remote lock objects 2026-09-05 12:27:51 +00:00
14b4fee214 Add idempotent object deletion 2026-09-05 12:22:44 +00:00
7f84fc5d1e Plan remote publication refactoring 2026-09-05 11:57:22 +00:00
a8b47f6185 Strengthen Notarius determinism tests 2026-09-05 03:38:07 +00:00
5b62a8d60d Make cross-configuration validation deterministic 2026-09-05 03:37:00 +00:00
0d4d71b9dc Complete configuration diagnostics workstream 2026-09-05 00:42:47 +00:00
979d8049b1 Clarify pipeline composition envelope handling 2026-09-05 00:31:32 +00:00
abd405015b Make Scriptorium validation deterministic 2026-09-05 00:29:05 +00:00
fddf78dc80 Make Notarius validation transactional 2026-09-05 00:26:54 +00:00
3136c26d4e Separate configuration domain validators 2026-09-05 00:24:58 +00:00
4b84b4223d Separate configuration validation ownership 2026-09-05 00:21:04 +00:00
7a0e2cb11b Plan configuration refactoring 2026-09-04 23:37:02 +00:00
74ab9218ff Close analyze checkpointing documentation 2026-09-04 18:38:11 +00:00
da99857fd0 Reconcile removed analysis artifacts 2026-09-04 18:37:17 +00:00
a520cfdcc8 Close analyze checkpointing workstream 2026-09-04 16:16:19 +00:00
7febccf550 Complete analyze checkpoint recovery 2026-09-04 16:10:54 +00:00
74977b4064 Checkpoint analyze artifacts incrementally 2026-09-04 16:04:59 +00:00
172e2d6a4f Consolidate analyze session execution state 2026-09-04 15:57:54 +00:00
5c520dc0d4 Decompose analyze artifact execution 2026-09-04 15:50:51 +00:00
6d18a3126e Plan analyze artifact checkpointing 2026-09-04 13:21:27 +00:00
1e222a4db3 Test real planning and execution parity 2026-09-04 13:03:39 +00:00
6a15052d77 Isolate runner object store construction 2026-09-04 13:01:00 +00:00
2e69c14898 Complete runner lifecycle refactor 2026-09-04 01:21:37 +00:00
dbda2a0161 Isolate runner finalization 2026-09-04 01:15:07 +00:00
2ee64f2eb1 Extract single-stage runner execution 2026-09-04 01:14:09 +00:00
8afe9427ca Isolate invocation collaborator composition 2026-09-04 01:11:11 +00:00
e0f19833a1 Establish explicit runner lifecycle state 2026-09-04 01:05:44 +00:00
ca1b694aa9 Unify pre-execution stage decisions 2026-09-04 01:01:46 +00:00
9331373245 Plan runner lifecycle refactor 2026-09-04 00:26:04 +00:00
ec072dc163 Finish analyze projection cleanup 2026-09-04 00:09:31 +00:00
f7ce1dcd56 Simplify previous manifest collection 2026-09-04 00:08:58 +00:00
df275874e3 Confine published diagnostic files 2026-09-04 00:07:50 +00:00
1da34ab5cd Complete session-only manifest migration 2026-09-03 04:22:19 +00:00
c0fbc14f2d Delete run manifest domain model 2026-09-03 04:16:36 +00:00
2d20900cbf Remove invocation analyze projection 2026-09-03 04:11:33 +00:00
7997f81f47 Make runner persistence session-only 2026-09-03 04:09:12 +00:00
6d748d65d3 Decouple publish from run manifests 2026-09-03 04:00:25 +00:00
49324d3f35 Plan session manifest simplification 2026-09-03 00:48:46 +00:00
c98b4ef4ef Align compatibility documentation 2026-09-02 14:38:51 +00:00
c8bae3a4a3 Clarify remote manifest recovery 2026-09-02 14:37:36 +00:00
ccbd7ee820 Require committed restore state 2026-09-02 14:36:02 +00:00
e9c4873030 Complete compatibility purge audit 2026-09-02 14:18:51 +00:00
57719b7d81 Require manifest-backed local artifacts 2026-09-02 14:09:32 +00:00
62b1f9b0ae Resolve previous artifacts from commit declarations 2026-09-02 13:53:23 +00:00
6c7a45d8d0 Require committed remote current state 2026-09-02 13:47:20 +00:00
82d75e7beb Remove legacy party configuration 2026-09-02 12:04:35 +00:00
61095e69ce Reject unversioned party documents 2026-09-02 11:57:51 +00:00
2cfca710ec Require explicit manifest schemas 2026-09-02 11:45:40 +00:00
9679117e94 Plan the compatibility purge 2026-09-02 03:53:36 +00:00
d67a0389e0 Cover bounded reads and cancelled replacements 2026-09-02 01:19:21 +00:00
9f84446509 Clean up failed Notarius extraction runs 2026-09-02 01:18:32 +00:00
ec9b0e919d Verify restored audio before installation 2026-09-02 01:17:21 +00:00
9bb7c1f4e3 Complete filesystem simplification workstream 2026-09-02 01:01:44 +00:00
3c092fbe34 Replace Notarius bundle promotion with rename 2026-09-02 00:56:53 +00:00
3a7e2e5175 Constrain restore installation to session root 2026-09-02 00:53:35 +00:00
45b27ad562 Protect restore planning from symlinks 2026-09-02 00:51:19 +00:00
1e418d6929 Simplify root-scoped cleanup 2026-09-02 00:48:40 +00:00
39b2665d8b Validate downloads before installing workspace files 2026-09-02 00:37:00 +00:00
ba858cc997 Migrate ordinary I/O from confined file helpers 2026-09-01 17:51:37 +00:00
0d57422b61 Simplify atomic file replacement and bounded reads 2026-09-01 17:40:08 +00:00
b7d989f61f Plan filesystem simplification 2026-09-01 15:34:39 +00:00
6813340f51 Strengthen transcription concurrency tests 2026-09-01 11:51:36 +00:00
eb788f17b8 Remove obsolete subprocess wait code 2026-09-01 11:48:34 +00:00
ebd3f415d3 Clarify Windows process setup failures 2026-09-01 11:48:00 +00:00
f15d8d8ce0 Preserve subprocess wait failures 2026-09-01 11:46:54 +00:00
6c7bfd5e31 Complete Go and process refactoring workstream 2026-08-31 23:56:39 +00:00
7c782a2603 Use errgroup for bounded transcription work 2026-08-31 23:55:08 +00:00
f1eef5b5e9 Move generated file writes into owning adapters 2026-08-31 23:53:21 +00:00
f14583321d Use stable Windows process handles for job assignment 2026-08-31 23:49:47 +00:00
04283e99d4 Separate subprocess execution lifecycle from policy 2026-08-31 23:48:49 +00:00
7284228bec Upgrade maintained Go baseline to 1.26 2026-08-31 23:45:54 +00:00
da74d7ad18 Plan Go and process refactoring 2026-08-31 23:40:42 +00:00
af5fbc1c4f Record maintainability audit results 2026-08-31 04:43:24 +00:00
5e4d2016e8 Record maintainability audit hypotheses 2026-08-31 04:30:03 +00:00
545aa6893b Prepare the v1.6.0 release
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-08-30 20:40:04 +00:00
98139f7e8b Harden release validation 2026-08-30 20:36:23 +00:00
f8fa0a2623 Document release procedure and complete release audit 2026-08-30 19:27:36 +00:00
9af773491b Use shared release scripts in Woodpecker 2026-08-30 19:22:26 +00:00
2a656f0f11 Add guarded release tag command 2026-08-30 19:20:12 +00:00
aec35a2d9b Add release candidate checker 2026-08-30 19:16:02 +00:00
23dc4e2078 Add release asset builder 2026-08-30 19:10:26 +00:00
c812fe3655 Harden pipeline state and plan release upgrades 2026-08-30 18:51:20 +00:00
3da97ca50c Add assembled pipeline configuration regression coverage 2026-08-30 15:26:23 +00:00
4c203d8588 Document pipeline configuration migration workflow 2026-08-30 15:19:30 +00:00
fcb5f825e1 Add split pipeline configuration example bundle 2026-08-30 15:17:46 +00:00
4c57ace2f6 Add semantic configuration profile comparison 2026-08-30 15:10:38 +00:00
dde7f76ecb Add configuration source reporting 2026-08-30 14:56:22 +00:00
a102db36af Add read-only configuration inspection commands 2026-08-30 14:43:25 +00:00
b5b1d22011 Expand family publish policies into concrete rules 2026-08-30 14:30:07 +00:00
c91599ef36 Add family artifact selection and provenance 2026-08-30 14:27:42 +00:00
257f10c9fb Resolve corresponding artifact family dependencies 2026-08-30 14:20:03 +00:00
fb9a4d14f4 Expand canonical party artifact families 2026-08-30 14:15:57 +00:00
c4435b76c4 Prepare canonical party and derived players inputs 2026-08-30 14:08:35 +00:00
61000a9466 Resolve canonical parties with campaign configuration 2026-08-30 14:01:51 +00:00
7e4ceb3d48 Add canonical party domain and players projection 2026-08-30 13:51:12 +00:00
4e991fa21d Expose pipeline profile selection and provenance 2026-08-30 13:41:26 +00:00
f86b17045d Unify application configuration loading 2026-08-30 13:32:45 +00:00
f302488075 Add named pipeline profile composition 2026-08-30 13:25:02 +00:00
8c1171478d Complete downstream semantic resume coverage 2026-08-30 13:14:33 +00:00
7ee637803d Protect transcript refinement resume semantics 2026-08-30 13:03:13 +00:00
4d6086fefb Protect initial pipeline stage resume semantics 2026-08-30 12:54:38 +00:00
82cb53e107 Add semantic configuration resume evidence 2026-08-30 12:44:26 +00:00
b97b12da7f Add explicit pipeline configuration imports 2026-08-30 12:33:43 +00:00
49ea747b17 Add presence-aware configuration composition 2026-08-30 12:21:27 +00:00
3b5e9db41f Plan pipeline configuration improvements 2026-08-30 03:41:56 +00:00
8b4b328c4e Prepare the v1.5.0 release notes
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-08-29 23:13:39 +00:00
ee2b8e63e6 Report the embedded release version 2026-08-29 23:13:01 +00:00
51edd384c0 Remove completed feature roadmaps 2026-08-29 23:11:19 +00:00
b804d0f2c8 Share analysis dependent staling traversal 2026-08-29 20:50:43 +00:00
fd5ccc668b Use one execution plan throughout the runner 2026-08-29 20:49:43 +00:00
0dc8ff9b52 Exclude external paths from analysis fingerprints 2026-08-29 20:45:27 +00:00
8657a28bdb Preserve flag-based artifact regeneration arguments 2026-08-29 20:43:44 +00:00
a9c5e4ad4e Recheck bounded prerequisites under lock 2026-08-29 20:42:51 +00:00
2176b4371d Close out the artifact workflow roadmap 2026-08-29 20:19:19 +00:00
effc10d75b Finalize artifact workflow documentation 2026-08-29 20:14:28 +00:00
5887839aa1 Add assembled workflow compatibility coverage 2026-08-29 20:08:48 +00:00
3128bef20a Add artifact-aware analyze resume planning 2026-08-29 20:01:57 +00:00
4e4e2b7d96 Preserve incremental analysis state on failure 2026-08-29 19:49:48 +00:00
99f4f9a0db Execute incremental analysis artifact plans 2026-08-29 19:43:28 +00:00
6abdd67bb5 Add incremental analysis work planning 2026-08-29 19:26:10 +00:00
c32e0c401f Add versioned analysis fingerprint reconciliation 2026-08-29 19:18:28 +00:00
ab5751459a Add deterministic analysis input identities 2026-08-29 19:02:16 +00:00
62de6abdbf Make configured artifacts manifest authoritative 2026-08-29 18:46:40 +00:00
903dc70682 Persist partial analysis artifact results 2026-08-29 18:36:21 +00:00
23c714da66 Add versioned analysis artifact state 2026-08-29 18:27:44 +00:00
6639775d7d Add the artifact regeneration command 2026-08-29 18:19:54 +00:00
966b95b176 Enforce bounded run prerequisites 2026-08-29 18:17:05 +00:00
3bcf2c08dd Add bounded run and plan commands 2026-08-29 18:07:24 +00:00
700ab655ca Add shared bounded pipeline plans 2026-08-29 18:02:37 +00:00
85c5647385 Separate stage order from invalidation dependencies 2026-08-29 18:00:22 +00:00
2ef7c76d99 Add post-transcript implementation plan 2026-08-29 17:47:15 +00:00
9bc1b0feda Plan post-transcript artifact regeneration 2026-08-29 17:29:08 +00:00
370 changed files with 35085 additions and 14535 deletions

View File

@@ -2,64 +2,26 @@ when:
- event: tag
steps:
validate:
image: golang:1.25
validate-release:
image: golang:1.26.7
commands:
- go test ./...
- go test -race ./...
- go vet ./...
- go build ./...
- go test ./internal/doccheck
- go test ./internal/config -run '^TestExamplesLoadAndValidate$'
cross-build:
image: golang:1.25
depends_on: validate
commands:
- |
set -eu
output_dir="$(mktemp -d)"
trap 'rm -rf "$output_dir"' EXIT
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
goos="${target%/*}"
goarch="${target#*/}"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -o "$output_dir/narratio-$goos-$goarch" ./cmd/narratio
done
- ./scripts/check-release-candidate.sh "$CI_COMMIT_TAG"
build-release-assets:
image: golang:1.25
depends_on: [validate, cross-build]
image: golang:1.26.7
depends_on:
- validate-release
commands:
- |
set -eu
version="$CI_COMMIT_TAG"
dist="dist"
pkg="gitea.maximumdirect.net/eric/narratio/cmd/narratio"
rm -rf "$dist"
mkdir -p "$dist"
build_binary() {
goos="$1"
goarch="$2"
suffix="$3"
output="$dist/narratio-$version-$goos-$goarch$suffix"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/narratio/internal/buildinfo.Version=$version" \
-o "$output" "$pkg"
}
build_binary linux amd64 ""
build_binary linux arm64 ""
build_binary darwin amd64 ""
build_binary darwin arm64 ""
build_binary windows amd64 ".exe"
build_binary windows arm64 ".exe"
case "$PWD" in
/*) ;;
*) echo "release workspace must have an absolute path" >&2; exit 1 ;;
esac
./scripts/build-release-assets.sh "$CI_COMMIT_TAG" "$PWD/dist"
publish-release:
image: woodpeckerci/plugin-release
image: woodpeckerci/plugin-release:0.3.1
depends_on:
- build-release-assets
settings:
@@ -67,6 +29,8 @@ steps:
from_secret: GITEA_RELEASE_TOKEN
files:
- dist/narratio-*
title: Narratio ${CI_COMMIT_TAG}
note: docs/releases/${CI_COMMIT_TAG}.md
checksum: sha256
checksum-file: SHA256SUMS
checksum-flatten: true

View File

@@ -3,6 +3,6 @@ when:
steps:
shuffled-race-tests:
image: golang:1.25
image: golang:1.26
commands:
- go test -race -shuffle=on -count=3 ./...

View File

@@ -3,44 +3,44 @@ when:
steps:
tests:
image: golang:1.25
image: golang:1.26
commands:
- go test ./...
race-tests:
image: golang:1.25
image: golang:1.26
depends_on: tests
commands:
- go test -race ./...
static-analysis:
image: golang:1.25
image: golang:1.26
depends_on: tests
commands:
- go vet ./...
build:
image: golang:1.25
image: golang:1.26
depends_on: tests
commands:
- go build ./...
documentation-and-examples:
image: golang:1.25
image: golang:1.26
depends_on: tests
commands:
- go test ./internal/doccheck
- go test ./internal/config -run '^TestExamplesLoadAndValidate$'
cross-build:
image: golang:1.25
image: golang:1.26
depends_on: [race-tests, static-analysis, build, documentation-and-examples]
commands:
- |
set -eu
output_dir="$(mktemp -d)"
trap 'rm -rf "$output_dir"' EXIT
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
for target in linux/amd64 linux/arm64 darwin/arm64; do
goos="${target%/*}"
goarch="${target#*/}"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -o "$output_dir/narratio-$goos-$goarch" ./cmd/narratio

View File

@@ -27,7 +27,7 @@ This requires resolvable `pipeline.yml`, `campaign.yml`, and concrete
- [Integration contracts](docs/integrations/) — external tools, formats, and
compatibility expectations.
- [Maintained examples](examples/README.md) — complete copyable configuration
and input files.
and input files, including the production/testing split bundle.
## Maintainer Documentation

View File

@@ -12,12 +12,15 @@ This runs the canonical full pipeline for session `2026-04-04`.
Top-level commands:
- `run <session_id>`: run full stage order.
- `version`: print the Narratio build version.
- `run <session_id>`: run all or one contiguous range of the canonical stage order.
- `regenerate-artifacts <session_id>`: force-run extraction through analysis.
- `run-stage <stage> <session_id>`: run one stage.
- `analyze <session_id>`: force-run analyze.
- `publish <session_id>`: force-run publish.
- `clean <session_id>` or `clean --all`: remove local work/spool state.
- `session <subcommand>`: session helper commands.
- `config <subcommand>`: validate, display, source-trace, catalog, or compare resolved pipeline configuration.
Session subcommands:
@@ -41,6 +44,8 @@ Most session-aware commands accept:
- `--session <session.yml>`
- `--session-id <session_id>`
- `--previous-session-id <session_id>`
- `--profile <name>`
- `--secrets-dir <directory>`
Rules:
@@ -49,6 +54,13 @@ Rules:
- if both positional `<session_id>` and `--session-id` are provided, values must match.
- `--previous-session-id` is a strict expectation: the selected session file
must contain the same `previous_session_id`.
- `--profile` selects `profiles/<name>.yml` relative to the pipeline root. It
may be supplied once; omitted, empty, and the exact name `default` select the
base configuration, while an unknown named profile fails. The same selection
applies to all common-flag commands, including `regenerate-artifacts`.
- `--secrets-dir` selects ConfigKit credential-file bootstrap explicitly. A
relative value uses caller CWD. Without the flag, a present `secrets/`
directory beside the resolved root pipeline is used; an absent one is skipped.
- `clean --all` cannot be combined with campaign/session selectors.
- notification delivery is currently limited to the configured `noop` mode; see
the [configuration reference](./config.md#notifications).
@@ -70,21 +82,144 @@ Commands with additional positionals keep their command-specific order:
## Command Reference
### `config validate`, `config show`, `config sources`, `config artifacts`, and `config diff`
```bash
narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
narratio config artifacts [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <directory>]
```
These commands resolve the selected profile, defaults, ordinary paths, and—if
a campaign is selected—the campaign-owned party. They neither discover or load
a session nor create a workspace, manifest, run, lock, adapter, or remote
connection. Like every pipeline-loading command, they bootstrap the selected or
conventional secrets directory before resolving configuration; `config show`
never includes bootstrap reports or secret values in its YAML output.
Typed credential references, including `notarius.llm_api_key`, must therefore
resolve even though these inspection commands never construct an adapter.
Campaign selection is optional for a pipeline without party-driven artifact
families. A pipeline with `scriptorium.artifact_families` needs a selected or
configured default campaign so Narratio can expand its concrete artifacts and
publish rules. `--campaign` and `--campaign-file` remain mutually exclusive.
Session, range, force, and artifact-execution flags are not accepted.
`config validate` writes a concise root-path, selected-profile (or `none`), and
effective-digest summary after successful complete validation. `config show`
writes one deterministic, secret-free YAML document containing defaulted and
expanded concrete configuration. It omits ConfigKit directives, artifact
family declarations, and runtime provenance.
`config sources` reports the same fully validated resolution without printing
effective values. Its header identifies the root, ordered imports, selected
profile and overlay, selected campaign, party source, and digest. The
remaining tab-separated records are sorted as `path`, `role`, and `source`.
Roles distinguish root, import, profile, centralized default, campaign, party,
and generated family ownership. A generated party member has
one family record and one party record at the same logical path. The output
never reads or prints secret values.
`config artifacts` prints the concrete catalog used by analysis, ordered by
artifact key. Its tab-separated header is `key`, `source`, `definitions`,
`enabled`, `output`, `dependencies`, `family`, `character`, `family_source`, and
`party_source`. Definitions and effective dependencies are sorted comma-separated
lists, and control characters in fields are escaped. Family-member rows identify
their family, character, defining family file, and party file. An empty catalog
prints only the header. Ordinary artifacts can be inspected without a campaign;
party-driven families retain the campaign requirement. See
[Scriptorium Artifact Entries](./config.md#scriptorium-artifact-entries) for the
configuration layout.
`config diff` resolves both supplied profile names independently and compares
their fully resolved, secret-free effective mappings. It does
not accept `--profile`; the two positional names must be distinct, declared
profiles. When party-driven families are present, both profiles must resolve to
the same selected campaign and party. Use `--campaign-file` if profile-specific
campaign configuration would otherwise select different files.
Equal profiles print `no differences`. Otherwise, sorted tab-separated records
use one of these forms, with compact JSON values:
```text
added <path> <right-value>
removed <path> <left-value>
changed <path> <left-value> <right-value>
```
Mappings are flattened to their logical field paths; lists remain one atomic
value. The command compares defaulted concrete artifacts and publish rules, not
profile names, source-file layout, or formatting. It succeeds when differences
are found, making it suitable for review and migration checks.
### `version`
```bash
narratio version
```
Official release binaries report their exact Git tag. Binaries built directly
from source without release linker metadata report `dev`.
### `run`
```bash
narratio run <session_id> [--force] [--artifacts <name[,name...]>] [...common config flags]
narratio run <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...common config flags]
```
Behavior:
- evaluates full stage order;
- runs `extract` between `trim` and `render`; an omitted or disabled Notarius
- evaluates one inclusive contiguous range of the canonical stage order;
- defaults an omitted `--from` to `prepare` and an omitted `--through` to
`cleanup`, so omitting both retains full-pipeline behavior;
- rejects unknown endpoints and a `--from` endpoint after `--through`;
- runs `render` before `extract`; an omitted or disabled Notarius
configuration records an explicit `notarius_disabled` self-skip;
- resolves every selected typed credential during the initial configuration
load, including a Notarius credential when extract is disabled, outside the
selected range, or later skipped as resumable;
- skips already-succeeded stages unless `--force` is set or a stage-specific
resume check finds its durable result obsolete;
- applies `--force` only to stages in the selected range;
- rejects repeated `--from`, `--through`, or `--force` options, including
`--name=value` spellings;
- continues interrupted or partially completed sessions by running non-succeeded stages;
- writes session and run manifests.
- writes the session manifest as the sole durable progress ledger. Each
invocation also creates its run-local directory and returns an immediate
command summary; neither creates another durable progress record.
- reports the resolved profile (or `none`) and effective configuration digest.
When `--artifacts` is present, the selected range must contain `analyze` or
`publish`. Either consumer is sufficient, including a one-stage range.
### `regenerate-artifacts`
```bash
narratio regenerate-artifacts <session_id> [--artifacts <name[,name...]>] [...common config flags]
```
Exactly equivalent to:
```bash
narratio run <session_id> --force --from extract --through analyze [caller options]
```
The command always reruns extraction. Analysis rebuilds the selected configured
artifacts and any prerequisites required by those targets; without
`--artifacts`, it uses the normal default analysis selection. Publish and notify
never run. Common session/configuration options and repeatable artifact values
pass through unchanged.
The forced extraction rebuilds the complete configured Notarius bundle,
disables Notarius checkpoint loading, and requests chunk-plan refresh.
`--artifacts` narrows only the analysis work; it never selects Notarius lanes.
Because the expansion owns `--force`, `--from`, and `--through`, callers cannot
supply those options. The shared `run` parser reports them as duplicate
singleton flags. The alias has no private execution options or behavior, and
runtime diagnostics may identify the operation as `run`.
### `run-stage`
@@ -100,11 +235,12 @@ Valid stage names:
- `polish`
- `normalize`
- `trim`
- `extract`
- `render`
- `extract`
- `analyze`
- `publish`
- `notify`
- `cleanup`
Rules:
@@ -122,6 +258,10 @@ Equivalent to:
narratio run-stage analyze <session_id> --force [...common config flags]
```
This selects only `analyze`: it does not invoke Notarius, replace extraction,
or remove existing extract outputs. `--artifacts` narrows the forced analysis
targets and their required analysis prerequisites.
### `publish`
```bash
@@ -134,6 +274,10 @@ Equivalent to:
narratio run-stage publish <session_id> --force [...common config flags]
```
This command selects only `publish`; it does not invoke cleanup. Use a normal
full run to include configured cleanup, or `run-stage cleanup` to retry cleanup
from valid publication evidence.
### `clean`
```bash
@@ -147,16 +291,25 @@ Behavior:
- `--all` removes all local session work and spool state;
- cache remains unless `--clear-cache` is provided.
See [Operations: Cleanup](./operations.md#cleanup) for deletion scope and
post-publish cleanup behavior.
See [Operations: Cleanup](./operations.md#cleanup) for manual and pipeline-stage
deletion scope.
### `session plan`
```bash
narratio session plan <session_id> [--force] [...common config flags]
narratio session plan <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...common config flags]
```
Validates config, prepares local workdir layout, and prints run/skip decisions for each stage.
Uses the same inclusive bounds, endpoint validation, force scope, and artifact
selection contract as `run`. It validates config and prints run/skip decisions
for selected stages only without creating the local workdir or changing the
manifest. Resume-capable selected stages are checked against durable evidence.
The output includes the resolved profile (or `none`) and effective configuration
digest without writing provenance or any manifest state.
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
execution order, and reusable current artifacts with concise reasons. These
artifact decisions come from the same reconciliation and work planner used by
execution; the preview does not predict output identities.
### `session validate`
@@ -164,7 +317,8 @@ Validates config, prepares local workdir layout, and prints run/skip decisions f
narratio session validate <session_id> [...common config flags]
```
Read-only preflight checks for config validity, required inputs, audio mode, previous-session requirements, publish outputs, and effective locks.
Read-only preflight checks for config validity, required inputs, audio mode,
previous-session requirements, publish outputs, and independent remote locks.
### `session status`
@@ -173,7 +327,12 @@ narratio session status <session_id> [...common config flags]
```
Prints local manifest state and, when storage is available, status for the
pointer-selected remote commit and its declared published outputs.
pointer-selected remote commit and its declared published outputs. Current
publish metadata reports `published`, `carried_locked`, `locked_unpublished`,
`optional_missing`, and `unselected`; carried output lines include the run
derived from their exact declared key, the key itself, and its declared
destination. A separately displayed `dest` is the destination requested by the
current configuration and may differ while an older declaration is locked.
### `session init`
@@ -192,6 +351,7 @@ Options:
- `--config <pipeline.yml>`
- `--campaign <id>` or `--campaign-file <campaign.yml>`
- `--secrets-dir <directory>`
- `--previous-session-id <id>`
- `--date <YYYY-MM-DD>`
- `--title <text>`
@@ -231,7 +391,8 @@ narratio session artifacts <session_id> [--remote] [...common config flags]
Lists effective built-in, configured Scriptorium, and configured extraction
sources; reports planned, available, unavailable, and published state without
reading payload bodies; and includes publish rules, lock state, and optional
remote published-state availability.
remote published-state availability. Remote inspection uses exact commit
declarations and displays the five current publish outcomes when recorded.
### `session locks`
@@ -243,23 +404,34 @@ narratio session locks remove <session_id> <source> [...common config flags]
Behavior:
- list mode reports the effective merge of static and remote locks;
- add/remove mutate only remote locks;
- static locks from pipeline config cannot be removed by CLI commands.
- list mode validates and reports independent remote locks sorted by source;
- add creates one lock and reports an existing lock unless `--force` is used;
- `--force` performs one generation-matched replacement and reports a
concurrent change for operator retry; and
- remove is idempotent and reports when the source was not locked.
See [Operations: Publish Locks](./operations.md#publish-locks) for lock storage
and precedence.
and publication behavior.
## `--artifacts` Selection Rules
- accepted on `run`, `run-stage`, `analyze`, and `publish`;
An artifact-family key selects all of its concrete character members. A
concrete generated key selects only that member; mixed family and concrete
selection is deduplicated and executed as concrete keys. The resulting plan
and command output identify both the concrete key and, where applicable, its
family and character ID.
- accepted on `run`, `session plan`, `run-stage`, `analyze`, and `publish`;
- repeatable and comma-separated values are combined, surrounding whitespace
is removed, and duplicate names are collapsed;
- names must exist in `pipeline.scriptorium.artifacts`;
- empty entries are invalid;
- repeated names are deduplicated.
- on `run-stage`, only `analyze` and `publish` accept the option.
Effects:
- filters analyze execution to selected configured artifacts;
- selects explicit analyze targets; required configured prerequisites may be
reused or rebuilt before them;
- filters publish rules that source `narratio.artifact.<name>`;
- does not filter built-in transcript/bounds or explicitly configured
`narratio.extraction.<name>` publish sources; and
@@ -291,6 +463,12 @@ Force publish only:
narratio publish 2026-04-04
```
Regenerate post-transcript artifacts without publishing:
```bash
narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout
```
## Output And Exit Behavior
- Successful commands write their result or summary to standard output and

View File

@@ -42,6 +42,55 @@ The downloaded remote session file is command-scoped: Narratio removes it after
the command finishes and records only the remote object provenance alongside
the durable copied session input.
### Read-only effective pipeline inspection
`narratio config validate`, `narratio config show`, `narratio config sources`,
and `narratio config artifacts` use the same `--config`, `--campaign`,
`--campaign-file`, and
`--profile` selection rules as pipeline commands, but do not select, discover,
or load a session. They resolve configured typed credential references as part
of the ordinary ConfigKit load but expose only their environment-variable names;
they do not create runtime state.
`narratio config diff <left-profile> <right-profile>` uses the same pipeline and
campaign selectors, resolves each named profile with an independent ConfigKit
load, and does not accept a separate `--profile` flag.
Campaign selection is optional only when the resolved pipeline has no
`scriptorium.artifact_families`. When families are declared, Narratio selects a
campaign through an explicit flag or `pipeline.campaigns.default_campaign_id`,
then parses the campaign-owned party and expands concrete artifacts and any
family publish rules before validation. `config validate` prints the resulting
root, profile, and effective digest. `config show` emits the normalized
effective pipeline YAML, with defaults and concrete expansion included but
ConfigKit directives and family declarations omitted. This review projection is
not promised to be a reloadable authoring document. `config sources` prints a stable
source projection instead of effective values: root/import/profile/default
ownership plus campaign/party and generated-family records. Canonical derived
players trace to the party. `config artifacts` lists the concrete artifact map,
effective dependencies, and defining sources. The [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
owns command syntax and output conventions.
`config diff` compares normalized field values rather than YAML text or source
ownership. It emits sorted `added`, `removed`, and `changed` records, uses
compact deterministic JSON values, treats lists atomically, and reports `no
differences` when the complete effective configurations are equal. Concrete
family members and generated publish rules participate after expansion; moving
an equal value between eligible root/import sources does not create a
difference.
### Migrating to the maintained bundle
Use the [production/testing bundle](../examples/production-testing/pipeline.yml)
as the complete copyable migration reference. Keep production settings in the
base, split them into explicit additive imports, and place testing differences
in the fixed `profiles/testing.yml` overlay. Put ordinary artifacts and families
in the recommended one-definition files shown by that bundle. Convert campaign
rosters to [canonical party input](integrations/party.md), then express character
work as families. Inspect the result with `config validate`, `config show`,
`config sources`, and `config artifacts`; use `config diff default testing` to
review the overlay before running a session. Unversioned party documents are
rejected.
### Identity segments
Campaign IDs (`campaign_id` and `default_campaign_id`), session IDs, previous
@@ -53,21 +102,95 @@ remote state with an unsafe legacy identity must be migrated before use.
## Validation and Merge Rules
- YAML decode is strict (`KnownFields(true)`) and accepts exactly one document:
unknown fields or trailing documents fail load.
- ConfigKit performs strict typed YAML decoding and accepts exactly one document;
unknown fields, duplicate keys, and trailing documents fail load.
- A pipeline file may explicitly import additive YAML fragments through the
root-only `configkit.imports` list. Imported files contribute fields to one
logical pipeline document; they do not override fields supplied by the root
or another import.
- An omitted profile, an empty profile option, and the exact name `default`
select the base configuration. Any other name selects
`profiles/<name>.yml` relative to the pipeline root.
- Campaign, session, and canonical-party documents are strict single-source
ConfigKit schemas. They do not enable imports, profiles, or `configkit`
directives.
- Configured timeout and retry-delay durations must be positive. An omitted
artifact timeout continues to inherit its configured Scriptorium timeout.
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
- Session and party inputs inspected before typed loading are limited to 8 MiB.
- Pipeline defaults are applied before validation.
- Defaults apply only to absent fields. Explicit empty strings, zero values,
`false`, and `null` retain ConfigKit's typed meaning and are then checked by
Narratio domain validation; they are not silently replaced by defaults.
Explicit null optional stage sections stay absent, while required sections
fail validation.
- Campaign and session identities must agree.
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
`players_file`, `party_file`) and the optional `spell_catalog_file` resolve
from session overrides when provided, otherwise from campaign defaults. An
empty or omitted session spell-catalog value inherits the campaign value.
`party_file`) are campaign-owned; the first three may be overridden by the
session. The optional `spell_catalog_file` also inherits a campaign value
when omitted by the session.
- `party_file` is parsed when pipeline and campaign configuration are combined.
It must be a versioned [canonical party](integrations/party.md), is
campaign-owned and derives the players input internally. `players_file` and
session `party_file` are unsupported configuration keys.
- Exactly one audio mode must be configured in session input:
- local (`audio_dir` or `audio_files`), or
- S3 (`audio_s3.prefix`).
### Pipeline imports and profiles
Large pipeline configurations may be split into explicitly named fragments and
may use fixed-path profile overlays:
```yaml
configkit:
imports:
- path: config/storage.yml
- path: config/integrations.yaml
campaigns:
root: /usr/local/share/narratio/campaigns
```
The maintained [production/testing bundle](../examples/production-testing/pipeline.yml)
is a complete copyable example of this structure, including canonical-party
artifact families.
Imports are resolved relative to the directory containing the root pipeline
file and are loaded in declaration order. Narratio does not scan directories or
infer fragments. Each import must be a confined regular `.yml` or `.yaml` file:
absolute paths, traversal, symlinks, directories, duplicate files, and an
import of the root pipeline itself are rejected. Imported files cannot declare
another `configkit` control section.
Base merging is additive. A map may be extended by multiple files when every
leaf is distinct, but a scalar, list, or map/list/scalar kind cannot be claimed
more than once, even when the repeated values are identical. Conflict errors
name the full field path and every source that claimed it. Narratio additionally
rejects an artifact or artifact-family key declared by more than one base file,
even when those files contribute disjoint children.
Profile names are case-sensitive and use ConfigKit's safe fixed-path naming
rules. Only the selected overlay is opened; a malformed unselected overlay does
not affect a base or different-profile load. Maps merge recursively, overlay
scalars replace base scalars, and overlay lists replace base lists completely.
Explicit `false`, zero, empty strings, empty containers, and null values remain
explicit inputs and are not refilled by defaults. Profiles cannot inherit,
stack, import files, or declare another ConfigKit control section.
During ConfigKit loading, Narratio applies fresh typed defaults, recorded
entry-level defaults and normalization, read-only file-local validation, and
ordinary path resolution before computing a deterministic effective
configuration digest. The digest represents the normalized, secret-free
runtime pipeline mapping; it excludes ConfigKit directives, source
provenance, profile identity, and raw environment secret values. Equivalent
effective mappings therefore have the same digest regardless of how fields are
split among the root and imports.
An imported field has the same meaning it would have in a monolithic root
pipeline. In particular, ordinary relative pipeline paths continue to resolve
from the root pipeline directory, not from the importing fragment's directory.
## Minimal Working Configuration
`pipeline.yml`
@@ -88,7 +211,6 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
```
@@ -103,15 +225,31 @@ inputs:
## Secrets Handling
- Do not place raw secrets in YAML.
- Use env var names in config (for example `pipeline.audita.llm_api_key_env`).
- Optionally load credential files from `pipeline.secrets.env_dir`. Each valid
environment-variable filename supplies one value; trailing CR/LF is removed.
- Credential fields contain environment-variable names, represented internally
as ConfigKit typed secret references (for example
`pipeline.notarius.llm_api_key: NOTARIUS_LLM_API_KEY`). A configured reference
must resolve to a nonempty environment value during load.
- Every surviving reference is resolved during ordinary pipeline loading,
regardless of whether its integration is disabled, outside the selected
stage range, or eligible for resume. Omit the field or set it to `null` when
that deployment should not require the credential.
- Every command that loads a pipeline accepts `--secrets-dir <path>`. When the
flag is omitted, Narratio uses a present `secrets/` directory beside the
resolved root `pipeline.yml`; an absent conventional directory is ignored.
Explicit relative paths are resolved from the caller's working directory,
and a missing or empty explicit selection is an error.
- Each immediate regular file whose name is a valid environment-variable name
supplies one value. ConfigKit removes trailing CR/LF, permits a symlink as the
selected directory, rejects non-hidden symlink entries, and limits input to
1 MiB per file, 8 MiB total, and 1024 entries. Dot-prefixed entries (including
files, directories, and symlinks) are ignored and omitted from bootstrap
reports, but still count toward the directory entry limit.
- An existing process environment value takes precedence over a credential file.
- Credential directories and files must not be symlinks and must be regular,
bounded files (at most 8 KiB per value). On POSIX, provision the directory
with no group/other access (normally `0700`) and files with no group/other
access (normally `0600`).
- Commands that need storage/auth load filesystem secrets before constructing adapters.
- On POSIX, operators must provision the directory with no group/other access
(normally `0700`) and files with no group/other access (normally `0600`);
ConfigKit validates type and bounds but does not enforce permission modes.
- Bootstrap happens once per command, before any configuration load or adapter
construction. Safe reports contain filenames, paths, and statuses, never values.
## Publish Configuration Summary
@@ -134,9 +272,6 @@ publish:
- source: narratio.artifact.session_recap
dest: artifacts/session_recap.md
required: true
locks:
- source: narratio.artifact.session_recap
reason: manual post-publish edits
```
Rules:
@@ -147,7 +282,8 @@ Rules:
a rule names that source; the Notarius index and complete bundle are not
publish sources.
- `outputs[].required` defaults to `true`.
- static locks (`pipeline.publish.locks`) merge with remote locks (`{session_prefix}/locks.yml`), with static locks taking precedence on duplicates.
- publish locks are independent remote session objects managed with
`narratio session locks`; they are not pipeline configuration fields.
## Full Schema
@@ -155,21 +291,21 @@ Rules:
| Field | Type | Required | Default / Rule |
| --- | --- | --- | --- |
| `configkit.imports[].path` | string | No | explicit additive pipeline fragment relative to the root pipeline directory; `.yml` or `.yaml` regular files only |
| `pipeline.workspace.root` | string | No | `/var/lib/narratio` |
| `pipeline.workspace.cleanup_after_publish` | bool | No | `false` |
| `pipeline.workspace.cleanup_after_publish` | bool | No | `false`; selects the published run's workspace target for final cleanup and requires effective `pipeline.publish.enabled=true` and `pipeline.publish.upload_run=true` |
| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` |
| `pipeline.campaigns.default_campaign_id` | string | No | empty |
| `pipeline.secrets.env_dir` | string | No | empty |
| `pipeline.storage.backend` | string | No | `local`; supported values are `local` and `s3` (case-insensitive) |
| `pipeline.storage.s3.bucket` | string | Conditional | required when backend is `s3` and S3 session-audio or publish upload is enabled |
| `pipeline.storage.s3.root_prefix` | string | No | `dnd` |
| `pipeline.storage.s3.region` | string | No | empty |
| `pipeline.storage.s3.endpoint` | string | No | empty |
| `pipeline.storage.s3.force_path_style` | bool | No | `false` |
| `pipeline.storage.s3.access_key_id_env` | string | No | `OBJECT_STORAGE_KEY_ID` |
| `pipeline.storage.s3.secret_access_key_env` | string | No | `OBJECT_STORAGE_KEY` |
| `pipeline.storage.s3.access_key_id` | secret reference | Conditional | unset; must be paired with `secret_access_key` |
| `pipeline.storage.s3.secret_access_key` | secret reference | Conditional | unset; must be paired with `access_key_id` |
| `pipeline.spool.root` | string | No | `/var/spool/narratio` |
| `pipeline.spool.delete_audio_after_publish` | bool | No | `false` |
| `pipeline.spool.delete_audio_after_publish` | bool | No | `false`; selects the published run's spool-audio target for final cleanup and requires effective `pipeline.publish.enabled=true` and `pipeline.publish.upload_run=true` |
| `pipeline.cache.root` | string | No | `/var/cache/narratio` |
| `pipeline.cache.s3_audio` | bool | No | `true` |
| `pipeline.publish.enabled` | bool | No | `true` |
@@ -178,9 +314,6 @@ Rules:
| `pipeline.publish.outputs[].source` | string | Yes (per rule) | must reference built-in or configured artifact source |
| `pipeline.publish.outputs[].dest` | string | Conditional | derived if omitted and source supports derivation |
| `pipeline.publish.outputs[].required` | bool | No | `true` |
| `pipeline.publish.locks[]` | list | No | empty |
| `pipeline.publish.locks[].source` | string | Yes (per lock) | must reference supported publish source |
| `pipeline.publish.locks[].reason` | string | No | empty |
| `pipeline.whisperx.transcribe_url` | string | Yes | absolute `http` or `https` URL |
| `pipeline.whisperx.language` | string | No | `en` |
| `pipeline.whisperx.timeout` | duration | No | `30m` |
@@ -198,7 +331,7 @@ Rules:
| `pipeline.seriatim.env.filler_max_duration` | float | No | unset |
| `pipeline.audita.binary` | string | No | `audita` |
| `pipeline.audita.timeout` | duration | No | `3h` |
| `pipeline.audita.llm_api_key_env` | string | No | empty |
| `pipeline.audita.llm_api_key` | secret reference | No | unset |
| `pipeline.audita.modules[]` | list[string] | No | empty |
| `pipeline.audita.base_url` | string | No | empty |
| `pipeline.audita.model` | string | No | empty |
@@ -226,10 +359,13 @@ Rules:
| `pipeline.trim.seriatim.report` | bool | No | `false` |
| `pipeline.notarius.enabled` | bool | No | `false` |
| `pipeline.notarius.binary` | string | No | `notarius` |
| `pipeline.notarius.llm_api_key` | secret reference | No | unset; exact environment-variable name forwarded to Notarius |
| `pipeline.notarius.config_path` | string | Conditional | required when enabled; relative paths resolve from the pipeline file directory |
| `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled |
| `pipeline.notarius.timeout` | duration | No | `3h`; must be positive |
| `pipeline.notarius.working_directory` | string | No | directory containing resolved `config_path`; relative paths resolve from the pipeline file directory |
| `pipeline.notarius.resume` | bool | No | `false`; request compatible Notarius checkpoint reuse during ordinary executed extraction |
| `pipeline.notarius.chunk_cache` | string | No | empty; when set, exactly `auto`, `bypass`, or `refresh` |
| `pipeline.notarius.references` | map[string]string | No | empty; maps normalized Notarius selectors to supported prepared Narratio source IDs; maximum 256 entries |
| `pipeline.notarius.outputs` | map | Conditional | at least one entry when enabled |
| `pipeline.render.enabled` | bool | No | `true` |
@@ -243,8 +379,51 @@ Rules:
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
| `pipeline.scriptorium.render_debug` | bool | No | `false` |
| `pipeline.scriptorium.artifacts` | map | No | empty |
| `pipeline.scriptorium.artifact_families` | map | No | empty; expands one ordinary artifact per canonical party character |
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
### Notarius Credential
`pipeline.notarius.llm_api_key` optionally names the one environment variable
to forward to the Notarius child process:
```yaml
notarius:
llm_api_key: OPENROUTER_API_KEY
```
The field contains the variable name, not a credential value. That name must
match `api_key_env` in the Notarius/PromptKit profile selected by the deployed
Notarius pipeline; Narratio does not infer or remap provider names. Omission or
`null` forwards nothing. A non-null selected reference must resolve at pipeline
load time through the process environment or existing secrets-directory
bootstrap, even when Notarius is disabled, extract is outside the requested
range, or a completed extraction can be reused.
The Notarius child receives only Narratio's minimal execution environment plus
this exact selected name and loaded value. Other credentials are not forwarded.
Changing the value, name, presence, or absence does not invalidate extract's
semantic resume evidence. The public name remains part of safe effective
inspection, so renaming it may appear in `config diff` and change the effective
configuration digest; rotating only its value does neither.
See the [Notarius integration contract](./integrations/notarius.md) for the
delivery and redaction boundary.
### Notarius Cache Controls
`pipeline.notarius.resume` defaults to `false`. When true, an executed ordinary
extraction passes `--resume`; the deployed Notarius configuration must already
enable checkpoint recording and should select an explicit persistent checkpoint
root. Narratio does not inspect or generate that configuration.
`pipeline.notarius.chunk_cache` is empty by default, leaving Notarius's
configured mode in control. A nonempty value must be `auto`, `bypass`, or
`refresh` and is passed as `--chunk_cache`. Forced extraction takes precedence
over both fields: it omits `--resume` and passes `--chunk_cache refresh`.
Neither field changes Narratio's semantic extraction fingerprint, so changing
them alone does not rerun an otherwise complete durable extraction.
### Notarius Reference Bindings
`pipeline.notarius.references` maps a Notarius CLI selector to a prepared
@@ -274,8 +453,8 @@ It validates only selector structure and the prepared source vocabulary;
Notarius owns target-slot declarations and media compatibility.
Before extraction, Narratio resolves every binding from the current prepared
session manifest and streams it into a verified invocation-local snapshot whose
absolute path is passed to Notarius. Missing, unsafe, empty,
session manifest and streams it into a verified, content-addressed session
snapshot whose absolute path is passed to Notarius. Missing, unsafe, empty,
changed-during-copy, or checksum-inconsistent prepared evidence fails with
guidance to force `prepare`. Bindings are sorted by normalized selector and are
part of extraction fingerprint and resume identity. See the
@@ -310,7 +489,7 @@ For each `pipeline.scriptorium.artifacts.<name>`:
| Field | Type | Required | Rule |
| --- | --- | --- | --- |
| `enabled` | bool | No | `false` if omitted |
| `depends_on[]` | list[string] | No | must reference configured artifact keys; no self-reference; enabled graph must be acyclic |
| `depends_on[]` | list[string] | No | explicit ordering-only edges; must reference configured artifact keys, may not self-reference, and join inferred input edges in one acyclic graph |
| `render_debug` | bool | No | per-artifact override |
| `prompt_id` | string | Conditional | required when artifact is enabled |
| `profile_id` | string | No | empty |
@@ -323,12 +502,13 @@ Narratio adds `session_id=narratio-session-<session_id>` to every Scriptorium re
Without `--artifacts`, analyze executes enabled configured artifacts. With an
explicit `--artifacts` list, the exact named configured artifacts are the
one-invocation execution set even if their `enabled` values are false; the list
does not automatically include dependencies. Named artifacts must therefore be
configured with valid executable fields, and their configured dependencies must
already be available to analyze. This override affects analyze planning only;
publish uses the list only to filter configured
`narratio.artifact.<name>` output rules.
one-invocation targets even if their `enabled` values are false. Analyze closes
those targets over the effective dependency graph: a current prerequisite is reused, while a
stale, missing, or failed prerequisite is rebuilt before its dependent.
Unrelated artifacts are not executed. Named targets and any prerequisite that
may require rebuilding must therefore have valid executable fields. This
override affects analyze planning only; publish uses the list only to filter
configured `narratio.artifact.<name>` output rules.
For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_name>`:
@@ -341,6 +521,53 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
loading. Use the canonical `source` identifier to select the input; Narratio
does not provide adapter-specific input passthrough fields.
Each `narratio.artifact.<name>` input source automatically contributes an
effective dependency on that configured artifact. Narratio unions these inferred
edges with explicit `depends_on` entries, then trims, deduplicates, and sorts the
result during final configuration resolution. Authors use `depends_on` only for
ordering constraints that are not already expressed by an artifact input.
Use [`narratio config artifacts`](./cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
to inspect keys, definition sources, outputs, and effective dependencies without
loading a session or creating runtime state.
### Scriptorium Artifact Families
`pipeline.scriptorium.artifact_families` declares a shared artifact template
for every canonical campaign character. Configuration resolution expands each
family into ordinary `pipeline.scriptorium.artifacts` entries before analyze
planning or Scriptorium invocation. Party parsing rejects unsupported document
schemas before family expansion.
For each `pipeline.scriptorium.artifact_families.<name>`:
| Field | Type | Required | Rule |
| --- | --- | --- | --- |
| `enabled`, `prompt_id`, `profile_id`, `timeout`, `render_debug`, `depends_on`, `inputs`, `vars` | ordinary artifact fields | No | copied to each generated artifact under the corresponding ordinary rules |
| `for_each` | string | Yes | exactly `party.characters` |
| `output_path_pattern` | string | Yes | safe path beneath `artifacts/` with exactly one `{character_id}` token and no other brace syntax |
| `member_vars` | map | No | maps an ordinary Scriptorium variable name to a supported canonical character selector |
| `member_dependencies` | list | No | unique family keys; each generated member depends on the corresponding generated member of each listed family |
| `publish` | map | No | typed family publish policy (`enabled`, `required`, `dest_pattern`) expanded into concrete publish outputs when enabled |
Generated keys are `<family>_<character_id>` and generated output paths must
not collide with explicit artifacts or another generated artifact. Families
expand even when disabled; normal analyze selection still omits disabled
artifacts unless they are explicitly selected by their concrete key.
Supported `member_vars` selectors are `character_id`, `player.name`,
`character.name`, `character.class_summary`, and `character.alias_summary`.
Their resolved values are strings. A member variable may not reuse a static
`vars` name; `session_id` remains owned and overwritten by Narratio as for any
other Scriptorium artifact.
Within a family only, an input source may use
`narratio.member_artifact.<family>`. Resolution rewrites the source to the
corresponding ordinary `narratio.artifact.<family>_<character_id>` source and
infers the concrete member dependency. Use `member_dependencies` only for
additional same-character ordering constraints that are not already inputs.
This syntax is rejected in explicit artifacts and never reaches runtime stages
or Scriptorium.
### Notifications
Narratio currently supports only `notification.mode: noop`, which is also the
@@ -358,8 +585,7 @@ integration.
| `inputs.speakers_file` | string | Yes | stable input default |
| `inputs.autocorrect_file` | string | Yes | stable input default |
| `inputs.glossary_file` | string | Yes | stable input default |
| `inputs.players_file` | string | Yes | stable input default |
| `inputs.party_file` | string | Yes | stable input default |
| `inputs.party_file` | string | Yes | stable campaign party source; relative paths resolve from `campaign.yml` |
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
### Session
@@ -374,8 +600,6 @@ integration.
| `inputs.speakers_file` | string | No | overrides campaign stable input |
| `inputs.autocorrect_file` | string | No | overrides campaign stable input |
| `inputs.glossary_file` | string | No | overrides campaign stable input |
| `inputs.players_file` | string | No | overrides campaign stable input |
| `inputs.party_file` | string | No | overrides campaign stable input |
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
| `inputs.audio_dir` | string | Conditional | local audio mode |
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |

View File

@@ -10,6 +10,11 @@ polished transcripts and generated artifacts. Start with the
[Architecture](policy/architecture.md) for normative system boundaries, and the
[Internal Overview](internal/overview.md) for implemented component ownership.
## Go Prerequisite
Maintained development and verification require Go 1.26. Use the official Go
1.26 toolchain before running repository commands.
## What To Read
| When working on | Read | Why |
@@ -25,6 +30,7 @@ polished transcripts and generated artifacts. Start with the
| Adapters or external tool contracts | [Adapter Internals](internal/adapters.md) and [Integration Contracts](integrations/README.md) | The internal guide owns adapter composition and mechanics; integration documents own external formats and protocols. |
| Manifests, artifacts, workspace paths, or publish behavior | [Manifest Internals](internal/manifest.md), [Artifact Internals](internal/artifacts.md), [Workspace Internals](internal/workspace.md), [Publish Internals](internal/stage-publish.md), and [Operations](operations.md) | These separate implementation state and resolution from operator-visible layout and lifecycle. |
| Maintained configuration or input examples | [Configuration](config.md) and [Examples](../examples/README.md) | The reference owns field meanings; the examples directory owns complete copyable files. |
| Preparing, validating, or publishing a release | [Release Procedure](release.md) | The maintainer procedure owns version selection, candidate validation, guarded tag publication, and optional later CI inspection. |
| Proposed or unimplemented behavior | `docs/roadmap/` | Future work belongs only in roadmap documentation until implemented. |
For an existing subsystem, also inspect its focused tests and package-level
@@ -46,13 +52,13 @@ go test ./internal/config -run '^TestExamplesLoadAndValidate$'
The documentation check verifies local Markdown links and the dependency graph
of the Woodpecker workflows. The configuration check loads every maintained
pipeline and session example. Release automation repeats these checks and
cross-compiles the CLI before it builds release assets; publishing depends on
that validation path, so a failure cannot publish a release.
pipeline and session example. Tag CI reuses this validation path before its
asynchronous asset publication; the maintainer release boundary is documented
in the [Release Procedure](release.md).
Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled
job to expose ordering and repeatability defects. Current runners cross-compile
for macOS and Windows, but do not provide native macOS or Windows execution.
Those cross-builds establish compilation only, not platform-equivalent runtime
evidence. Add native checks only when official runner labels and successful
the supported Linux amd64 and arm64 targets and the best-effort macOS arm64
target. The macOS cross-build establishes compilation only, not native runtime
evidence. Add a native check only when an official runner label and successful
native-run evidence are available.

View File

@@ -21,6 +21,7 @@ focused stage documents.
- [Audita](./audita.md): transcript polishing (`audita process`).
- [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle
discovery (`notarius run`).
- [Party](./party.md): canonical campaign roster input.
- [Seriatim](./seriatim.md): merge, normalize, trim, and render operations.
- [Scriptorium](./scriptorium.md): artifact generation and debug rendering
(`scriptorium run|render`).

View File

@@ -41,7 +41,6 @@ Construction fails for invalid static config values, including:
Run fails for:
- missing required request paths;
- missing required credential env var when configured (`llm_api_key_env`);
- subprocess execution failure;
- invalid processed transcript JSON (`segments` array required);
- invalid report JSON when reporting is enabled.
@@ -53,6 +52,9 @@ Failure results still include output/log/config/exit metadata for diagnostics.
## Deterministic Behavior
- CLI args are built from runner config + request in a fixed order.
- A configured typed `llm_api_key` reference is resolved during pipeline load;
the captured value is supplied only as the child's `AUDITA_LLM_API_KEY`
override and is never re-read from the parent environment.
- Generated invocation YAML (`audita.generated.v1`) is emitted when requested.
- Manifest writes are stage-owned; adapter itself is stateless.

View File

@@ -7,14 +7,14 @@ lanes from the final trimmed Seriatim transcript. Narratio owns invocation,
safe bundle discovery, lane selection, and its own artifact metadata. Notarius
owns pipeline definitions, lane schemas, the receipt, and bundle formats.
Canonical Notarius v0.6.0 references:
Canonical Notarius v0.7.0 references:
- [CLI reference](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/cli.md)
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/consumers/subprocess.md)
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/consumers/dnd-pipeline.md)
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/run-result.md)
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/json-output.md)
- [D&D spell-catalog overlay](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/dnd-spell-catalog-overlays.md)
- [CLI reference](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/cli.md)
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/consumers/subprocess.md)
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/consumers/dnd-pipeline.md)
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/run-result.md)
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/json-output.md)
- [D&D spell-catalog overlay](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/dnd-spell-catalog-overlays.md)
The [complete Narratio example](../../examples/pipeline.full.annotated.yml)
records the exact current constraints for all ten D&D lanes. Treat the linked
@@ -25,22 +25,35 @@ duplicate the complete schemas.
When `pipeline.notarius.enabled` is true, Narratio resolves the executable,
configuration path, input path, output directory, and working directory to
absolute paths. Narratio requires the Notarius v0.6.0 CLI contract when
references are configured and invokes each binding as a separate argument
before `--json`:
absolute paths. Configured references require support for the `--reference`
contract introduced in Notarius v0.6.0. Cache controls require support for the
selected `--resume` and `--chunk_cache` flags. Notarius v0.7.0 is the tested
cache integration baseline, not an unconditional minimum version; Narratio
does not impose a runtime version gate. Each reference binding is a separate
argument before `--json`:
```text
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> [--reference <selector>=<verified_snapshot_path>]... --json
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> [--resume] [--chunk_cache <mode>] [--reference <selector>=<verified_snapshot_path>]... --json
```
Reference paths are absolute invocation-local snapshots streamed from the
manifest-verified canonical files prepared inside the current Narratio session
workspace. Narratio verifies snapshot checksum and size before and after the
subprocess, and passes only configured bindings, ordered lexically by normalized
selector, as direct argument-vector entries without shell interpretation. A CLI
Reference paths are absolute stable snapshots streamed from manifest-verified
canonical files prepared inside the current Narratio session workspace. They
live at `cache/notarius/references/<sha256>/<prepared-basename>` and retain the
full lowercase content digest. Narratio verifies snapshot checksum and size
before and after the subprocess, reconstructs missing or corrupt regular files
from a still-valid prepared source, and passes only configured bindings,
ordered lexically by normalized selector, as direct argument-vector entries
without shell interpretation. Symlinks, directories, other non-regular
conflicts, and prepared-source mismatches fail rather than being deleted. A CLI
binding takes precedence over a matching external path in Notarius
configuration. Narratio never emits `--without-reference`.
For canonical party configuration, the `party` binding is the unchanged,
validated authored roster and the `players` binding is its generated
projection. Both retain their established `narratio.input.party` and
`narratio.input.players` source IDs, and both are resolved from the prepared
manifest rather than from campaign configuration at extraction time.
The maintained D&D boundary binds only the four campaign-owned external slots:
```text
@@ -69,9 +82,41 @@ It does not pass a Narratio session ID or run `notarius config validate`
automatically; the configured working directory and Narratio's minimal child
environment apply to the subprocess.
Ordinary extraction maps `pipeline.notarius.resume: true` to `--resume` and a
nonempty `pipeline.notarius.chunk_cache` to `--chunk_cache <mode>`. Forced
extraction always omits `--resume` and supplies `--chunk_cache refresh`.
Notarius owns checkpoint enablement, recording, identity, and both cache roots;
Narratio neither reads nor synthesizes its configuration. Use explicit
persistent roots in Notarius when reuse must span subprocess invocations.
Changing a reference path/digest or other upstream identity input correctly
causes a cold miss. Chunk refresh bypasses lookup and publishes the accepted
replacement for the active source; it does not delete unrelated plans or
checkpoint state.
When `pipeline.notarius.llm_api_key` is absent or null, Narratio adds no
credential to that environment. When configured, the field names an environment
variable whose already-loaded nonempty value is forwarded under that exact
name. Operators must use the same name as the `api_key_env` in the selected
Notarius/PromptKit profile. Narratio does not inspect the Notarius configuration
to infer a provider or destination name, and it does not forward unrelated
credentials from its parent environment or secrets directory.
The selected name is registered with Narratio's shared streaming redactor.
Credential bytes written by the child to captured stdout, captured stderr, or
stderr-derived failure diagnostics are replaced with `<redacted>`, including
custom names not known to the built-in sensitive-name list. Narratio does not
serialize the credential into arguments, stage metadata, manifests, or
generated configuration.
This captured-stream protection is not a filesystem sandbox. Notarius remains
responsible for keeping credentials out of files it writes directly, including
bundle artifacts and its own diagnostics. Narratio does not scan or rewrite
arbitrary downstream files and cannot promise redaction of transformed or
encoded credentials.
## Accepted Result
Narratio's supported invocation baseline is Notarius v0.6.0. The accepted
Narratio's tested invocation baseline is Notarius v0.7.0. The accepted
receipt remains `notarius.run-result.v2`; reference flags do not change the
receipt or ten-lane output contract. The receipt
must identify the configured pipeline, and its `index_file` must be exactly
@@ -96,6 +141,8 @@ version, and, when configured, module key. Missing, duplicate, rejected, or
incompatible required lanes fail extraction even if Notarius exited zero. A
configured lane whose v2 validation summary is `rejected` or `incomplete` also
fails extraction.
An accepted reused producer may report `producer_attempt_count: 0`; Narratio
preserves that valid summary rather than treating it as a missing field.
Unconfigured lanes may remain in the preserved bundle but do not become
selectable Narratio sources.
@@ -124,3 +171,18 @@ Configuration fields and defaults are in [Configuration](../config.md).
Operator paths, rerun procedures, and bundle retention are in
[Operations](../operations.md). See [Troubleshooting](../troubleshooting.md)
for failure recovery.
## Opt-in Integration Test
The production-boundary cache test requires an absolute path to a locally
built Notarius v0.7.0-compatible executable. It uses only a local
OpenAI-compatible `httptest` provider and temporary cache/config/output roots:
```sh
NARRATIO_TEST_NOTARIUS_BINARY=/absolute/path/to/notarius \
GOWORK=off go test ./internal/stage \
-run '^TestNotariusCacheReuseIntegration$' -count=1 -v
```
When the variable is unset, the normal test suite skips this test and never
searches `PATH`, a sibling checkout, the network, or a real provider.

View File

@@ -0,0 +1,71 @@
# Canonical Party Input
`party.yml` is a campaign-owned roster input. It must be a single, versioned
`narratio.party.v1` document when Narratio resolves a pipeline, campaign, and
session together.
```yaml
schema_version: narratio.party.v1
characters:
arannis:
player:
name: Eric
character:
name: Arannis
alias:
- Ari
- The Grey Owl
classes:
- name: wizard
level: 8
```
`characters` is a non-empty mapping. Each key is a stable character ID using
the configured-artifact key grammar: a lowercase ASCII letter followed by zero
or more lowercase ASCII letters, digits, or underscores. Character order is
preserved where roster order matters.
Every entry has `player.name`, `character.name`, and a non-empty
`character.classes` list. Class entries require a non-empty `name` and may
include a positive integer `level`. The optional, intentionally singular
`character.alias` field is a list. Names, aliases, and class names must be
non-empty, trimmed display strings without control characters. Character names
and aliases must be unique across the full roster under Unicode-aware
case-insensitive comparison; player names may repeat.
The document has exactly one YAML document and accepts no unknown fields.
`schema_version` is required: a missing, wrong, or malformed value is an error.
## Migration
Unversioned party inputs are rejected. Replace each legacy party document with
the versioned structure above, move each roster entry under `characters`, and
remove separate player and session-party settings: those keys are unsupported.
The configuration rules and source-relative path behavior are defined in the
[Configuration Reference](../config.md).
## Derived players document
During `prepare`, Narratio copies the canonical party source bytes unchanged
to `inputs/party.yml` and writes this deterministic players-only projection to
`inputs/players.yml`:
```yaml
schema_version: narratio.players.v1
players:
- name: Eric
character:
id: arannis
name: Arannis
alias:
- Ari
- The Grey Owl
```
There is one entry per character, sorted by stable character ID. Repeated
player names remain separate entries. The optional `alias` list retains its
declared order and is omitted when empty. The projection carries no class
data. Its prepared manifest record is marked `derived_from_party`; it is not a
separate user-provided `players_file`.

View File

@@ -35,45 +35,74 @@ Adapters do not own:
## Default Wiring
`internal/app/runner.go` initializes default adapters when not injected:
The application composes default adapters per invocation when they were not
injected through the stage environment, and only when the selected execution
plan needs them:
- WhisperX HTTP client from pipeline config.
- Seriatim subprocess runner.
- Audita subprocess runner.
- Scriptorium subprocess runner.
- Notarius subprocess runner when extraction is enabled.
- Noop notifier (`notify.NoopSender`).
- WhisperX HTTP client for `transcribe`.
- Seriatim subprocess runner for `merge`, `normalize`, `trim`, or `render`.
- Audita subprocess runner for `polish`.
- Scriptorium subprocess runner for `trim` or `analyze`.
- Notarius subprocess runner for `extract` when extraction is enabled.
- Noop notifier (`notify.NoopSender`) for `notify`.
- Object store only when required by selected stages/config.
Remote publish locks are loaded only for a selected, enabled publish that
uploads a run. Shared session lifecycle setup still applies to every selected
range, but an unselected integration is neither initialized nor validated by
application-owned composition. Constructor, clock, randomness, remote-lock,
and post-publish deletion boundaries are scoped to the invocation; stages still
receive their collaborators through the stage environment. Each selected stage
retains its own fail-fast configuration and input validation.
`session plan` is outside production adapter composition. It performs
resume validation and models selected transitions against cloned manifest
state without constructing or invoking stage-execution adapters. The shared
command configuration loader may still use object storage to retrieve a missing
remote session file before planning begins.
Notarius is composed only when extraction is enabled; the extract stage owns
prepared reference resolution, receipt, bundle, and configured-lane policy.
The adapter validates the ordered selector/absolute-path pairs and is the sole
owner of serializing them as repeated `--reference` arguments before `--json`.
It also serializes an effective resume request as `--resume` and a nonempty
validated chunk mode as `--chunk_cache <mode>`. The extract stage, not the
adapter, resolves force precedence before constructing the request.
Its request may also carry one already-resolved opaque ConfigKit secret
reference. Only the Notarius adapter reads that value, rejects an unresolved or
empty programmatic reference, and maps the exact configured name/value into the
shared subprocess override and sensitive-name fields. It never rereads the
parent environment. A nil reference preserves the previous invocation.
Object-store construction goes through `newCommandObjectStore`, which loads
configured filesystem secrets before adapter initialization.
Application-owned object-store construction loads configured filesystem
secrets before invoking the invocation's storage factory. Production commands
use the concrete storage adapter constructor; focused tests can supply an
in-memory backend without changing package state.
## Failure Semantics
- Constructor errors fail stage execution setup early.
- Runtime adapter errors propagate to stage code and then manifest failure handling.
- Subprocess adapters persist stage logs/generated configs through stage-managed paths.
- Shared subprocess execution starts an owned process group on Linux/macOS or a
kill-on-close job object on Windows. Every terminal path disposes of that
owned tree before returning. After a natural leader exit, Unix checks for
remaining group members and uses bounded graceful then forceful termination;
Windows closes the job so kill-on-close applies. Cancellation, deadlines, and
- Shared subprocess execution keeps private lifecycle mechanics separate from
Narratio's environment, diagnostics, redaction, and error policy. It starts
an owned process group on Linux and macOS. Every terminal path disposes of
that owned tree before returning. After a natural leader exit, the runner checks
for remaining group members and uses bounded graceful then forceful termination.
Cancellation, deadlines, and
diagnostic limits use the same terminal disposal path without losing their
original result classification. Child environments contain only the execution
baseline and adapter-specified values; configured credentials are explicit
sensitive values. Stdout and stderr are redacted while streaming into separate
baseline and adapter-specified values; configured credentials are resolved
once by ConfigKit and passed explicitly as sensitive values. Adapters do not
re-read their named environment references. Stdout and stderr are redacted while streaming into separate
8 MiB diagnostic captures; a bounded wait closes a stream retained by a
departed leader's descendant. Unsupported platforms reject owned command
execution.
## Implementation And Tests
- Composition: `internal/app/runner.go`, `internal/app/object_store.go`
- Composition: `internal/app/runner_composition.go`,
`internal/app/runner_dependencies.go`, `internal/app/object_store.go`
- Shared subprocess mechanics: `internal/adapters/subprocess`
- Focused adapters: `internal/adapters/{whisperx,seriatim,audita,scriptorium,notarius,storage,notify}`
- `internal/adapters/whisperx/http_test.go`

View File

@@ -20,8 +20,8 @@ The internal registry recognizes these stable built-in source IDs:
- `narratio.transcript.final_trimmed_markdown`
- `narratio.bounds.session`
Registry entries bind each ID to its producer, output kind, canonical fallback,
and content validator. The focused stage documents own their input/output flow;
Registry entries bind each ID to its producer, output kind, and content
validator. The focused stage documents own their input/output flow;
[Configuration](../config.md) owns where operators may select these IDs.
## Configured, Extraction, And Previous-Session Sources
@@ -39,7 +39,10 @@ ID.
Prepared stable source IDs are `narratio.input.players`,
`narratio.input.party`, `narratio.input.glossary`, and
`narratio.input.spell_catalog`. Artifact policy owns their canonical manifest
kind and prepared filename vocabulary.
kind and prepared filename vocabulary. Canonical party mode preserves the
party source bytes in the party record and supplies the players record from the
deterministic `derived_from_party` projection; both remain ordinary prepared
source IDs for consumers.
## Runtime Catalog
@@ -47,22 +50,43 @@ kind and prepared filename vocabulary.
- `planned`: source registered for run context;
- `executable`: included in the effective analyze artifact set;
- `available`: local file exists and validates;
- `available`: the source's canonical evidence owner validates its current
manifest record and durable bytes;
- `provenance`: availability source.
Configured definitions are always registered. Without an explicit selection,
the effective analyze set contains enabled definitions. With `--artifacts`, the
exact named configured definitions become the effective set for that invocation,
regardless of their `enabled` value; dependencies are not added implicitly.
Availability is separate from executability: a non-executable configured output
may be reused from a canonical non-empty file, while an executable definition
is generated by analyze. Extraction entries are registered from configuration
and become available only after compatible extraction evidence is hydrated.
regardless of their `enabled` value. The effective-set resolver itself does not
expand dependencies. Configuration has already materialized the sorted union of
explicit ordering edges and configured-artifact input edges, and the analyze
work planner closes targets over that one concrete prerequisite graph.
Availability is separate from executability. Configuration may normalize a
family selection into its concrete generated members before this resolver runs.
The effective set retains optional family
and character origin metadata, but its keys, catalog sources, and runtime
lookups remain concrete configured-artifact identities.
Family publish policies are likewise expanded into ordinary configured-source
publish rules during configuration resolution.
Configured outputs, including non-executable prerequisites, become available
only when the versioned analyze state identifies a current result whose source,
contract, canonical configured path, size, and checksum match a confined
no-follow regular file. An incidental canonical file is unavailable, and a
manifest with an unsupported aggregate-only representation is rejected before
catalog construction.
Extraction entries are registered from configuration and become available only
after compatible extraction evidence is hydrated.
During an analyze invocation, a newly validated and atomically materialized
configured output is marked available with its producer run ID, contract,
checksum, and size. Later scheduled dependents therefore observe the same
semantic identity whether their prerequisite was reused from current manifest
evidence or produced earlier in the invocation.
Current provenance values:
- `generated.current_analyze_run`
- `filesystem.disabled_artifact_output`
- `manifest.current_analyze_artifact`
- `manifest.inputs.previous_cache`
- `current_session.previous_cache`
@@ -70,12 +94,31 @@ Current provenance values:
Built-ins:
1. manifest producer outputs (when present)
2. canonical session-path fallback
- resolve only from the current manifest's succeeded registered producer;
- require exactly one output with the registered source ID and output kind, a
safe session-rooted path, producer run ID, canonical checksum, and valid
content; and
- verify the registered checksum against the durable bytes. Incidental files,
canonical filenames, duplicate outputs, failed/stale producer records, and
malformed evidence are unavailable.
Configured sources (`narratio.artifact.*`):
- resolve only through runtime catalog availability.
- resolve only through runtime catalog availability;
- use the shared typed analyze-evidence inspection in
`analyze_evidence.go` for prior current-session results;
- require the supported analyze-state and fingerprint versions, a `current`
record for the exact configured key and source ID, a complete contract, the
configured canonical relative path, positive stored size, and stored
checksum matching bytes read from a confined no-follow regular file; and
- treat non-current statuses, malformed records, removed keys,
unsafe or missing files, and size/checksum mismatches as unavailable without
rewriting manifest state. Catalog construction iterates current
configuration, so removed or renamed records are not advertised.
`narratio.member_artifact.*` is not a runtime source family. Configuration
resolution accepts it only in an artifact-family declaration and rewrites it
to the corresponding configured source before this catalog is built.
Prepared stable sources (`narratio.input.*`):
@@ -100,9 +143,12 @@ Extraction sources (`narratio.extraction.*`):
Previous-session sources (`narratio.previous_session.artifact.*`):
- resolve only from local `previous/` cache state;
- prefer manifest-backed previous-input paths;
- fallback to existing previous-cache filesystem paths.
- resolve only from one matching `previous_artifact` input record in the
current manifest, including its exact source ID and checksum;
- are hydrated only from the unique `published_output` declaration for their
canonical source in the selected remote commit; and
- never infer a path from a previous manifest, current configuration, basename,
or existing cache file.
Source absence is evaluated by the consuming artifact input. An optional input
is omitted from that invocation; a required input fails resolution. This is
@@ -130,25 +176,34 @@ Validation by content type:
Artifacts package owns shared remote current-state loading mechanics used by
restore, status and validation checks, and previous-cache planning.
The selected session manifest is accepted only with
`schema_version: narratio.session-manifest.v1` and complete stage identity.
An unsupported local or downloaded manifest must be replaced by regeneration,
verified restore, or republication; artifact resolution never fills in missing
authority markers.
For a new-protocol current state, the pointer-selected immutable commit is the
complete restore authority. Callers receive its declared object identities and
must not supplement them by listing mutable session prefixes. The legacy reader
is intentionally separate and remains migration-only support.
must not supplement them by listing mutable session prefixes.
A `published_output` declaration must use a canonical publish-compatible source
ID and an exact `runs/{run_id}/published/{destination}` key in the same session.
Only this declaration type may refer to an earlier run; other declared objects
remain confined to the selected commit's run.
The reader opens each small control object directly and enforces owner-specific
limits before decoding: 64 KiB for the mutable commit pointer, 4 MiB for the
immutable commit manifest, and 8 MiB for the selected session manifest. Legacy
compatibility applies a 4 KiB limit to `current/run_id.txt` and the same 8 MiB
manifest limit to `current/manifest.json`. These are exposed as
`MaxCurrentCommitPointerBytes`, `MaxRemoteCommitManifestBytes`,
`MaxRemoteSessionManifestBytes`, `MaxLegacyCurrentRunPointerBytes`, and
`MaxLegacyCurrentManifestBytes`.
immutable commit manifest, and 8 MiB for the selected session manifest. These
are exposed as `MaxCurrentCommitPointerBytes`,
`MaxRemoteCommitManifestBytes`, and `MaxRemoteSessionManifestBytes`.
Each read uses the generation and size metadata returned with its opened body.
Actual bytes remain subject to a limit-plus-one read even if size metadata is
absent or inaccurate. Immutable selections then retain their declared-size,
checksum, generation, and identity checks. No current-state control object is
downloaded through a temporary file.
downloaded through a temporary file. Artifact and previous-session downloads
validate their complete sibling temporary file against available committed
identity before replacing a canonical local path.
Core helpers:
@@ -156,10 +211,9 @@ Core helpers:
- `ValidateCurrentStateIdentity`
- `RemoteCommitManifest` and `CurrentCommitPointer`
Typed missing-state errors:
Typed missing-state error:
- `CurrentRunPointerMissingError` (`ErrCurrentRunPointerMissing`)
- `CurrentManifestMissingError` (`ErrCurrentManifestMissing`)
- `CurrentCommitPointerMissingError` (`ErrCurrentCommitPointerMissing`)
Identity validation supports caller-provided expectations:
@@ -173,6 +227,21 @@ Caller policy is intentionally outside artifacts helpers:
- some callers downgrade missing state to status/findings;
- some callers skip optional behavior when state is missing.
## Remote Lock Objects
The artifacts package defines the independent remote-lock payload and identity
contract. Each canonical publish source maps to one object below the validated
session `locks/` prefix; its filename is the lowercase SHA-256 digest of the
exact source followed by `.lock`.
Lock payloads are strict versioned JSON containing the canonical source, an
optional reason, and a UTC creation time. Reads are bounded to
`MaxRemoteLockObjectBytes` (64 KiB), reject unknown fields and trailing values,
and verify that the payload source matches the key digest. Exact lookup reports
presence explicitly, prefix listing validates every object and sorts by source,
creation uses an absence precondition, forced replacement uses one observed
generation, and removal is idempotent.
## Key Path Helpers
`internal/artifacts/paths.go` and S3-key helpers define canonical helpers for:
@@ -185,8 +254,13 @@ Caller policy is intentionally outside artifacts helpers:
New publication creates run-scoped immutable objects, including
`runs/{run_id}/commit.json` and `runs/{run_id}/session-manifest.json`. The sole
mutable selector is `current/commit-pointer.json`; readers verify its selected
commit and declared object generations/checksums. Legacy current-pair loading
is confined to `current_state_legacy.go` for migration only.
commit and declared object generations/checksums.
A `published_output` declaration may identify its commit's run or an earlier
immutable run in the same campaign/session namespace. The key parser validates
that namespace, rejects mutable and malformed keys, and derives the originating
run from the exact declaration. All other commit artifacts remain confined to
the commit's own run prefix.
Campaign, session, and Narratio run IDs are validated as portable opaque
segments at configuration and artifact boundaries before they can be used in a
@@ -217,7 +291,6 @@ physical layout.
`internal/artifacts/prepared_input.go`
- Current state: `internal/artifacts/current_state.go`,
`internal/artifacts/current_state_commit.go`,
`internal/artifacts/current_state_legacy.go`
- Paths and keys: `internal/artifacts/paths.go`,
`internal/artifacts/s3_keys.go`
- Previous requirements: `internal/artifacts/previous_requirements.go`

View File

@@ -11,6 +11,11 @@ physical restore scope belong in
Restore separates remote authority, local conflict policy, and filesystem
mutation so each remains testable independently.
The public command supplies production defaults to one private,
invocation-scoped dependency value. Discovery, planning, and execution use that
same value, so focused sequencing tests can replace one boundary without
mutable package hooks.
## Discovery Contract
Discovery delegates current-state pointer and manifest loading to
@@ -34,18 +39,25 @@ Planner behavior:
- a new-protocol restore uses only the selected commit's declared artifact set;
each action carries that artifact's immutable key, checksum, size, and
generation. Coherent legacy state remains on the isolated compatibility path;
generation;
- remote-to-local mapping is traversal-safe;
- actions are sorted by local relative path and then remote key;
- planning opens the selected session root and inspects every target by its
normalized session-relative path. Missing ancestors produce a missing target;
an ancestor or leaf symlink is a non-regular conflict and is never opened for
hashing;
- force converts differing eligible regular files from conflicts to downloads;
directories and other non-regular targets remain conflicts.
directories, symlinks, and other non-regular targets remain conflicts.
For a non-dry-run restore, planning/classification happens only after acquiring
the session lock. Runner manifest/reuse checks acquire that same lock first.
Previous-cache readiness is resolved through `previouscache.Resolve` for restore,
prepare, status, and validation. A committed source is selected only by its
exact source identity; legacy fallback remains isolated and rejects ambiguity.
prepare, status, and validation. Each requirement selects the unique
`published_output` for its canonical source in the pointer-selected commit,
then strips only that run's immutable `published/` prefix for its safe local
cache path. It never probes candidate objects or derives paths from manifests,
configuration, or basenames.
## Execution Contract
@@ -53,10 +65,14 @@ Execution order and safety:
- non-manifest downloads happen before manifest install;
- `manifest.json` installs last;
- downloads use sibling temp files plus atomic rename;
- downloads use sibling temp files, validate the complete object, then use an
atomic rename;
- manifest replacement is validated before rename;
- each committed object is verified against its declared checksum, size, and
generation before installation;
- installation reopens the selected session root, creates only validated
missing directory components with workspace modes, and rejects symlinked or
non-regular ancestors and leaves before replacement;
- a committed manifest already verified during discovery is retained for the
matching restore action and revalidated before installation, avoiding a
second body transfer;
@@ -67,15 +83,17 @@ Execution order and safety:
- restored manifest local references are rebased beneath the selected local
session root. Unsafe relative references and producer-machine absolute paths
outside the manifest's producer session root are rejected; producer-local
spool/cache and cleanup locations are not restored as authority.
spool paths and run-local diagnostic references are not restored as
authority. Cleanup remains ordinary restored stage state and is not reset or
synthesized by restore.
Audio restore path:
- uses `audio.MaterializeS3Audio`;
- integrates spool and S3 audio cache paths;
- reuses cached audio only when its no-follow regular file, content digest, and
- reuses cached audio only when its opened regular file, content digest, and
identity sidecar all match the selected remote object version; otherwise it
refreshes through the durable download path.
refreshes through the validated download path.
## Reporting Contract
@@ -92,9 +110,7 @@ Audio restore path:
loaded at discovery; later pointer changes cannot add objects or substitute a
different run into its plan;
- a verified `current/commit-pointer.json` and its selected immutable commit
establish new-protocol remote commitment; coherent legacy
`current/run_id.txt` plus `current/manifest.json` remains read-only migration
support;
establish remote commitment; when absent, republish the session;
- restore does not execute pipeline stages.
## Implementation And Tests
@@ -107,4 +123,5 @@ Audio restore path:
- Tests: `internal/app/restore_discovery_test.go`,
`internal/app/restore_plan_test.go`,
`internal/app/restore_execution_test.go`,
`internal/app/restore_recovery_test.go`,
`internal/app/restore_workflow_test.go`

View File

@@ -0,0 +1,215 @@
# Configuration Internals
User-visible fields, defaults, and selection behavior belong in the
[Configuration Reference](../config.md). This document describes the internal
pipeline-loading boundary implemented by `internal/config`.
## Pipeline Loading
`LoadPipeline` assembles and validates a pipeline in this order:
1. Give ConfigKit a fresh typed `PipelineConfig` default value, the root file,
enabled imports, and the caller's profile selection. Omitted, empty, and
exact `default` selections use the base; named profiles use the fixed
`profiles/<name>.yml` convention.
2. ConfigKit parses the root and declared imports, additively merges base
sources, applies only the selected overlay, and strictly decodes the result.
3. The definition resolver applies recorded entry-level and inherited defaults,
runs deterministic domain normalization, records derived publish destinations,
and resolves ordinary paths relative to the root pipeline file.
4. The definition validator performs read-only checks that require only the
pipeline file. Checks requiring canonical-party family expansion remain at
the existing explicit pipeline/campaign resolution boundary.
5. Narratio inspects public ConfigKit metadata to reject artifact and family keys
declared by multiple base files, adapts source ownership records, attaches
runtime-only state, and computes the normalized effective digest.
This ordering preserves monolithic configuration behavior. Moving a field to
an imported fragment changes its source ownership, not its path base, default,
or schema semantics.
## Loaded Context Resolution
`LoadedPipelineCampaign` carries one already composed pipeline and its selected
campaign into session resolution. `LoadSessionWithPipelineCampaignOptions`
loads a local session against that context, while
`ResolveLoadedPipelineCampaign` also accepts an already loaded remote session
or no session while a caller retrieves one. Compatibility loaders route through
these functions after their initial pipeline and campaign reads.
Application commands own pipeline and campaign discovery, campaign-file versus
registry selection, and the corresponding mutual-exclusion rules. Once they
have a `LoadedPipelineCampaign`, local session discovery and remote-session
download retain that exact pipeline object and its private provenance. Removing
a temporary downloaded session file therefore cannot invalidate the resolved
pipeline or campaign context.
Campaign and session files use independent ConfigKit definitions with imports
and profiles disabled. Campaign files normally load directly from their source;
session files are first read through the 8 MiB bounded-read boundary because the
application must reject unresolved template placeholders before passing an exact
byte snapshot to ConfigKit. Local and remotely downloaded session bytes then use
the same typed loader and retain the existing post-load identity expectations,
stable-input precedence, and source-relative path semantics.
The application also has a separate read-only inspection resolver for `config
validate`, `config show`, `config sources`, and `config artifacts`. It uses the same production root/profile and
campaign selection functions, but never routes through session discovery,
remote-session download, adapter composition, workspace
initialization, manifest access, or cleanup. A pipeline with retained artifact
family declarations must resolve its selected campaign before ordinary pipeline
validation, which expands its canonical-party members and generated publish
rules. A pipeline without those declarations may be validated by itself.
Secret-directory bootstrap remains invocation-scoped and happens before the
ordinary ConfigKit load because explicit typed references must resolve even for
inspection.
`MarshalEffectivePipeline` is the configuration-owned projection for `config
show`. It serializes one explicit typed safe projection with the existing YAML
encoder. The projection has no slots for resolution-only artifact family
declarations or private provenance. The result has one trailing newline;
commands do not marshal runtime objects directly.
`EffectivePipelineSources` and `EffectiveCampaignSources` provide the separate
safe provenance projection for `config sources`. Pipeline ownership begins with
the complete logical field paths projected from the schema and classifies
each contributor as root, import, profile, or centralized default. The
projection replaces generated concrete member paths with paired family and
canonical-party records, and does the same for generated publish rules.
Campaign records identify campaign-owned fields and party inputs; canonical
derived players point to the party source. The application command only joins these sorted
records with selection metadata and never reparses configuration files.
`EffectiveArtifactCatalog` projects the same finalized concrete artifact map for
`config artifacts`. It derives source identity from each map key, carries sorted
effective dependencies, aggregates ConfigKit definition contributors, and adds
family/character/party provenance for generated members. It does not inspect
files named after artifacts or infer another runtime graph.
`config diff` uses a thin paired loader that performs two independent ConfigKit
loads. When campaign
resolution is needed, the command loads one selected campaign and party and
expands both effective pipelines from that same party value. The configuration
owner projects each normalized effective mapping directly from that same typed
projection into sorted logical paths using JSON number preservation;
mapping leaves are compared individually while sequence values remain atomic.
Values are compact deterministic JSON representations for command output, not
raw YAML fragments, ownership records, or secret material. A differing digest
with no projected difference is treated as an internal consistency error.
Campaign context construction reads the campaign-owned party source through
`ParseParty`. The required canonical party retains its raw bytes and normalized
roster in runtime-only `ResolvedParty` provenance. Canonical resolution creates
a deterministic `derived_from_party` players projection. The party is the only
configured roster authority; player and session-party fields are unsupported.
## Canonical Party Domain
`ParseParty` is the package-owned boundary for parsing a party source. Its
single-source ConfigKit definition disables imports and profiles, requires
`schema_version` through `VersionRule`, and strictly decodes the
character-keyed typed schema before applying Narratio's party-domain checks.
Successful metadata coordinates recover authored character-map order without a
second YAML parse; class and alias slice order comes directly from typed decode.
The canonical value retains a separate exact byte copy of its source so
consumers can materialize the authored party document without reserializing it.
Its `PlayersYAML` method deterministically derives the ID-sorted, versioned
players-only projection.
An unversioned source is rejected before resolution, family expansion, or
prepare-stage materialization.
## Validation Ownership
Configuration validation remains within `internal/config`, with the public
entry points and pipeline-domain sequencing separated from the private owners
for core pipeline/session rules, external adapters, publish/storage, Notarius,
Scriptorium, and shared scalar/path checks. These are package-local ownership
boundaries rather than extension points or a general validation framework.
Validation is read-only, fails fast, and does not define which of several
independent invalid fields is reported first. Map-backed declarations are
nevertheless traversed deterministically. Reusable domain normalization is an
explicit preceding operation for both loaded and programmatically constructed
pipelines. Notarius normalization commits canonical scalar, reference, and output
values only after the complete normalization succeeds; its validator then checks
that candidate without changing it. Scriptorium validates artifact declarations
and outputs, dependencies and inputs, variables, referenced outputs, and the final
dependency graph in deterministic artifact order.
Diagnostics identify the relevant semantic field. Merge conflicts also
retain every claiming source needed to correct an additive ownership conflict.
Complete prose, punctuation, formatting, and private validation choreography are
not compatibility contracts.
## Diagnostics And Runtime Metadata
Syntax, duplicate-key, merge, profile, and schema failures include the
relevant source name and full field path. Additive conflicts report every
claiming source so operators can repair the split without repeatedly
rediscovering additional conflicts.
The loaded pipeline retains private runtime metadata for the absolute root
path, ordered imports, selected profile name and selection source, selected
overlay, original ConfigKit metadata and source snapshots, effective digest,
and leaf ownership. Base
leaves retain their root/import owners, replaced leaves belong to the selected
overlay, and centrally supplied values use the synthetic `default` owner. This
metadata does not participate in YAML decoding or alter the public
configuration model.
The effective digest is SHA-256 over a versioned deterministic JSON encoding of
the same typed safe projection used by show and diff. 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 ConfigKit and resolution metadata
are private, the digest excludes source layout, profile name, and ownership.
Configuration stores credentials as ConfigKit typed references. A successful
load resolves their values inside the opaque typed boundary, while this safe
projection serializes only `EnvName()` identities. Credential-value changes are
therefore never hashed; reference-name changes remain ordinary provenance.
The optional Notarius credential follows the same path even for disabled or
resumable extraction; normalization copies the opaque reference without reading
or resolving it again. Direct YAML serialization has a configuration-owned safe
projection so only the reference name can leave this boundary.
`recomputePipelineEffectiveDigest` is the single package-owned refresh point
for later runtime expansion.
## Test Surfaces
`pipeline_configkit_test.go` exercises real imports and fixed-path profiles,
base selection, unselected-overlay isolation, source attribution, and Narratio's
unique artifact/family base-declaration rule. Existing focused configuration
tests protect defaults, explicit-value semantics, strict decoding, validation,
metadata, and root-relative path behavior. Application configuration-loader tests protect the
single-read boundary by changing the pipeline file after its initial load and
confirming local session resolution retains the original pipeline. Other
configuration tests continue to protect defaults and validation after assembly.
`party_test.go` protects the versioned typed party schema, domain invariants,
authored character order, retained bytes, and deterministic players projection
without involving campaign or runtime wiring.
`party_resolution_test.go` protects campaign-owned party loading, canonical
input restrictions, source provenance, and virtual players input selection.
## Artifact Family Resolution
Pipeline loading retains `scriptorium.artifact_families` as a resolution-only
declaration. Once campaign party resolution establishes a canonical roster,
configuration expands families in sorted family-key and character-ID order
into ordinary `ScriptoriumArtifactConfig` values. The expansion owns the narrow
`{character_id}` output substitution, closed member-variable selectors, key and
output collision checks, and the runtime-only family-origin catalog. It then
removes family declarations from `ScriptoriumConfig`, runs ordinary Scriptorium
validation, and refreshes the effective pipeline digest. Stages and adapters
therefore receive only concrete artifact maps.
The catalog retains sorted family member keys plus family/character/source
origins and the typed dependency/publish declarations for their later owners.
`member_dependencies` add corresponding ordinary concrete dependencies, while
the family-only `narratio.member_artifact.<family>` input form is rewritten to
the matching ordinary configured-artifact source. The catalog records those
resolved dependency and input identities with their declaring family and party
member. No member-artifact source is registered as a runtime policy source.
An enabled family publish declaration expands to ordinary configured-artifact
publish rules before the existing publish and lock validators run. Runtime
publication consequently receives no family wildcard or special matcher.

View File

@@ -1,71 +1,55 @@
# Internal: File Operations
`internal/fileops` owns the narrow mechanics for durable replacement of one
`internal/fileops` owns the narrow mechanics for atomically visible replacement of one
byte file. Callers keep ownership of serialization, validation, cancellation,
and destination-directory policy.
## Destination Confinement
## Destination Paths
Before it creates, replaces, or installs a destination file, `fileops` opens
each ancestor from the filesystem root and rejects symbolic links or components
that change during traversal. The resulting parent-directory handle is retained
for sibling temporary-file creation and rename, so a later pathname swap cannot
redirect the replacement. Existing destination symlinks are replaced as leaf
entries; their targets are never followed.
Ordinary workspace replacement uses standard operating-system path semantics.
Callers with a root-scoped safety contract own that validation at their boundary.
Remote object acquisition uses a writer supplied by the storage owner. The
writer receives a `fileops`-owned, already-open sibling temporary file rather
than a mutable destination path. Callers still own remote object selection,
validation, conflict handling, and final mode.
Remote object acquisition is owned by its domain caller. The caller writes a
sibling temporary file, validates the complete result, and passes that file to
`InstallSiblingTempFile` only when it is ready to become canonical. Callers own
remote object selection, validation, conflict handling, and temporary-file
cleanup.
Directory promotion keeps the verified destination parent open while it creates
the temporary tree, copies regular source entries, and performs the platform
no-replace rename. Platforms without a verified handle-relative atomic
no-replace primitive reject promotion before writing a temporary tree.
Directory bundles are not promoted by `internal/fileops`; extraction owns its
validated Notarius bundle rename.
## Cleanup Contract
`RemoveAllUnderRoot` accepts an explicit root and a proper descendant. It opens
the root and each target ancestor without following symlinks, then removes the
tree through those directory handles. It rejects root deletion and any symlink
encountered in the target path or tree; repeated removal of a missing target is
successful. Command and post-publish policy remains owned by `internal/app`.
`RemoveAllUnderRoot` accepts an explicit root and a proper descendant, opens
that root with `os.OpenRoot`, and removes the normalized root-relative target
with `Root.RemoveAll`. It rejects root, traversal, and out-of-root targets; a
missing target is successful. `RemoveFileUnderRoot` additionally requires an
existing leaf to be regular. Command and post-publish policy remains owned by
`internal/app`.
## Confined Reads
## Bounded Reads
`ReadRegularFileUnderRoot` is the no-follow, bounded read primitive for a
caller-selected root and relative file path; `ReadRegularFile` is its
path-based convenience wrapper. They verify every ancestor through directory
handles and admit only a stable regular-file handle. Callers enforce their own
byte limits and access policy. Credential mode policy and environment
precedence remain owned by `internal/app`.
`ReadFileBounded` opens a normal path and reads at most its caller-supplied
limit. Credential mode policy and environment precedence remain owned by
`internal/app`.
## Replacement Contract
`ReplaceFileAtomic` requires an existing destination directory. It creates a
sibling temporary file, writes the complete byte sequence, applies the
caller-supplied mode, syncs and closes the file, runs an optional pre-rename
check, replaces the destination with a rename, then syncs the containing
directory.
caller-supplied mode, closes the file, runs an optional pre-rename check, and
replaces the destination with a rename.
The pre-rename check is the last point at which a caller can cancel without
installing a new destination. A failure before the rename leaves the old
destination unchanged and removes the temporary file; any cleanup failure is
returned alongside the primary failure. A failure after the rename may leave
the new file visible, but it is not reported as crash-durable.
destination unchanged and removes the temporary file. Successful replacement
provides atomic visibility; it makes no crash- or power-loss durability claim.
Replacement follows the operating system's same-filesystem rename semantics.
If a platform cannot replace an existing destination, the operation returns an
error and never removes the old file as an emulation step.
## Directory-Sync Support
Linux and macOS attempt to sync the destination directory. Windows opens the
directory with backup semantics and flushes its buffers. If either operation
is unavailable for the platform, directory handle, or filesystem,
`ErrDirectorySyncUnsupported` is returned. Narratio does not treat that result
as successful crash-durable replacement.
`WriteFileAtomic`, copy helpers, and downloaded temporary-file installation
retain their compatibility behavior of creating the destination parent with
the repository's workspace permissions before using this contract.
`WriteFileAtomic` and the copy helpers create the destination parent with the
repository's workspace permissions. `InstallSiblingTempFile` requires the
caller to provide an existing temporary file in the existing destination
directory.

View File

@@ -2,29 +2,44 @@
## Purpose
Explain the session-progress and invocation-audit models implemented by
`internal/manifest`. Physical manifest placement belongs in
Explain the session-progress model implemented by `internal/manifest`. Physical
manifest placement belongs in
[Operations](../operations.md#local-state-layout).
## Session Manifest
`manifest.Manifest` records:
- required `schema_version: narratio.session-manifest.v1`
- identity (`session_id`, `campaign`, `run_id`)
- local path metadata (`local_workdir`, `local_spool_dir`)
- remote identity metadata (`s3_bucket`, `s3_session_prefix`, `s3_run_prefix`)
- `inputs` records
- durable `artifacts` records
- per-stage `stages` map
- an optional `post_publish_cleanup` obligation, which binds a committed run,
remote commit identity, and each exact root-confined local target to its
completion evidence
- per-stage `stages` map, including the ordinary cleanup stage record
Session, campaign, and run identities in local and downloaded manifests must be
portable opaque segments. Unsafe legacy identities are rejected with migration
guidance rather than being normalized into a different workspace or remote
namespace.
Decoding is strict at every typed manifest field. Unknown fields are rejected;
the schema version remains `narratio.session-manifest.v1` and there is no
compatibility reader that silently drops retired state. Cleanup progress uses
only the cleanup entry in `stages`.
Every persisted session manifest has a non-nil `stages` map. Each entry is
non-nil and its embedded `name` exactly matches its map key; loading never
invents either marker. A missing or unsupported schema version, or incomplete
stage identity, is unsupported local state: move it aside and regenerate it,
or restore a verified current committed snapshot when that is authoritative.
Prepare records independent `party` and `players` input checksums. In canonical
party mode, the party record retains its campaign source identity while the
players record uses `derived_from_party`; raw roster content is never embedded
in manifest metadata. Both records remain the durable authority for consumers
of their prepared input source IDs.
The model admits these stage states:
- `pending`
@@ -35,15 +50,79 @@ The model admits these stage states:
- `stale`
- `interrupted`
## Run Manifest
### Analyze-owned artifact state
`manifest.RunManifest` is created for each invocation and records:
Every `analyze` stage record carries `analyze_state_version: 1` and an
`analyze_artifacts` map keyed by normalized configured artifact key. The
version is the authority marker: version 1 with no entries is a valid evaluated
empty set. An absent or unsupported version is unsupported local state and is
rejected rather than being treated as aggregate-only authority.
- invocation identity and `force` flag
- requested stages
- per-stage action (`run` or `skip`)
- per-stage status
- overall run status (`running`, `succeeded`, `failed`)
Each analyze artifact record has one disposition:
- `current`: the configured artifact is available and carries a versioned
fingerprint plus a complete output record and separate output size;
- `stale`: the recorded semantic identity is no longer current;
- `missing`: no validated current result exists;
- `failed`: the attempted work failed and carries a bounded diagnostic; or
- `unselected`: the artifact was intentionally outside the evaluated set.
Records bind their normalized key and dependencies, fingerprint contract when
evaluated, canonical session-relative output identity when current, producing
Narratio run, update time, and bounded non-secret Scriptorium provenance and
diagnostic paths. A current output includes its configured source ID, contract,
checksum, and positive byte size. Non-current records cannot carry an output,
so an older file is not advertised through stale, missing, failed, or
unselected state.
Family-produced records additionally retain optional `family` and
`character_id` provenance supplied by configuration resolution. These fields
do not replace the concrete configured key or infer family membership from a
name, so older records without them remain valid.
The session-stage collection is the reconciled authority across invocations.
These records remain analyze-owned data inside the fixed stage; they are not
dynamic stages or generic subtasks.
Analyze checkpoints one complete validated candidate collection after each
successful artifact and before exposing that artifact to later work. It saves a
detached manifest candidate and installs its version, collection, and updated
timestamp into live state only after the store reports success. Aggregate stage
outputs are ordinary result outputs rebuilt in configured-key order from current
session records only.
Successful incremental execution replaces only evaluated artifact records and
preserves valid unrelated current records. Rebuilt outputs are compared by
bytes and contract: an unchanged identity permits an unselected dependent with
the same recomputed fingerprint to remain current, while a changed identity
removes output authority from every unselected transitive dependent by marking
it stale. A partial analyze invocation can therefore succeed while unrelated
configured records remain stale. Existing canonical files never create current
records without validated execution and a successful checkpoint.
Aggregate analyze status is deliberately coarser than this collection. Resume
validation may skip a succeeded aggregate record when the selected artifact
closure is current even if unrelated records are stale. Conversely, a stale
aggregate record may cross the ordinary runner boundary and perform zero
Scriptorium calls when reconciliation proves every selected artifact current;
ordinary successful stage completion then restores the aggregate status.
On an artifact error, analyze installs a complete retry collection on the live
manifest before returning. The active record is failed without output, current
transitive dependents are stale, unrelated current records survive, and only
earlier checkpointed completions remain current. The runner's ordinary failure
path marks aggregate analyze failed, clears aggregate outputs, invalidates
delivery dependents, and saves that collection. A terminal session-save failure
is joined with the original adapter, validation, filesystem, cancellation, or
checkpoint cause.
If an artifact checkpoint fails, analyze rolls its working collection back to
the last committed collection before constructing the active failed record.
Newly materialized canonical bytes therefore remain incidental and cannot
become current authority. A later ordinary run reconciles the durable records,
reuses earlier current artifacts, and resumes with the failed or stale work.
The same recovery applies after process interruption: the aggregate record may
be non-successful, but every completed artifact checkpoint remains reusable.
## Remote Commit Manifest
@@ -60,66 +139,145 @@ binds that object by checksum, size, and generation. Readers strictly reject
unknown fields, version mismatches, pointer/commit identity mismatches, and
objects that do not match their declaration.
The reader retains a temporary, clearly isolated compatibility path for a
coherent legacy `current/manifest.json` plus `current/run_id.txt` pair. That
path is removable after migration and is never used to write new state.
An absent commit pointer means no remote state is published. Republish to
establish a current immutable snapshot.
## Persistence Semantics
`manifest.LocalStore`:
- validates loaded documents;
- normalizes missing maps/stage records;
- writes through a sibling temporary file, syncing the completed file and
destination directory after atomic replacement;
- accepts only the exact supported session-manifest schema version;
- rejects unknown fields and multiple JSON values;
- validates stage-map identity without synthesizing missing maps, records, or
names;
- retains only representation-preserving collection normalization;
- writes through a sibling temporary file and atomic replacement visibility;
- updates `updated_at` on save.
If the operating system or filesystem cannot sync a directory, save returns an
explicit error instead of claiming crash-durable replacement. A returned error
after the rename can therefore leave the new manifest visible but not confirmed
durable; callers must reload it before retrying.
Manifest replacement provides atomic visibility, not a crash- or power-loss
durability guarantee. Callers reload after a returned save error before retrying.
An analyze checkpoint error leaves the prior manifest as per-artifact authority.
The runner then attempts to persist the bounded failed/stale retry collection;
if that terminal save also fails, its error is joined with the originating
failure and the next invocation must reconcile from the last readable manifest.
## Execution Semantics
The application runner marks an executing stage running and then succeeded or
failed in both manifests, persisting each transition. On success it records
outputs, logs, generated configuration references, and metadata. Artifact
Planning and execution share one pure pre-execution decision over the recorded
stage status, force selection, and normalized resume-validation result. That
decision identifies run versus reuse and any required stale and dependent
invalidation transition. Planning applies it only to a cloned manifest.
Execution remains the sole owner of observed stage results and persistence.
For each selected stage, the application runner computes semantic evidence,
performs any eligible resume validation, applies the pre-execution decision,
and persists required invalidation before either reusing the existing success
or beginning an attempt. The same one-stage execution owner marks the attempt
running before invoking the stage and then records and persists its failed,
skipped, or successful terminal state before a later stage may start. On
success it records outputs, logs, generated configuration references, metadata,
and—when the stage implements the optional contract—a versioned
semantic-configuration fingerprint. Artifact
records may include optional contract and external provenance objects; old
manifests remain compatible when those fields are absent. A successful forced
rerun marks only succeeded downstream session-stage records stale.
rerun marks only succeeded transitive dependent session-stage records stale.
The application owns a fixed dependency relation distinct from execution order;
dependents are returned in canonical order. Render and extract therefore never
stale one another, while either can stale analyze, publish, notify, and cleanup.
Starting an execution clears the current session-stage record's prior outputs,
logs, generated configuration references, and metadata. Failed and skipped
logs, generated configuration references, metadata, and semantic fingerprint.
Failed and skipped
transitions enforce the same clearing rule directly, while success repopulates
only fields returned by the new result. Marking a record stale does not clear
those details because resume validation and diagnosis may still require them
before execution begins. Invocation run manifests remain immutable audit
records of their own outcomes.
before execution begins.
Aggregate lifecycle clearing deliberately preserves the analyze-owned
per-artifact collection. This lets later reconciliation replace only evaluated
entries without erasing unrelated current results. Other stages retain their
existing aggregate-only lifecycle behavior and are forbidden from carrying the
analyze-specific fields.
A stage may explicitly return a skipped disposition and stable reason. The
runner persists that outcome in both manifests, clears older outputs for the
session-stage record along with older logs, generated configuration references,
and metadata, then applies any bounded details from the current skip and
continues. This self-skip is distinct from deciding not to execute an
runner persists that outcome in the session manifest, clears older outputs,
logs, generated configuration references, and metadata, then applies any
bounded details from the current skip and continues. This self-skip is distinct from deciding not to execute an
already-succeeded stage and is reconsidered on later runs. Skipped results
cannot contain outputs.
cannot contain outputs. An intentional self-skip records the current semantic
fingerprint because it is a completed, reusable stage result; failed or
interrupted work never promotes one.
When an already-succeeded stage is skipped, the invocation run manifest records
the `skip` action and reason. The session manifest deliberately retains its
existing succeeded record because it remains the cross-invocation progress
authority. Stages with a resume validator, currently extraction, may reject an
otherwise eligible skip when the recorded durable result is obsolete; the
runner marks it stale and executes it.
When an already-succeeded stage is skipped, the session manifest deliberately
retains its existing succeeded record because it remains the cross-invocation
progress authority. If a stage supplies semantic configuration evidence, reuse first
requires the persisted positive schema version and lowercase SHA-256 digest to
match the current resolved stage semantics. Missing semantic evidence, malformed
evidence, or a mismatch makes the stage and its fixed transitive dependents
stale. The existing stage-specific resume
validator runs only after this semantic check succeeds; both checks are
required. Extraction, analyze, and cleanup have resume validators and may
reject an otherwise eligible skip when their selected durable evidence is
obsolete; the runner marks the aggregate record stale and executes it.
Analyze's validator can still accept a partial selection when only unrelated
artifact records are stale.
Session manifest is the authoritative stage-progress ledger across invocations.
Run manifest is invocation-scoped audit state.
Implemented reuse coverage is deliberately split between aggregate semantic
evidence and focused durable validators:
| Work | Reuse authority | Focused owners |
| --- | --- | --- |
| prepare | aggregate semantic fingerprint | [prepare](stage-prepare.md) |
| transcribe | aggregate semantic fingerprint | [transcribe](stage-transcribe.md), [WhisperX](../integrations/whisperx.md) |
| merge | aggregate semantic fingerprint | [merge](stage-merge.md), [Seriatim](../integrations/seriatim.md) |
| polish | aggregate semantic fingerprint | [polish](stage-polish.md), [Audita](../integrations/audita.md) |
| normalize | aggregate semantic fingerprint | [normalize](stage-normalize.md), [Seriatim](../integrations/seriatim.md) |
| trim | aggregate semantic fingerprint | [trim](stage-trim.md), [Scriptorium](../integrations/scriptorium.md), [Seriatim](../integrations/seriatim.md) |
| render | aggregate semantic fingerprint | [render](stage-render.md), [Seriatim](../integrations/seriatim.md) |
| extract | aggregate semantic fingerprint plus reference/output validator | [extract](stage-extract.md), [Notarius](../integrations/notarius.md) |
| analyze artifacts | per-artifact fingerprint, reconciliation, and output validator | [analyze](stage-analyze.md), [Scriptorium](../integrations/scriptorium.md) |
| publish | aggregate semantic fingerprint plus immediate lock/commit checks | [publish](stage-publish.md), [storage adapter](adapters.md) |
| notify | aggregate delivery-mode fingerprint | [pipeline overview](overview.md), [configuration](../config.md#notifications) |
| cleanup | aggregate semantic fingerprint plus publish-evidence and target-absence validator | [cleanup](stage-cleanup.md), [workspace](workspace.md) |
These contracts record resolved choices Narratio can observe, not operational
runner tuning. External model, module, prompt, profile, and configuration-file
contents that a tool privately loads remain outside the contract when their
configured identifier is unchanged; operators must force the affected work
after such a private content change.
The session manifest is the authoritative stage-progress ledger across
invocations. It retains the most recently resolved invocation's bounded
configuration provenance. It identifies the selected profile name and source
(`default` or `cli`) plus the effective configuration digest, but never a raw
secret or profile content. This provenance is informational: it does not
participate in stage resume or cache decisions. A profile change therefore
invalidates only stages whose semantic configuration changed. When a private
external-tool model, module, prompt, or profile changes behind an unchanged
configured identifier, use `--force` for the affected work.
`session plan` computes the same current fingerprint and applies the shared
decision and invalidation rules to a cloned manifest. It predicts the runner
decision without persisting session state. The semantic fingerprint owner
hashes deterministic JSON from stage-owned typed structs; stage providers must
exclude secrets, complete effective-configuration dumps, and operational
values that cannot affect canonical results. Concrete coverage is owned by the
focused stage and integration documents linked above.
Before an explicitly bounded execution starts after `prepare`, the application
reads the session manifest and accepts only `succeeded` or `skipped` for every
excluded canonical prefix stage. The first other status or absent record fails
the request before layout mutation, adapter initialization, session-manifest
writes, or run-directory creation. Excluded prefix records are not passed to
resume validators. Records after the selected end are not prerequisites and
may be made stale by selected work without being scheduled.
After a publish commits remotely, any configured local cleanup is first recorded
as a session-manifest obligation before deletion begins. Each target becomes
complete only after its confined deletion (or safe absence check) and a
successful manifest save. An incomplete obligation is retried on later
invocations independently of their selected stages and retains the committed
run and remote identity that authorized it.
successful manifest save. An incomplete obligation is retried when publish
executes again and retains the committed run and remote identity that authorized
it; an invocation that does not execute publish does not perform cleanup.
Each invocation derives campaign, session, run, local-path, and remote-prefix
metadata from the validated resolved configuration as one projection. A persisted
@@ -127,30 +285,30 @@ session manifest must agree on campaign and session identity before execution;
the current projection is refreshed for every invocation while stage progress,
inputs, and durable artifacts remain session history.
For handled failures after an invocation record is created, the runner records
the failure on the session ledger and persists it before persisting the failed
run audit record. This preserves the resume authority while making a partial
persistence disagreement visible. Abrupt process death remains an accepted case
where a durable running record can require operator interpretation.
For handled failures after the session identity transition, the runner records
the failure on the session ledger and atomically attempts its save while
preserving the originating error. Abrupt process death remains an accepted case
where the last session transition can require operator interpretation.
## Invariants
- stage resume/skip decisions are session-manifest driven.
- semantic fingerprint comparison precedes stage-specific resume validation.
- only successful and intentional-skipped results promote current semantic
evidence.
- running, failed, and self-skipped stages do not retain result payloads from
an earlier success.
- stale stages retain prior details until replacement execution starts.
- force reruns stale downstream succeeded stages.
- run manifest does not replace session manifest as progress authority.
- force reruns stale succeeded stages in the fixed dependency relation.
- the session manifest is the only durable local progress ledger.
- remote commitment is established by a verified current pointer and remote
commit relationship, never by a mutable session-manifest boolean.
## Implementation And Tests
- Models and transitions: `internal/manifest/manifest.go`,
`internal/manifest/run_manifest.go`
- Models and transitions: `internal/manifest/manifest.go`
- Remote commit model and readers: `internal/artifacts/remote_commit.go`,
`internal/artifacts/current_state_commit.go`,
`internal/artifacts/current_state_legacy.go`
- Persistence and validation: `internal/manifest/store.go`
- Package tests: `internal/manifest/*_test.go`
- Assembled execution behavior: `internal/app/runner_test.go`,

View File

@@ -22,20 +22,29 @@ session safety controls, and runs commands. Pipeline commands execute the
canonical stage sequence through adapter interfaces, while manifests record
progress and artifact services resolve durable inputs and outputs.
The pipeline runner follows one explicit invocation lifecycle: inspect bounded
prerequisites without mutation, prepare the workspace layout, acquire one
session lock, load or create the authoritative session manifest, compose only
the collaborators needed by the selected range, execute each selected stage in
order, and then return the summary. Cleanup, when selected, is the ordinary
final stage rather than runner finalization work.
The lock remains held from manifest initialization through finalization, and a
failed stage or required manifest save prevents later stages from starting.
## Components
| Area | Implemented owners | Responsibility |
| --- | --- | --- |
| Executable | `cmd/narratio` | Process entry, standard stream wiring, argument handoff, and exit status. |
| Application orchestration | `internal/app` | Command dispatch, configuration selection, secret-file environment loading, production composition, session locking, planning, execution, restore, cleanup gates, and user-facing reporting. |
| Configuration | `internal/config` | Strict YAML loading, discovery, defaults, normalization, session templating, and validation. |
| Application orchestration | `internal/app` | Command dispatch, configuration selection, secret-file environment loading, production composition, session locking, planning, execution, restore, and user-facing reporting. |
| Configuration | [`internal/config`](configuration.md) | ConfigKit-backed pipeline imports and profiles, canonical party and family expansion, strict YAML loading, defaults, normalization, session templating, and validation. |
| Pipeline stages | `internal/stage` | Canonical stage registry, shared stage contract, execution dependencies, and implemented stage behavior. |
| External boundaries | `internal/adapters`, `internal/audio` | WhisperX HTTP, downstream subprocesses, notification, object storage, and S3 audio materialization behind Narratio contracts. |
| Manifests | `internal/manifest` | Durable session progress, invocation audit state, stage transitions, validation, and atomic persistence. |
| Manifests | `internal/manifest` | Durable session progress, stage transitions, validation, and atomic persistence. |
| Artifacts and paths | `internal/artifacts`, `internal/pathsafe` | Artifact identities and resolution, local and remote path/key models, current-state discovery, and confined relative destinations. |
| Previous-session cache | `internal/previouscache` | Deterministic planning and materialization requirements for configured previous-session inputs. |
| Artifact policy | `internal/artifactpolicy` | Source and destination policy, configured artifact identity validation, and publish destination safety. |
| Shared models and file operations | `internal/artifactmodel`, `internal/contracts`, [`internal/fileops`](fileops.md) | Transcript and artifact data contracts plus durable single-file replacement helpers; unsupported directory syncing is reported explicitly. |
| Shared models and file operations | `internal/artifactmodel`, `internal/contracts`, [`internal/fileops`](fileops.md) | Transcript and artifact data contracts plus atomic single-file replacement, bounded reads, workspace modes, and root-scoped cleanup helpers. |
| Logging | `internal/logging` | Application logger construction and shared structured logging behavior. |
The application boundary composes concrete implementations. Stages depend on
@@ -43,6 +52,26 @@ Narratio-level contracts; external transport and SDK details remain in
adapters. The normative rules for these relationships remain in
[Architecture](../policy/architecture.md).
Command execution normalizes one private dependency value per invocation.
Pipeline configuration-path discovery, configuration reads, secret-directory
reads, remote downloads, object-store construction, and restore phase functions
use that value throughout the command. Public command entry points supply the
canonical discovery and production defaults, while focused tests may provide
invocation-owned discovery candidates or replace only the external or sequencing
boundary they exercise without mutating package state.
Pipeline execution and `session plan` share the same inclusive contiguous-range
model and the same pure pre-execution decision over recorded status, force, and
normalized resume validation. Planning applies that decision and modeled
selected-stage transitions to cloned session state; it does not persist, create
invocation state, initialize stage-execution adapters, or claim to observe an
execution result. Command configuration loading can still retrieve a
missing session file through configured remote storage. It retains the initially
composed pipeline and selected campaign while resolving either a local or
downloaded remote session, so one invocation cannot mix pipeline revisions.
Analyze planning additionally exposes the artifact closure's targets,
prerequisite rebuilds, execution order, and current reuse.
## Pipeline Stage Set
The implemented canonical order is:
@@ -53,26 +82,38 @@ The implemented canonical order is:
4. [`polish`](stage-polish.md)
5. [`normalize`](stage-normalize.md)
6. [`trim`](stage-trim.md)
7. [`extract`](stage-extract.md)
8. [`render`](stage-render.md)
7. [`render`](stage-render.md)
8. [`extract`](stage-extract.md)
9. [`analyze`](stage-analyze.md)
10. [`publish`](stage-publish.md)
11. `notify` (no-op)
12. [`cleanup`](stage-cleanup.md)
`notify` currently has no persisted pipeline outputs and uses the explicit
`noop` notification mode. The focused stage documents own implementation
mechanics. The
`noop` notification mode. Its versioned semantic evidence records that delivery
mode and excludes adapter credentials and response data. The focused stage
documents own implementation mechanics. The
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
and execution semantics.
Execution order and invalidation are separate application contracts. The stage
registry owns the flat execution sequence. The application orchestration owner
uses a fixed, validated dependency relation to find transitive dependents in
canonical order. In particular, `render` and `extract` are sibling consumers of
trimmed transcript state: neither invalidates the other, while either can stale
`analyze`, `publish`, `notify`, and `cleanup`. Cleanup is a dependent of notify,
so any earlier replacement can stale a prior cleanup result.
## Focused Documentation
- [Configuration Internals](configuration.md): pipeline imports and profiles,
field ownership, decoding, and root-relative path semantics.
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
failure behavior, and test surfaces.
- [Artifact Internals](artifacts.md): source identities, runtime catalog,
resolution, previous requirements, and current-state helpers.
- [Manifest Internals](manifest.md): session and run records, persistence, and
execution transitions.
- [Manifest Internals](manifest.md): session records, persistence, and execution
transitions.
- [Storage Internals](storage.md): object-store interface and S3 behavior.
- [Workspace Internals](workspace.md): local layout, locking, and cleanup
guardrails.
@@ -84,10 +125,11 @@ and execution semantics.
- [`polish`](stage-polish.md)
- [`normalize`](stage-normalize.md)
- [`trim`](stage-trim.md)
- [`extract`](stage-extract.md)
- [`render`](stage-render.md)
- [`extract`](stage-extract.md)
- [`analyze`](stage-analyze.md)
- [`publish`](stage-publish.md)
- [`cleanup`](stage-cleanup.md)
Use this map to find an owner, then read its focused documentation and tests
before changing behavior.

View File

@@ -2,11 +2,17 @@
## Purpose
Execute selected configured Scriptorium artifacts in dependency order and materialize outputs.
Reconcile configured Scriptorium artifacts, execute only required work in
dependency order, and safely materialize validated outputs.
## Inputs
- configured artifacts from `pipeline.scriptorium.artifacts`
- ordinary configured artifacts from `pipeline.scriptorium.artifacts`; canonical
party artifact families have already expanded into this map during
configuration resolution, including corresponding member dependencies and
rewritten member-artifact input sources. Each artifact's dependency list is
already the normalized union of explicit ordering edges and configured-artifact
input edges.
- optional selected artifact keys supplied through the stage environment
- built-in, configured, extraction, and previous-session source references in
artifact inputs
@@ -21,30 +27,113 @@ Supported source families:
## Outputs
- one materialized output per executed configured artifact (`output_path`)
- one current per-artifact manifest record per validated materialized output
- stage metadata describing selected/generated/reused artifacts
## Key Behavior
- when Scriptorium is absent or no configured artifact is executable, completes
successfully with no outputs and records explanatory metadata. This is not an
explicit self-skip: both manifests record success, satisfy publish's
prerequisite, and an ordinary later run reuses the result until forced.
- when `pipeline.scriptorium` is absent or no configured artifact is
executable, completes successfully and records explanatory metadata. Before
returning, it reconciles and checkpoints the complete session collection:
records removed from configuration are dropped, while valid records for
configured but unselected artifacts remain authoritative. This is not an
explicit self-skip: the session manifest records success, satisfies publish's
prerequisite, and an ordinary later run reuses the result while the effective
set remains empty. Enabling or selecting an artifact later makes missing
versioned evidence non-resumable and schedules it without requiring force.
- builds a runtime artifact catalog containing built-ins, configured artifacts,
and configured extraction lanes. Extraction availability is hydrated only
from compatible successful extraction evidence.
- uses enabled configured artifacts by default. An explicit `--artifacts`
selection is a one-invocation override: it makes exactly the named configured
artifacts executable even when disabled, and does not automatically include
dependencies. A selected artifact's dependencies must instead already be
available to the catalog.
- marks non-executable configured artifacts as reusable when output files already exist.
selection is a one-invocation override that makes exactly the named
configured artifacts explicit targets even when disabled. The work planner
adds required configured prerequisites, reuses current ones, and schedules
stale, missing, or otherwise non-current prerequisites before dependents.
- makes a non-executable configured artifact reusable only when its current
manifest record and durable output pass the configured-artifact evidence
contract; an incidental or stale canonical file is unavailable.
- validates selected artifact dependency order (cycle-safe topo ordering).
- resolves required/optional inputs per artifact source definition.
- omits an unavailable optional input; an unavailable required input fails.
- consumes only the concrete dependency graph finalized by configuration;
planning, prerequisite closure, execution, reuse, invalidation, and semantic
evidence do not infer a second graph from inputs.
- resolves required/optional inputs per artifact source definition into an
ordered semantic identity. Each identity records the configured input name,
canonical source ID, required policy, explicit presence, source contract,
checksum, size, and a source-based logical identity. Workspace paths and
producer run IDs are excluded.
- orders input identities by configured input name independently of Go map
iteration. Runtime adapter paths remain a separate execution-only map.
- omits an unavailable optional input from the adapter request while retaining
explicit absence in its semantic identity; an unavailable required input
fails.
- resolves prepared stable input sources through the shared manifest-authoritative
identity resolver; it does not accept incidental files or fall back to
campaign/session source paths.
- reuses checksums and sizes from validated prepared, extraction, and current
configured-artifact evidence. Other resolved inputs are hashed as confined
regular files with streaming reads and the central resolved-artifact size
limit.
- owns a versioned SHA-256 fingerprint contract with one fixed-field canonical
JSON payload and no map serialization. Configured artifacts are fingerprinted
in deterministic dependency order.
- fingerprints the normalized artifact key, prompt and profile identifiers,
effective render-debug behavior, session-relative output identity, sorted
dependency keys, ordered input declarations and semantic identities,
validated current dependency-output identities, and sorted effective
Scriptorium variables (including Narratio's sticky session variable).
- provides read-only reconciliation that classifies each configured record as
current, stale, missing, failed, or otherwise non-resumable, and
separately identifies manifest records removed from current configuration.
A record is current only when its fingerprint version and value match and its
configured output still passes manifest-authoritative evidence validation.
- owns a read-only typed work planner. Its explicit targets are enabled
artifacts by default or the exact normalized `--artifacts` selection when
supplied. It closes targets over configured prerequisites, orders the closure
topologically, reuses current members, and schedules every non-current member
before its dependents.
- force applies only to explicit targets. A current prerequisite is reused
unless it is itself an explicit forced target; disabled prerequisites may be
rebuilt when required, while unrelated disabled artifacts are excluded.
- the work plan carries explicit targets, prerequisite-only work, deterministic
execution and reuse lists, invalidated and removed records, and a cloned
reconciled starting collection. Valid unrelated configured records survive
reconciliation, removed records are omitted, and incidental files never
become current without manifest-backed regeneration.
- implements aggregate resume validation by running the same read-only catalog,
fingerprint reconciliation, and work planner used by execution. A succeeded
aggregate record is reusable exactly when the selected closure schedules no
artifact work and no removed configuration records require a checkpoint;
stale unrelated records do not block a partial selection.
- exposes the typed artifact decision to `session plan`. Planning applies it to
a cloned manifest after modeling earlier selected stage transitions, so
aggregate run/skip and artifact execute/reuse decisions match the ordinary
runner without creating durable state or invoking Scriptorium.
- executes only the work plan's scheduled entries. Manifest-validated current
prerequisites remain available through the runtime catalog without invoking
Scriptorium; newly produced prerequisites enter that catalog with the same
contract, checksum, and size identity used for persisted current evidence.
- before the first adapter call, checkpoints the reconciled starting collection
when it removed configuration records or cleared obsolete authority. After
each successful artifact, checkpoints the complete collection before making
that artifact available to the next scheduled dependent.
- keeps adapter output in the invocation's run-local analyze directory until
it is a safe, non-empty, bounded regular file with a calculated checksum and
complete output contract. Canonical replacement uses the shared atomic file
operation boundary and verifies that the installed checksum matches the
validated run-local bytes.
- records each successful artifact's freshly computed fingerprint, canonical
relative output path, contract, checksum, size, producer run ID, bounded
Scriptorium provenance, logs, and generated configuration references in the
analyze-owned manifest collection.
- preserves valid unrelated current records during partial execution. If a
rebuilt output's bytes and contract are unchanged, unselected dependents may
remain current. If that semantic identity changes, unselected transitive
dependents become stale without being executed; dependents included in the
invocation are evaluated in dependency order instead.
- returns aggregate outputs from every current session record in normalized-key
order after the complete reconciled collection has been checkpointed;
unrelated stale records do not make an otherwise successful partial
invocation fail.
- resolves previous-session sources from local `previous/` cache only.
- runs optional render-debug, then artifact execution.
- validates non-empty output files and materializes canonical outputs.
@@ -59,17 +148,63 @@ Supported source families:
guidance.
- dependency cycles or unavailable required dependencies fail.
- adapter validation failures fail stage.
- a scheduled artifact failure installs a retry collection on the live manifest
with the active artifact marked `failed`, a bounded error, and no output
authority. Current transitive dependents become stale without execution.
- earlier artifacts remain current only after their run-local output passed
validation, canonical materialization, and a successful complete-collection
manifest checkpoint; unattempted later artifacts retain their reconciled
session state.
- unrelated current records survive a partial failure. Old canonical bytes for
the failed artifact and newly materialized bytes whose checkpoint fails are
incidental, not current evidence.
- a checkpoint failure stops the queue immediately. The in-memory retry
collection starts from the last successful checkpoint, marks the active
artifact failed without output authority, and stales its current transitive
dependents.
- the runner's ordinary error path marks aggregate analyze failed, clears its
aggregate outputs, invalidates publish, notify, and cleanup through the
application dependency relation, and persists the live retry collection.
Terminal-save errors are joined with the original artifact or checkpoint
failure.
## Invariants
- `analyze` performs no remote storage calls for previous-session source resolution.
- input-identity resolution is read-only: it does not invoke adapters,
materialize outputs, update status, or create run-local files.
- fingerprints exclude timeouts, retries, timestamps, producer and Narratio run
IDs, executable and config paths, workspace roots, diagnostic locations, and
executable or private transitive configuration contents. A change that is
visible only inside Scriptorium—such as a file privately loaded by its config
path—requires an explicit forced regeneration.
- output provenance and metadata are deterministic per execution.
- a canonical file without current per-artifact manifest evidence is never
promoted to current state.
- an ordinary retry after interruption or partial failure reuses every valid
earlier checkpoint and schedules only failed, stale, missing, or otherwise
non-current work in dependency order.
## Related Contracts And Tests
- [Configuration](../config.md#scriptorium-artifact-entries) owns artifact
fields and source-selection rules.
fields and source-selection rules, including
[artifact families](../config.md#scriptorium-artifact-families).
- [CLI](../cli.md) owns user-visible artifact selection.
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
- Implementation and tests: `internal/stage/analyze.go`,
`internal/stage/analyze_test.go`
`internal/stage/analyze_artifact_execution.go`,
`internal/stage/analyze_execution_state.go`,
`internal/stage/analyze_input_identity.go`,
`internal/stage/analyze_fingerprint.go`,
`internal/stage/analyze_reconciliation.go`, `internal/stage/analyze_plan.go`,
`internal/stage/analyze_resume.go`, `internal/stage/analyze_test.go`,
`internal/stage/analyze_checkpoint_test.go`,
`internal/stage/analyze_input_identity_test.go`,
`internal/stage/analyze_fingerprint_test.go`,
`internal/stage/analyze_reconciliation_test.go`,
`internal/stage/analyze_plan_test.go`,
`internal/stage/analyze_incremental_execution_test.go`,
`internal/stage/analyze_failure_test.go`,
`internal/stage/analyze_resume_test.go`, and
`internal/app/analyze_checkpoint_recovery_test.go`.

View File

@@ -0,0 +1,54 @@
# Stage: cleanup
## Purpose
Remove explicitly configured local data for the run selected by a successful
publish commit. Cleanup is the twelfth and final canonical stage, after
`notify`.
## Authorization And Targets
Cleanup is disabled unless at least one of these policies is true:
- `pipeline.spool.delete_audio_after_publish`
- `pipeline.workspace.cleanup_after_publish`
An enabled cleanup requires the manifest's publish stage to be succeeded with
`uploaded=true`, a canonical `published_run_id`, and the exact remote commit and
current commit-pointer keys for the manifest campaign/session prefix. The
configured campaign and session must match the manifest. Cleanup derives its
only possible targets through the canonical artifact path helpers:
- the published run's session spool-audio directory; and
- the published run's workspace run directory.
It does not scan parent directories, infer the newest run, or read object
storage. Every deletion uses the root-confined file operation, which rejects a
root itself, traversal, symlink escape, and out-of-root targets.
## Execution And Resume
Disabled cleanup records an explicit skipped result. Enabled cleanup removes
targets in deterministic policy order and treats an already-missing target as
success. A successful record contains the published run ID and completed policy
names.
A previous cleanup success is reusable only while its semantic configuration
matches and every selected target remains absent. A recreated target makes the
stage non-resumable. A failure is recorded against cleanup; publish remains
succeeded and remote current state is unchanged. On a later normal full run,
valid upstream stages are skipped and cleanup is retried without republishing.
`narratio publish` selects publish only and never invokes cleanup. A bounded run
through `notify` or any earlier endpoint also excludes it. Operators may run
`narratio run-stage cleanup <session_id>` directly; missing, failed, malformed,
or identity-mismatched publication evidence fails before deletion.
## Implementation And Tests
- Planning and evidence validation: `internal/stage/cleanup_plan.go`
- Execution and resume: `internal/stage/cleanup.go`
- Root-confined deletion: `internal/fileops/cleanup.go`
- Focused tests: `internal/stage/cleanup_plan_test.go`,
`internal/stage/cleanup_test.go`, and
`internal/app/cleanup_orchestration_test.go`

View File

@@ -2,7 +2,7 @@
## Responsibility
`extract` runs after `trim` and before `render`. It converts the canonical
`extract` runs after `render` and before `analyze`. It converts the canonical
`narratio.transcript.final_trimmed` JSON into configured Notarius lane artifacts.
An omitted or disabled Notarius section makes the stage explicitly self-skip
with reason `notarius_disabled`, no outputs, and no Notarius runner.
@@ -19,12 +19,15 @@ procedures belong in [Operations](../operations.md).
1. resolves the final trimmed transcript from the shared artifact catalog;
2. resolves every configured prepared reference through the shared
manifest-authoritative identity resolver before creating run-local output;
3. streams each verified reference into an invocation-local snapshot and
rejects any source change observed while copying;
4. fingerprints the Notarius invocation contract, including sorted reference
identities;
3. resolves each reference to a stable content-addressed session snapshot,
reusing verified bytes or reconstructing a missing/corrupt regular entry,
and rejects any source change observed while copying;
4. fingerprints the byte- and provenance-bearing Notarius invocation evidence,
including sorted reference identities;
5. creates a run-local staging directory and invokes the injected
`notarius.Runner`;
`notarius.Runner`, carrying the optional already-resolved credential
reference without exposing its value, plus effective resume and chunk-cache
controls after force precedence;
6. revalidates the reference snapshots, then validates the v2 successful
receipt, confined index, management documents, configured required lane
descriptors, validation summaries, and regular payload files;
@@ -41,28 +44,50 @@ Narratio run ID, the resolved trimmed-input identity, and invocation
fingerprint. The input identity binds the exact transcript bytes, canonical
source ID, producer stage/output/run identity, and resolution provenance.
Reference metadata contains only selector, source ID, canonical session-relative
path, checksum, and size; adapter requests receive selector and absolute
invocation-local snapshot path, never payload contents. Snapshot bytes must
path, checksum, and size; adapter requests receive selector and absolute stable
snapshot path, never payload contents. Snapshot bytes must
match the prepared identity both before and after Notarius runs, so a concurrent
prepared-file replacement cannot make recorded provenance describe different
bytes from those supplied to Notarius.
Validation completes before
promotion, so a rejected result cannot expose a partial durable bundle.
Validation completes before the single durable rename, so a rejected result
cannot expose a partial durable bundle.
Snapshot destinations are owned by `internal/artifacts` under
`cache/notarius/references/<sha256>/<prepared-basename>`. They contain no run ID
or timestamp and can be reused across invocations. Identical bytes and basename
share a verified file; changed bytes select a different digest directory.
Unsafe path components and non-regular conflicts fail without recursive
deletion. Snapshot paths are delivery details and never become manifest output
evidence or prerequisites for reuse of an already complete extract bundle.
Any executed extraction outcome that replaces a different effective outcome
marks succeeded downstream stages stale. Repeating the same disabled self-skip
with no outputs is stable and does not repeatedly invalidate downstream stages.
marks succeeded analysis and delivery dependents stale. Render is an independent
sibling and remains current. Repeating the same disabled self-skip with no
outputs is stable and does not repeatedly invalidate dependent stages.
## Resume Validation
`internal/stage/extract_resume.go` permits a skip only when the existing stage
record succeeded and still matches the current invocation fingerprint. The
fingerprint covers the resolved executable and config paths, pipeline ID,
timeout, working directory, sorted configured output contracts, the current
direct trimmed-transcript identity, and sorted prepared-reference identities.
The same reference helper and transcript identity are resolved again for
artifact evidence, so changing the current transcript bytes or producer
identity makes the prior extraction obsolete.
Before the focused validator runs, the application compares extract's versioned
semantic fingerprint. It covers enablement, Notarius pipeline identity, sorted
reference selector/source mappings, sorted declared output contracts, and each
canonical `narratio.extraction.<key>` output identity. It excludes executable,
timeout, working directory, config path, and private Notarius config contents.
Credential presence, reference name, and resolved value are likewise
operational-only and excluded. Ordinary command loading still resolves a
selected reference before resume evaluation, but a credential-only change does
not rerun a valid completed extraction or stale its dependents.
`internal/stage/extract_resume.go` then permits a skip only when the existing
stage record still matches the current byte- and provenance-bearing invocation
evidence. That evidence covers the current direct trimmed-transcript identity,
sorted prepared-reference identities, pipeline identity, and configured output
contracts. The same reference helper and transcript identity are resolved again
for artifact evidence, so changing current transcript bytes, reference bytes,
or producer identity makes the prior extraction obsolete. Operational runner
settings do not invalidate otherwise current durable evidence. This includes
`resume` and `chunk_cache`; changing them alone cannot make a complete
extraction non-resumable. If extraction executes, ordinary runs forward the
configured values, while force disables resume and selects `refresh`.
A valid prepared-reference change makes extraction non-resumable. Missing,
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
@@ -75,35 +100,41 @@ contracts and provenance, regular-file status, and stored checksums. Missing or
obsolete results are non-resumable and run again; unsafe filesystem conditions
return an error rather than silently accepting or replacing data.
The fingerprint cannot observe files imported by Notarius configuration,
profile contents, prompt/module definitions, or other transitive inputs.
Operators must force extraction after changing any such input.
Neither contract can observe files imported by Notarius configuration, profile
contents, prompt/module definitions, or other transitive inputs. Operators must
force extraction after changing any such private input behind a stable
identifier.
## Failure Behavior
Adapter startup, timeout, nonzero exit, receipt decoding, path confinement,
index compatibility, inconsistent warning or diagnostic envelopes,
required-lane rejection or incomplete validation, payload inspection,
checksum, or promotion errors fail the stage through ordinary manifest
checksum, or installation errors fail the stage through ordinary manifest
transition handling.
Stdout receipt and stderr diagnostics remain separate. Downstream stages are
not given selectable extraction sources unless the complete configured result
has passed validation and promotion.
has passed validation and installation.
When a replacement attempt begins, the current session-stage record no longer
advertises payload from the previous success. A failed replacement therefore
has no current outputs, logs, generated configuration references, or metadata,
while the earlier invocation manifest and immutable promoted bundle remain
available for audit and recovery.
while the earlier immutable promoted bundle remains available for audit and
recovery.
## Implementation And Focused Tests
- Stage execution, selection, and resume validation: `internal/stage/extract.go`,
`internal/stage/extract_resume.go`,
`internal/stage/extract_test.go`
`internal/stage/extract_test.go`,
`internal/stage/semantic_contracts_delivery.go`
- Stable snapshot materialization and verification:
`internal/stage/extract_references.go`
- Subprocess boundary: `internal/adapters/notarius/subprocess.go`,
`internal/adapters/notarius/subprocess_test.go`
- Catalog hydration: `internal/artifacts/extraction_catalog.go`,
`internal/artifacts/extraction_catalog_test.go`
- Composition and downstream behavior: `internal/app/runner_test.go`,
`internal/stage/analyze_test.go`, `internal/stage/publish_test.go`
- Opt-in Notarius v0.7.0 production-boundary coverage:
`internal/stage/notarius_cache_integration_test.go`

View File

@@ -29,9 +29,23 @@ Normalize raw transcript inputs and merge into base transcript via Seriatim.
- base transcript must validate before stage success.
- report output is config-gated.
## Resume Evidence
Merge records a versioned semantic-configuration fingerprint for the Seriatim
merge operation, output schema, coalesce gap, and every configured advanced
merge transformation. A change reruns merge and stales only its fixed
descendants; prepare and transcribe remain reusable. Binary path, timeout,
report emission, logs, and diagnostic retention are operational exclusions.
Configuration or resources loaded privately inside Seriatim are outside
Narratio's observable contract and require `--force` when changed. An existing
successful merge record without evidence reruns once when selected.
## Related Contracts And Tests
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
- [Configuration](../config.md#pipeline) owns operator-selected Seriatim values.
- Implementation and tests: `internal/stage/merge.go`,
`internal/stage/merge_test.go`
`internal/stage/merge_test.go`,
`internal/stage/semantic_contracts_initial.go`, and
`internal/stage/semantic_contracts_initial_test.go`

View File

@@ -15,7 +15,7 @@ Normalize polished transcript into final transcript using Seriatim.
## Key Behavior
- resolves polished transcript from manifest outputs/canonical fallback.
- resolves polished transcript only from verified manifest output evidence.
- applies `pipeline.normalize` config or default normalize config.
- runs Seriatim normalize with configured timeout/binary.
- validates normalized transcript and optional report.
@@ -26,9 +26,17 @@ Normalize polished transcript into final transcript using Seriatim.
- final transcript must validate as processed transcript JSON (`segments` array).
- normalize defaults are applied when `pipeline.normalize` is unset.
## Resume Semantics
The versioned semantic fingerprint covers the Seriatim normalize operation,
output schema and canonical output identity, plus the configured transcript
transformations. Seriatim's executable and timeout and optional report
generation are operational and do not invalidate the normalized transcript.
## Related Contracts And Tests
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
- [Configuration](../config.md#pipeline) owns normalize fields and defaults.
- Implementation and tests: `internal/stage/normalize.go`,
`internal/stage/normalize_test.go`
`internal/stage/normalize_test.go`,
`internal/stage/semantic_contracts_refinement.go`

View File

@@ -16,7 +16,7 @@ Run Audita polishing on base transcript and produce polished transcript.
## Key Behavior
- resolves base transcript from merge outputs/canonical fallback.
- resolves base transcript only from verified merge manifest output evidence.
- invokes an Audita runner configured with static model/runtime options; the
invocation supplies paths and modules.
- validates processed transcript structure (`segments` array required).
@@ -28,10 +28,27 @@ Run Audita polishing on base transcript and produce polished transcript.
- polished transcript schema validation is mandatory.
- report output is config-gated.
## Resume Semantics
The versioned semantic fingerprint covers the Audita service endpoint, model,
validation model, module set, transcript description, output schema, selected
external configuration path, and canonical polished-transcript identity. Module
ordering is normalized because the configured modules form a set. Audita's
executable, timeouts, concurrency, report and debug behavior, work retention,
and resolved credential value are operational and do not invalidate a
successful result. The public credential reference name may participate in
configuration provenance but never exposes its resolved value.
Narratio can fingerprint a selected model, module, or configuration identifier,
but it cannot inspect content that Audita privately resolves behind that stable
identifier. Force `polish` after changing such private content without changing
its identifier.
## Related Contracts And Tests
- [Audita](../integrations/audita.md) owns subprocess, validation, and failure
semantics.
- [Configuration](../config.md#pipeline) owns operator-selected Audita values.
- Implementation and tests: `internal/stage/polish.go`,
`internal/stage/polish_test.go`
`internal/stage/polish_test.go`,
`internal/stage/semantic_contracts_refinement.go`

View File

@@ -39,6 +39,8 @@ Materialize canonical current-session inputs before processing stages.
- materializes a configured spell catalog with checksum and provenance, or
safely removes an obsolete canonical spell catalog and its manifest record
when the effective input is omitted.
- copies the validated canonical party bytes unchanged and deterministically
generates the prepared players projection.
- scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements.
- clears managed `previous/` state on every invocation, then, when requirements exist:
- resolves the pointer-selected previous source through the shared resolver;
@@ -46,8 +48,10 @@ Materialize canonical current-session inputs before processing stages.
- records previous inputs in `manifest.inputs`.
Required previous-session inputs fail when unavailable; optional missing inputs
are typed skipped results. Committed sources use their exact source-to-destination
mapping, while the isolated legacy reader rejects ambiguous fallback matches.
are typed skipped results. Each requirement resolves only from its unique
`published_output` declaration in the pointer-selected remote commit. Prepare
strips that selected run's immutable `published/` prefix to derive the local
cache path, then validates the committed checksum, size, and generation.
## Invariants
@@ -57,6 +61,26 @@ mapping, while the isolated legacy reader rejects ambiguous fallback matches.
- managed `previous/` state represents only the current requirement set.
- `manifest.inputs` ordering is deterministic (`kind`, `path`).
## Resume Evidence
Prepare records a versioned semantic-configuration fingerprint for the
resolved campaign/session selection, local-versus-S3 audio mode and canonical
audio names, stable-input ownership/presence, previous-session identity, and
the party mode plus canonical players projection version, and the effective
previous-artifact requirement set. A change reruns prepare and
stales its fixed descendants. Existing successful records without this
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. 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
- [Configuration](../config.md) owns audio selection, stable input fields, and
@@ -66,5 +90,10 @@ mapping, while the isolated legacy reader rejects ambiguous fallback matches.
- [Storage Internals](storage.md) and [Artifact Internals](artifacts.md) explain
the internal collaborators.
- Implementation and tests: `internal/stage/prepare.go`,
`internal/stage/prepare_test.go`, `internal/audio/s3_audio_test.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`,
`internal/previouscache/*_test.go`

View File

@@ -7,15 +7,16 @@ Upload run/session outputs to object storage and atomically advance remote curre
## Inputs
- successful preceding stages from the [canonical stage set](overview.md#pipeline-stage-set)
- invocation-scoped run files
- declared run-local diagnostic files
- resolved publish output rules
- effective publish locks (static + remote merged lock set), revalidated at the
remote commit boundary
- independent per-source remote lock objects read at planning start
- the validated prior current commit when an initially locked source may carry
- durable previous-session cache files when present
## Outputs
- uploaded invocation record and selected publish outputs;
- uploaded session manifest, declared run-local diagnostics, and selected
publish outputs;
- uploaded durable previous-session cache files when present;
- immutable run-scoped commit manifest; and
- current commit pointer, written last.
@@ -25,36 +26,63 @@ Exact remote placement and the operator workflow belong in
## Key Behavior
`publish.go` retains the transaction-owning `Run` path. Source discovery,
manifest-derived archive metadata, confined opening, and checksum validation
live in `publish_sources.go`; artifact-catalog matching, selection, required
and optional rules, destination resolution, and collision inputs live in
`publish_outputs.go`. Copying, hashing, immutable upload, reuse checks, and
remote verification live in `publish_transfer.go`; committed-manifest,
immutable-commit, and current-pointer encoding and writes live in
`publish_commit.go`. `publish_output_plan.go` owns the deterministic, I/O-free
five-outcome model and its secret-free metadata projection.
- when publishing or run upload is disabled, completes successfully with no
outputs and records explanatory metadata. This is not an explicit self-skip:
both manifests record success, and an ordinary later run reuses that result
until publish is forced.
the session manifest records success. Enablement and upload policy are
fingerprinted, so changing either automatically makes the prior result
non-resumable.
- validates prerequisite stage success and object-store availability.
- derives a deterministic run-archive allowlist from the validated run
`manifest.json`: declared run-local outputs, logs, generated configs, and the
manifest itself. Unlisted workspace files are not archive candidates.
- derives a deterministic run-archive allowlist from the session manifest's
declared current-run outputs, logs, and generated configurations. References
outside the current run root, including canonical session files and prior-run
diagnostics, are not archive candidates. No distinguished diagnostic file is
read or uploaded.
- opens each archive candidate beneath its archive root without following
symlinked ancestors or leaf entries, verifies that it is a regular file and
checks a declared checksum when present, then streams the opened descriptor.
- derives the durable previous-cache archive from its validated manifest using
the same confinement and regular-file checks.
- resolves publish output sources through runtime artifact catalog and manifest-aware resolution.
- resolves publish output sources through runtime artifact catalog and
manifest-aware resolution. Configured Scriptorium outputs are publishable
only from validated `current` per-artifact analyze evidence; an incidental
canonical file, aggregate output, stale/failed/unselected record, or
mismatched path, size, or checksum remains unavailable. Built-in and
extraction sources likewise require their own current manifest evidence.
- publishes extraction lanes only through explicit configured output rules;
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
- selected artifact filter applies to configured artifact sources only.
- locked outputs are skipped intentionally (including required ones).
- optional missing outputs are skipped; required missing unlocked outputs fail.
- plans every configured output into exactly one source-sorted outcome:
`published`, `carried_locked`, `locked_unpublished`, `optional_missing`, or
`unselected`.
- an initially locked selected output carries its exact prior
`published_output` declaration when one exists. The declaration may point to
an earlier run in the same session and retains its source, key, checksum,
size, and generation; the bytes are not copied.
- verifies a carried object's exact key, size, and generation through object
metadata without downloading or opening the local output source.
- an initially locked source with no prior declaration remains absent even
when the output rule is required. Optional missing unlocked outputs remain
absent; required missing unlocked outputs fail.
- creates one complete immutable source-to-destination mapping before upload;
- uploads and verifies every declared immutable object and the commit manifest;
- updates `current/commit-pointer.json` exactly once, last; and
- does not write the legacy `current/manifest.json` or `current/run_id.txt` pair.
- rechecks remote lock state immediately before the pointer update. A newly
committed lock aborts selection, leaving any uploaded immutable attempt
unselected.
- reads the mutable remote lock document through a direct limit-plus-one read
capped by `MaxRemoteLockStoreBytes` (1 MiB), retaining the generation returned
with the opened body for conditional updates. Oversized lock documents fail
before YAML decoding; published artifact payloads do not use this limit.
- writes the immutable commit from the combined new and exact carried
declarations.
- rereads every initially unlocked participating source after the immutable
commit. A newly present lock aborts selection, leaving the uploaded immutable
attempt unselected. Removing an initial lock may conservatively produce one
carry because the initial snapshot controls the plan.
- updates `current/commit-pointer.json` exactly once, last, and writes no other
mutable publication control record.
## Metadata Signals
@@ -62,9 +90,8 @@ Includes counts/lists for:
- run uploads
- published output uploads
- previous uploads
- skipped optional outputs
- skipped unselected outputs
- locked outputs
- one `publish_output_outcomes` collection containing all five output states,
exact declared keys, and key-derived origin runs where applicable
- remote commit and current-pointer key paths
- the run identifier selected by the commit
@@ -77,23 +104,44 @@ Includes counts/lists for:
from verified descriptors; symlinks, special files, replacement races, and
undeclared entries are rejected or ignored before uploads begin.
- run-local diagnostics, including Notarius receipt and stderr files, are
archived only when recorded by the run manifest.
- publish locks are not overridden by `--force`; remote locks are revalidated
immediately before current-state selection.
- post-commit local cleanup is authorized by the committed publish metadata and
is durably recorded by the application lifecycle before any local deletion.
archived only when recorded by the session manifest. A run with no declared
current-run diagnostics legitimately uploads none.
- independent publish locks are not overridden by `--force`; every initially
unlocked relevant source is reread immediately before current-state
selection.
- successful uploaded publication records the exact run ID, remote commit key,
and current-pointer key consumed by the later cleanup stage.
The commit boundary and cleanup gate are normative architecture invariants; see
## Resume Semantics
The versioned semantic fingerprint covers enabled behavior, run-upload policy,
normalized source/destination/required output rules, and the remote backend,
bucket, region, endpoint, and root-prefix identity. Rule ordering is
canonicalized. Credential
environment names, path-addressing transport mode, local workspace placement,
and run identifiers are excluded. Independent remote locks remain mutable state
and are read directly by publish at planning and commit boundaries; semantic
evidence does not replace those safety checks.
The commit boundary and cleanup authorization are normative architecture invariants; see
[Architecture](../policy/architecture.md#publish-commit-boundary).
Publish never deletes local state. The standalone `publish` command selects
only this stage; a full run selects notify and cleanup afterward.
## Related Contracts And Tests
- [Configuration](../config.md#publish-configuration-summary) owns output and
static-lock fields.
- [Configuration](../config.md#publish-configuration-summary) owns output
rules.
- [Operations](../operations.md#publish-locks) owns remote lock lifecycle and
physical remote state.
- [Artifact Internals](artifacts.md) explains source resolution and current-state
helpers.
- Implementation and tests: `internal/stage/publish.go`,
`internal/stage/publish_test.go`, `internal/app/operator_helpers_test.go`,
`internal/app/post_publish_cleanup_test.go`
`internal/stage/publish_sources.go`, `internal/stage/publish_outputs.go`,
`internal/stage/publish_output_plan.go`,
`internal/stage/publish_transfer.go`, `internal/stage/publish_commit.go`,
`internal/stage/publish_test.go`,
`internal/stage/semantic_contracts_delivery.go`,
`internal/app/operator_helpers_test.go`, and
`internal/app/cleanup_orchestration_test.go`

View File

@@ -3,6 +3,10 @@
## Purpose
Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim.
It runs after `trim` and before `extract` in the canonical sequence. Render and
extract are independent sibling consumers: replacing render output does not
invalidate extraction, but it does invalidate succeeded analysis and delivery
records that may consume rendered transcripts.
## Inputs
@@ -17,12 +21,13 @@ Render Markdown transcript artifacts from normalized JSON transcripts via Seriat
## Key Behavior
- uses `pipeline.render` settings (enabled/format/title/booleans).
- resolves inputs manifest-first, then canonical fallback.
- resolves inputs only from verified manifest output evidence.
- writes run-local outputs first, then materializes canonical session outputs.
- records input provenance, output paths, adapter metadata, logs, and generated config refs.
- when `pipeline.render.enabled=false`, completes successfully with no outputs
and records explanatory metadata. This is not an explicit self-skip: both
manifests record success, and enabling render later requires a forced run.
manifests record success. Because enablement is fingerprinted, enabling
render later automatically makes the prior result non-resumable.
## Failure Semantics
@@ -36,9 +41,20 @@ Render Markdown transcript artifacts from normalized JSON transcripts via Seriat
- only `format: markdown` is supported.
- render stage owns production of built-in Markdown transcript sources.
## Resume Semantics
The versioned semantic fingerprint covers enablement, final format, resolved
title (including the session-title fallback), timestamp, segment-ID and
metadata inclusion, both canonical input identities, and both Markdown output
identities. Seriatim's executable, timeout, and report behavior are operational
and do not invalidate rendered transcripts. A render-only change leaves the
independent `extract` sibling reusable while invalidating their shared
downstream consumers.
## Related Contracts And Tests
- [Seriatim](../integrations/seriatim.md) owns render subprocess behavior.
- [Configuration](../config.md#pipeline) owns render fields and defaults.
- Implementation and tests: `internal/stage/render.go`,
`internal/stage/render_test.go`
`internal/stage/render_test.go`,
`internal/stage/semantic_contracts_refinement.go`

View File

@@ -16,7 +16,7 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
- discovers prepared audio from manifest inputs or canonical audio directory.
- derives the transcript identity from the prepared `.flac` filename.
- dispatches WhisperX requests through a bounded worker pool.
- dispatches one WhisperX request per prepared file through a bounded errgroup.
- validates each output as JSON.
- writes run-local outputs then materializes canonical transcript outputs only
after every planned request succeeds.
@@ -31,6 +31,19 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
- each successful output is validated before stage success, and cancellation or
incomplete dispatch cannot be reported as a successful result.
## Resume Evidence
Transcribe records a versioned semantic-configuration fingerprint containing
the Narratio-visible WhisperX service URL and recognition language. Changes to
either rerun transcription and stale its fixed descendants while leaving
prepare reusable. Retry count/delay, concurrency, timeout, credentials, and
diagnostic locations are operational and do not change this evidence.
WhisperX models or private service configuration not exposed by Narratio's
adapter contract cannot be fingerprinted; use `--force` after changing them.
An existing successful transcribe record without evidence reruns once when
selected.
## Related Contracts And Tests
- [WhisperX](../integrations/whisperx.md) owns HTTP, retry, timeout, and
@@ -38,4 +51,6 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
- [Configuration](../config.md#pipeline) owns concurrency and other
operator-selected values.
- Implementation and tests: `internal/stage/transcribe.go`,
`internal/stage/transcribe_test.go`
`internal/stage/transcribe_test.go`,
`internal/stage/semantic_contracts_initial.go`, and
`internal/stage/semantic_contracts_initial_test.go`

View File

@@ -33,6 +33,19 @@ When `trim.enabled=false`:
- bounds output exists only in enabled trim path.
- render-debug output is diagnostic and not a declared stage output.
## Resume Semantics
The versioned semantic fingerprint covers enablement, the bounds prompt and
profile identifiers, the Scriptorium configuration identity, transcript input
name, sticky session variable, bounds and trimmed output identities, and the
Seriatim trim operation. Diagnostic bounds rendering, diagnostic output paths,
timeouts, executable paths, and optional reports are operational and do not
invalidate the canonical trimmed transcript.
Narratio cannot inspect prompt, profile, or configuration content that
Scriptorium or Seriatim privately resolves behind a stable identifier. Force
`trim` after changing such private content without changing its identifier.
## Related Contracts And Tests
- [Scriptorium](../integrations/scriptorium.md) owns bounds generation and
@@ -40,4 +53,5 @@ When `trim.enabled=false`:
- [Seriatim](../integrations/seriatim.md) owns transcript trimming behavior.
- [Configuration](../config.md#pipeline) owns trim fields and defaults.
- Implementation and tests: `internal/stage/trim.go`,
`internal/stage/trim_test.go`
`internal/stage/trim_test.go`,
`internal/stage/semantic_contracts_refinement.go`

View File

@@ -16,6 +16,7 @@ operator-selected storage fields and credential mechanisms belong in
- `Download(ctx, key, localPath)`
- `Upload(ctx, localPath, key, opts)`
- `UploadConditional(ctx, source, key, opts, condition)`
- `Delete(ctx, key)` removes one object and succeeds when it is already absent
- `Exists(ctx, key)`
Key invariant:
@@ -47,6 +48,8 @@ not own discovery, defaults, or configuration validation.
- `Read` binds a returned body to its S3 ETag. `UploadConditional` maps an ETag
match or absence precondition directly to the provider request and reports a
failed precondition without performing a local check-then-write replacement.
- `Delete` maps one normalized key to the provider delete-object operation and
treats an absent object as success.
- `Exists` maps not-found responses to `false`.
## Invariants
@@ -55,6 +58,11 @@ not own discovery, defaults, or configuration validation.
- bounded reads never retain more than the caller's limit plus one byte and do
not replace owner-specific size policy.
- publish ordering semantics are owned by stage/app code, not storage adapters.
- A caller that installs remote content into a canonical workspace path first
writes a sibling temporary file, validates the complete object against its
owner-specific contract, and only then replaces the destination. Temporary
files used solely for remote comparisons are removed by their callers and do
not represent installed workspace state.
## Implementation And Tests

View File

@@ -14,8 +14,8 @@ path model, and layout creation goes through `EnsureLayoutFor`. Callers should
consume those helpers instead of rebuilding relative paths.
`internal/pathsafe` validates relative destinations. `internal/fileops` opens
cleanup roots and their descendants through no-follow directory handles before
removing them.
explicit cleanup roots and removes only normalized proper descendants through
the resulting root handle.
`internal/fileops` owns the ordinary workspace mode contract. On POSIX,
`WorkspaceDirectoryMode` is setgid `02775` and `WorkspaceFileMode` is `0664`.
@@ -33,15 +33,16 @@ previous-session cache paths remain session-durable and are never redirected
into run-local output space.
Extraction uses run-local receipt, stderr, and output-root helpers, then
promotes the validated external bundle to the unique immutable Notarius bundle
path supplied by `internal/artifacts`. `internal/fileops.PromoteDirectory`
copies only regular files and directories to a same-filesystem temporary
sibling. Source traversal uses confined directory handles and identity checks
so replacing an inspected root, directory, or file is rejected rather than
followed. The completed tree is atomically renamed without replacing an
existing destination. Exact physical paths belong in
validates and renames the external bundle to the unique immutable Notarius
bundle path supplied by `internal/artifacts`. Exact physical paths belong in
[Operations](../operations.md#extraction-workflow).
Prepared references delivered to Notarius use session-durable paths owned by
`internal/artifacts` beneath
`cache/notarius/references/<sha256>/<prepared-basename>`. These verified
content-addressed snapshots are outside run-local layout, survive ordinary
published-run cleanup, and are excluded from publish and restore enumeration.
## Locking
`artifacts.LocalStore` enforces the single-writer session lock via an
@@ -50,21 +51,25 @@ file retains owner metadata after release or process death; its existence is
not evidence that a lock is active. Command and restore flows wait for this
lock only while their context remains active, and report a release failure.
The runner assigns every invocation a run-workspace identity but creates the
physical directory only before an executing stage that requires it. Fully
reused invocations and cleanup-only invocations therefore do not accumulate
empty run directories.
## Cleanup Semantics
Automatic post-publish cleanup:
The canonical cleanup stage:
- is created only after a successful publish commit with complete publish
metadata, then is persisted before any deletion;
- requires `uploaded=true`, a remote commit key, and a current commit-pointer
key in publish metadata;
- runs after notify only when selected by the invocation range;
- requires a succeeded publish record with `uploaded=true`, the published run
ID, remote commit key, and current commit-pointer key;
- consumes the resolved cleanup policy described in
[Configuration](../config.md);
- refuses unsafe deletes (root delete, out-of-root delete, and symlinked
ancestors or entries);
- retries any recorded incomplete target on later invocations even when no
publish work is selected. Missing targets are a successful, idempotent
cleanup result only after the completion evidence is saved.
- refuses unsafe deletes (root delete, traversal, and out-of-root targets);
- treats missing targets as successful, idempotent deletion; and
- uses its ordinary failed/succeeded manifest stage record for retry. A later
full run can skip reusable upstream stages and retry cleanup without
republishing.
Manual cleanup uses the same root-confined deletion mechanism. Invocation
syntax and exact deletion scope belong in [CLI](../cli.md#clean) and
@@ -75,6 +80,8 @@ syntax and exact deletion scope belong in [CLI](../cli.md#clean) and
- campaign-aware session root is mandatory.
- manifest-driven stage state is durable across runs.
- cleanup guardrails prevent destructive root/out-of-scope deletion.
- restore installation is confined to validated relative paths beneath the
selected session root.
- ordinary workspace paths retain group-writable directory and file modes across
nested creation, replacement, and Notarius promotion.
@@ -83,12 +90,14 @@ syntax and exact deletion scope belong in [CLI](../cli.md#clean) and
- Path model and local store: `internal/artifacts/paths.go`,
`internal/artifacts/local.go`
- Run-local materialization: `internal/stage/run_local.go`
- Immutable bundle promotion: `internal/fileops/directory.go`
- Immutable bundle installation: `internal/stage/extract.go`
- Workspace modes: `internal/fileops/modes.go`
- Cleanup confinement: `internal/fileops/cleanup.go`,
`internal/app/cleanup_targets.go`, `internal/app/post_publish_cleanup.go`
`internal/stage/cleanup.go`, `internal/stage/cleanup_plan.go`, and
`internal/app/cleanup_targets.go`
- Tests: `internal/artifacts/paths_model_test.go`,
`internal/artifacts/local_test.go`, `internal/stage/run_local_test.go`,
`internal/fileops/directory_test.go`, `internal/fileops/modes_posix_test.go`,
`internal/fileops/modes_posix_test.go`,
`internal/fileops/cleanup_test.go`, `internal/app/cleanup_targets_test.go`,
`internal/app/post_publish_cleanup_test.go`
`internal/stage/cleanup_test.go`, and
`internal/app/cleanup_orchestration_test.go`

View File

@@ -70,6 +70,22 @@ narratio run 2026-04-04
narratio session status 2026-04-04
```
Run, plan, and status output identify the resolved pipeline profile (or `none`)
and effective configuration digest. Status distinguishes the current resolved
value from the last value persisted in the session manifest, which helps
diagnose profile switches without changing resume authority.
Before switching an operational profile, compare its effective meaning with the
production base through `narratio config diff default <profile>`.
The command is read-only and succeeds whether it finds differences or not. Its
sorted records describe defaulted, expanded concrete configuration—not source
file layout—so it can be used to review model, artifact, and publish changes
without creating a session or run. Use `narratio config artifacts` to review the
concrete artifact identities, definitions, outputs, and dependencies. Select the
same campaign explicitly when profiles could resolve different campaign paths;
see the [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
for syntax and record format.
## Stage Execution and Continuation Behavior
Canonical stage order:
@@ -80,22 +96,45 @@ Canonical stage order:
4. `polish`
5. `normalize`
6. `trim`
7. `extract`
8. `render`
7. `render`
8. `extract`
9. `analyze`
10. `publish`
11. `notify`
12. `cleanup`
Execution rules:
- succeeded stages are skipped unless `--force` is set;
- succeeded stages are skipped unless `--force` is set; stages with semantic
configuration contracts additionally require matching versioned evidence;
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
- forcing an upstream stage marks succeeded downstream stages as `stale` before
the replacement runs; and
- forcing a stage marks succeeded transitive dependents as `stale` before the
replacement runs; render and extract are independent siblings; and
- an executed failure, changed self-skip, or success that replaces a different
effective upstream outcome also marks succeeded downstream stages stale. A
effective outcome uses the same fixed dependency relation. A
repeated self-skip with the same reason and no outputs is stable and does not
perpetually rerun downstream work.
perpetually rerun dependent work.
Every aggregate stage except analyze currently provides semantic-configuration
evidence; analyze retains its more precise per-artifact fingerprints and
validator.
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.
A trim change invalidates both render and extract through the fixed dependency
relation, while a render-only change preserves the extract sibling.
Operational timeouts, retry/concurrency tuning, executable paths,
workspace/cache/spool placement, reports, diagnostics, and secret values are
excluded. Configuration, models, prompts, modules, or resources loaded
privately inside external tools remain unobservable to Narratio. If their
contents change behind the same configured identifier, explicitly force the
affected stage.
An explicit self-skip is a durable `skipped` stage outcome that later runs
reconsider. It differs from successful no-output execution: disabled `render`
@@ -111,14 +150,105 @@ Single-stage execution:
narratio run-stage normalize 2026-04-04 --force
```
Contiguous bounded execution uses inclusive canonical endpoints:
```bash
narratio session plan 2026-04-04 --from extract --through analyze --force
narratio run 2026-04-04 --from extract --through analyze --force
```
Omitting `--from` selects from `prepare`; omitting `--through` selects through
`cleanup`. Force applies only within the selected range. Repeating `--from`,
`--through`, or `--force` is rejected instead of resolving by argument order.
The plan command uses the same selection contract and prints only the selected
range. Planning is read-only: it clones the loaded manifest, models selected
stage transitions and invalidation in memory, and invokes resume validation
without writing the manifest, creating run directories, materializing files,
or invoking pipeline adapters. Analyze detail separates explicit targets,
prerequisite rebuilds, scheduled execution, and current reuse. This lets a
coarsely stale aggregate analyze stage show zero artifact executions when its
selected artifact evidence is still semantically current.
Before a bounded run or plan whose range starts after `prepare`, every excluded
prefix stage must already have a session-manifest status of `succeeded` or
`skipped`. Narratio reports the first absent, pending, running, failed, stale,
or interrupted prerequisite without creating a run-local directory or changing
session state. Widen `--from` to include that stage, or recover it explicitly
before retrying. Excluded prefix stages are not resume-validated or repaired as
part of the bounded invocation; selected stages still reject missing, unsafe,
or manifest-inconsistent inputs at their owning boundary.
Stages after `--through` are not prerequisites and are never scheduled by the
bounded invocation. A selected forced stage can mark one of those succeeded
dependents stale through the fixed invalidation relation, but the dependent
does not execute until a later invocation selects it. Production composition
likewise initializes only collaborators needed by the selected range and
shared session lifecycle. In particular, render does not require Notarius or
Scriptorium, extract does not require Scriptorium, and analyze does not require
the transcription, Seriatim, Audita, or Notarius adapters.
## Artifact Development Workflows
Choose the narrowest workflow that matches the intended replacement:
| Intent | Command | Preserved and rebuilt state |
| --- | --- | --- |
| Rebuild analysis only | [`narratio analyze 2026-04-04`](./cli.md#analyze) | Preserves transcripts and the current extraction bundle; force-runs selected analysis artifacts and their required analysis prerequisites. |
| Rebuild extraction and analysis | [`narratio regenerate-artifacts 2026-04-04`](./cli.md#regenerate-artifacts) | Preserves transcripts; force-runs the complete configured extraction bundle, then selected analysis. `--artifacts` narrows analysis only. |
| Continue bounded work normally | [`narratio run 2026-04-04 --from extract --through analyze`](./cli.md#run) | Uses ordinary resume decisions: current extraction and analysis can be skipped, while incomplete or obsolete work runs. |
All three stop before publication. Inspect the results and run
[`narratio publish`](./cli.md#publish) separately when delivery is intended.
For the extraction-plus-analysis development loop, use:
```bash
narratio regenerate-artifacts 2026-04-04
narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout
```
This command is a transparent expansion to a forced bounded `run` from
`extract` through `analyze`. Extraction always rebuilds its complete configured
bundle. Analysis rebuilds the selected targets and their required analysis
prerequisites, or uses the normal default selection when no artifact names are
given. The command does not run publish, notify, or cleanup; delivery and
retention remain separate operator actions.
Inspect current artifact evidence, then publish explicitly when the regenerated
set is ready:
```bash
narratio session artifacts 2026-04-04
narratio publish 2026-04-04
```
If planning or execution reports stale, missing, failed, or tampered
analysis evidence, regenerate the affected target instead of copying an older
canonical file into place or editing the manifest. See
[Troubleshooting: Analysis artifact evidence is not current](./troubleshooting.md#analysis-artifact-evidence-is-not-current).
Analyze checkpoints each completed configured artifact before starting later
artifact work. After a partial failure or interruption, retry normally: valid
earlier checkpoints are reused and execution resumes with failed, stale, or
missing work in dependency order. `--force` is unnecessary unless the operator
intends to replace otherwise-current artifacts.
## Artifact Selection
`--artifacts` can be used on `run`, `run-stage`, `analyze`, and `publish`.
For a configured artifact family, selecting its family key expands to every
concrete character artifact. Select a concrete generated key to operate on one
member only. Manifests and plan output retain the concrete key as the durable
identity and include the family and character ID as optional provenance.
`--artifacts` can be used on `run`, `session plan`, `run-stage`, `analyze`, and
`publish`. For a bounded run or plan, the selected range must contain `analyze`
or `publish`.
Selection behavior:
- validates names against `pipeline.scriptorium.artifacts`;
- filters analyze execution to selected configured artifacts;
- selects explicit analyze targets and permits their required configured
prerequisites to be reused or rebuilt first;
- filters publish rules for `narratio.artifact.<name>` sources only;
- does not suppress built-in transcript, bounds, or explicitly configured
`narratio.extraction.<name>` publish sources; and
@@ -143,9 +273,19 @@ are not selectable or published implicitly.
Configured Notarius references resolve only from the current manifest-backed
prepared inputs. Their canonical locations are `inputs/party.yml`,
`inputs/players.yml`, `inputs/glossary.yml`, and, when configured,
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies
under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical
prepared files cannot change the bytes consumed by an in-flight invocation.
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified,
content-addressed copies under
`cache/notarius/references/<sha256>/<prepared-basename>`. The full lowercase
SHA-256 digest identifies the reference bytes, so retries and later invocations
reuse the same path while changed bytes select a new path. Older snapshots are
retained and are not current artifact evidence. A concurrent refresh of
canonical prepared files therefore cannot change the bytes consumed by an
in-flight invocation.
For a canonical party, preparation retains the validated authored party bytes
at `inputs/party.yml` and generates `inputs/players.yml` from that roster.
The manifest records their checksums separately, with the players input marked
as derived from the party; refresh preparation after changing the roster rather
than editing either prepared file.
Inspect the effective stable-input inventory and
prepared-file readiness with:
@@ -167,15 +307,35 @@ narratio run-stage prepare 2026-04-04 --force
Starting a replacement clears the previous extraction payload from the current
session-stage record. If that replacement fails or self-skips, the current
record does not fall back to the earlier outputs. The earlier run manifest and
immutable bundle remain available for inspection, but downstream resolution
requires a new current successful extraction record.
record does not fall back to the earlier outputs. The earlier immutable bundle
remains available for inspection, but downstream resolution requires a new
current successful extraction record.
Atomic Notarius bundle promotion is supported on Linux and macOS. On Windows
and other operating systems, extraction fails before copying the bundle into a
temporary promotion tree because Narratio has no verified atomic no-replace
directory primitive there. This is an extraction limitation, not a broader
platform-support guarantee for every Narratio workflow.
Notarius extraction validates its run-local bundle and moves it once to the
unique durable bundle path. A pre-existing durable destination is a collision;
no duplicate promotion tree is created.
`pipeline.notarius.resume` and `pipeline.notarius.chunk_cache` control work
inside an executed Notarius invocation. Ordinary extraction forwards their
configured values. Forced extraction always disables checkpoint loading and
requests chunk-plan `refresh`, even when the configured mode is `bypass`.
Refresh regenerates and replaces only the active input's accepted chunk plan;
it is not cache deletion.
Notarius checkpoint recording must be enabled in the deployed Notarius
configuration. Narratio neither creates a Notarius pipeline/configuration nor
reads it to infer checkpoint availability. Configure explicit persistent
Notarius chunk-plan and checkpoint roots when reuse must survive separate
Narratio subprocesses. A changed reference, transcript, selected profile,
profile content, endpoint, or other Notarius checkpoint identity input can
produce a legitimate cold miss.
There are two independent resume boundaries: Narratio may skip a completed
`extract` stage without invoking Notarius, while `--resume` allows Notarius to
reuse compatible accepted work only after Narratio has decided extraction must
execute. Deleting Narratio's local reference snapshots does not invalidate a
complete durable extraction; missing snapshots are reconstructed only when a
later extraction actually runs.
## External Command Lifecycle
@@ -198,12 +358,15 @@ Run-local diagnostics are:
- `runs/{run_id}/extract/notarius.stderr.log`
- `runs/{run_id}/extract/notarius-output/` before durable promotion
The run-record upload is an allowlist derived from the validated run manifest,
not a workspace scan. Each declared source is opened without following
symlinked ancestors or the leaf, verified as a regular file, and streamed from
that verified descriptor. Unlisted files and unsafe entries are never uploaded.
The durable bundle is never scanned for implicit publication; only lanes named
by explicit `pipeline.publish.outputs` rules are uploaded.
The run upload is an allowlist derived from the session manifest's declared
current-run outputs, logs, and generated configurations, not a workspace scan.
References to canonical session files and prior-run paths are excluded. Each
declared source is opened without following symlinked ancestors or the leaf,
verified as a regular file, and streamed from that verified descriptor.
Unlisted files and unsafe entries are never uploaded; a run with no declared
current-run diagnostics uploads no run-local files. No distinguished diagnostic
file is uploaded. The durable bundle is never scanned for implicit publication;
only lanes named by explicit `pipeline.publish.outputs` rules are uploaded.
To intentionally replace the current extraction result, run:
@@ -213,6 +376,10 @@ narratio run-stage extract 2026-04-04 --force
Narratio automatically reruns extraction when its recorded invocation contract,
prepared Narratio reference identities, or durable output validation changes.
The semantic portion covers Notarius enablement, pipeline identity, declared
reference mapping, and output contracts. Executable, timeout, working directory,
and private config-file paths are operational and do not invalidate a current
result.
It cannot fingerprint configuration files, profiles, prompts, modules, or
other references loaded transitively by Notarius itself. Force extraction after
changing any of those inputs, even when the top-level Narratio and Notarius
@@ -221,6 +388,13 @@ marks successful downstream stages stale. Ordinary extraction failures or
outcome changes also stale affected downstream stages, while an identical
repeated `notarius_disabled` self-skip does not repeatedly invalidate them.
Publish reuse additionally tracks enabled/run-upload behavior, normalized
output rules, and remote backend/bucket/region/endpoint/root identity.
Credential environment names, local workspace placement, and run IDs are
excluded. Regardless of semantic reuse evidence, executing publish reads
independent remote locks directly and rechecks initially unlocked sources
before commit selection.
## Publish Workflow
Run publish only:
@@ -235,6 +409,9 @@ Equivalent:
narratio run-stage publish 2026-04-04 --force
```
This standalone command stops after publish and does not invoke cleanup. A
default full `run` continues through notify and cleanup.
Publish commit model:
- uploads eligible run files under `{session_prefix}/runs/{run_id}/`, excluding
@@ -258,31 +435,44 @@ commit by version, checksum, size, and storage generation; committed artifacts
are also checksum- and generation-bound. Readers accept this contract now and
strictly reject mismatched or unknown data.
Legacy reads are limited to a coherent `current/manifest.json` and
`current/run_id.txt` pair; a torn pair is rejected. New publication does not
write that pair and remote commit state does not carry local
`current_pointer_written` metadata.
A selected commit may carry a `published_output` declaration from an earlier
run in the same campaign/session. Restore, previous-session caching, status,
and artifact inspection use that declaration's exact immutable key and stable
source; they do not reconstruct a current-run key or scan the session prefix.
Other artifact types remain confined to the selected commit's own run.
When the pointer is absent, remote state is not published; republish the
session to establish a committed snapshot. Remote commit state does not carry
local `current_pointer_written` metadata.
## Publish Locks
Lock sources:
Each mutable lock is an independent strict JSON object at
`{session_prefix}/locks/{sha256(source)}.lock`. The lowercase digest is computed
from the exact canonical source. Listing validates every object in that
reserved prefix and reports locks sorted by source.
- static locks in `pipeline.publish.locks`
- mutable remote locks in `{session_prefix}/locks.yml`
Upgrades do not read, migrate, or delete the retired `{session_prefix}/locks.yml`
object. It has no effect on publication and may be removed manually after
verifying lock commands and publication behavior with the upgraded Narratio
version. No migration command is required.
Effective lock rules:
Operational rules:
- static and remote locks are merged;
- static locks win on source collisions;
- locked outputs are intentional skips;
- lock add/remove commands mutate only remote lock state through generation-bound
conditional writes. A command retries a bounded number of concurrent
conflicts while its invocation context remains active, so it never replaces a
different lock-document generation; and
- a publish re-reads remote locks immediately before it writes the current
commit pointer. A lock committed before that recheck prevents selecting the
new snapshot, even though its already-uploaded immutable objects may remain
available for a later retry.
- add uses conditional creation and reports an existing lock;
- add with `--force` observes the current generation and attempts one
generation-matched replacement. A concurrent change is reported for an
explicit operator retry;
- remove is idempotent and reports whether a lock was present;
- the initial publish lock snapshot controls output planning. A locked source
carries its exact declaration from the prior current commit when available;
a never-published locked source remains absent even when required;
- carried bytes are not copied. Publish verifies the prior object's exact key,
size, and generation without downloading it; and
- after immutable uploads and the immutable commit, publish re-reads every
initially unlocked participating source. A newly added lock prevents the
current pointer from advancing, although the immutable attempt may remain.
A removed initial lock may conservatively cause one carry.
Examples:
@@ -316,13 +506,22 @@ Default restore scope:
- the committed session manifest and the committed transcript/artifact objects
declared by the selected remote commit
- `previous/**` when needed by configured previous-session artifact inputs
- `previous/**` when needed by configured previous-session artifact inputs;
each is selected from its exact committed `published_output` declaration
Optional:
- `--include-audio` to include `audio/**`
- `--force` to overwrite eligible conflicting regular files; it never replaces
directories or other non-regular local targets
directories, symlinks, or other non-regular local targets
Restore treats a local leaf symlink or any symlinked ancestor beneath the
selected session root as a conflict, even when the linked content matches the
selected remote object. `--force` does not override that conflict.
Before each install, restore validates the destination again beneath the
selected session root. A target changed into a symlink, directory, or other
non-regular entry is not overwritten.
Restore writes an execution report at `reports/restore-latest.json`.
@@ -350,10 +549,24 @@ Durable session paths:
- `logs/**`
- `config/**`
- `runs/**`
- `cache/notarius/references/<sha256>/<prepared-basename>`
`manifest.json` is the versioned session-progress document. Narratio accepts
only `narratio.session-manifest.v1`. Do not edit unsupported session state to
add missing markers. Move it aside and regenerate it, restore a verified
current committed snapshot, or republish healthy regenerated state as the
relevant workflow requires.
Validated Notarius bundles live below `artifacts/notarius/{run_id}/`; receipt,
stderr, and pre-promotion output remain in the producing run's `extract`
directory as described in [Extraction Workflow](#extraction-workflow).
Stable reference snapshots live in the session cache path above. Ordinary
published-run cleanup removes its selected run-local target but retains this
cache, and publish/restore never enumerate it as an artifact. Remove a snapshot
or digest directory manually only while no command is active for the session;
regular missing or corrupt content is reconstructed on the next executed
extraction, while symlinks, directories, and other unsafe conflicts must be
investigated and removed deliberately.
Run-local layout:
@@ -375,6 +588,8 @@ Cache layout (durable S3 audio cache):
Each cached audio file has an adjacent managed identity record. It binds the
file to its remote object version and verified digest; deleting or altering the
record simply causes Narratio to download and verify the object again.
Remote audio and restored files are fully transferred into sibling temporary
files and validated before they replace their canonical workspace paths.
### Workspace Permissions
@@ -386,18 +601,28 @@ converge on `0664`. Narratio explicitly applies these modes so a restrictive
caller umask does not remove group write or setgid. It does not change file or
directory ownership: the configured workspace's existing group is inherited.
Windows does not implement POSIX mode bits or setgid semantics. Configure the
workspace, spool, and cache locations with an ACL that grants the collaborating
group read/write access, and configure credential locations with an ACL limited
to the intended credential owner. Do not use POSIX mode displays as evidence of
Windows access control.
API keys are credentials, not ordinary workspace data. Store them outside the
shared workspace or in a separately restricted credential location; ordinary
workspace group access must never be treated as authorization to read keys.
On POSIX, provision a credential directory as `0700` and credential files as
`0600`; Narratio rejects group- or other-readable configured credential paths.
On Windows, restrict the directory and files with ACLs to the credential owner.
Provision a credential directory as `0700` and credential files as `0600`;
these permission modes are an operator requirement and are not enforced by
ConfigKit.
Every pipeline-loading command bootstraps credentials once, before the first
configuration load and before any adapter is constructed. `--secrets-dir`
selects an explicit directory (relative paths use caller CWD). Otherwise a
present `secrets/` beside the resolved root pipeline is used, while an absent
conventional directory is skipped. Explicit missing/empty selections and
invalid existing directories fail the command. ConfigKit follows a symlink used
as the selected directory, but immediate entries must be regular non-symlink
files. Existing environment variables—including empty values—always win.
S3 `access_key_id` and `secret_access_key` are an optional pair of typed
environment references. When both are absent, the AWS SDK retains its default
credential provider chain. When both are configured, Narratio supplies their
load-time resolved values through an explicit static provider. Audita's optional
`llm_api_key` is likewise captured at pipeline load and supplied only through
the child process's `AUDITA_LLM_API_KEY` override.
External adapter results are individually bounded before Narratio validates or
materializes them. These per-file limits do not reserve disk space: prevent hard
@@ -430,13 +655,29 @@ Rules:
- `clean` deletes work/spool session state;
- cache is preserved unless `--clear-cache` is set;
- each deletion is confined beneath its configured workspace, spool, or cache
root and refuses symlinked paths;
- automatic post-publish cleanup is gated by successful publish commit plus:
root; root, traversal, and out-of-root targets are refused;
- the final cleanup stage is gated by successful publish evidence plus:
- `pipeline.spool.delete_audio_after_publish=true`
- `pipeline.workspace.cleanup_after_publish=true`
- Narratio first records the exact run-scoped cleanup obligation. If cleanup
reports incomplete, the remote committed snapshot remains current; rerun
Narratio to retry only the outstanding confined local cleanup.
- the manifest publish record has `uploaded=true`, the published run ID, the
exact remote commit key, and the current commit-pointer key.
Cleanup derives only the configured run-scoped spool and workspace targets,
uses root-confined deletion, and does not read or mutate object storage. A
failure leaves publish succeeded and the remote committed snapshot current.
Retry with a normal full run: valid upstream work, including publish and notify,
is skipped while the failed cleanup stage runs again. `run-stage cleanup` is
also available when the manifest has valid publish evidence. A range ending at
`notify` or earlier and the standalone `publish` command never delete local
state.
Older local manifests that contain the retired cleanup-obligation field are
intentionally incompatible with strict manifest decoding. If such an
obligation is still outstanding, complete it with the pre-upgrade binary.
Otherwise verify that the remote publication is committed, move aside or
regenerate the incompatible local manifest (or restore the verified remote
snapshot), and then use the cleanup stage. Manual JSON editing is not the
normal migration path.
## Operational Caveats
@@ -444,4 +685,5 @@ Rules:
- Publish requires prerequisite stages through `render` and `analyze` to be succeeded.
- Markdown publish defaults require render outputs (`transcripts/final.md` and `transcripts/final.trimmed.md`).
- Restore requires configured object storage and committed remote current state.
- Storage-backed commands load filesystem secrets before object-store initialization.
- Pipeline-loading commands bootstrap filesystem secrets once before configuration
resolution and object-store initialization.

View File

@@ -29,6 +29,10 @@ in the [integration documentation](../integrations/).
The pipeline has one canonical ordered stage set. Configuration may enable,
disable, or parameterize supported behavior, but it must not turn that sequence
into an arbitrary DAG or hide orchestration in generic workflow abstractions.
An invocation selects either the full sequence or one inclusive contiguous
range of it. Execution remains flat and canonical even though invalidation is
dependency-aware: the application owns a separate fixed relation used only to
stale transitive dependents, including dependents outside a selected range.
The implemented stage inventory belongs in the
[Internal Overview](../internal/overview.md).
@@ -87,8 +91,10 @@ merely on incidental files existing on disk.
A failed or interrupted stage must not be presented as successful. Failure
should preserve enough local state and diagnostics for inspection, recovery,
and resume. Forcing an upstream stage invalidates succeeded downstream work
according to the canonical stage order.
and resume. Forcing a stage invalidates succeeded transitive dependents
according to a fixed application-owned relation that is separate from canonical
execution order. The relation is validated against the stage inventory and is
not configurable.
A stage may explicitly self-skip with a stable reason and no outputs. That
outcome is persisted, clears older outputs owned by the stage, and is
@@ -110,8 +116,11 @@ adapter and publish metadata.
Resume and skip decisions are manifest-driven. Filesystem state may be
inspected and validated, but file presence alone does not replace recorded
stage state. Invocation-scoped run records provide an audit of one execution;
they do not replace the session manifest as progress authority.
stage state. Each invocation retains an in-memory command summary and a
run-local workspace identity, but neither is a durable progress ledger. The
physical run directory is created on demand only when an executing stage
requires it; a fully reused invocation or a cleanup-only invocation does not
leave an empty run directory.
Restore treats committed remote current state as its authority. It must plan
deterministically, confine remote-to-local paths, protect local conflicts, and
@@ -137,8 +146,16 @@ Configuration is strict, explicit, centralized, and operator-oriented.
- YAML decoding rejects unknown fields.
- Defaults are centralized and testable.
- Empty configured values do not silently replace meaningful defaults.
- Validation rejects invalid composition before stage execution where
- Validation rejects invalid configuration before stage execution where
practical.
- Root-owned imports and one selected profile resolve deterministically through
the configuration owner; commands do not implement their own merge rules.
- Canonical party rosters are campaign-owned. Their derived players projection
and concrete character-family artifacts are resolved before runtime stages
or adapters receive configuration.
- Resume uses stage- or artifact-owned semantic evidence for observable
result-affecting configuration; profile identity and an effective digest are
provenance, never blanket cache keys.
- Session templating remains narrow and deterministic rather than becoming a
general configuration language.
- Secret values are supplied indirectly and are not persisted in ordinary
@@ -171,8 +188,8 @@ availability.
Writes, moves, replacements, and deletions must use narrow, explicit,
root-confined destinations. Symlinks, traversal, broad roots, and ambiguous
relative destinations must not expand the scope of an operation. Cleanup is
permitted only through explicit operator action or configured post-publish
gates, and it must preserve durable cache unless cache removal is explicitly
permitted only through explicit operator action or the configured final cleanup
stage, and it must preserve durable cache unless cache removal is explicitly
requested.
Physical layout, retention, and operational lifecycle belong in
@@ -188,16 +205,26 @@ the immutable commit manifest, and finally the current commit pointer.
`current/commit-pointer.json` is the sole mutable selector and must be written
exactly once, last. Failed, incomplete, skipped, or uncommitted publish attempts
must not be presented as current remote state. Publish locks remain authoritative
and are not bypassed by a forced run. Mutable remote locks use provider-enforced
generation preconditions and are revalidated immediately before pointer
selection; loss of that check leaves the prior committed snapshot current.
and are not bypassed by a forced run. Each mutable remote lock is an independent
per-source object; lock mutations use provider-enforced generation preconditions.
Publish revalidates every initially unlocked participating source after writing
the immutable commit and before pointer selection. A lock observed then leaves
the prior committed snapshot current. A lock created after that final check may
race the pointer write; the commit protocol does not claim a stronger distributed
transaction boundary.
Automatic local cleanup is permitted only after a successful publish commit,
only when explicitly configured, and only through the path-safety guardrails.
It is a durable local obligation bound to that committed run and its exact
targets, not an inferred side effect of the current stage list. A cleanup
failure makes the invocation incomplete while leaving the committed remote
snapshot authoritative; later invocations resume the recorded obligation.
It is the final ordinary pipeline stage after `notify`. The stage derives its
exact run-scoped targets from validated publish metadata and the configured
campaign/session identity; it never infers a target from directory contents or
rereads remote storage. A cleanup failure makes the invocation incomplete while
leaving publish succeeded and the committed remote snapshot authoritative. A
normal later full run reuses valid upstream stages and retries cleanup.
The standalone `publish` command selects only publish and therefore never
invokes cleanup. Operators may select cleanup directly only when the manifest
contains valid successful publication evidence.
## Security, Privacy, And Diagnostics
@@ -207,20 +234,26 @@ configuration, logs, reports, diagnostics, and Notarius artifacts—is
intentionally shareable with the configured workspace group. API-key material
is sensitive and is not covered by the ordinary workspace-sharing policy.
On POSIX systems, Narratio-created ordinary workspace directories converge on
Narratio supports Linux on amd64 and arm64. macOS arm64 is best effort: it has
a cross-build gate and release asset, but no native runtime release gate.
Windows and macOS amd64 are unsupported and have no compatibility promise or
release asset.
On supported systems, Narratio-created ordinary workspace directories converge on
setgid `02775` and ordinary workspace files on `0664`, even when the caller's
umask is restrictive. This preserves the existing workspace group for nested
creation and atomic replacements without changing ownership. API-key storage
uses a separate restrictive contract. On Windows, POSIX mode bits and setgid
are not authoritative; operators must provide the equivalent shared-group and
credential-restricted ACLs described in [Operations](../operations.md#workspace-permissions).
uses a separate restrictive contract.
Raw secrets must not be stored in pipeline, campaign, or session YAML or written
to manifests, logs, generated configuration, reports, publish metadata,
documentation, or examples. Secrets enter through configured environment
variable names or secret-file references. Diagnostics should avoid transcript
and prompt content unless a deliberate, bounded inspection mechanism requires
it.
documentation, or examples. Pipeline configuration names environment variables;
command-owned startup may populate previously unset variables from ConfigKit's
explicit or conventional secrets directory. This bootstrap occurs once per
invocation before any typed configuration load or adapter construction and is
not hidden inside library loaders. Safe bootstrap diagnostics may identify
files and statuses but never values. Diagnostics should avoid transcript and
prompt content unless a deliberate, bounded inspection mechanism requires it.
Logs, reports, generated invocation files, generated configuration, and render
debug files are diagnostics, not canonical pipeline products. They should be

View File

@@ -59,6 +59,8 @@ secret values.
| --- | --- | --- | --- |
| Product orientation and minimal end-to-end quickstart | `README.md` | What Narratio is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, implementation detail. |
| Contributor entry point | `docs/development.md` | Task-oriented reading guide, minimal contributor orientation, baseline validation commands, and links to canonical docs. | Package inventory, architecture rules, subsystem behavior, detailed change recipes. |
| Maintainer release procedure | `docs/release.md` | Version selection, candidate preparation and validation, guarded tag publication, release completion boundary, failure recovery, and optional asynchronous inspection. | Script implementation mechanics, current application contracts, and historical release summaries. |
| Historical release summary | `docs/releases/<tag>.md` | Immutable summary, compatibility, upgrade, and changes for one released version. | Current maintainer procedure and current application contract details. |
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, architectural boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. |
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression-test policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |

104
docs/release.md Normal file
View File

@@ -0,0 +1,104 @@
# Releasing Narratio
This document is the maintainer procedure for creating a Narratio source and
binary release. The synchronous release boundary is a successful push of one
new tag to `origin`; Woodpecker and Gitea publication happen later and do not
change that result.
## Go prerequisite
Release preparation requires the maintained Go 1.26 baseline. Tag CI currently
uses the explicitly pinned `golang:1.26.7` image for candidate validation and
asset construction; that asynchronous work does not change the tag-push
completion boundary.
## Choose a version and write its note
Narratio is past `v1.0.0`. Use an unused stable tag in the exact form
`vMAJOR.MINOR.PATCH`:
- increment `MINOR` for backward-compatible features;
- increment `PATCH` for backward-compatible fixes; and
- reserve a new `MAJOR` for an intentional breaking documented contract.
Before preparing the candidate, create
`docs/releases/vMAJOR.MINOR.PATCH.md` with this structure:
```markdown
# Narratio vMAJOR.MINOR.PATCH
This release ...
## Summary
## Compatibility
## Upgrade
## Changes
```
The compatibility section identifies relevant CLI, configuration, artifact,
integration, or operating-contract changes. The upgrade section states the
required operator action, or explicitly says that no special action is
required. Release notes are immutable historical summaries; link to the
current canonical documentation for detailed behavior.
Commit the note and all candidate changes, then use the ordinary development
workflow to push that commit to `main`. Do not create a release tag before the
candidate is committed and `origin/main` contains the exact same commit.
## Validate the candidate
Run the shared checker from any directory:
```sh
scripts/check-release-candidate.sh vMAJOR.MINOR.PATCH
```
It validates the version and matching note, module hygiene, formatting,
whitespace, uncached tests, race tests, static checks, documentation, examples,
and three official cross-build assets. It uses `GOWORK=off`, does not contact
application services, CI, or Gitea, and does not create tags or modify tracked
source. Fix any failure on `main`, commit it, push it normally, and rerun the
checker.
The checker builds Linux assets for `amd64` and `arm64` and a macOS asset for
`arm64`. The macOS cross-build proves compilation; it is not native macOS
runtime evidence.
## Publish the tag
From a clean checkout on `main` whose `HEAD` equals `origin/main`, run:
```sh
scripts/release.sh vMAJOR.MINOR.PATCH
```
The command fetches and checks `origin/main`, re-runs candidate validation,
then fetches and checks again before creating an explicitly unsigned lightweight
tag for the originally recorded commit. It refuses dirty, divergent, changed,
or already-tagged candidates. It pushes only:
```text
refs/tags/vMAJOR.MINOR.PATCH:refs/tags/vMAJOR.MINOR.PATCH
```
It never commits changes, pushes `main`, force-pushes, moves a tag, or pushes
all tags. A successful push of that exact ref completes the release command;
the command prints the tag and commit, then returns without waiting for CI,
querying Gitea, downloading assets, or checking checksums.
If a failure occurs before the tag is created, correct the candidate on `main`
and repeat validation. If the push fails after local tag creation, the local tag
is intentionally retained for inspection and the command must not be retried
blindly. Once the upstream tag has been pushed, it is immutable. Correct any
defect or failed asynchronous publication with a new patch version, a new
release note, and the complete procedure again.
## Optional asynchronous inspection
After a successful tag push, a human may later inspect the tag-triggered
Woodpecker run and the corresponding Gitea release for binaries and checksums.
This is optional follow-up only. Automated releasers must not wait for, poll,
or treat CI/Gitea completion as a condition of the successful tag push.

28
docs/releases/README.md Normal file
View File

@@ -0,0 +1,28 @@
# Release Notes
This directory contains immutable historical release notes for Narratio.
Future notes are created with the matching stable version and use this minimum
structure:
```markdown
# Narratio vMAJOR.MINOR.PATCH
This release ...
## Summary
## Compatibility
## Upgrade
## Changes
```
See the [release procedure](../release.md) for creating a candidate and tag.
When asynchronous publication succeeds, the corresponding Gitea release is the
canonical source for downloadable binaries and checksums.
- [v2.0.0](v2.0.0.md)
- [v1.7.0](v1.7.0.md)
- [v1.6.0](v1.6.0.md)
- [v1.5.0](v1.5.0.md)

47
docs/releases/v1.5.0.md Normal file
View File

@@ -0,0 +1,47 @@
# Narratio v1.5.0
Narratio v1.5.0 makes repeated post-transcript artifact development faster and
more explicit while retaining the fixed, stage-driven pipeline model.
## Highlights
- The canonical pipeline now completes deterministic rendering before
extraction, cleanly separating transcript-generating stages from
artifact-generating stages.
- `narratio run` and `narratio session plan` accept inclusive `--from` and
`--through` bounds. Excluded transcript stages are not executed or
invalidated by a bounded artifact-regeneration run.
- `narratio regenerate-artifacts SESSION` is an exact convenience alias for a
forced run from `extract` through `analyze`, including focused
`--artifacts` selections.
- Configured Scriptorium artifacts now have independent,
manifest-authoritative freshness. Narratio reuses validated current work,
rebuilds stale prerequisites in dependency order, and persists successful,
failed, and newly stale artifact state when an analysis invocation only
partially succeeds.
- Publish consumes only configured artifacts backed by current manifest
evidence; incidental or tampered files are not promoted as current output.
## Reliability And Administration
- Bounded prerequisites are checked again under the session lock before any
run mutation, closing a concurrent-run race.
- Analysis fingerprints are stable across executable and configuration path
changes and continue to cover only Narratio-observable semantic inputs.
- Runner composition now carries one validated execution plan from command
parsing through prerequisite validation, adapter composition, manifest
recording, and stage execution.
- `narratio version` reports the exact tag embedded in official release
binaries; ordinary source builds report `dev`.
## Upgrade Notes
- Existing unbounded commands and direct `run-stage`, `analyze`, and `publish`
workflows retain their meanings.
- Manifests written before artifact-level analysis state remain readable.
Legacy aggregate analysis success is not sufficient freshness evidence, so
the first analysis evaluation after upgrading may regenerate configured
artifacts once.
- Narratio cannot observe executable contents or configuration, prompt,
profile, module, and other files loaded privately by Scriptorium. Explicitly
force affected artifacts after changing those private inputs.

74
docs/releases/v1.6.0.md Normal file
View File

@@ -0,0 +1,74 @@
# Narratio v1.6.0
Narratio v1.6.0 makes large pipeline configurations easier to organize,
inspect, and vary while adding character-oriented artifact generation and a
guarded, reproducible release procedure.
## Summary
Pipeline configuration can now be assembled from explicit additive imports and
a selected production or testing profile. Campaigns can own a canonical,
versioned party roster, and Scriptorium artifact families can expand one
definition into concrete per-character artifacts, dependencies, variables, and
publish rules.
New read-only configuration commands expose the fully resolved pipeline,
source provenance, semantic digest, and profile differences before a session is
run. Stage reuse now records configuration-sensitive semantic evidence so
profile or configuration changes cannot silently reuse incompatible work.
## Compatibility
This is a backward-compatible feature release. Existing monolithic pipeline
files, concrete Scriptorium artifacts, publish rules, and configurations
without profiles remain supported. When profiles are declared and no explicit
profile is selected, Narratio uses the configured production default.
An unversioned party file plus a separate players file remains available as an
isolated legacy compatibility path, but it cannot drive artifact families. New
campaigns and new party-oriented features should use the `narratio.party.v1`
schema. Existing manifests remain readable; missing legacy semantic evidence
is treated as stale rather than trusted.
Narratio continues to consume the documented Notarius D&D pipeline contract.
The release workflow still cross-compiles Linux, macOS, and Windows binaries
for `amd64` and `arm64`; cross-compilation is not native runtime evidence for
macOS or Windows.
## Upgrade
No special action is required for existing monolithic configurations that do
not adopt profiles or artifact families. On the first run after upgrading,
stages recorded by older manifests may regenerate once because those records do
not contain the new semantic configuration evidence.
To adopt the new configuration model, use the maintained
`examples/production-testing` bundle as a migration reference: split stable
settings into explicit imports, define a production default and optional
testing profile, convert campaign party data to `narratio.party.v1`, remove the
separate players file, and then introduce character artifact families. Review
the result with `narratio config validate`, `config show`, `config sources`, and
`config diff` before running a session.
Campaign, session, previous-session, and run identifiers must satisfy the
documented portable identity grammar. Existing manifests or remote state with
unsafe legacy identifiers must be migrated before use.
## Changes
- Added root-owned, non-recursive additive pipeline imports with strict,
source-aware conflict detection.
- Added named pipeline profiles with an explicit production default and
deliberate command-line selection.
- Added `config validate`, `config show`, `config sources`, and `config diff`
for read-only inspection of resolved configuration and provenance.
- Added the strict `narratio.party.v1` campaign roster, including stable
character IDs, player and character names, optional aliases, and classes.
- Added deterministic players derivation and canonical party delivery to
downstream integrations.
- Added character-oriented artifact families, corresponding member
dependencies, family selection, and generated publish policies.
- Added semantic configuration fingerprints and stage-specific resume checks
across transcript and artifact stages.
- Added shared release candidate, asset build, and guarded tag-publication
scripts, with tag-triggered publication remaining asynchronous.

100
docs/releases/v1.7.0.md Normal file
View File

@@ -0,0 +1,100 @@
# Narratio v1.7.0
Narratio v1.7.0 completes the maintainability refactor begun after v1.6.0. It
simplifies filesystem and process handling, makes resumable work more precise,
replaces legacy local and remote state with explicit authorities, and turns
cleanup into the final ordinary pipeline stage.
## Summary
The session manifest is now the sole durable local progress ledger. Analyze
checkpoints successful artifacts individually, allowing an interrupted or
partially failed analysis to resume at the first incomplete artifact without
rerunning successful siblings unless forced.
Remote publication now commits immutable run state before advancing one
verified current pointer. Publish locks are independent per-source objects;
locked outputs retain their previously committed declarations rather than
being overwritten or silently omitted. Local cleanup runs after notification
as a separately recorded, safely retryable stage.
The release also adopts Go 1.26, simplifies ordinary file operations around
Narratio's trusted group-owned workspace model, improves subprocess lifecycle
handling, decomposes several large orchestration and validation components,
and removes mutable global test seams.
## Compatibility
This is a minor release with deliberate removal of compatibility paths that
were deprecated and documented as subject to removal:
- Campaigns must use one versioned `narratio.party.v1` `party.yml`. Separate
players files, session-level party overrides, and unversioned party documents
are rejected.
- Session manifests must declare `schema_version:
narratio.session-manifest.v1` and satisfy the current strict schema. Older
unversioned state and manifests containing the retired
`post_publish_cleanup` obligation are rejected rather than migrated.
- Remote current state must be selected by
`current/commit-pointer.json` and its referenced immutable run commit. Legacy
current-state layouts are no longer inferred.
- The retired aggregate `locks.yml` object is ignored. Publish locks now live
as independent strict objects under the session lock prefix.
- Run manifests are no longer produced or consumed. The session manifest owns
durable stage progress; invocation summaries and run workspaces remain
operational data rather than a second progress authority.
The CLI and current pipeline/configuration formats otherwise remain compatible.
Building and maintaining Narratio now requires Go 1.26. Cross-built Linux,
macOS, and Windows assets remain available for `amd64` and `arm64`.
## Upgrade
Before upgrading a campaign that still uses compatibility data:
1. Replace legacy party/player inputs with the documented
[`narratio.party.v1`](../integrations/party.md) campaign roster.
2. If a local manifest contains an outstanding `post_publish_cleanup`
obligation, complete it with the pre-upgrade binary. Otherwise verify the
remote commit, move aside or regenerate the incompatible manifest, or
restore a verified committed snapshot. Do not migrate state by manually
editing JSON.
3. Republish sessions that do not yet have a valid immutable run commit and
`current/commit-pointer.json`; legacy remote layouts are not current state in
v1.7.0.
4. Recreate any required publication locks with `narratio session locks add`.
After verifying the new locks and publication behavior, the ignored legacy
`locks.yml` object may be removed manually.
Existing compatible session manifests and already committed remote snapshots
need no special action. Consult [Operations](../operations.md) and
[Troubleshooting](../troubleshooting.md) for recovery procedures.
## Changes
- Upgraded the maintained development and release baseline to Go 1.26.
- Reworked subprocess execution and Windows process-tree supervision while
preserving contextual wait and cleanup failures.
- Replaced custom confined ordinary I/O and directory-promotion machinery with
simpler standard-library operations while retaining bounded reads, atomic
single-file visibility, workspace modes, validated downloads, and
root-confined destructive operations.
- Removed legacy party, manifest, artifact-discovery, remote-current, and lock
compatibility paths.
- Consolidated durable progress in the session manifest and removed the run
manifest domain model.
- Unified runner planning and execution decisions, separated invocation
lifecycle responsibilities, and avoided creating empty run workspaces for
fully reused or cleanup-only invocations.
- Added per-artifact analyze execution, checkpointing, reconciliation, and
recovery.
- Decomposed configuration validation by domain and made validation diagnostics
deterministic without treating incidental ordering or wording as a contract.
- Introduced immutable remote publish commits, one final current pointer,
explicit output planning, safe cross-run carried outputs, and independent
per-artifact remote locks.
- Added `cleanup` as the final canonical stage after `notify`, with validated
publish evidence, safe retry, explicit selection, and no separate cleanup
obligation.
- Replaced mutable command test hooks with invocation-scoped dependencies and
improved structural test diagnostics.

94
docs/releases/v2.0.0.md Normal file
View File

@@ -0,0 +1,94 @@
# Narratio v2.0.0
Narratio v2.0.0 replaces its configuration infrastructure with ConfigKit,
simplifies the supported release platforms, and makes artifact definitions and
their effective dependency graph directly inspectable.
## Summary
Pipeline roots now use ConfigKit v0.1.2 for strict YAML loading, explicit
imports, fixed-path named profiles, provenance, defaults, and typed credential
references. The recommended production layout keeps production values in the
base, defines each ordinary artifact and artifact family in its own imported
file, and keeps testing changes in `profiles/testing.yml`.
Configured-artifact inputs now create their dependency edges automatically.
`narratio config artifacts` reports the finalized concrete catalog, including
generated party-family members, without loading a session or initializing
runtime adapters.
## Compatibility
This is a breaking configuration and platform release. There is no runtime
fallback for the retired Narratio composition/profile catalog, `pipeline.secrets`
or `secrets.env_dir`, credential `_env` fields, or the previous YAML
presence/default behavior. Defaults apply only to absent values; explicit empty,
zero, false, and null values retain their typed meaning and may fail Narratio
domain validation.
The maintained binaries are Linux amd64, Linux arm64, and macOS arm64. Windows
and macOS amd64 builds are no longer produced or supported. Existing v1 release
notes remain historical descriptions of their releases.
Inspection commands still avoid session, manifest, workspace, adapter, and
remote-service activity, but they are not credential-free loaders: every
explicit typed credential reference in the selected effective configuration must
resolve; references replaced or removed by that profile need not resolve.
Pipelines containing artifact families also require a campaign so the canonical
party can be expanded.
`config show` is a secret-safe review projection, not a promised reloadable
authoring file.
## Upgrade
Migrate each operational bundle before running v2:
1. Replace old composition syntax with root-owned
[`configkit.imports`](../config.md#pipeline-imports-and-profiles). Imports are
additive base sources; they do not override one another.
2. Fold the old production overlay into the base pipeline. Move testing changes
to `profiles/testing.yml`; compare the two independent resolutions with
`narratio config diff default testing`.
3. Follow the maintained
[`examples/production-testing`](../../examples/production-testing/pipeline.yml)
layout: one explicit artifact entry per `artifacts/<key>.yml` and one family
entry per `artifact-families/<key>.yml`. The map key, not the filename, is the
stable artifact identity.
4. Move credential files to an untracked `secrets/` directory beside
`pipeline.yml`, or pass `--secrets-dir`. Rename S3 fields to `access_key_id`
and `secret_access_key`, and rename the Audita field to `llm_api_key`.
5. For static S3 credentials, configure both S3 references (commonly
`OBJECT_STORAGE_KEY_ID` and `OBJECT_STORAGE_KEY`) and make them resolvable.
To use instance roles, workload identity, shared SDK files, or another AWS SDK
discovery provider, omit both references. A partial pair is invalid.
6. Remove a `depends_on` entry when it only duplicates a
`narratio.artifact.<key>` input. Keep explicit entries that express ordering
without supplying the upstream output as an input.
7. Review ConfigKit's stricter explicit-value/null semantics and correct values
that previously acquired defaults after being explicitly empty. Do not use
`config show` output as a source migration mechanism.
8. Run `config validate`, `config show`, `config sources`, and `config artifacts`
with the same profile, campaign, and secrets selection intended for runtime.
Resolve the campaign-owned party whenever families are present.
9. Update deployment and download automation to one of the three maintained
platform assets before removing the v1 binary.
The [configuration reference](../config.md), [CLI reference](../cli.md), and
[maintained examples](../../examples/README.md) are the canonical detailed
guides.
## Changes
- Adopted ConfigKit v0.1.2 across pipeline, campaign, session, and party loading
while keeping Narratio domain validation and post-campaign expansion explicit.
- Added ConfigKit imports and fixed `profiles/<name>.yml` pipeline overlays,
unified safe effective show/diff/digest projection, and source provenance.
- Added invocation-scoped ConfigKit secrets-directory bootstrap and typed S3 and
Audita credential references without exposing resolved values.
- Inferred, normalized, and validated configured-artifact input dependencies as
part of the one concrete graph consumed by analyze planning and resume logic.
- Added `config artifacts` with stable concrete identities, definition sources,
effective dependencies, and generated family/party provenance.
- Added the maintained production-base/testing-overlay artifact authoring layout.
- Reduced release assets and active platform support to Linux amd64/arm64 and
macOS arm64.

70
docs/releases/v2.1.0.md Normal file
View File

@@ -0,0 +1,70 @@
# Narratio v2.1.0
This release adds explicit Notarius credential forwarding and cache reuse,
with predictable forced regeneration and clearer artifact-development guidance.
## Summary
Narratio can pass one typed API-key reference to Notarius and opt into reuse of
its accepted execution checkpoints. Stable, verified reference snapshots allow
unchanged references to retain their cache identity across Narratio invocations.
Configuration loading now uses ConfigKit v0.3.0.
## Compatibility
The new `notarius.llm_api_key`, `notarius.resume`, and `notarius.chunk_cache`
fields are optional. Existing v2 configuration needs no schema migration.
Configured credential references must resolve during configuration loading,
even when extraction will not execute; omitted references add no requirement.
Forced extraction now omits Notarius checkpoint loading and requests chunk-plan
refresh. This includes `regenerate-artifacts`, which remains an alias for a
forced run from extract through analyze. `analyze` continues to force analysis
only, preserving extraction results and transcripts.
Notarius v0.7.0 is the tested cache integration baseline, not an unconditional
minimum-version gate. The executable must support the flags selected for the
invocation, including `--chunk_cache refresh` for forced extraction. Reference
bindings retain the contract introduced in Notarius v0.6.0. Receipt and artifact
schemas are unchanged; reused producers may report zero production attempts.
Supported release platforms remain Linux amd64/arm64 and best-effort macOS
arm64. macOS validation is cross-compilation, not native runtime testing.
## Upgrade
No special migration is required for existing v2 configuration. To use the new
features:
- Declare `notarius.llm_api_key` with the environment-variable name expected by
the deployed Notarius profile, supplying its value through the existing
environment or secrets-directory mechanism.
- Enable checkpoint recording in Notarius configuration before setting
Narratio's `notarius.resume: true`. Prefer explicit persistent Notarius cache
roots for service deployments. An optional `notarius.chunk_cache` overrides
its ordinary chunk-plan policy.
- Expect an initial checkpoint miss when moving from old run-local reference
paths to stable session snapshots. No migration or deletion of old caches is
required. Force refreshes the current input's plan, not the whole cache.
- Use `narratio analyze SESSION` for analysis-only iteration, and
`narratio regenerate-artifacts SESSION` when extraction should also be rebuilt.
See [Configuration](../config.md), the
[Notarius integration contract](../integrations/notarius.md), and
[Artifact Development Workflows](../operations.md#artifact-development-workflows)
for current details.
## Changes
- Upgraded ConfigKit from v0.1.2 to v0.3.0 and isolated pipeline-discovery tests
from machine configuration.
- Added optional typed Notarius credentials, exact-name environment delivery,
safe configuration projections, and captured-stream redaction for custom names.
- Added Notarius resume and chunk-cache controls with force taking precedence.
- Added verified, content-addressed session reference snapshots that survive
ordinary run cleanup without becoming published artifacts.
- Preserved relative-workspace support for snapshot delivery and confinement.
- Kept credentials and cache-policy controls outside extract's semantic resume
identity, so operational changes alone do not invalidate completed results.
- Added an opt-in real Notarius integration test using a local provider to
verify reuse, partial-failure recovery, and forced refresh without paid calls.

33
docs/releases/v2.1.1.md Normal file
View File

@@ -0,0 +1,33 @@
# Narratio v2.1.1
This release fixes configuration loading for publish rules that explicitly set
requiredness while relying on a derived destination.
## Summary
Publish rules with a derivable source and omitted `dest` now load correctly
whether `required` is omitted, explicitly true, or explicitly false. Previously,
explicit requiredness could trigger ConfigKit's `resolve.unrecorded_change`
diagnostic instead of accepting valid configuration.
## Compatibility
This is a backward-compatible bookkeeping correction. Publish schemas,
destination derivation, requiredness defaults, and YAML syntax support are
unchanged. Explicit destinations and explicit false values remain intact.
No ConfigKit upgrade or publication/storage behavior change is involved.
## Upgrade
No special upgrade action or configuration migration is required. Operators
need not add a derivable `dest`, remove explicit `required` values, or change
flow-style YAML to block-style YAML. See the
[configuration reference](../config.md#publish-configuration-summary).
## Changes
- Recorded derived publish destinations at ConfigKit's atomic sequence boundary
while retaining field-level resolution records.
- Added real load/validation and safe effective-configuration regression coverage
for built-in and configured-artifact sources, explicit requiredness, and an
explicit destination that must remain unchanged.

26
docs/releases/v2.1.2.md Normal file
View File

@@ -0,0 +1,26 @@
# Narratio v2.1.2
This release allows secrets directories to contain hidden metadata without
preventing credential loading.
## Summary
ConfigKit v0.3.1 ignores dot-prefixed entries such as `.git`, `.gitignore`, and
`.DS_Store` during secrets bootstrap. These entries no longer block otherwise
valid credentials from loading and are omitted from bootstrap reports.
## Compatibility
This is a backward-compatible dependency bug fix. Configuration schemas,
existing-environment precedence, and validation of non-hidden entries are
unchanged. Hidden entries still count toward the directory entry limit.
## Upgrade
No special upgrade action or configuration migration is required. See the
[credential configuration reference](../config.md) for secrets-directory usage.
## Changes
- Updated ConfigKit from v0.3.0 to v0.3.1.
- Clarified hidden-entry handling in the secrets-directory documentation.

View File

@@ -0,0 +1,171 @@
# Bugfix Request: Preserve Publish Destination Derivation with Explicit Rules
Status: Implemented; release target v2.1.1
## Summary
Narratio should successfully derive a publish destination when a valid
`publish.outputs` rule omits `dest`, regardless of whether that rule explicitly
sets `required`. Narratio v2.1.0 currently rejects this valid configuration in
some cases with a ConfigKit `resolve.unrecorded_change` diagnostic.
This is a configuration-resolution bookkeeping defect, not a YAML formatting
restriction. The fix should preserve the existing public configuration
contract: operators may omit a derivable destination and may independently set
`required` to either `true` or `false`.
## Observed Behavior
A pipeline containing a rule equivalent to the following fails during native
configuration loading:
```yaml
publish:
outputs: [{"source": "narratio.transcript.final", "required": true}]
```
Narratio reports:
```text
error [resolve.unrecorded_change] /publish/outputs: application resolution changed a value without a covering record
```
The same failure is possible with ordinary block-style YAML when `required` is
explicit and `dest` is omitted. Flow-style YAML is valid input and is not the
cause of the defect.
The existing destination-derivation tests do not expose this case because
their rules also omit `required`. Narratio then supplies the default value for
`required` and records a change covering the complete `publish.outputs`
sequence. That broader record happens to cover the destination mutation as
well, masking the missing resolution record that occurs when `required` is
already explicit.
## Expected Contract
The current configuration contract remains unchanged:
- `publish.outputs[].source` is required;
- `publish.outputs[].dest` may be omitted when Narratio can derive it from the
selected built-in or configured artifact source;
- `publish.outputs[].required` defaults to `true`, but an operator may set it
explicitly to either `true` or `false`; and
- equivalent valid YAML representations have equivalent configuration
semantics.
For example, both rules below should load and validate, retain their explicit
requiredness, and receive the canonical derived destination:
```yaml
publish:
outputs:
- source: narratio.transcript.final
required: true
- source: narratio.transcript.final_markdown
required: false
```
## Root Cause
ConfigKit intentionally treats sequences as atomic when comparing values
before and after application resolution. Changing a field within one
`publish.outputs` entry is therefore observed as a change to the
`/publish/outputs` sequence.
Narratio currently derives each missing destination by recording a
normalization at the individual `publish.outputs[index].dest` path. That child
record does not cover the atomic parent-sequence change. A separate
container-level record is currently created only when Narratio also defaults
an omitted `required` value, so configurations with explicit `required` values
lose that incidental coverage.
## Recommended Fix
Make Narratio's publish-output resolution bookkeeping conform to ConfigKit's
atomic-sequence contract. Whenever resolution derives one or more destinations
inside `publish.outputs`, the recorded resolution changes must cover the
`/publish/outputs` container. Destination derivation must not depend on an
unrelated `required` default being applied in the same list.
The implementation may retain more specific provenance where useful, but it
must provide valid container-level coverage for the sequence mutation. Do not
change ConfigKit's sequence comparison semantics merely to accommodate this
call site.
## Scope
- Correct destination derivation for publish rules that explicitly set
`required` and omit a derivable `dest`.
- Preserve explicit `required: true` and `required: false` values.
- Preserve canonical destination derivation for built-in transcript sources
and configured artifact sources.
- Add focused regression coverage through Narratio's real configuration load
and validation boundary.
- Prepare a backward-compatible patch release after the fix passes the normal
repository validation.
## Non-goals
- Changing the `publish.outputs` schema, defaults, source vocabulary, or
destination derivation rules.
- Requiring operators or configuration generators to supply `dest` when it is
derivable.
- Requiring operators to omit explicit `required: true` values.
- Treating flow-style YAML or JSON-compatible YAML values as unsupported.
- Changing ConfigKit's documented atomic treatment of sequences.
- Adding storage, publication, or network integration behavior.
## Regression Coverage
Add one focused package-level behavioral test that loads and validates a
pipeline with omitted destinations and explicit requiredness. A representative
list should include both `required: true` and `required: false`, then assert
that:
- loading and validation succeed;
- each destination is derived from its source; and
- each explicit `required` value is preserved.
Using the compact flow-style representation that exposed the integration
failure is reasonable, while the existing block-style cases continue to cover
omitted `required`. The test should exercise the public configuration behavior,
not ConfigKit call choreography or Narratio's private recording mechanism.
No network service, object store, credential, or test double is required.
## Target End State
All valid publish rules with derivable sources behave consistently across
these cases:
| `dest` | `required` | Result |
| --- | --- | --- |
| omitted | omitted | destination derived; required defaults to `true` |
| omitted | `true` | destination derived; explicit `true` retained |
| omitted | `false` | destination derived; explicit `false` retained |
| explicit | either value | explicit destination and requiredness retained |
`narratio config validate`, `narratio config show`, and ordinary pipeline
loading accept the corrected cases without an unrecorded-change diagnostic.
No operator configuration migration or workaround is required.
## Release
This is a backward-compatible correction to documented configuration behavior
and should be released as Narratio `v2.1.1`. The release note should identify
the corrected explicit-required/derived-destination interaction and state that
no special upgrade action is required.
## Acceptance Criteria
- The focused regression test fails against v2.1.0 behavior and passes after
the fix.
- Publish rules with explicit `required: true` or `required: false` and omitted
derivable destinations load and validate successfully.
- Derived destinations and explicit requiredness appear correctly in the safe
effective configuration.
- Existing destination derivation, publish validation, imports, profiles, and
configuration inspection tests remain green.
- The complete repository validation and release-candidate checks pass.
- The correction is published as the immutable `v2.1.1` tag with the required
release note.

View File

@@ -1,546 +0,0 @@
# Notarius v0.6 CLI References Implementation Plan
## Purpose And Status
This is the executable implementation plan for the accepted target state in
[Notarius v0.6 CLI Reference Integration](notarius-v0.6-cli-references.md). It
is written for a `gpt-5.6-terra` coding agent that will implement exactly one
pending stage per prompt, in order.
The feature roadmap owns user intent, architectural boundaries, settled policy,
and the target end state. This document owns delivery order, concrete changes,
test allocation, and implementation status. Do not restate or change a roadmap
decision here during implementation; if current Notarius v0.6.0 evidence
contradicts the roadmap, stop and record the conflict instead of inventing a
different contract.
| Stage | Outcome | Status |
| ---: | --- | --- |
| 1 | Add the reference-selector and configuration vocabulary, including optional spell-catalog inputs. | Completed |
| 2 | Materialize and inventory the optional spell catalog through the prepare and operator lifecycle. | Completed |
| 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Completed |
| 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Completed |
| 5 | Resolve references in extract and bind fingerprints, resume, and metadata to their identities. | Completed |
| 6 | Prove assembled extraction lifecycle and downstream invalidation behavior. | Completed |
| 7 | Update canonical documentation and maintained examples for the completed feature. | Completed |
| 8 | Perform compatibility, quality, and repository-wide closure validation. | Completed |
## Governing Decisions
The following requirements are settled and are not questions for the
implementing agent:
1. `pipeline.notarius.references` is a map whose key is a Notarius v0.6 CLI
reference selector and whose value is a prepared Narratio source ID. It is
not a map of paths.
2. Every configured binding is required. There is no per-entry `required`
field. An optional Notarius reference is omitted by omitting the map entry.
An empty or omitted map remains valid for custom pipelines and backward
compatibility.
The map is limited by the centrally declared configuration constant
`MaxNotariusReferenceBindings = 256`, which is far above the four-entry
maintained D&D case while bounding argv and manifest growth.
3. The supported prepared reference sources are
`narratio.input.party`, `narratio.input.players`,
`narratio.input.glossary`, and `narratio.input.spell_catalog`.
Arbitrary Notarius slot names and qualified selectors may bind those sources;
direct paths and later-stage artifacts may not.
4. Campaign and session `spell_catalog_file` are optional. A session value
overrides the campaign value; an empty session value inherits the campaign
value. A `narratio.input.spell_catalog` reference binding requires an
effective configured file.
5. Prepared sources are authoritative only when the current session manifest
records the matching canonical input and checksum. Consumers do not fall
back to campaign/session source paths or accept an incidental workspace file.
6. Narratio passes absolute prepared-file paths to Notarius. Fingerprints and
metadata use the canonical workspace-relative path identity together with
selector, source ID, SHA-256 checksum, and size so workspace relocation does
not become the only identity signal.
7. External CLI references are sorted by normalized selector. They override
matching external references in the Notarius configuration. Narratio never
emits `--without-reference`, the deprecated `roster` alias, or CLI bindings
for generated D&D artifact handoffs.
8. Notarius remains authoritative for whether a selected target declares a
slot, accepted reference media types and sizes, generated-handoff collisions,
pipeline topology, and D&D payload schemas. Narratio validates selector
structure and its own source contract only.
9. Notarius v0.6.0 is the minimum supported CLI contract when references are
configured. Do not add version-string parsing or an automatic per-session
`notarius config validate` subprocess.
10. The existing receipt-v2, bundle-confinement, diagnostic, ten-lane selection,
immutable promotion, and analysis-source behavior must remain intact.
11. The default test suite remains offline, deterministic, and independent of
a sibling checkout or installed Notarius binary. A real v0.6.0 smoke run is
useful supplementary evidence when locally available, not a default-suite
dependency.
12. Add no external Go dependency for this feature. Use narrow owner-specific
types and existing file, path, artifact, manifest, adapter, and stage
facilities.
## Instructions For Every Stage
For each implementation prompt, the coding agent must:
1. Read `docs/development.md`, all three files under `docs/policy/`, the feature
roadmap, this plan, and the stage-specific documents and source named below.
Inspect the current tree because earlier stages may have changed names or
ownership boundaries.
2. Use the repository knowledge graph first for code discovery and call tracing;
use text search for documentation, configuration, examples, string literals,
and evidence the graph cannot supply.
3. Confirm the worktree state and preserve unrelated changes. Implement only the
current stage. Do not begin a later stage merely because an adjacent file is
open.
4. Keep production code, focused tests, fakes, and fixtures consistent within
the stage. Remove superseded helpers when their final caller migrates. Do not
retain two competing source maps, path resolvers, fingerprint paths, or
subprocess argument builders.
5. Follow the testing policy's ownership rule. Parser/config tests own selector
and configuration cases; artifact tests own prepared-file identity and
integrity; adapter tests own exact arguments; stage tests own orchestration and
resume; application tests own lifecycle invalidation. Do not repeat every
lower-level case at higher levels.
6. Keep errors actionable and content-free. They may identify a selector,
Narratio source ID, canonical path, or checksum mismatch, but must not include
reference contents. Preserve ordinary group-workspace permissions and
restrictive API-key handling.
7. Run `gofmt` on changed Go files and focused tests while iterating. Before
marking any stage complete, run at minimum:
```sh
go test ./...
go test -race ./...
go vet ./...
go build ./...
go test ./internal/doccheck
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
```
Default tests must not contact live services or require credentials.
8. Compare the final diff against the stage goal and exit criteria. Update only
the current stage's status row from `Pending` to `Completed`. Do not mark a
stage complete while a required check fails or required behavior is absent.
Intermediate commits are implementation-branch state and must not be released
before Stage 7 has reconciled current-behavior documentation.
## Stage 1 — Reference And Configuration Vocabulary
**Read first:** `docs/config.md`, `docs/integrations/notarius.md`,
`internal/config/config.go`, `internal/config/defaults.go`,
`internal/config/load.go`, `internal/config/validate.go`,
`internal/config/notarius_test.go`, `internal/config/campaign_config_test.go`,
and `internal/artifactpolicy/policy.go` and its tests. Read the tagged Notarius
v0.6.0 `docs/cli.md` reference-selector section from `../notarius` when that
checkout is available; otherwise use the canonical link from the feature
roadmap.
**Depends on:** None.
**Goal:** Establish one normalized reference-selector grammar and the strict
configuration model needed by later stages, without adding path-valued Notarius
configuration or making spell catalogs mandatory for every campaign.
**Work:**
- Add a small dependency-free `internal/notariusref` package as the contract
owner for Notarius reference selector normalization. Its exported normalizer
must trim the selector and each component, reject empty components and `=`,
and accept only the v0.6 forms `slot`,
`chunk.slot`, `lane.slot`, `lane.extract.slot`, `lane.merge.slot`, and
`lane.normalize.slot`. A three-component selector accepts only `extract`,
`merge`, or `normalize` in its middle component. Do not check the selector
against a Notarius module or lane registry.
- Add `References map[string]string` with YAML key `references` to
`config.NotariusConfig`. During enabled Notarius validation, sort raw keys,
normalize each selector through the shared contract, trim each source value,
reject empty values and normalized-selector collisions, require each value to
be one of the four prepared reference sources, and replace the config map with
its normalized form. Enforce the named, centrally discoverable
`config.MaxNotariusReferenceBindings` limit of 256 entries with an error that
identifies the field and limit. Keep a nil/empty map valid. Rely on strict YAML
decoding to reject duplicate identical keys, but explicitly reject distinct
raw keys that normalize to one selector.
- Add `SpellCatalogFile string` with YAML key `spell_catalog_file` to campaign
inputs and session inputs, plus `SpellCatalogFile ResolvedInputFile` to
resolved stable inputs. Merge it with the existing session-over-campaign
helper. It is not part of the campaign-required input set. Reject a non-empty
configured scalar that becomes empty after trimming.
- Add `artifactpolicy.SourceInputSpellCatalog` and make the artifact-policy
owner describe all four prepared reference sources, including their canonical
manifest kind and filename. Use that owner for stable-source recognition
instead of adding a second switch in configuration validation. Preserve the
existing three source IDs and their behavior.
- Extend cross-configuration validation so a normalized reference to
`narratio.input.spell_catalog` requires a non-empty effective resolved
`spell_catalog_file`. Existing campaign requirements already guarantee party,
players, and glossary declarations. Do not check filesystem existence during
configuration validation.
- If analyze's current private filename switch must change to keep the tree
behaviorally coherent, make it delegate to the artifact-policy descriptor and
recognize spell catalog; Stage 3 will replace the filesystem-only resolver.
**Tests and exit criteria:** At the contract/config owners, cover every accepted
selector shape; zero, empty, excess, invalid-stage, and `=` forms; whitespace
normalization; normalized collisions; unsupported and empty source IDs; nil and
empty maps; exactly the configured binding limit and limit plus one; strict
unknown fields; campaign inheritance and session override; optional omission;
and the cross-config missing-spell-catalog failure. Prefer table-driven parser
and validator tests over assertions against private helper structure. Existing
configuration and example tests must still pass without adding spell catalogs
to every campaign. The codebase has one selector grammar owner and one
prepared-source descriptor owner.
## Stage 2 — Spell Catalog Prepare And Operator Lifecycle
**Read first:** `docs/internal/stage-prepare.md`, `docs/internal/workspace.md`,
`docs/operations.md`, `internal/stage/prepare.go` and its tests,
`internal/app/operator_inspection.go`, `internal/app/operator_findings.go` and
their focused tests, `internal/manifest/manifest.go`, and the relevant confined
file-operation helpers.
**Depends on:** Stage 1.
**Goal:** Make the effective optional spell catalog a normal prepared session
input with canonical storage, checksum/provenance, safe stale-file handling, and
operator visibility.
**Work:**
- Resolve `StableInputs.SpellCatalogFile` with the same origin-preserving
campaign/session behavior as the five existing stable inputs. When configured,
require a regular readable source, copy it atomically to
`inputs/spell_catalog.json`, preserve ordinary workspace permissions, and add
one manifest input record with kind `spell_catalog`, canonical destination,
checksum, and `campaign_config` or `session_config` source provenance.
- Treat the input as optional when no effective path is configured. Do not call
the required-input path resolver with an empty value and do not create a
manifest record. Remove an obsolete canonical `inputs/spell_catalog.json`
without following it when a forced prepare transitions from configured to
absent; refuse to recursively remove a directory or other ambiguous object at
that exact file path.
- Include a configured spell catalog in operator inspection and validation
findings. Omission is not an error unless Stage 1 cross-configuration policy
says the enabled Notarius reference requires it. Reuse the common resolved
stable-input enumeration where practical instead of extending parallel
hand-written lists in several functions.
- Adjust input-slice capacity, deterministic ordering, test fixtures, and any
manifest assumptions affected by the optional sixth stable file. Do not parse
or schema-validate the JSON payload in Narratio; Notarius owns that contract.
**Tests and exit criteria:** Through prepare and operator package behavior, cover
campaign and session source provenance, canonical destination bytes and
checksum, optional omission, missing configured source, replacement after source
change, safe removal when configuration is removed, rejection of an ambiguous
destination object, deterministic manifest ordering, and operator reporting.
Do not duplicate selector-validation cases from Stage 1. Existing sessions with
no spell catalog remain valid and produce no stale manifest entry.
## Stage 3 — Manifest-Authoritative Prepared Input Resolution
**Read first:** `docs/internal/artifacts.md`, `docs/internal/manifest.md`,
`docs/internal/stage-analyze.md`, `internal/artifacts/artifact_resolver.go`,
`internal/artifacts/resolve.go`, `internal/artifacts/checksum.go`, their tests,
and the prepared stable-input resolution path in `internal/stage/analyze.go` and
`internal/stage/analyze_test.go`.
**Depends on:** Stage 2.
**Goal:** Give analyze and extract one integrity-checked resolver for prepared
stable sources so neither stage trusts incidental files or reconstructs its own
source-to-filename table.
**Work:**
- Add an artifacts-owned `PreparedInputIdentity` contract containing source ID,
manifest kind, absolute canonical path, slash-separated path relative to the
session root, SHA-256 checksum, and byte size. Add one resolver that accepts
session paths, the current session manifest, and a stable source ID. Provide a
typed or sentinel absence classification so callers can distinguish no current
manifest record from corrupt or unsafe recorded evidence.
- Derive kind and filename exclusively from the artifact-policy descriptor. The
resolver must require exactly one current manifest input record with the
expected kind and canonical path; resolve/rebase recorded local paths through
existing session-local path safety helpers; require the result to equal the
canonical file below `inputs/`; reject escapes, symlinks, non-regular files,
empty files, missing checksums, duplicate records, and checksum mismatches; and
calculate size without loading the complete file into memory. Do not fall back
to the configured campaign/session path or accept canonical file presence
without manifest evidence. Zero matching manifest records is the typed absent
case; once a record exists, a missing or invalid file is an integrity error,
not optional absence.
- Return owner-neutral errors from `internal/artifacts`. At stage boundaries,
wrap unavailable or stale prepared inputs with the source ID and actionable
`narratio run-stage prepare <session_id> --force` guidance. Do not include file
contents.
- Replace analyze's private prepared-source filename switch and filesystem-only
resolver with the shared artifact resolver. Preserve required-versus-optional
Scriptorium input behavior: a typed absent optional source is omitted, an
absent required source fails with prepare guidance, and invalid recorded
evidence fails regardless of optionality. Make `narratio.input.spell_catalog`
usable wherever another prepared Scriptorium source is accepted.
**Tests and exit criteria:** Artifact-package tests own valid resolution and the
missing-record, duplicate-record, wrong-kind/path, traversal/rebase, symlink,
non-regular, empty, missing-checksum, and checksum-mismatch boundaries. Analyze
tests need only prove required/optional stage behavior and successful use of the
shared source, including spell catalog; do not repeat the artifact resolver's
full matrix. Remove the old filename/path resolver after its final caller moves.
## Stage 4 — Notarius Adapter Reference Arguments
**Read first:** `docs/internal/adapters.md`, `docs/integrations/notarius.md`,
`internal/adapters/notarius/runner.go`, `fake.go`, `subprocess.go`, and focused
adapter tests. Re-read the Notarius v0.6.0 subprocess and CLI reference-selector
contracts from the tagged sibling checkout when available.
**Depends on:** Stage 1.
**Goal:** Extend the transport-neutral Notarius request and production adapter
to emit safe, exact, repeatable v0.6 `--reference` arguments without changing
receipt or bundle ingestion.
**Work:**
- Add a transport-neutral reference binding containing normalized selector and
absolute path, and add an ordered slice of those bindings to `RunRequest`.
Keep source IDs and manifest identities out of the adapter contract; those are
stage policy.
- Validate each adapter binding before process launch: normalize/validate the
selector through the shared Stage 1 contract, require a non-empty absolute
path, reject duplicate normalized selectors, and avoid mutating the caller's
slice. Do not open or parse the reference file in the adapter.
- Build arguments as repeated pairs `--reference`,
`<normalized-selector>=<absolute-path>` after `--output-dir` and before
`--json`. Preserve one argument for the combined selector/path value so spaces,
additional `=` characters within the path portion, and platform separators do
not involve shell interpretation. The request order is authoritative; Stage 5
will supply sorted bindings.
- Preserve current executable, environment, timeout, cancellation, diagnostic,
receipt-v2, bounded-read, confinement, and bundle-discovery behavior. Do not
add `--without-reference`, generated reference arguments, version probing, or
configuration preflight.
- Update the fake only as required to retain and expose the extended request.
**Tests and exit criteria:** Adapter tests own exact argv with zero and multiple
references, position before `--json`, spaces and `=` in paths, selector
normalization, duplicate/invalid selector rejection, relative/empty path
rejection, and no subprocess start after request-validation failure. Existing
receipt-v2 and bundle fixture tests must remain unchanged in meaning and pass.
Do not assert stage-level source sorting here beyond preserving the request
order.
## Stage 5 — Extract Reference Identity, Invocation, And Resume
**Read first:** `docs/internal/stage-extract.md`,
`docs/integrations/notarius.md`, `docs/internal/manifest.md`,
`internal/stage/extract.go`, `internal/stage/extract_resume.go`, their focused
tests, the Stage 3 prepared-input identity contract, and the Stage 4 Notarius
request contract.
**Depends on:** Stages 3 and 4.
**Goal:** Make configured references part of the actual extraction invocation
and durable reuse contract, using one resolution path for initial execution and
resume validation.
**Work:**
- Add one extract-owned reference-resolution helper used by both `Run` and
`ValidateResume`. Iterate normalized config bindings in lexical selector
order, resolve each source through the Stage 3 manifest-authoritative resolver,
and produce both adapter bindings and immutable reference identities. Resolve
every reference before creating run-local receipt, log, output, or promotion
directories and before invoking the adapter.
- Define the fingerprint/metadata identity as normalized selector, source ID,
canonical session-relative slash path, SHA-256 checksum, and byte size. Do not
include contents or original campaign/session absolute paths. Pass only
selector and absolute prepared path to the adapter.
- Extend the extraction fingerprint document with the sorted reference
identities. Keep all existing binary, config path, pipeline, timeout, working
directory, trimmed-transcript identity, and required-output identities. The
result must be independent of YAML map iteration order and must change for a
selector, source, relative path, checksum, or size change.
- Persist `reference_count` and a bounded deterministic `references` metadata
list on successful extraction. Each entry contains exactly `selector`,
`source_id`, `path`, `checksum`, and `size_bytes`. Empty bindings produce count
zero and an empty list. Do not duplicate Notarius reference payloads or
downstream error messages.
- Make resume recompute current reference identities through the same helper
before comparing the configuration fingerprint. A valid changed prepared
input yields a fingerprint mismatch and a non-resumable result so extraction
reruns. Missing, unsafe, or checksum-inconsistent current input is an error
with prepare-force guidance because immediately rerunning extract cannot
succeed. Do not silently reuse the old bundle.
- Preserve explicit disabled-stage skip without resolving references. Preserve
required lane selection, immutable promotion, receipt identity, and bundle
evidence behavior.
**Tests and exit criteria:** Stage tests own sorted request construction for all
four D&D bindings, zero bindings, failure before adapter invocation for an
unavailable source, content-free metadata, and fingerprint changes for each
identity field while remaining stable across map order. Resume tests must prove
reuse with unchanged references, non-reuse after a valid prepared-reference
change, hard failure for missing or checksum-invalid current evidence, and no
reference resolution when disabled. Use the fake adapter; do not duplicate exact
subprocess argv cases from Stage 4.
## Stage 6 — Assembled Lifecycle And Invalidation Coverage
**Read first:** `docs/internal/overview.md`, `docs/internal/manifest.md`,
`docs/internal/stage-extract.md`, `docs/internal/stage-prepare.md`,
`internal/app/runner.go`,
`internal/app/extract_lifecycle_test.go`, and representative full pipeline and
stage fixtures. Inspect existing downstream invalidation tests before adding
new cases.
**Depends on:** Stage 5.
**Goal:** Prove at the application boundary that prepared campaign context
reaches Notarius and that reference changes cannot leave extraction or later
analysis falsely current.
**Work:**
- Extend the smallest existing assembled runner fixture to execute prepare and
extract with party, players, glossary, and spell catalog bindings. Assert that
the fake Notarius request receives the four canonical prepared absolute paths,
not the original campaign/session source paths, and that the successful
manifest records bounded reference identity.
- Add one lifecycle regression covering a valid reference-content change:
rerun/force prepare so the manifest and prepared checksum change, then verify
extract resume is rejected, Notarius runs again, and succeeded canonical
downstream stages are invalidated according to the existing stage-order
policy. Assert outcomes, not private runner call choreography.
- Add one representative session override case to prove the overridden prepared
bytes/checksum reach extraction. Do not repeat all four configuration merge
cases or artifact-integrity failures already owned by earlier stages.
- Confirm an empty reference map preserves the pre-v0.6 invocation behavior and
that all ten configured D&D lanes remain registered as the same
`narratio.extraction.<key>` sources available to analyze.
- Fix production integration defects exposed by these assembled tests without
broadening the feature or adding a DAG, generic reference workflow, or direct
Notarius payload parsing.
**Tests and exit criteria:** The application-level tests must be deterministic,
offline, and fake only the external Notarius boundary. They must credibly fail
if Narratio passes original paths, omits one configured reference, reuses stale
extraction, or loses a configured lane, while remaining insensitive to private
helper structure and exact non-contractual diagnostics. Earlier focused suites
and the repository baseline remain green.
## Stage 7 — Canonical Documentation And Maintained Examples
**Read first:** `docs/policy/documentation.md`, `docs/config.md`,
`docs/operations.md`, `docs/troubleshooting.md`,
`docs/integrations/notarius.md`, `docs/internal/overview.md`,
`docs/internal/adapters.md`, `docs/internal/artifacts.md`,
`docs/internal/stage-prepare.md`, `docs/internal/stage-extract.md`,
`docs/internal/stage-analyze.md`, `examples/README.md`, and all maintained
pipeline, campaign, and session examples affected by the new fields.
**Depends on:** Stage 6.
**Goal:** Move the completed behavior from roadmap-only future state into its
canonical current-behavior owners and provide valid copyable D&D examples
without duplicating volatile Notarius contracts.
**Work:**
- Update `docs/config.md` with `pipeline.notarius.references`, its selector-to-
source shape, normalization/validation rules, required-by-presence behavior,
supported stable source IDs, and campaign/session `spell_catalog_file`
precedence and optionality. Keep complete copyable YAML in `examples/`.
- Update `docs/integrations/notarius.md` to the v0.6.0 baseline and exact
repeatable-reference invocation boundary. Explain absolute CLI paths,
precedence over configured external paths, the four maintained external D&D
slots, the generated-handoff exclusion, and unchanged receipt-v2/ten-lane
output compatibility. Link to Notarius's canonical v0.6 CLI and D&D consumer
docs instead of copying its target/module matrix.
- Update operations and troubleshooting with prepared input location,
fingerprint/rerun consequences, operator inspection, missing-reference
diagnosis, and Notarius undeclared-slot/generated-collision failures. Update
internal component documents only with implemented ownership and flow; do not
duplicate configuration field definitions there.
- Add a valid, secret-free sample spell catalog following Notarius v0.6's
published overlay schema, add `spell_catalog_file` to the sample campaign, and
configure all four external reference bindings in the complete annotated D&D
pipeline. Add the same bindings to other Notarius-enabled maintained examples
only when their selected pipeline declares them; do not add a Notarius section
to examples that intentionally omit extraction.
- Ensure the maintained command snippets place repeated `--reference` arguments
before `--json`, use `party` rather than `roster`, and never show generated
handoffs on the CLI. Remove stale v0.5 compatibility wording where it refers to
the supported invocation baseline.
**Tests and exit criteria:** Run documentation-link checks and the example
loader explicitly. Verify every changed example is accepted by strict config
validation, contains no credentials or private infrastructure values, and has
one canonical owner for each volatile fact. Search current-behavior docs and
examples for stale v0.5 invocation wording, deprecated `roster` emission, and
generated D&D CLI handoff examples. Do not mark the roadmap itself implemented;
its status remains target-state context until the implementation sprint is
reviewed and closed.
## Stage 8 — Compatibility And Quality Closure
**Read first:** The feature roadmap, every completed stage diff, the final
current-behavior docs, `.woodpecker/verify.yml`, `.woodpecker/release.yml`, and
`.woodpecker/shuffle.yml`. Re-read the tagged Notarius v0.6.0
`docs/consumers/dnd-pipeline.md`, `docs/cli.md`, and linked spell-catalog overlay
contract when the sibling checkout is available.
**Depends on:** Stage 7.
**Goal:** Verify the delivered code matches the accepted boundary, remains
compatible with all ten default D&D artifacts, and is ready for review without
dead compatibility paths or duplicated policy.
**Work:**
- Audit the final diff against every target-state and out-of-scope statement in
the feature roadmap. Confirm only four external prepared sources are exposed,
custom selectors remain possible, every configured binding is required, and
no Notarius pipeline topology or generated-handoff logic moved into Narratio.
- Trace initial extract and resume paths to confirm both use the same prepared
identity and reference resolution, the adapter is the sole argv builder, and
artifact policy is the sole source-to-kind/filename vocabulary. Remove dead
helpers, redundant switches, stale fixtures, and low-value duplicate tests
found during this review.
- Confirm the complete D&D example still declares and validates the exact ten
output lanes and that analyze can consume those sources after reference-
enabled extraction. Confirm empty-reference custom pipelines remain supported.
- If a local Notarius v0.6.0 binary and its required offline/test configuration
are already available, perform a non-credentialed smoke invocation with all
four reference flags and record the result in the implementation handoff. Do
not download tools, contact paid providers, add a default test dependency, or
block completion solely because this supplementary environment is absent.
- Run the repository baseline plus the scheduled shuffled suite and release
cross-build commands:
```sh
go test ./...
go test -race ./...
go test -race -shuffle=on -count=3 ./...
go vet ./...
go build ./...
go test ./internal/doccheck
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
narratio_cross_dir="$(mktemp -d)"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-linux-amd64" ./cmd/narratio
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-darwin-amd64" ./cmd/narratio
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-windows-amd64.exe" ./cmd/narratio
```
Cross-builds are compilation evidence only; do not claim native macOS or
Windows runtime validation.
**Tests and exit criteria:** Every required command passes, `git diff --check`
is clean, the worktree contains no unintended generated test artifacts, and the
implementation is traceably complete against the roadmap. Summarize any
unavailable supplementary smoke evidence without treating it as a product
question or silently weakening the default suite.
## Open Questions
None. The feature roadmap and governing decisions above are sufficient to
implement the plan without additional product or architecture choices.

View File

@@ -1,274 +0,0 @@
# Notarius v0.6 CLI Reference Integration
## Status
Accepted target state. Delivery sequencing and implementation status are owned
by [implementation.md](implementation.md).
## Purpose
Upgrade Narratio's extraction boundary to the Notarius v0.6.0 subprocess
contract and supply session reference documents explicitly with repeatable
`--reference selector=path` arguments.
The maintained D&D integration must make the prepared party roster, player
context, glossary, and optional spell catalog available to every compatible
Notarius target. Notarius must continue to own pipeline topology, reference-slot
compatibility, generated artifact handoffs, prompts, and D&D schemas. Narratio
owns selection and preparation of its external reference files, exact CLI
invocation, provenance, and extraction reuse correctness.
## Current State And Gap
Narratio currently invokes Notarius as:
```text
notarius run <pipeline_id> --config <config_path> --input <transcript> --output-dir <staging_dir> --json
```
The `prepare` stage already materializes campaign/session party, players, and
glossary files under the session `inputs/` directory, but `extract` does not
pass them to Notarius. Narratio also has no stable spell-catalog input. As a
result, a Notarius deployment must duplicate these paths in its own
configuration, cannot reliably receive session overrides, and may extract
without the same campaign context supplied to Narratio's analysis stage.
Notarius v0.6.0 makes an unqualified CLI selector pipeline-scoped. For example,
`--reference party=/absolute/path/party.yml` supplies the file to every
selected target that declares `party`. Scoped selectors remain available for
exceptional overrides. CLI paths are resolved from the Notarius process working
directory, so subprocess callers are expected to provide absolute paths.
The v0.6.0 receipt, index, warning, diagnostic, and ten-lane D&D artifact
contracts remain compatible with Narratio's current v0.5 integration. This
feature changes the invocation and input-provenance contract rather than the
accepted output inventory.
## User Outcome
With the maintained complete D&D configuration, an operator can declare the
campaign reference sources once in Narratio. For each extraction Narratio will:
1. materialize the effective campaign/session files during `prepare`;
2. resolve those prepared files by stable Narratio source ID;
3. pass absolute paths for `party`, `players`, `glossary`, and, when configured,
`spell_catalog` to Notarius through repeatable CLI arguments;
4. fail before launching Notarius when a configured reference is unavailable;
5. rerun extraction when a selector, source binding, or reference file changes;
and
6. retain bounded reference identities and checksums for diagnosis and
provenance without copying reference contents into manifest metadata.
Session-level stable-input overrides must flow through the same mechanism. A
custom Notarius pipeline may bind different external slots without requiring a
Narratio code change.
## Chosen Architecture
### Explicit Reference Bindings
Extend `pipeline.notarius` with an explicit map from a Notarius CLI selector to
a prepared Narratio input source:
```yaml
notarius:
enabled: true
binary: notarius
config_path: /usr/local/etc/notarius/config.yml
pipeline_id: dnd-session
working_directory: /usr/local/etc/notarius
references:
party: narratio.input.party
players: narratio.input.players
glossary: narratio.input.glossary
spell_catalog: narratio.input.spell_catalog
outputs:
# Existing required lane contracts remain unchanged.
```
Each configured binding is required. An operator who does not maintain an
optional Notarius reference, such as a spell catalog, omits that binding. This
keeps missing-input behavior explicit and avoids a second required/optional
policy inside each entry.
The maintained complete D&D example will show all four external reference
slots. The three existing campaign context bindings use the canonical `party`,
`players`, and `glossary` spellings. Narratio will not emit the deprecated
`roster` alias.
The binding is deliberately source-based rather than path-based. Pipeline
configuration should not reconstruct session workspace paths or bypass
`prepare`; it names the stable input whose effective campaign/session value is
already owned by Narratio. The map also avoids hard-coded behavior keyed to the
literal `dnd-session` pipeline ID, preserving custom-pipeline support.
Narratio accepts the selector forms published by Notarius v0.6.0:
- `slot`;
- `chunk.slot`;
- `lane.slot`; and
- `lane.extract.slot`, `lane.merge.slot`, or `lane.normalize.slot`.
Configuration validation will reject empty or structurally invalid selectors,
selectors containing `=`, unsupported source IDs, and duplicate YAML keys.
Notarius remains authoritative for whether a selected target actually declares
the slot and whether a file satisfies that slot's media type and size contract.
Narratio will not duplicate the Notarius module registry.
### Stable Reference Inputs
Continue to use the existing prepared sources and canonical files:
| Narratio source | Prepared file | Notarius slot |
| --- | --- | --- |
| `narratio.input.party` | `inputs/party.yml` | `party` |
| `narratio.input.players` | `inputs/players.yml` | `players` |
| `narratio.input.glossary` | `inputs/glossary.yml` | `glossary` |
| `narratio.input.spell_catalog` | `inputs/spell_catalog.json` | `spell_catalog` |
Add optional `spell_catalog_file` fields to campaign and session inputs, with
the existing campaign-default/session-override resolution behavior. When
provided, `prepare` copies it into the session input area and records its
origin and checksum consistently with the other stable inputs. The prepared
filename remains JSON so Notarius can apply its published spell-catalog media
contract.
The new source must be added everywhere stable inputs are enumerated: strict
configuration decoding and merging, validation, prepare materialization,
artifact policy/source descriptions, operator inspection, manifest input
records, examples, and canonical documentation. It remains optional at the
campaign level; a configured Notarius binding makes it mandatory for that
extraction.
Extract and analyze should use one shared prepared-input source resolver rather
than maintain separate source-to-filename tables. The resolver must return an
absolute, regular, non-empty file beneath the current session workspace and
produce actionable `prepare --force` guidance when a configured source is
missing. It must not fall back to the original campaign path after preparation.
### Adapter Request And CLI Construction
Extend the transport-neutral Notarius run request with an ordered collection of
resolved reference bindings. Each binding contains only its selector and
absolute prepared-file path. The extraction stage resolves source IDs and file
identity; the subprocess adapter validates and serializes the request.
The production command becomes:
```text
notarius run <pipeline_id>
--config <config_path>
--input <trimmed_json>
--output-dir <staging_dir>
--reference party=<absolute_prepared_party_path>
--reference players=<absolute_prepared_players_path>
--reference glossary=<absolute_prepared_glossary_path>
--reference spell_catalog=<absolute_prepared_spell_catalog_path>
--json
```
Only configured bindings are emitted. Selectors are sorted before request
construction so argument order, tests, logs, and fingerprints are deterministic.
Arguments are passed directly to the subprocess without shell interpretation;
paths containing spaces or platform-specific separators remain one argument.
CLI bindings intentionally override matching external paths in the deployed
Notarius configuration. Narratio must not pass `--without-reference` and must
not synthesize CLI bindings for `location_registry`, `item_registry`,
`npc_registry`, `scene_descriptions`, `combat_turns`, or `npc_occurrences`.
Those are generated same-run artifact handoffs in the complete D&D pipeline and
remain entirely under Notarius configuration and execution control. A custom
configuration that collides an external CLI binding with a generated handoff is
expected to fail with Notarius's normal resolution error.
### Fingerprints, Resume, And Provenance
Reference identity is part of the extraction input contract. The extraction
fingerprint and resume validator must include, in deterministic selector order:
- the selector;
- the configured Narratio source ID;
- the resolved prepared path identity; and
- the prepared file's content checksum and size.
This is required even though Notarius generates a prompt session ID from the
input module and transcript bytes: Notarius intentionally does not include
references in that identifier. Narratio must therefore prevent an old
extraction from being reused after a roster, player list, glossary, spell
catalog, selector, or source mapping changes.
A changed reference makes the prior `extract` result non-reusable and follows
Narratio's normal downstream invalidation rules. A failed reference-resolution
or checksum check also prevents reuse; it must not silently accept the prior
bundle.
Successful extract metadata should record a bounded, deterministic list of
selector, source ID, workspace-relative path, checksum, and size. It must not
record reference contents, original absolute operator paths, or values from the
files. Existing receipt and bundle provenance behavior remains unchanged.
### Error And Compatibility Behavior
Narratio's documented minimum supported Notarius version becomes v0.6.0 for an
enabled reference binding. Compatibility remains contract-based rather than
dependent on parsing `notarius --version`: an older or incompatible executable
will fail at the CLI boundary with captured diagnostics.
Errors must identify the responsible selector and Narratio source without
including file contents. Configuration errors are reported before pipeline
execution. Missing, empty, non-regular, unsafe, or unreadable prepared files
fail extraction before the Notarius subprocess starts. Notarius continues to
report undeclared slots, media incompatibility, size limits, required-slot
failures, and generated-handoff collisions.
When Notarius is disabled, extraction retains its current explicit skip
behavior and does not resolve reference inputs. Receipt v2 ingestion, bundle
confinement, ten-lane selection, and downstream artifact source IDs are not
otherwise changed by this feature.
## Target End State
Narratio and Notarius have a clear orchestration boundary:
- `prepare` owns the effective, immutable session copies of external campaign
context;
- `extract` maps configured stable source IDs to Notarius v0.6 CLI selectors,
supplies absolute file paths, and owns reuse/provenance policy;
- the Notarius adapter owns exact subprocess serialization and supported result
decoding;
- Notarius owns slot compatibility, reference precedence within its pipeline,
generated artifact handoffs, and output schemas; and
- `analyze` consumes the resulting ten structured lane artifacts exactly as it
does today.
The maintained complete D&D workflow passes party, players, glossary, and spell
catalog context from the same prepared session inputs used elsewhere in
Narratio. Updating any of those documents deterministically causes fresh
extraction, and operators can diagnose the effective bindings without exposing
file contents.
## Out Of Scope
- Reproducing Notarius pipeline, lane, binding, or media-type validation in
Narratio.
- Passing or overriding Notarius generated artifact handoffs.
- Adding `--without-reference`, Notarius resume/recompute controls, lane
selection, model selection, profile selection, or session-ID overrides.
- Changing the ten accepted D&D lane contracts or the Scriptorium analysis
design.
- Reading reference payloads into Narratio manifests or logs.
- Automatically running `notarius config validate` for every session.
## Settled Policy Choices
The implementation must preserve these choices unless implementation evidence
shows a contract conflict:
- explicit selector-to-source mappings are preferred over pipeline-ID-specific
defaults;
- every configured mapping is required;
- `spell_catalog_file` is optional until a mapping requests its prepared
source;
- the complete D&D example demonstrates all four external references; and
- Notarius v0.6.0 is the minimum supported CLI contract for reference-enabled
extraction.

View File

@@ -56,7 +56,8 @@ Symptom:
Likely cause:
- stale field name, typo, invalid enum, or invalid duration/path format.
- stale field name, typo, invalid enum, invalid duration/path format, or an
unsupported local manifest schema/state marker.
Diagnostics:
@@ -68,9 +69,69 @@ Safe fix:
- align config with [Configuration](./config.md) and the
[maintained examples](../examples/README.md).
- for a manifest schema error, move the unsupported local state aside and
regenerate it; restore a verified current committed snapshot or republish
healthy regenerated state when appropriate. Do not add version or stage
markers by hand.
- if an older manifest is rejected for its retired cleanup-obligation field,
finish outstanding cleanup with the pre-upgrade binary. Otherwise verify the
remote commit, move aside or regenerate the incompatible manifest, and use
the cleanup stage. Do not treat manual JSON editing as the normal migration
path.
Relevant reference: [Configuration](./config.md).
## Party schema version rejected
Symptom:
- configuration loading reports that `party.schema_version` is required or
unsupported.
Likely cause:
- the campaign `party.yml` is an unversioned legacy document, or names a
schema other than `narratio.party.v1`.
Safe fix:
- replace the file with one exact, single-document
[`narratio.party.v1` party](./integrations/party.md); do not add a version
marker to an otherwise incompatible legacy structure. The versioned party is
the only configured roster authority.
Relevant reference: [Canonical Party Input](./integrations/party.md).
## Unexpected imported or profile value
Symptom:
- an effective configuration value differs from the root file, or a duplicate
ownership/configuration error is hard to locate.
Diagnostics:
```bash
narratio config sources --config /path/pipeline.yml --profile testing
```
Add `--campaign` or `--campaign-file` when the pipeline has party-driven
artifact families. The output identifies each effective logical field's root,
import, profile, default, campaign, party, or family source without printing
the field value or credential contents.
Safe fix:
- move a duplicated base field so it has one owner;
- correct the selected profile or its overlay; or
- correct the campaign party/family declaration that owns generated values.
To review what would actually change before switching profiles, run `config
diff` with the same pipeline and campaign selectors. It compares normalized
effective values rather than YAML formatting or source-file layout.
Relevant reference: [Configuration inspection](./config.md#read-only-effective-pipeline-inspection).
## Audio mode conflict
Symptom:
@@ -117,6 +178,75 @@ Safe fix:
Relevant reference: [CLI artifact selection](./cli.md).
## Bounded run prerequisite is unusable
Symptom:
- `run` or `session plan` reports that a prerequisite stage is absent or has a
pending, running, failed, stale, or interrupted status before the selected
start.
Likely cause:
- `--from` excludes upstream work that has not reached the terminal
`succeeded` or `skipped` state in the session manifest.
Diagnostics:
```bash
narratio session status 2026-04-04
narratio session plan 2026-04-04 --from render --through analyze
```
Safe fix:
- widen the bounded range to include the first reported stage, or recover that
stage explicitly with `run-stage` before retrying. The failed check does not
create a run-local directory or modify the manifest. Narratio does not
resume-validate excluded prefix stages, and stages after `--through` are not
prerequisites.
If prerequisite statuses are terminal but a selected stage reports a missing,
unsafe, or checksum-inconsistent artifact, repair the artifact at the stage
that owns it; do not edit the manifest to bypass the selected stage's concrete
input validation.
Relevant reference: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior).
## Notarius credential reference unavailable
Symptom:
- configuration loading reports `secret.unset`, `secret.empty`, or an invalid
name for `pipeline.notarius.llm_api_key`, including during inspection or a
run that would not execute extraction.
Likely causes:
- the named environment variable is absent or empty;
- the selected secrets directory lacks a file with that exact name;
- a process environment variable exists but is empty, so bootstrap correctly
leaves it in place; or
- the Narratio reference name does not match `api_key_env` in the selected
Notarius/PromptKit profile.
Safe fix:
- set the exact nonempty environment variable or provide an equivalently named
file through the conventional or explicit secrets directory;
- remove or set the field to `null` when that deployment needs no credential;
and
- use `config show`, `config sources`, and `config diff` to inspect the public
reference name and ownership without exposing its value.
Narratio resolves selected credential references during ordinary pipeline
loading, before stage selection or resume. Credential changes alone do not
invalidate a completed extraction, but the current reference must still resolve
for the command to proceed.
Relevant references: [Secrets](./config.md#secrets-handling) and
[Notarius credential](./config.md#notarius-credential).
## Notarius executable missing
Symptom:
@@ -193,6 +323,59 @@ Do not point Notarius directly at the original source path or edit the manifest
checksum. Relevant references: [Notarius reference configuration](./config.md#notarius-reference-bindings)
and [Operations: Extraction Workflow](./operations.md#extraction-workflow).
## Notarius reference snapshot cache conflict
Symptom:
- extraction reports that a path beneath
`cache/notarius/references/<sha256>/` is a symlink, directory, or another
non-regular or unsafe entry.
Safe fix:
- stop commands for the affected session and inspect the exact reported path;
- investigate unexpected links or special files as possible tampering; and
- remove only the reported conflicting leaf or digest directory after
confirming it belongs to the reconstructible reference cache.
A missing snapshot or corrupt regular snapshot is reconstructed automatically
from currently verified prepared input. Do not recursively delete the session
root, edit the manifest checksum, or remove cache entries while extraction is
active.
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
## Notarius resume executes work instead of reusing it
Symptom:
- Narratio executes `extract`, and Notarius makes provider calls despite
`pipeline.notarius.resume: true`.
Likely causes:
- checkpoint recording is disabled in the Notarius configuration;
- Notarius is using a nonpersistent or different checkpoint root;
- transcript/reference bytes, reference paths, pipeline/profile content,
local-backend endpoint, selected lanes, or another checkpoint identity input
changed; or
- Narratio extraction was forced, which deliberately omits `--resume`.
Safe fix:
- validate the deployed Notarius configuration and configure explicit
persistent chunk-plan and checkpoint roots;
- inspect Notarius checkpoint decision reason codes for a legitimate cold miss;
and
- retry without force only when reuse is intended.
Remember that Narratio skipping a completed `extract` stage is separate from
Notarius reusing work inside an extraction that does execute. Narratio does not
read the Notarius configuration to infer checkpoint enablement.
Relevant references: [Notarius cache controls](./config.md#notarius-cache-controls)
and [Notarius integration](./integrations/notarius.md).
## Notarius reference selector or generated-handoff collision
Symptom:
@@ -218,29 +401,6 @@ Narratio validates selector structure and prepared evidence, while Notarius
owns slot declarations, media compatibility, and generated-handoff conflicts.
Relevant reference: [Notarius integration](./integrations/notarius.md).
## Atomic Notarius promotion unsupported
Symptom:
- extraction fails with `atomic no-replace directory promotion is unsupported`
before a durable bundle or temporary promotion tree is created.
Likely cause:
- Narratio is running on an operating system other than Linux, macOS, or
Windows, where the required atomic no-replace directory primitive has not
been implemented and verified.
Safe fix:
- run extraction on Linux, macOS, or Windows. Do not replace the atomic commit
with a manual copy or move; the session manifest must never observe a partial
or overwritten bundle.
This is an extraction-specific platform boundary, not a support statement for
unrelated Narratio workflows. See
[Operations: Extraction Workflow](./operations.md#extraction-workflow).
## Notarius receipt or index incompatible
Symptom:
@@ -288,18 +448,23 @@ Symptom:
Likely causes:
- the executable/config path, pipeline ID, timeout, working directory, or
configured output contracts changed;
- the pipeline ID or configured output contracts changed;
- a configured prepared reference selector, source, path, checksum, or byte
size changed;
- the direct trimmed transcript identity or bytes changed; or
- the durable bundle, index, lane set, provenance, regular-file status, or
checksum no longer validates.
Safe fix:
- allow the automatic rerun after verifying the current configuration. Treat
an unsafe path or symlink error as filesystem corruption or tampering and
investigate it rather than replacing files manually.
an unsafe path or bundle symlink error as filesystem corruption or tampering
and investigate it rather than replacing files manually.
Executable, config path, timeout, working directory, and Notarius credential
changes are operational and do not by themselves invalidate reusable extraction
evidence. A newly selected credential must nevertheless resolve during command
configuration loading.
## Notarius transitive configuration changed
@@ -321,6 +486,151 @@ successful stages are then marked stale normally.
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
## Analysis artifact evidence is not current
Symptom:
- ordinary continuation or `session plan` schedules one or more configured
artifacts even though a canonical output file exists; or
- publish reports a configured artifact source unavailable.
Likely causes:
- the per-artifact record is stale, missing, failed, unselected, or malformed;
- a configured prompt/profile, dependency, input identity, output path, or
effective variable changed; or
- the recorded output is missing, unsafe, empty, or has a size/checksum that no
longer matches its manifest evidence.
Diagnostics:
```bash
narratio session status 2026-04-04
narratio session artifacts 2026-04-04
narratio session plan 2026-04-04 --from analyze --through analyze
```
Safe fix:
- investigate unexpected path or checksum changes as possible tampering;
- otherwise let the selected analyze work rerun, or explicitly regenerate only
the affected targets; and
- never edit the fingerprint/checksum in the manifest or copy an old file into
the canonical path as a substitute for current evidence.
```bash
narratio analyze 2026-04-04 --artifacts session_recap
```
Relevant references: [Operations: Artifact Selection](./operations.md#artifact-selection)
and [Artifact Internals](./internal/artifacts.md#resolution-rules).
## Analysis manifest checkpoint failed
Symptom:
- analyze reports that it could not save the session manifest after completing
an artifact.
Likely causes:
- the workspace is unwritable or out of space;
- the manifest path or its parent directory has unsafe permissions or type; or
- a transient local filesystem error interrupted atomic manifest replacement.
Safe fix:
- correct the reported filesystem or permission problem;
- inspect `session artifacts` to identify the last durable current records; and
- rerun analyze normally. Narratio reuses earlier successful checkpoints and
retries the failed or non-current artifact and its required dependents.
Do not treat a newly created canonical artifact file as current when its
checkpoint failed, and do not edit the manifest to advertise it. If the error
recurs, preserve the manifest and run-local diagnostics before repairing the
workspace.
Relevant references: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior)
and [Manifest Internals](./internal/manifest.md#persistence-semantics).
## Built-in transcript or bounds source is unavailable
Symptom:
- analyze, extract, render, or publish says a built-in source is unavailable
even though a file exists at its usual session path.
Likely cause:
- the producing stage has not succeeded, its output lacks the exact registered
source, run ID, or checksum, its evidence is duplicated or stale, or the
durable bytes no longer match the manifest checksum.
Safe fix:
- do not copy or rename the file to make it discoverable. Rerun the producing
stage to regenerate both the bytes and current manifest evidence, then retry
the consumer.
## An older manifest schema is rejected
Symptom:
- Narratio rejects a local session manifest because `schema_version` is missing
or unsupported; or
- restore rejects a committed remote session manifest for the same reason.
Likely cause:
- the manifest predates the supported canonical state contract. Narratio
rejects it at the schema boundary rather than interpreting aggregate output
lists or incidental files as current authority.
Safe fix:
- for unsupported local state, move the old manifest aside and regenerate, or
restore a verified current committed snapshot when one is authoritative;
- for unsupported committed remote state, regenerate healthy local state as
needed and republish the session.
```bash
narratio run 2026-04-04
narratio publish 2026-04-04
```
After current records exist, inspect them and publish explicitly. Do not copy
old files into canonical paths merely to influence selection; availability is
manifest-owned.
Relevant references: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior)
and [Manifest Internals](./internal/manifest.md#analyze-owned-artifact-state).
## Scriptorium private input changed without a rerun
Symptom:
- a prompt, profile, imported configuration file, executable, or other input
loaded privately by Scriptorium changed, but Narratio still considers an
artifact current.
Likely cause:
- analysis fingerprints cover Narratio-observable semantic identities, not
executable contents or arbitrary files and transitive configuration that
Scriptorium loads behind its configured paths and identifiers.
Safe fix:
- explicitly force the affected target after changing an unobserved private
input. Force applies to explicit targets; current prerequisites remain
reusable unless selected themselves.
```bash
narratio analyze 2026-04-04 --artifacts session_recap
```
Relevant reference: [Analyze Internals](./internal/stage-analyze.md#invariants).
## Previous-session artifact input missing
Symptom:
@@ -330,7 +640,9 @@ Symptom:
Likely causes:
- missing `session.previous_session_id`;
- previous artifact not restored/published for source session.
- the selected previous-session commit does not declare the required canonical
source as a `published_output`; or
- the declared published object is unavailable or fails identity validation.
Diagnostics:
@@ -388,6 +700,8 @@ Symptom:
Likely cause:
- local durable files differ from remote restore sources.
- a restore target or one of its ancestors is a symlink or another non-regular
filesystem entry.
Diagnostics:
@@ -398,7 +712,11 @@ narratio session restore 2026-04-04 --dry-run
Safe fix:
- review conflicts;
- rerun with `--force` only when remote state should overwrite local.
- rerun with `--force` only when remote state should overwrite an eligible
regular local file; repair a symlink or other non-regular conflict first.
If the filesystem changed after the plan was printed, rerun restore after
repairing the destination; installation performs the same safety check again.
Relevant reference: [Operations: Restore Workflow](./operations.md#restore-workflow).
@@ -491,7 +809,8 @@ Symptom:
Likely causes:
- invalid `pipeline.secrets.env_dir`;
- missing, unreadable, or invalid explicit `--secrets-dir`;
- invalid entries in the conventional `secrets/` directory beside the root pipeline;
- missing credential environment variables;
- invalid S3 endpoint/bucket settings.
@@ -499,12 +818,12 @@ Diagnostics:
```bash
ls -la /path/to/secrets_dir
env | sed 's/=.*//' | grep -E 'OBJECT_STORAGE|AWS|AUDITA|SCRIPTORIUM'
env | sed 's/=.*//' | grep -E 'OBJECT_STORAGE|AWS|AUDITA|NOTARIUS|SCRIPTORIUM'
```
Safe fix:
- correct secret-file path and permissions;
- correct `--secrets-dir`, secret-file type/name, and operator-managed permissions;
- provide required env vars;
- keep secret values out of YAML.

View File

@@ -11,6 +11,26 @@ in the [configuration reference](../docs/config.md).
WhisperX URL.
- [Production-shaped pipeline](pipeline.production.yml): S3 storage, publish,
external tools, and configured Scriptorium artifacts.
- [Production-base/testing-overlay bundle](production-testing/pipeline.yml):
explicit `conf.d` imports, one ordinary artifact per `artifacts/<key>.yml`,
one family per `artifact-families/<key>.yml`, and the fixed
`profiles/testing.yml` overlay. Production values live in the base. The
bundle demonstrates an input-only artifact dependency, two character families
sharing the campaign party, and a testing-only disabled artifact. Inspect it
offline with:
```sh
narratio config validate --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
narratio config show --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing
narratio config sources --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing
narratio config artifacts --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing
narratio config diff default testing --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
```
These inspection commands load configuration and resolve explicit credential
references, but do not load a session or create runtime state. This bundle is
intentionally local-storage and credential-reference free so the walkthrough
needs no tools or cloud services.
- [Full annotated pipeline](pipeline.full.annotated.yml): every implemented
pipeline section with explanatory comments.
- [Extraction subset pipeline](pipeline.extraction-subset.yml): a focused
@@ -37,14 +57,23 @@ with the sample campaign and a compatible local- or S3-audio session.
- The sample campaign references its local
[speakers](campaigns/sample-campaign/speakers.yml),
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
[glossary](campaigns/sample-campaign/glossary.yml),
[players](campaigns/sample-campaign/players.yml), and
[party](campaigns/sample-campaign/party.yml) fixtures, plus an optional
[glossary](campaigns/sample-campaign/glossary.yml), and canonical
[party](campaigns/sample-campaign/party.yml) fixture, plus an optional
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
follows the Notarius v0.6 contract.
follows the Notarius v0.7 contract. Narratio derives the players projection
from this party source.
- [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that
reserves the expected filename and directory shape. Replace it with a real
FLAC file before running transcription.
The examples contain environment-variable names but no credential values. They
use fictional campaign content and reserved example domains.
The examples contain environment-variable names but no credential values. Set
`OBJECT_STORAGE_KEY_ID`, `OBJECT_STORAGE_KEY`, `AUDITA_LLM_API_KEY`, and
`NOTARIUS_LLM_API_KEY` to nonempty synthetic or real values before loading the
examples that reference them. They use fictional campaign content and reserved
example domains.
For a deployed bundle, keep credential files out of source control in a
`secrets/` directory beside the root `pipeline.yml`, one file per referenced
environment-variable name, or select another directory with `--secrets-dir`.
Static S3 credentials are optional: omit both S3 credential references to use
the AWS SDK discovery chain.

View File

@@ -4,6 +4,5 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
spell_catalog_file: ./spell_catalog.json

View File

@@ -1,2 +1,26 @@
- name: Example Hero
type: pc
schema_version: narratio.party.v1
characters:
arannis:
player:
name: Rowan Hale
character:
name: Arannis
alias:
- Ari
- The Grey Owl
classes:
- name: wizard
level: 8
brenna:
player:
name: Rowan Hale
character:
name: Brenna
alias:
- Shield of Dawn
classes:
- name: paladin
level: 6
- name: warlock
level: 2

View File

@@ -1,2 +0,0 @@
- name: Example Player
role: player

View File

@@ -7,9 +7,8 @@ workspace:
# Optional: remove run-scoped workdir after successful publish commit.
cleanup_after_publish: false
# Optional: local secret file loader (directory of ENV_VAR_NAME files).
# secrets:
# env_dir: ./secrets
# Credential files, when used, belong in a sibling secrets/ directory or in the
# directory selected with --secrets-dir; they are not pipeline configuration.
storage:
# Defaults to "local". Use "s3" explicitly for publish + S3 audio workflows.
@@ -24,8 +23,8 @@ storage:
endpoint: ""
force_path_style: false
# Optional; defaults shown explicitly.
access_key_id_env: OBJECT_STORAGE_KEY_ID
secret_access_key_env: OBJECT_STORAGE_KEY
access_key_id: OBJECT_STORAGE_KEY_ID
secret_access_key: OBJECT_STORAGE_KEY
campaigns:
# Optional; defaults to /usr/local/share/narratio/campaigns.
@@ -94,7 +93,7 @@ audita:
# Optional overrides; defaults shown explicitly where applicable.
binary: audita
timeout: 3h
llm_api_key_env: AUDITA_LLM_API_KEY
llm_api_key: AUDITA_LLM_API_KEY
modules: [glossary, homophones, spoken_word, grammar]
base_url: ""
model: ""
@@ -129,13 +128,22 @@ trim:
report: false
notarius:
# Optional structured extraction between trim and render.
# Optional structured extraction after render and before analyze.
enabled: true
binary: notarius
# Optional environment-variable reference. Match this name to api_key_env in
# the selected Notarius/PromptKit profile; never put the credential here.
llm_api_key: NOTARIUS_LLM_API_KEY
config_path: /usr/local/etc/notarius/config.yml
pipeline_id: dnd-session
timeout: 3h
working_directory: /usr/local/etc/notarius
# Reuse compatible accepted Notarius checkpoints during executed extraction.
# The Notarius config must enable recording and provide persistent cache roots.
resume: true
# auto uses or creates a source-addressed chunk plan. Forced Narratio
# extraction overrides this with refresh and disables checkpoint loading.
chunk_cache: auto
# External campaign references use prepared Narratio source IDs. Omit an
# optional binding when the selected Notarius pipeline does not need it.
references:

View File

@@ -8,8 +8,8 @@ storage:
bucket: my-dnd-archive
root_prefix: dnd
region: us-east-1
access_key_id_env: OBJECT_STORAGE_KEY_ID
secret_access_key_env: OBJECT_STORAGE_KEY
access_key_id: OBJECT_STORAGE_KEY_ID
secret_access_key: OBJECT_STORAGE_KEY
campaigns:
root: /usr/local/share/narratio/campaigns
@@ -57,7 +57,7 @@ seriatim:
audita:
binary: audita
timeout: 3h
llm_api_key_env: AUDITA_LLM_API_KEY
llm_api_key: AUDITA_LLM_API_KEY
modules: [glossary, homophones, spoken_word, grammar]
output_schema: audita-v1
work_dir_retention: auto

View File

@@ -0,0 +1,20 @@
scriptorium:
artifact_families:
character_items:
enabled: true
for_each: party.characters
prompt_id: dnd.character_items
profile_id: production-placeholder
output_path_pattern: artifacts/characters/{character_id}/items.md
inputs:
character_meta:
source: narratio.member_artifact.character_meta
required: true
member_vars:
character_id: character_id
character_name: character.name
aliases: character.alias_summary
publish:
enabled: true
required: false
dest_pattern: artifacts/characters/{character_id}/items.md

View File

@@ -0,0 +1,13 @@
scriptorium:
artifact_families:
character_meta:
enabled: true
for_each: party.characters
prompt_id: dnd.character_meta
profile_id: production-placeholder
output_path_pattern: artifacts/characters/{character_id}/meta.md
member_vars:
character_id: character_id
character_name: character.name
player_name: player.name
class_summary: character.class_summary

View File

@@ -0,0 +1,11 @@
scriptorium:
artifacts:
player_handout:
enabled: true
prompt_id: dnd.player_handout
profile_id: production-placeholder
output_path: artifacts/player-handout.md
inputs:
recap:
source: narratio.artifact.session_recap
required: true

View File

@@ -0,0 +1,11 @@
scriptorium:
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
profile_id: production-placeholder
output_path: artifacts/session-recap.md
inputs:
transcript:
source: narratio.transcript.final_trimmed
required: true

View File

@@ -0,0 +1,12 @@
workspace:
root: ./workspace
campaigns:
root: ../../campaigns
default_campaign_id: sample-campaign
cache:
root: ./cache
spool:
root: ./spool

View File

@@ -0,0 +1,7 @@
publish:
enabled: true
upload_run: false
outputs:
- source: narratio.transcript.final_markdown
dest: transcripts/final.md
required: true

View File

@@ -0,0 +1,2 @@
storage:
backend: local

View File

@@ -0,0 +1,18 @@
whisperx:
transcribe_url: https://transcription.example.com/transcribe
language: en
seriatim:
binary: seriatim
output_schema: seriatim-intermediate
audita:
binary: audita
modules: [glossary, grammar]
output_schema: audita-v1
model: narratio-production-model-placeholder
validation_model: narratio-production-validator-placeholder
normalize:
output_path: transcripts/final.json
output_schema: seriatim-intermediate

View File

@@ -0,0 +1,14 @@
# Copyable production-base/testing-overlay entry point. ConfigKit expands only
# these explicit path and glob imports; Narratio never scans directories itself.
configkit:
imports:
- path: conf.d/platform.yml
- path: conf.d/storage.yml
- path: conf.d/transcript.yml
- path: conf.d/publish.yml
- glob: artifacts/*.yml
- glob: artifact-families/*.yml
scriptorium:
binary: scriptorium
config_path: ./scriptorium/config.yml

View File

@@ -0,0 +1,18 @@
audita:
model: narratio-testing-model-placeholder
validation_model: narratio-testing-validator-placeholder
scriptorium:
artifacts:
session_recap:
profile_id: testing-placeholder
player_handout:
profile_id: testing-placeholder
testing_notes:
enabled: false
output_path: artifacts/testing-notes.md
artifact_families:
character_meta:
profile_id: testing-placeholder
character_items:
profile_id: testing-placeholder

6
go.mod
View File

@@ -1,12 +1,15 @@
module gitea.maximumdirect.net/eric/narratio
go 1.25.0
go 1.26.0
require (
gitea.maximumdirect.net/eric/ConfigKit v0.3.1
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/credentials v1.19.16
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
github.com/aws/smithy-go v1.25.1
github.com/google/go-cmp v0.7.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -26,4 +29,5 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
)

8
go.sum
View File

@@ -1,3 +1,5 @@
gitea.maximumdirect.net/eric/ConfigKit v0.3.1 h1:etfTI/dxO3NnS18La0V6IeUh/Iv+3PjVfEyaN8hTuOM=
gitea.maximumdirect.net/eric/ConfigKit v0.3.1/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
@@ -34,6 +36,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOIt
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"path/filepath"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
@@ -84,17 +83,17 @@ func materializePlaceholders(req PolishRequest) error {
"merged_transcript_path": req.MergedTranscriptPath,
"output_path": req.OutputProcessedPath,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("audita noop/fake stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("audita noop/fake stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("audita noop/fake stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("audita noop/fake stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
@@ -124,7 +123,7 @@ func writeJSONIfRequested(path string, payload any) error {
if err != nil {
return fmt.Errorf("marshal placeholder json for %q: %w", path, err)
}
if err := subprocess.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write placeholder json %q: %w", path, err)
}
return nil

View File

@@ -0,0 +1,19 @@
package audita
import (
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gopkg.in/yaml.v3"
)
func writeGeneratedYAML(path string, payload any) error {
data, err := yaml.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal generated YAML for %q: %w", path, err)
}
if err := fileops.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write generated YAML %q: %w", path, err)
}
return nil
}

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -24,7 +23,8 @@ const MaxReportOutputBytes int64 = 16 * 1024 * 1024
type SubprocessRunnerConfig struct {
Binary string
Timeout time.Duration
LLMAPIKeyEnv string
LLMAPIKey string
LLMAPIKeyReference string
Modules []string
BaseURL string
Model string
@@ -43,7 +43,8 @@ type SubprocessRunnerConfig struct {
type SubprocessRunner struct {
binary string
timeout time.Duration
llmAPIKeyEnv string
llmAPIKey string
llmAPIKeyReference string
modules []string
baseURL string
model string
@@ -62,7 +63,8 @@ type SubprocessRunner struct {
func NewSubprocessRunnerFromConfigValues(
binary string,
timeout string,
llmAPIKeyEnv string,
llmAPIKey string,
llmAPIKeyReference string,
modules []string,
baseURL string,
model string,
@@ -86,7 +88,8 @@ func NewSubprocessRunnerFromConfigValues(
return NewSubprocessRunner(SubprocessRunnerConfig{
Binary: binary,
Timeout: parsedTimeout,
LLMAPIKeyEnv: llmAPIKeyEnv,
LLMAPIKey: llmAPIKey,
LLMAPIKeyReference: llmAPIKeyReference,
Modules: modules,
BaseURL: baseURL,
Model: model,
@@ -110,6 +113,9 @@ func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error)
if cfg.Timeout <= 0 {
return nil, fmt.Errorf("audita timeout must be > 0")
}
if (cfg.LLMAPIKey == "") != (strings.TrimSpace(cfg.LLMAPIKeyReference) == "") {
return nil, fmt.Errorf("audita credential value and reference must be provided together")
}
for i, module := range cfg.Modules {
if strings.TrimSpace(module) == "" {
return nil, fmt.Errorf("audita module at index %d is empty", i)
@@ -152,7 +158,8 @@ func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error)
return &SubprocessRunner{
binary: strings.TrimSpace(cfg.Binary),
timeout: cfg.Timeout,
llmAPIKeyEnv: strings.TrimSpace(cfg.LLMAPIKeyEnv),
llmAPIKey: cfg.LLMAPIKey,
llmAPIKeyReference: strings.TrimSpace(cfg.LLMAPIKeyReference),
modules: modules,
baseURL: strings.TrimSpace(cfg.BaseURL),
model: strings.TrimSpace(cfg.Model),
@@ -195,16 +202,10 @@ func (r *SubprocessRunner) Run(ctx context.Context, req PolishRequest) (PolishRe
}
args := r.buildArgs(req, reqModules)
credentialEnvVar := strings.TrimSpace(r.llmAPIKeyEnv)
credentialPresent := false
credentialPresent := r.llmAPIKeyReference != ""
env := map[string]string{}
if credentialEnvVar != "" {
credential, ok := os.LookupEnv(credentialEnvVar)
if !ok || strings.TrimSpace(credential) == "" {
return PolishResult{}, fmt.Errorf("audita: required credential environment variable %s is not set", credentialEnvVar)
}
env["AUDITA_LLM_API_KEY"] = credential
credentialPresent = true
if credentialPresent {
env["AUDITA_LLM_API_KEY"] = r.llmAPIKey
}
if req.GeneratedConfigPath != "" {
@@ -269,7 +270,7 @@ func (r *SubprocessRunner) Run(ctx context.Context, req PolishRequest) (PolishRe
"total_llm_concurrency": r.totalLLMConcurrency,
"proposal_llm_concurrency": r.proposalLLMConcurrency,
"validation_llm_concurrency": r.validationLLMConcurrency,
"credential_env_var": r.llmAPIKeyEnv,
"credential_env_var": r.llmAPIKeyReference,
"credential_present": credentialPresent,
},
}, nil
@@ -299,7 +300,7 @@ func (r *SubprocessRunner) failureResult(req PolishRequest, modules []string, ru
"total_llm_concurrency": r.totalLLMConcurrency,
"proposal_llm_concurrency": r.proposalLLMConcurrency,
"validation_llm_concurrency": r.validationLLMConcurrency,
"credential_env_var": r.llmAPIKeyEnv,
"credential_env_var": r.llmAPIKeyReference,
"credential_present": credentialPresent,
},
}
@@ -375,10 +376,10 @@ func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args []strin
"output_path": req.OutputProcessedPath,
"report_path": req.ReportPath,
"work_dir": req.WorkDir,
"credential_env_var": r.llmAPIKeyEnv,
"credential_env_var": r.llmAPIKeyReference,
"credential_present": credentialPresent,
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
return writeGeneratedYAML(req.GeneratedConfigPath, payload)
}
func validateProcessedOutput(path string) error {
@@ -425,7 +426,7 @@ func validateJSONFile(path string) error {
}
func readAuditaResult(path string, limit int64, category string) ([]byte, error) {
data, err := fileops.ReadRegularFile(path, limit)
data, err := fileops.ReadFileBounded(path, limit)
if err != nil {
return nil, fmt.Errorf("audita %s result exceeds or cannot be read within %d-byte limit: %w", category, limit, err)
}

View File

@@ -6,16 +6,12 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "success")
@@ -31,7 +27,8 @@ func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
runner, err := NewSubprocessRunner(SubprocessRunnerConfig{
Binary: wrapper,
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary", "homophones", "glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
@@ -48,6 +45,7 @@ func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
if err != nil {
t.Fatalf("NewSubprocessRunner() error = %v", err)
}
t.Setenv("OPENAI_KEY_SOURCE", "changed-after-resolution")
dir := t.TempDir()
req := PolishRequest{
@@ -132,47 +130,31 @@ func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
}
}
func TestSubprocessRunnerMissingConfiguredCredentialFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyEnv: "MISSING_AUDITA_KEY",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
func TestSubprocessRunnerRejectsIncompleteResolvedCredential(t *testing.T) {
_, err := NewSubprocessRunner(SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyReference: "MISSING_AUDITA_KEY",
})
req := auditaReqForTest(t, false)
_, err := runner.Run(context.Background(), req)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "required credential environment variable MISSING_AUDITA_KEY is not set") {
t.Fatalf("error = %q, want missing credential guidance", err.Error())
if err == nil || !strings.Contains(err.Error(), "credential value and reference must be provided together") {
t.Fatalf("NewSubprocessRunner() error = %v, want credential pair error", err)
}
}
func TestSubprocessRunnerUnconfiguredCredentialEnvOmitsCredential(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "success")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyEnv: "",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
})
req := auditaReqForTest(t, false)
@@ -190,9 +172,6 @@ func TestSubprocessRunnerUnconfiguredCredentialEnvOmitsCredential(t *testing.T)
}
func TestSubprocessRunnerOmitsUnspecifiedParentEnvironment(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "success")
t.Setenv("AUDITA_INHERITED_MARKER", "inherited-from-parent")
@@ -200,13 +179,13 @@ func TestSubprocessRunnerOmitsUnspecifiedParentEnvironment(t *testing.T) {
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
LLMAPIKeyEnv: "",
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
LLMAPIKey: "",
})
req := auditaReqForTest(t, false)
if _, err := runner.Run(context.Background(), req); err != nil {
@@ -220,21 +199,18 @@ func TestSubprocessRunnerOmitsUnspecifiedParentEnvironment(t *testing.T) {
}
func TestSubprocessRunnerOmitsModulesFlagWhenNotConfigured(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "success")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyEnv: "",
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "",
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
})
req := auditaReqForTest(t, false)
if _, err := runner.Run(context.Background(), req); err != nil {
@@ -249,19 +225,16 @@ func TestSubprocessRunnerOmitsModulesFlagWhenNotConfigured(t *testing.T) {
}
func TestSubprocessRunnerOmitsBaseURLAndModelFlagsWhenNotConfigured(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "success")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyEnv: "",
Report: false,
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "",
Report: false,
})
req := auditaReqForTest(t, false)
if _, err := runner.Run(context.Background(), req); err != nil {
@@ -279,22 +252,20 @@ func TestSubprocessRunnerOmitsBaseURLAndModelFlagsWhenNotConfigured(t *testing.T
}
func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "fail")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: true,
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: true,
})
req := auditaReqForTest(t, true)
_, err := runner.Run(context.Background(), req)
@@ -310,25 +281,26 @@ func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
if !strings.Contains(err.Error(), req.StdoutLogPath) || !strings.Contains(err.Error(), req.StderrLogPath) {
t.Fatalf("error = %q, want stdout/stderr log paths", err.Error())
}
if strings.Contains(err.Error(), "super-secret") {
t.Fatalf("failure diagnostic exposed credential value: %q", err.Error())
}
}
func TestSubprocessRunnerSubprocessFailureAddsStderrDescriptorHint(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "fail_bad_fd")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: true,
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: true,
})
req := auditaReqForTest(t, true)
_, err := runner.Run(context.Background(), req)
@@ -341,22 +313,20 @@ func TestSubprocessRunnerSubprocessFailureAddsStderrDescriptorHint(t *testing.T)
}
func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "missing_output")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
})
req := auditaReqForTest(t, false)
_, err := runner.Run(context.Background(), req)
@@ -369,22 +339,20 @@ func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
}
func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "invalid_output")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
})
req := auditaReqForTest(t, false)
_, err := runner.Run(context.Background(), req)
@@ -397,22 +365,20 @@ func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
}
func TestSubprocessRunnerSegmentsMissingFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "segments_missing")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
})
req := auditaReqForTest(t, false)
_, err := runner.Run(context.Background(), req)
@@ -425,22 +391,20 @@ func TestSubprocessRunnerSegmentsMissingFails(t *testing.T) {
}
func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "invalid_report")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: true,
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: true,
})
req := auditaReqForTest(t, true)
_, err := runner.Run(context.Background(), req)
@@ -453,21 +417,22 @@ func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
}
func TestSubprocessRunnerConstructorValidation(t *testing.T) {
_, err := NewSubprocessRunnerFromConfigValues("", "3h", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true)
_, err := NewSubprocessRunnerFromConfigValues("", "3h", "synthetic-secret", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true)
if err == nil {
t.Fatal("expected binary validation error")
}
_, err = NewSubprocessRunnerFromConfigValues("audita", "bad", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true)
_, err = NewSubprocessRunnerFromConfigValues("audita", "bad", "synthetic-secret", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true)
if err == nil {
t.Fatal("expected timeout parse error")
}
_, err = NewSubprocessRunner(SubprocessRunnerConfig{
Binary: "audita",
Timeout: mustParseAuditaDuration(t, "1s"),
LLMAPIKeyEnv: "AUDITA_LLM_API_KEY",
Modules: []string{"glossary"},
BaseURL: "://",
Model: "openrouter/google/gemma-4-31b-it",
Binary: "audita",
Timeout: mustParseAuditaDuration(t, "1s"),
LLMAPIKey: "synthetic-secret",
LLMAPIKeyReference: "AUDITA_LLM_API_KEY",
Modules: []string{"glossary"},
BaseURL: "://",
Model: "openrouter/google/gemma-4-31b-it",
})
if err == nil {
t.Fatal("expected base url validation error")

View File

@@ -4,6 +4,8 @@ package notarius
import (
"context"
"time"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
const ReceiptSchemaVersion = "notarius.run-result.v2"
@@ -22,7 +24,9 @@ type ReferenceBinding struct {
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
type RunRequest struct {
Binary string
Binary string
// LLMAPIKey is an optional already-resolved credential reference.
LLMAPIKey *configkit.Secret
ConfigPath string
PipelineID string
InputPath string
@@ -31,6 +35,8 @@ type RunRequest struct {
ReceiptPath string
LogPath string
Timeout time.Duration
Resume bool
ChunkCache string
References []ReferenceBinding
}

View File

@@ -52,6 +52,20 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
if err != nil {
return RunResult{}, err
}
var envOverrides map[string]string
var sensitiveEnvNames []string
if req.LLMAPIKey != nil {
if !req.LLMAPIKey.IsResolved() {
return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty")
}
name := req.LLMAPIKey.EnvName()
value := req.LLMAPIKey.Value()
if value == "" {
return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty")
}
envOverrides = map[string]string{name: value}
sensitiveEnvNames = []string{name}
}
args := []string{
"run", req.PipelineID,
@@ -59,18 +73,26 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
"--input", req.InputPath,
"--output-dir", req.OutputRoot,
}
if req.Resume {
args = append(args, "--resume")
}
if req.ChunkCache != "" {
args = append(args, "--chunk_cache", req.ChunkCache)
}
for _, reference := range references {
args = append(args, "--reference", reference.Selector+"="+reference.Path)
}
args = append(args, "--json")
processResult, err := r.run(ctx, subprocess.RunRequest{
Executable: req.Binary,
Args: args,
WorkingDir: req.WorkingDirectory,
Timeout: req.Timeout,
DiagnosticOwner: "notarius",
StdoutLogPath: req.ReceiptPath,
StderrLogPath: req.LogPath,
Executable: req.Binary,
Args: args,
WorkingDir: req.WorkingDirectory,
Timeout: req.Timeout,
DiagnosticOwner: "notarius",
StdoutLogPath: req.ReceiptPath,
StderrLogPath: req.LogPath,
EnvOverrides: envOverrides,
SensitiveEnvNames: sensitiveEnvNames,
})
baseResult := RunResult{
ReceiptPath: req.ReceiptPath,
@@ -143,6 +165,9 @@ func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) {
if req.Timeout <= 0 {
return nil, fmt.Errorf("notarius timeout must be positive")
}
if !validChunkCacheMode(req.ChunkCache) {
return nil, fmt.Errorf("notarius chunk cache mode must be empty, auto, bypass, or refresh")
}
for label, path := range map[string]string{
"config": req.ConfigPath,
"input": req.InputPath,
@@ -201,6 +226,15 @@ func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) {
return references, nil
}
func validChunkCacheMode(mode string) bool {
switch mode {
case "", "auto", "bypass", "refresh":
return true
default:
return false
}
}
type receiptDocument struct {
SchemaVersion string `json:"schema_version"`
RunID string `json:"run_id"`
@@ -248,7 +282,7 @@ func validateValidationSummaries(documents []validationSummaryDocument) ([]Valid
if document.Status != "complete" && document.Status != "rejected" && document.Status != "incomplete" {
return nil, fmt.Errorf("notarius validation summary status %q is invalid", document.Status)
}
if document.ProducerAttemptCount == nil || *document.ProducerAttemptCount <= 0 || !validTerminalAction(document.TerminalAction) {
if document.ProducerAttemptCount == nil || *document.ProducerAttemptCount < 0 || !validTerminalAction(document.TerminalAction) {
return nil, fmt.Errorf("notarius validation summary is missing required fields")
}
summaries = append(summaries, ValidationSummary{
@@ -671,7 +705,7 @@ func sumWarningOccurrences(values []WarningSummary) (int, error) {
}
func decodeBoundedJSON(path string, limit int64, destination any) error {
data, err := fileops.ReadRegularFile(path, limit)
data, err := fileops.ReadFileBounded(path, limit)
if err != nil {
return fmt.Errorf("notarius JSON result exceeds or cannot be read within %d-byte limit: %w", limit, err)
}

View File

@@ -11,6 +11,7 @@ import (
"testing"
"time"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
sharedsubprocess "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
)
@@ -76,6 +77,127 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
}
}
func TestSubprocessRunnerMapsCacheControlsToArguments(t *testing.T) {
tests := []struct {
name string
resume bool
chunkCache string
want []string
}{
{name: "resume", resume: true, want: []string{"--resume"}},
{name: "auto", chunkCache: "auto", want: []string{"--chunk_cache", "auto"}},
{name: "bypass", chunkCache: "bypass", want: []string{"--chunk_cache", "bypass"}},
{name: "refresh", chunkCache: "refresh", want: []string{"--chunk_cache", "refresh"}},
{name: "resume and mode", resume: true, chunkCache: "auto", want: []string{"--resume", "--chunk_cache", "auto"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := validRunRequest(t)
req.Resume = test.resume
req.ChunkCache = test.chunkCache
var captured sharedsubprocess.RunRequest
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
captured = processReq
writeValidBundleAndReceipt(t, req, false)
return sharedsubprocess.RunResult{ExitCode: 0}, nil
}}
if _, err := runner.Run(context.Background(), req); err != nil {
t.Fatalf("Run() error = %v", err)
}
want := []string{
"run", req.PipelineID, "--config", req.ConfigPath, "--input", req.InputPath,
"--output-dir", req.OutputRoot,
}
want = append(want, test.want...)
want = append(want, "--json")
if !reflect.DeepEqual(captured.Args, want) {
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, want)
}
})
}
}
func TestSubprocessRunnerRejectsInvalidChunkCacheBeforeLaunch(t *testing.T) {
for _, mode := range []string{"AUTO", "invalid", " auto "} {
t.Run(mode, func(t *testing.T) {
req := validRunRequest(t)
req.ChunkCache = mode
started := false
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
started = true
return sharedsubprocess.RunResult{}, nil
}}
_, err := runner.Run(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "chunk cache mode") {
t.Fatalf("Run() error = %v, want chunk cache validation failure", err)
}
if started {
t.Fatal("subprocess started with invalid chunk cache mode")
}
})
}
}
func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t *testing.T) {
const envName = "NARRATIO_NOTARIUS_CUSTOM_LLM_KEY"
const loadedValue = "loaded-notarius-credential"
credential := loadTestCredential(t, envName, loadedValue)
t.Setenv(envName, "changed-parent-credential")
req := validRunRequest(t)
req.LLMAPIKey = credential
var captured sharedsubprocess.RunRequest
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
captured = processReq
writeValidBundleAndReceipt(t, req, false)
return sharedsubprocess.RunResult{ExitCode: 0}, nil
}}
if _, err := runner.Run(context.Background(), req); err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(captured.EnvOverrides) != 1 || captured.EnvOverrides[envName] != loadedValue {
t.Fatal("subprocess did not receive exactly the loaded credential override")
}
if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{envName}) {
t.Fatalf("sensitive environment names = %#v, want configured reference name", captured.SensitiveEnvNames)
}
wantArgs := []string{
"run", req.PipelineID, "--config", req.ConfigPath, "--input", req.InputPath,
"--output-dir", req.OutputRoot, "--json",
}
if !reflect.DeepEqual(captured.Args, wantArgs) {
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs)
}
}
func TestSubprocessRunnerRejectsInvalidProgrammaticCredentialBeforeLaunch(t *testing.T) {
for _, test := range []struct {
name string
reference configkit.Secret
}{
{name: "unresolved", reference: configkit.SecretEnv("NARRATIO_NOTARIUS_UNRESOLVED_KEY")},
{name: "empty", reference: configkit.SecretEnv("")},
} {
t.Run(test.name, func(t *testing.T) {
req := validRunRequest(t)
req.LLMAPIKey = &test.reference
started := false
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
started = true
return sharedsubprocess.RunResult{}, nil
}}
_, err := runner.Run(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "resolved and nonempty") {
t.Fatalf("Run() error = %v, want credential validation failure", err)
}
if started {
t.Fatal("subprocess started with an invalid credential")
}
})
}
}
func TestSubprocessRunnerBuildsOrderedReferenceArguments(t *testing.T) {
req := validRunRequest(t)
referenceRoot := t.TempDir()
@@ -172,16 +294,23 @@ func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) {
}
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
const envName = "NARRATIO_NOTARIUS_CUSTOM_CAPTURE_KEY"
const canary = "notarius-captured-stream-canary"
const unrelated = "NARRATIO_NOTARIUS_UNRELATED_PARENT_KEY"
req := validRunRequest(t)
req.LLMAPIKey = loadTestCredential(t, envName, canary)
writeValidBundleAndReceipt(t, req, false)
receiptFixture := req.ReceiptPath + ".fixture"
data, err := os.ReadFile(req.ReceiptPath)
if err != nil {
t.Fatalf("ReadFile(receipt) error = %v", err)
}
if err := os.WriteFile(receiptFixture, data, 0o644); err != nil {
t.Fatalf("WriteFile(receipt fixture) error = %v", err)
var receipt map[string]any
if err := json.Unmarshal(data, &receipt); err != nil {
t.Fatalf("decode receipt fixture: %v", err)
}
receipt["credential_echo"] = canary
writeJSONFile(t, receiptFixture, receipt)
if err := os.Remove(req.ReceiptPath); err != nil {
t.Fatalf("Remove(receipt) error = %v", err)
}
@@ -193,27 +322,55 @@ func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T)
script := writeShellScript(t, `#!/bin/sh
pwd > "$NOTARIUS_CAPTURE_DIR/working-directory"
printf '%s' "$NOTARIUS_INHERITED_VALUE" > "$NOTARIUS_CAPTURE_DIR/environment"
printf 'diagnostic stream\n' >&2
if [ "$`+envName+`" != '`+canary+`' ]; then
printf 'selected credential mismatch\n' >&2
exit 8
fi
if [ -n "$`+unrelated+`" ]; then
printf 'unrelated credential inherited\n' >&2
exit 9
fi
printf '%s\n' "$`+envName+`" >&2
cat "$NOTARIUS_RECEIPT_FIXTURE"
`)
req.Binary = script
t.Setenv("NOTARIUS_CAPTURE_DIR", captureDir)
t.Setenv("NOTARIUS_INHERITED_VALUE", "inherited-value")
t.Setenv("NOTARIUS_RECEIPT_FIXTURE", receiptFixture)
t.Setenv(unrelated, "unrelated-parent-credential")
if _, err := NewSubprocessRunner().Run(context.Background(), req); err != nil {
t.Fatalf("Run() error = %v", err)
}
assertTextFile(t, filepath.Join(captureDir, "working-directory"), req.WorkingDirectory+"\n")
assertTextFile(t, filepath.Join(captureDir, "environment"), "")
assertTextFile(t, req.LogPath, "diagnostic stream\n")
assertRedactedCredentialFile(t, req.LogPath, canary)
receiptBytes, err := os.ReadFile(req.ReceiptPath)
if err != nil {
t.Fatalf("ReadFile(receipt) error = %v", err)
}
if strings.Contains(string(receiptBytes), "diagnostic stream") {
t.Fatal("receipt contains stderr output")
assertRedactedCredentialBytes(t, receiptBytes, canary)
}
func TestSubprocessRunnerRedactsCredentialOnProcessFailure(t *testing.T) {
const envName = "NARRATIO_NOTARIUS_CUSTOM_FAILURE_KEY"
const canary = "notarius-failure-stream-canary"
req := validRunRequest(t)
req.LLMAPIKey = loadTestCredential(t, envName, canary)
req.Binary = writeShellScript(t, `#!/bin/sh
printf '%s\n' "$`+envName+`"
printf '%s\n' "$`+envName+`" >&2
exit 7
`)
_, err := NewSubprocessRunner().Run(context.Background(), req)
if err == nil {
t.Fatal("Run() error = nil, want process failure")
}
if strings.Contains(err.Error(), canary) || !strings.Contains(err.Error(), "<redacted>") {
t.Fatal("process failure did not return redacted diagnostics")
}
assertRedactedCredentialFile(t, req.ReceiptPath, canary)
assertRedactedCredentialFile(t, req.LogPath, canary)
}
func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T) {
@@ -579,6 +736,56 @@ func validRunRequest(t *testing.T) RunRequest {
}
}
func loadTestCredential(t *testing.T, envName, value string) *configkit.Secret {
t.Helper()
t.Setenv(envName, value)
type testConfig struct {
Credential *configkit.Secret `yaml:"credential"`
}
result, err := configkit.Load(configkit.Bytes("credential.yml", []byte("credential: "+envName+"\n")), configkit.Options[testConfig]{})
if err != nil {
t.Fatalf("load test credential: %v", err)
}
return result.Config.Credential
}
func assertRedactedCredentialFile(t *testing.T, path, canary string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", path, err)
}
assertRedactedCredentialBytes(t, data, canary)
}
func assertRedactedCredentialBytes(t *testing.T, data []byte, canary string) {
t.Helper()
text := string(data)
if strings.Contains(text, canary) || !strings.Contains(text, "<redacted>") {
t.Fatal("captured stream did not contain the required redaction marker")
}
}
func TestValidateValidationSummariesAcceptsReusedProducerWithoutAttempts(t *testing.T) {
zero := 0
summaries, err := validateValidationSummaries([]validationSummaryDocument{{
Stage: "chunk", Status: "complete", ProducerAttemptCount: &zero, TerminalAction: "accepted",
}})
if err != nil {
t.Fatalf("validateValidationSummaries() error = %v", err)
}
if len(summaries) != 1 || summaries[0].ProducerAttemptCount != 0 {
t.Fatalf("summaries = %#v, want one reused producer with zero attempts", summaries)
}
negative := -1
if _, err := validateValidationSummaries([]validationSummaryDocument{{
Stage: "chunk", Status: "complete", ProducerAttemptCount: &negative, TerminalAction: "accepted",
}}); err == nil {
t.Fatal("validateValidationSummaries() negative producer attempts error = nil")
}
}
func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown bool) {
t.Helper()
bundle := filepath.Join(req.OutputRoot, "notarius-run-1")

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
@@ -143,7 +142,7 @@ func (f *FakeRunner) RenderArtifact(ctx context.Context, req RenderArtifactReque
func materializeRunPlaceholders(req RunArtifactRequest) error {
if req.OutputPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("scriptorium noop/fake run artifact\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.OutputPath, []byte("scriptorium noop/fake run artifact\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write run output %q: %w", req.OutputPath, err)
}
}
@@ -155,17 +154,17 @@ func materializeRunPlaceholders(req RunArtifactRequest) error {
"prompt_id": req.PromptID,
"output_path": req.OutputPath,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake run stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake run stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake run stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake run stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
@@ -174,7 +173,7 @@ func materializeRunPlaceholders(req RunArtifactRequest) error {
func materializeRenderPlaceholders(req RenderArtifactRequest) error {
if req.OutputPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("{\"schema\":\"scriptorium.render.v1\",\"placeholder\":true}\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.OutputPath, []byte("{\"schema\":\"scriptorium.render.v1\",\"placeholder\":true}\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write render output %q: %w", req.OutputPath, err)
}
}
@@ -186,17 +185,17 @@ func materializeRenderPlaceholders(req RenderArtifactRequest) error {
"prompt_id": req.PromptID,
"output_path": req.OutputPath,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake render stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake render stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake render stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake render stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}

View File

@@ -0,0 +1,19 @@
package scriptorium
import (
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gopkg.in/yaml.v3"
)
func writeGeneratedYAML(path string, payload any) error {
data, err := yaml.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal generated YAML for %q: %w", path, err)
}
if err := fileops.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write generated YAML %q: %w", path, err)
}
return nil
}

View File

@@ -342,11 +342,11 @@ func writeInvocationConfig(path string, payload invocationPayload) error {
"render_format": payload.RenderFormat,
"render_prompt_logged": payload.RenderPromptStore,
}
return subprocess.WriteYAMLAtomic(path, data, fileops.WorkspaceFileMode)
return writeGeneratedYAML(path, data)
}
func validateNonEmptyOutput(path string) error {
data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes)
data, err := fileops.ReadFileBounded(path, MaxOutputFileBytes)
if err != nil {
return fmt.Errorf("scriptorium artifact output exceeds or cannot be read within %d-byte limit: %w", MaxOutputFileBytes, err)
}

View File

@@ -6,16 +6,12 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success")
@@ -95,9 +91,6 @@ func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *tes
}
func TestSubprocessRunnerRunExitCodeOneFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit1")
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -118,9 +111,6 @@ func TestSubprocessRunnerRunExitCodeOneFails(t *testing.T) {
}
func TestSubprocessRunnerRunExitCodeTwoReturnsValidationFailureAndPreservesOutput(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit2_with_output")
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -144,9 +134,6 @@ func TestSubprocessRunnerRunExitCodeTwoReturnsValidationFailureAndPreservesOutpu
}
func TestSubprocessRunnerRunMissingOutputOnSuccessFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success_missing_output")
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -166,9 +153,6 @@ func TestSubprocessRunnerRunMissingOutputOnSuccessFails(t *testing.T) {
}
func TestSubprocessRunnerRenderSuccess(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
t.Setenv("SCRIPTORIUM_HELPER_MODE", "render_success")
recordPath := filepath.Join(t.TempDir(), "record.json")

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
@@ -261,7 +260,7 @@ func (f *FakeRunner) Render(ctx context.Context, req RenderRequest) (RenderResul
func materializePlaceholders(req MergeRequest) error {
if req.OutputMergedTranscriptPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write merged transcript %q: %w", req.OutputMergedTranscriptPath, err)
}
}
@@ -272,22 +271,22 @@ func materializePlaceholders(req MergeRequest) error {
"input_transcript_paths": req.InputTranscriptPaths,
"output_path": req.OutputMergedTranscriptPath,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
if req.ReportPath != "" {
if err := subprocess.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write report %q: %w", req.ReportPath, err)
}
}
@@ -296,7 +295,7 @@ func materializePlaceholders(req MergeRequest) error {
func materializeTrimPlaceholders(req TrimRequest) error {
if req.OutputTrimmedPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputTrimmedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.OutputTrimmedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write trimmed transcript %q: %w", req.OutputTrimmedPath, err)
}
}
@@ -309,17 +308,17 @@ func materializeTrimPlaceholders(req TrimRequest) error {
"output_path": req.OutputTrimmedPath,
"keep_selector": req.KeepSelector,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake trim stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake trim stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake trim stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake trim stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
@@ -328,7 +327,7 @@ func materializeTrimPlaceholders(req TrimRequest) error {
func materializeNormalizePlaceholders(req NormalizeRequest) error {
if req.OutputNormalizedPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputNormalizedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.OutputNormalizedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write normalized transcript %q: %w", req.OutputNormalizedPath, err)
}
}
@@ -344,22 +343,22 @@ func materializeNormalizePlaceholders(req NormalizeRequest) error {
if req.ReportPath != "" {
payload["report_path"] = req.ReportPath
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake normalize stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake normalize stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake normalize stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake normalize stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
if req.ReportPath != "" {
if err := subprocess.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write report %q: %w", req.ReportPath, err)
}
}
@@ -368,7 +367,7 @@ func materializeNormalizePlaceholders(req NormalizeRequest) error {
func materializeRenderPlaceholders(req RenderRequest) error {
if req.OutputRenderedPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputRenderedPath, []byte("# Transcript\n\nRendered markdown placeholder.\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.OutputRenderedPath, []byte("# Transcript\n\nRendered markdown placeholder.\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write rendered transcript %q: %w", req.OutputRenderedPath, err)
}
}
@@ -385,17 +384,17 @@ func materializeRenderPlaceholders(req RenderRequest) error {
"include_segment_ids": req.IncludeSegmentIDs,
"include_metadata": req.IncludeMetadata,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake render stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake render stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake render stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake render stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}

View File

@@ -0,0 +1,19 @@
package seriatim
import (
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gopkg.in/yaml.v3"
)
func writeGeneratedYAML(path string, payload any) error {
data, err := yaml.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal generated YAML for %q: %w", path, err)
}
if err := fileops.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write generated YAML %q: %w", path, err)
}
return nil
}

View File

@@ -553,7 +553,7 @@ func (r *SubprocessRunner) writeMergeInvocationConfig(req MergeRequest, args []s
payload["coalesce_gap"] = *r.coalesceGap
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
return writeGeneratedYAML(req.GeneratedConfigPath, payload)
}
func buildTrimArgs(req TrimRequest) []string {
@@ -605,7 +605,7 @@ func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, ti
"output_path": req.OutputTrimmedPath,
"keep_selector": req.KeepSelector,
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
return writeGeneratedYAML(req.GeneratedConfigPath, payload)
}
func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary string, timeout time.Duration, outputSchema string) error {
@@ -620,7 +620,7 @@ func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary
"output_schema": outputSchema,
"report_path": req.ReportPath,
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
return writeGeneratedYAML(req.GeneratedConfigPath, payload)
}
func writeRenderInvocationConfig(req RenderRequest, args []string, binary string, timeout time.Duration, format string) error {
@@ -638,7 +638,7 @@ func writeRenderInvocationConfig(req RenderRequest, args []string, binary string
"include_segment_ids": req.IncludeSegmentIDs,
"include_metadata": req.IncludeMetadata,
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
return writeGeneratedYAML(req.GeneratedConfigPath, payload)
}
func validateJSONFile(path string) error {
@@ -692,7 +692,7 @@ func validateNonEmptyTextFile(path string) error {
}
func readSeriatimResult(path, category string) ([]byte, error) {
data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes)
data, err := fileops.ReadFileBounded(path, MaxOutputFileBytes)
if err != nil {
return nil, fmt.Errorf("seriatim %s exceeds or cannot be read within %d-byte limit: %w", category, MaxOutputFileBytes, err)
}

View File

@@ -6,16 +6,12 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestSubprocessRunnerSuccessWithReportArgsAndEnv(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "success")
@@ -131,9 +127,6 @@ func TestSubprocessRunnerSuccessWithReportArgsAndEnv(t *testing.T) {
}
func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "fail")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -153,9 +146,6 @@ func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
}
func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -172,9 +162,6 @@ func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
}
func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "invalid_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -191,9 +178,6 @@ func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
}
func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "invalid_report")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -210,9 +194,6 @@ func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
}
func TestSubprocessRunnerTrimSuccessInvocationAndProvenance(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "trim_success")
@@ -297,9 +278,6 @@ func TestSubprocessRunnerTrimSuccessInvocationAndProvenance(t *testing.T) {
}
func TestSubprocessRunnerTrimSubprocessFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "fail")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -319,9 +297,6 @@ func TestSubprocessRunnerTrimSubprocessFailure(t *testing.T) {
}
func TestSubprocessRunnerTrimMissingOutputFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -338,9 +313,6 @@ func TestSubprocessRunnerTrimMissingOutputFails(t *testing.T) {
}
func TestSubprocessRunnerTrimInvalidOutputJSONFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "invalid_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -357,9 +329,6 @@ func TestSubprocessRunnerTrimInvalidOutputJSONFails(t *testing.T) {
}
func TestSubprocessRunnerTrimOutputMissingSegmentsFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "trim_missing_segments")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -376,9 +345,6 @@ func TestSubprocessRunnerTrimOutputMissingSegmentsFails(t *testing.T) {
}
func TestSubprocessRunnerNormalizeSuccessInvocationAndProvenance(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "normalize_success")
@@ -453,9 +419,6 @@ func TestSubprocessRunnerNormalizeSuccessInvocationAndProvenance(t *testing.T) {
}
func TestSubprocessRunnerNormalizeSubprocessFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "fail")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -475,9 +438,6 @@ func TestSubprocessRunnerNormalizeSubprocessFailure(t *testing.T) {
}
func TestSubprocessRunnerNormalizeMissingOutputFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "normalize_missing_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -494,9 +454,6 @@ func TestSubprocessRunnerNormalizeMissingOutputFails(t *testing.T) {
}
func TestSubprocessRunnerNormalizeInvalidOutputJSONFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "normalize_invalid_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -513,9 +470,6 @@ func TestSubprocessRunnerNormalizeInvalidOutputJSONFails(t *testing.T) {
}
func TestSubprocessRunnerNormalizeOutputMissingSegmentsFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "normalize_missing_segments")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -532,9 +486,6 @@ func TestSubprocessRunnerNormalizeOutputMissingSegmentsFails(t *testing.T) {
}
func TestSubprocessRunnerNormalizeReportMissingFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "normalize_report_missing")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -551,9 +502,6 @@ func TestSubprocessRunnerNormalizeReportMissingFails(t *testing.T) {
}
func TestSubprocessRunnerNormalizeInvalidReportJSONFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "normalize_invalid_report")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -570,9 +518,6 @@ func TestSubprocessRunnerNormalizeInvalidReportJSONFails(t *testing.T) {
}
func TestSubprocessRunnerRenderSuccessInvocationAndProvenance(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
@@ -639,9 +584,6 @@ func TestSubprocessRunnerRenderSuccessInvocationAndProvenance(t *testing.T) {
}
func TestSubprocessRunnerRenderWithoutTitleOmitsTitleArg(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
recordPath := filepath.Join(t.TempDir(), "record.json")
@@ -663,9 +605,6 @@ func TestSubprocessRunnerRenderWithoutTitleOmitsTitleArg(t *testing.T) {
}
func TestSubprocessRunnerRenderSubprocessFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "fail")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -682,9 +621,6 @@ func TestSubprocessRunnerRenderSubprocessFailure(t *testing.T) {
}
func TestSubprocessRunnerRenderMissingOutputFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
@@ -701,9 +637,6 @@ func TestSubprocessRunnerRenderMissingOutputFails(t *testing.T) {
}
func TestSubprocessRunnerRenderEmptyOutputFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "render_empty_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))

View File

@@ -19,14 +19,17 @@ import (
type FakeBackend struct {
mu sync.RWMutex
Objects map[string]FakeObject
Uploads []FakeUploadCall
Downloads []FakeDownloadCall
Reads []FakeReadCall
Objects map[string]FakeObject
Uploads []FakeUploadCall
Downloads []FakeDownloadCall
Reads []FakeReadCall
Deletes []string
ExistsKeys []string
ListErr error
DownloadErr error
UploadErr error
DeleteErr error
ExistsErr error
UploadHook func(FakeUploadCall) error
DownloadHook func(FakeDownloadCall) error
@@ -315,6 +318,25 @@ func fakeObjectETag(data []byte) string {
return hex.EncodeToString(sum[:])
}
// Delete removes one object. Removing an absent object succeeds.
func (f *FakeBackend) Delete(ctx context.Context, key string) error {
if err := ctx.Err(); err != nil {
return err
}
if f.DeleteErr != nil {
return f.DeleteErr
}
normalizedKey := normalizeObjectKey(key)
if normalizedKey == "" {
return fmt.Errorf("delete object: key is required")
}
f.mu.Lock()
defer f.mu.Unlock()
f.Deletes = append(f.Deletes, normalizedKey)
delete(f.Objects, normalizedKey)
return nil
}
// Exists checks object presence.
func (f *FakeBackend) Exists(ctx context.Context, key string) (bool, error) {
if err := ctx.Err(); err != nil {
@@ -323,8 +345,12 @@ func (f *FakeBackend) Exists(ctx context.Context, key string) (bool, error) {
if f.ExistsErr != nil {
return false, f.ExistsErr
}
normalizedKey := normalizeObjectKey(key)
f.mu.Lock()
f.ExistsKeys = append(f.ExistsKeys, normalizedKey)
f.mu.Unlock()
f.mu.RLock()
_, ok := f.Objects[normalizeObjectKey(key)]
_, ok := f.Objects[normalizedKey]
f.mu.RUnlock()
return ok, nil
}

View File

@@ -71,23 +71,55 @@ func TestFakeBackendUploadAndExists(t *testing.T) {
}
}
func TestFakeBackendDeleteIsIdempotent(t *testing.T) {
fake := &FakeBackend{}
fake.SeedObject(FakeObject{Key: "locks/source.lock", Data: []byte("lock")})
for range 2 {
if err := fake.Delete(context.Background(), `locks\source.lock`); err != nil {
t.Fatalf("Delete() error = %v", err)
}
}
if _, ok := fake.Objects["locks/source.lock"]; ok {
t.Fatal("Delete() left the object present")
}
if len(fake.Deletes) != 2 || fake.Deletes[0] != "locks/source.lock" || fake.Deletes[1] != "locks/source.lock" {
t.Fatalf("Delete() calls = %#v, want two normalized keys", fake.Deletes)
}
}
func TestFakeBackendDeleteHonorsContextAndInjectedError(t *testing.T) {
canceled, cancel := context.WithCancel(context.Background())
cancel()
fake := &FakeBackend{}
if err := fake.Delete(canceled, "locks/source.lock"); !errors.Is(err, context.Canceled) {
t.Fatalf("Delete() canceled error = %v, want context.Canceled", err)
}
wantErr := errors.New("delete fail")
fake.DeleteErr = wantErr
if err := fake.Delete(context.Background(), "locks/source.lock"); !errors.Is(err, wantErr) {
t.Fatalf("Delete() injected error = %v, want %v", err, wantErr)
}
}
func TestFakeBackendConditionalUploadRejectsStaleGeneration(t *testing.T) {
fake := &FakeBackend{}
fake.SeedObject(FakeObject{Key: "locks.yml", Data: []byte("old")})
old := fake.Objects["locks.yml"].ETag
if _, err := fake.UploadConditional(context.Background(), strings.NewReader("new"), "locks.yml", UploadOptions{}, WriteCondition{MatchETag: old}); err != nil {
fake.SeedObject(FakeObject{Key: "control.json", Data: []byte("old")})
old := fake.Objects["control.json"].ETag
if _, err := fake.UploadConditional(context.Background(), strings.NewReader("new"), "control.json", UploadOptions{}, WriteCondition{MatchETag: old}); err != nil {
t.Fatalf("UploadConditional() error = %v", err)
}
if _, err := fake.UploadConditional(context.Background(), strings.NewReader("lost"), "locks.yml", UploadOptions{}, WriteCondition{MatchETag: old}); !errors.Is(err, ErrConditionNotMet) {
if _, err := fake.UploadConditional(context.Background(), strings.NewReader("lost"), "control.json", UploadOptions{}, WriteCondition{MatchETag: old}); !errors.Is(err, ErrConditionNotMet) {
t.Fatalf("UploadConditional() error = %v, want ErrConditionNotMet", err)
}
if got := string(fake.Objects["locks.yml"].Data); got != "new" {
t.Fatalf("locks object = %q, want successful replacement preserved", got)
if got := string(fake.Objects["control.json"].Data); got != "new" {
t.Fatalf("control object = %q, want successful replacement preserved", got)
}
}
func TestFakeBackendObjectErrors(t *testing.T) {
fake := &FakeBackend{DownloadErr: errors.New("download fail"), UploadErr: errors.New("upload fail"), ListErr: errors.New("list fail"), ExistsErr: errors.New("exists fail")}
fake := &FakeBackend{DownloadErr: errors.New("download fail"), UploadErr: errors.New("upload fail"), ListErr: errors.New("list fail"), DeleteErr: errors.New("delete fail"), ExistsErr: errors.New("exists fail")}
if _, err := fake.List(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "list fail") {
t.Fatalf("List() error = %v, want list fail", err)
@@ -100,6 +132,9 @@ func TestFakeBackendObjectErrors(t *testing.T) {
if _, err := fake.Upload(context.Background(), local, "x", UploadOptions{}); err == nil || !strings.Contains(err.Error(), "upload fail") {
t.Fatalf("Upload() error = %v, want upload fail", err)
}
if err := fake.Delete(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "delete fail") {
t.Fatalf("Delete() error = %v, want delete fail", err)
}
if _, err := fake.Exists(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "exists fail") {
t.Fatalf("Exists() error = %v, want exists fail", err)
}

View File

@@ -28,6 +28,7 @@ type ObjectStore interface {
Download(ctx context.Context, key, localPath string) error
Upload(ctx context.Context, localPath, key string, opts UploadOptions) (ObjectInfo, error)
UploadConditional(ctx context.Context, source io.Reader, key string, opts UploadOptions, condition WriteCondition) (ObjectInfo, error)
Delete(ctx context.Context, key string) error
Exists(ctx context.Context, key string) (bool, error)
}

View File

@@ -23,6 +23,7 @@ type s3API interface {
ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error)
HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
}
@@ -36,8 +37,12 @@ type s3ClientOptions struct {
Region string
Endpoint string
ForcePathStyle bool
AccessKeyID string
SecretKey string
Credentials *s3StaticCredentials
}
type s3StaticCredentials struct {
AccessKeyID string
SecretAccessKey string
}
var newS3Client = func(ctx context.Context, opts s3ClientOptions) (s3API, error) {
@@ -45,11 +50,11 @@ var newS3Client = func(ctx context.Context, opts s3ClientOptions) (s3API, error)
if strings.TrimSpace(opts.Region) != "" {
loadOpts = append(loadOpts, awsconfig.WithRegion(strings.TrimSpace(opts.Region)))
}
if strings.TrimSpace(opts.AccessKeyID) != "" && strings.TrimSpace(opts.SecretKey) != "" {
if opts.Credentials != nil {
loadOpts = append(loadOpts, awsconfig.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(
strings.TrimSpace(opts.AccessKeyID),
strings.TrimSpace(opts.SecretKey),
opts.Credentials.AccessKeyID,
opts.Credentials.SecretAccessKey,
"",
),
))
@@ -74,14 +79,24 @@ func NewS3BackendFromConfig(ctx context.Context, cfg config.StorageS3Config) (*S
if bucket == "" {
return nil, fmt.Errorf("storage.s3.bucket is required")
}
if (cfg.AccessKeyID == nil) != (cfg.SecretAccessKey == nil) {
return nil, fmt.Errorf("storage.s3 access key ID and secret access key references must be configured together")
}
if cfg.AccessKeyID != nil && (!cfg.AccessKeyID.IsResolved() || !cfg.SecretAccessKey.IsResolved()) {
return nil, fmt.Errorf("storage.s3 configured credential references must be resolved")
}
client, err := newS3Client(ctx, s3ClientOptions{
clientOptions := s3ClientOptions{
Region: cfg.Region,
Endpoint: cfg.Endpoint,
ForcePathStyle: cfg.ForcePathStyle,
AccessKeyID: s3CredentialFromEnv(orDefaultEnvName(cfg.AccessKeyIDEnv, config.DefaultS3AccessKeyIDEnv)),
SecretKey: s3CredentialFromEnv(orDefaultEnvName(cfg.SecretKeyEnv, config.DefaultS3SecretAccessKeyEnv)),
})
}
if cfg.AccessKeyID != nil && cfg.SecretAccessKey != nil {
clientOptions.Credentials = &s3StaticCredentials{
AccessKeyID: cfg.AccessKeyID.Value(), SecretAccessKey: cfg.SecretAccessKey.Value(),
}
}
client, err := newS3Client(ctx, clientOptions)
if err != nil {
return nil, fmt.Errorf("build s3 client: %w", err)
}
@@ -92,26 +107,6 @@ func NewS3BackendFromConfig(ctx context.Context, cfg config.StorageS3Config) (*S
}, nil
}
func s3CredentialFromEnv(envVarName string) string {
name := strings.TrimSpace(envVarName)
if name == "" {
return ""
}
value, ok := os.LookupEnv(name)
if !ok {
return ""
}
return strings.TrimSpace(value)
}
func orDefaultEnvName(name, fallback string) string {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return fallback
}
return trimmed
}
// List returns objects under prefix.
func (b *S3Backend) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
normalizedPrefix := normalizeObjectKey(prefix)
@@ -300,6 +295,25 @@ func (b *S3Backend) uploadReader(ctx context.Context, source io.Reader, key stri
return info, nil
}
// Delete removes one object. S3 deletion is idempotent when the key is absent.
func (b *S3Backend) Delete(ctx context.Context, key string) error {
if err := ctx.Err(); err != nil {
return err
}
normalizedKey := normalizeObjectKey(key)
if normalizedKey == "" {
return fmt.Errorf("delete object: key is required")
}
_, err := b.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &b.bucket,
Key: &normalizedKey,
})
if err != nil {
return fmt.Errorf("delete object %q: %w", normalizedKey, err)
}
return nil
}
// Exists checks whether one object key exists.
func (b *S3Backend) Exists(ctx context.Context, key string) (bool, error) {
normalizedKey := normalizeObjectKey(key)

View File

@@ -14,6 +14,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
@@ -29,16 +30,18 @@ type fakeS3API struct {
getETag *string
getLastModified *time.Time
putOut *s3.PutObjectOutput
putErr error
putOut *s3.PutObjectOutput
putErr error
deleteErr error
headErr error
lastList *s3.ListObjectsV2Input
lastLists []*s3.ListObjectsV2Input
lastGet *s3.GetObjectInput
lastPut *s3.PutObjectInput
lastHead *s3.HeadObjectInput
lastList *s3.ListObjectsV2Input
lastLists []*s3.ListObjectsV2Input
lastGet *s3.GetObjectInput
lastPut *s3.PutObjectInput
lastDelete *s3.DeleteObjectInput
lastHead *s3.HeadObjectInput
}
func (f *fakeS3API) ListObjectsV2(_ context.Context, params *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
@@ -116,6 +119,14 @@ func (f *fakeS3API) PutObject(_ context.Context, params *s3.PutObjectInput, _ ..
return f.putOut, nil
}
func (f *fakeS3API) DeleteObject(_ context.Context, params *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) {
f.lastDelete = params
if f.deleteErr != nil {
return nil, f.deleteErr
}
return &s3.DeleteObjectOutput{}, nil
}
func (f *fakeS3API) HeadObject(_ context.Context, params *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
f.lastHead = params
if f.headErr != nil {
@@ -177,14 +188,14 @@ func TestS3BackendDownloadCreatesParentDirectory(t *testing.T) {
func TestS3BackendReadReturnsOpenedObjectMetadata(t *testing.T) {
lastModified := time.Date(2026, 8, 11, 1, 2, 3, 0, time.UTC)
client := &fakeS3API{
getBody: io.NopCloser(strings.NewReader("locks")),
getBody: io.NopCloser(strings.NewReader("state")),
getSize: int64Ptr(5),
getETag: strPtr(`"generation"`),
getLastModified: &lastModified,
}
backend := &S3Backend{bucket: "bucket-1", client: client}
info, body, err := backend.Read(context.Background(), `sessions\locks.yml`)
info, body, err := backend.Read(context.Background(), `sessions\control.json`)
if err != nil {
t.Fatalf("Read() error = %v", err)
}
@@ -193,10 +204,10 @@ func TestS3BackendReadReturnsOpenedObjectMetadata(t *testing.T) {
if readErr != nil || closeErr != nil {
t.Fatalf("read body error=%v close error=%v", readErr, closeErr)
}
if info.Key != "sessions/locks.yml" || info.Size != 5 || info.ETag != "generation" || info.LastModified == nil || !info.LastModified.Equal(lastModified) {
if info.Key != "sessions/control.json" || info.Size != 5 || info.ETag != "generation" || info.LastModified == nil || !info.LastModified.Equal(lastModified) {
t.Fatalf("Read() info = %#v, want opened object metadata", info)
}
if string(data) != "locks" || client.lastGet == nil || *client.lastGet.Key != "sessions/locks.yml" {
if string(data) != "state" || client.lastGet == nil || *client.lastGet.Key != "sessions/control.json" {
t.Fatalf("Read() data=%q request=%#v", data, client.lastGet)
}
}
@@ -238,14 +249,14 @@ func TestS3BackendUploadAndExists(t *testing.T) {
func TestS3BackendConditionalUploadUsesProviderPrecondition(t *testing.T) {
client := &fakeS3API{putOut: &s3.PutObjectOutput{ETag: strPtr(`"etag123"`)}}
backend := &S3Backend{bucket: "bucket-1", client: client}
if _, err := backend.UploadConditional(context.Background(), strings.NewReader("payload"), "locks.yml", UploadOptions{}, WriteCondition{MatchETag: "before"}); err != nil {
if _, err := backend.UploadConditional(context.Background(), strings.NewReader("payload"), "control.json", UploadOptions{}, WriteCondition{MatchETag: "before"}); err != nil {
t.Fatalf("UploadConditional() error = %v", err)
}
if client.lastPut == nil || client.lastPut.IfMatch == nil || *client.lastPut.IfMatch != "before" || client.lastPut.IfNoneMatch != nil {
t.Fatalf("PutObject conditional input = %#v", client.lastPut)
}
client.putErr = &smithy.GenericAPIError{Code: "PreconditionFailed", Message: "changed"}
_, err := backend.UploadConditional(context.Background(), strings.NewReader("payload"), "locks.yml", UploadOptions{}, WriteCondition{RequireAbsent: true})
_, err := backend.UploadConditional(context.Background(), strings.NewReader("payload"), "control.json", UploadOptions{}, WriteCondition{RequireAbsent: true})
if !errors.Is(err, ErrConditionNotMet) {
t.Fatalf("UploadConditional() error = %v, want ErrConditionNotMet", err)
}
@@ -275,11 +286,44 @@ func TestS3BackendExistsNotFound(t *testing.T) {
}
}
func TestS3BackendDeleteMapsRequestAndErrors(t *testing.T) {
client := &fakeS3API{}
backend := &S3Backend{bucket: "bucket-1", client: client}
if err := backend.Delete(context.Background(), `locks\source.lock`); err != nil {
t.Fatalf("Delete() error = %v", err)
}
if client.lastDelete == nil || *client.lastDelete.Bucket != "bucket-1" || *client.lastDelete.Key != "locks/source.lock" {
t.Fatalf("DeleteObject input = %#v, want normalized bucket and key", client.lastDelete)
}
wantErr := errors.New("permission denied")
client.deleteErr = wantErr
err := backend.Delete(context.Background(), "locks/source.lock")
if !errors.Is(err, wantErr) || !strings.Contains(err.Error(), "locks/source.lock") {
t.Fatalf("Delete() error = %v, want contextual backend error", err)
}
}
func TestS3BackendDeleteHonorsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
client := &fakeS3API{}
err := (&S3Backend{bucket: "bucket-1", client: client}).Delete(ctx, "locks/source.lock")
if !errors.Is(err, context.Canceled) {
t.Fatalf("Delete() error = %v, want context.Canceled", err)
}
if client.lastDelete != nil {
t.Fatal("Delete() called S3 after cancellation")
}
}
func TestNewS3BackendFromConfigUsesClientOptions(t *testing.T) {
original := newS3Client
t.Cleanup(func() { newS3Client = original })
t.Setenv("OBJECT_STORAGE_KEY_ID", "id-123")
t.Setenv("OBJECT_STORAGE_KEY", "secret-abc")
accessKey := resolvedTestSecret(t, "NARRATIO_S3_TEST_ACCESS", "id-123")
secretKey := resolvedTestSecret(t, "NARRATIO_S3_TEST_SECRET", "secret-abc")
t.Setenv("NARRATIO_S3_TEST_ACCESS", "changed-id")
t.Setenv("NARRATIO_S3_TEST_SECRET", "changed-secret")
var got s3ClientOptions
newS3Client = func(_ context.Context, opts s3ClientOptions) (s3API, error) {
@@ -288,10 +332,12 @@ func TestNewS3BackendFromConfigUsesClientOptions(t *testing.T) {
}
backend, err := NewS3BackendFromConfig(context.Background(), config.StorageS3Config{
Bucket: "my-archive",
Region: "us-east-1",
Endpoint: "http://localhost:9000",
ForcePathStyle: true,
Bucket: "my-archive",
Region: "us-east-1",
Endpoint: "http://localhost:9000",
ForcePathStyle: true,
AccessKeyID: accessKey,
SecretAccessKey: secretKey,
})
if err != nil {
t.Fatalf("NewS3BackendFromConfig() error = %v", err)
@@ -302,7 +348,7 @@ func TestNewS3BackendFromConfigUsesClientOptions(t *testing.T) {
if got.Region != "us-east-1" || got.Endpoint != "http://localhost:9000" || !got.ForcePathStyle {
t.Fatalf("client options = %#v, want region/endpoint/path-style values", got)
}
if got.AccessKeyID != "id-123" || got.SecretKey != "secret-abc" {
if got.Credentials == nil || got.Credentials.AccessKeyID != "id-123" || got.Credentials.SecretAccessKey != "secret-abc" {
t.Fatalf("client options credentials = %#v, want env-resolved static credentials", got)
}
}
@@ -314,7 +360,21 @@ func TestNewS3BackendFromConfigRequiresBucket(t *testing.T) {
}
}
func TestNewS3BackendFromConfigFallsBackWhenCredentialEnvMissing(t *testing.T) {
func TestNewS3BackendFromConfigRejectsIncompleteOrUnresolvedCredentials(t *testing.T) {
unresolved := configkit.SecretEnv("NARRATIO_UNRESOLVED_S3_KEY")
for name, cfg := range map[string]config.StorageS3Config{
"partial": {Bucket: "my-archive", AccessKeyID: &unresolved},
"unresolved": {Bucket: "my-archive", AccessKeyID: &unresolved, SecretAccessKey: &unresolved},
} {
t.Run(name, func(t *testing.T) {
if _, err := NewS3BackendFromConfig(context.Background(), cfg); err == nil {
t.Fatal("NewS3BackendFromConfig() succeeded, want credential error")
}
})
}
}
func TestNewS3BackendFromConfigUsesDefaultProviderWithoutCredentialReferences(t *testing.T) {
original := newS3Client
t.Cleanup(func() { newS3Client = original })
@@ -325,19 +385,30 @@ func TestNewS3BackendFromConfigFallsBackWhenCredentialEnvMissing(t *testing.T) {
}
_, err := NewS3BackendFromConfig(context.Background(), config.StorageS3Config{
Bucket: "my-archive",
Region: "us-east-1",
AccessKeyIDEnv: "MISSING_ACCESS_KEY_ID",
SecretKeyEnv: "MISSING_SECRET_KEY",
Bucket: "my-archive",
Region: "us-east-1",
})
if err != nil {
t.Fatalf("NewS3BackendFromConfig() error = %v", err)
}
if got.AccessKeyID != "" || got.SecretKey != "" {
t.Fatalf("client options credentials = %#v, want empty fallback values", got)
if got.Credentials != nil {
t.Fatalf("client options credentials = %#v, want default provider chain", got.Credentials)
}
}
func resolvedTestSecret(t *testing.T, name, value string) *configkit.Secret {
t.Helper()
t.Setenv(name, value)
type document struct {
Secret *configkit.Secret `yaml:"secret"`
}
result, err := configkit.Load(configkit.Bytes("test.yml", []byte("secret: "+name+"\n")), configkit.Options[document]{})
if err != nil {
t.Fatalf("resolve test secret: %v", err)
}
return result.Config.Secret
}
func strPtr(v string) *string { return &v }
func int64Ptr(v int64) *int64 { return &v }
func boolPtr(v bool) *bool { return &v }

View File

@@ -12,9 +12,9 @@ import (
func TestDownloadObjectToTempSuccess(t *testing.T) {
store := &FakeBackend{}
store.SeedObject(FakeObject{Key: "sessions/a/current/run_id.txt", Data: []byte("run-123\n")})
store.SeedObject(FakeObject{Key: "sessions/a/runs/run-123/metadata.txt", Data: []byte("run-123\n")})
path, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/current/run_id.txt", "narratio-test-*.txt")
path, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/runs/run-123/metadata.txt", "narratio-test-*.txt")
if err != nil {
t.Fatalf("DownloadObjectToTemp() error = %v", err)
}
@@ -38,7 +38,7 @@ func TestDownloadObjectToTempFailedDownloadRemovesTempFile(t *testing.T) {
t.Fatalf("Glob(before) error = %v", err)
}
path, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/current/run_id.txt", pattern)
path, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/runs/run-123/metadata.txt", pattern)
if !errors.Is(err, sentinel) {
t.Fatalf("DownloadObjectToTemp() error = %v, want %v", err, sentinel)
}
@@ -58,7 +58,7 @@ func TestDownloadObjectToTempCallerContextWrappingPreservesCause(t *testing.T) {
sentinel := errors.New("object missing")
store := &FakeBackend{DownloadErr: sentinel}
_, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/current/run_id.txt", "narratio-test-*.txt")
_, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/runs/run-123/metadata.txt", "narratio-test-*.txt")
if err == nil {
t.Fatal("DownloadObjectToTemp() error = nil, want error")
}

View File

@@ -72,7 +72,7 @@ type logWriters struct {
Stdout io.Writer
Stderr io.Writer
limits chan *captureLimitError
limits chan error
mu sync.Mutex
limit *captureLimitError
stdout *diagnosticWriter
@@ -92,7 +92,7 @@ type diagnosticWriter struct {
}
func openLogWriters(stdoutPath, stderrPath, owner string, sensitiveValues []string) (*logWriters, error) {
logs := &logWriters{limits: make(chan *captureLimitError, 1)}
logs := &logWriters{limits: make(chan error, 1)}
cleanStdout := cleanLogPath(stdoutPath)
cleanStderr := cleanLogPath(stderrPath)
@@ -221,7 +221,7 @@ func (w *diagnosticWriter) reachLimit() error {
return limit
}
func (l *logWriters) Limits() <-chan *captureLimitError { return l.limits }
func (l *logWriters) Limits() <-chan error { return l.limits }
func (l *logWriters) Limit() *captureLimitError {
l.mu.Lock()
@@ -255,7 +255,7 @@ func openDiagnosticFile(path string) (*os.File, error) {
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil {
return nil, fmt.Errorf("create log directory for %q: %w", path, err)
}
file, err := fileops.OpenFileConfined(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileops.WorkspaceFileMode)
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileops.WorkspaceFileMode)
if err != nil {
return nil, fmt.Errorf("open log file %q: %w", path, err)
}

View File

@@ -1,3 +1,4 @@
// Package subprocess provides reusable process execution and generated-config helpers.
// Package subprocess provides internal process execution and Narratio-specific
// subprocess policy. Its private execution mechanism owns process lifecycle;
// the Run boundary owns environments, diagnostics, redaction, and errors.
package subprocess

View File

@@ -0,0 +1,91 @@
package subprocess
import (
"context"
"fmt"
"os/exec"
"time"
)
type executionReason uint8
const (
executionNotStarted executionReason = iota
executionNaturalExit
executionCanceled
executionDeadline
executionAborted
)
type executionOutcome struct {
reason executionReason
startedAt time.Time
completedAt time.Time
exitCode int
startErr error
waitErr error
contextErr error
abortErr error
cleanupErr error
}
func executeOwnedCommand(ctx context.Context, cmd *exec.Cmd, abort <-chan error) (outcome executionOutcome) {
outcome = executionOutcome{startedAt: time.Now().UTC(), exitCode: -1}
defer func() { outcome.completedAt = time.Now().UTC() }()
if outcome.contextErr = ctx.Err(); outcome.contextErr != nil {
outcome.reason = executionNotStarted
return outcome
}
tree, err := newOwnedProcessTree()
if err != nil {
outcome.reason, outcome.startErr = executionNotStarted, err
return outcome
}
defer func() { outcome.cleanupErr = joinErrors(outcome.cleanupErr, tree.Dispose()) }()
cmd.WaitDelay = forcefulTerminationWait
if outcome.startErr = tree.Start(cmd); outcome.startErr != nil {
outcome.reason = executionNotStarted
return outcome
}
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
select {
case outcome.waitErr = <-waitCh:
outcome.reason = executionNaturalExit
case <-ctx.Done():
outcome.contextErr = ctx.Err()
if outcome.contextErr == context.DeadlineExceeded {
outcome.reason = executionDeadline
} else {
outcome.reason = executionCanceled
}
outcome.waitErr, outcome.cleanupErr = terminateAndReap(tree, waitCh)
case outcome.abortErr = <-abort:
outcome.reason = executionAborted
outcome.waitErr, outcome.cleanupErr = terminateAndReap(tree, waitCh)
}
if cmd.ProcessState != nil {
outcome.exitCode = cmd.ProcessState.ExitCode()
}
return outcome
}
func terminateAndReap(tree ownedProcessTree, waitCh <-chan error) (waitErr, cleanupErr error) {
cleanupErr = tree.TerminateGracefully()
timer := time.NewTimer(gracefulTerminationWait)
defer timer.Stop()
select {
case waitErr = <-waitCh:
return waitErr, joinErrors(cleanupErr, tree.TerminateForcefully())
case <-timer.C:
}
cleanupErr = joinErrors(cleanupErr, tree.TerminateForcefully())
forcefulTimer := time.NewTimer(forcefulTerminationWait)
defer forcefulTimer.Stop()
select {
case waitErr = <-waitCh:
return waitErr, cleanupErr
case <-forcefulTimer.C:
return nil, joinErrors(cleanupErr, fmt.Errorf("owned subprocess did not reap within %s after forceful termination", forcefulTerminationWait))
}
}

View File

@@ -0,0 +1,54 @@
package subprocess
import (
"errors"
"os/exec"
"testing"
)
func TestTerminateAndReapPreservesWaitAndCleanupErrors(t *testing.T) {
waitErr := errors.New("wait failed")
gracefulErr := errors.New("graceful termination failed")
forcefulErr := errors.New("forceful termination failed")
tree := &recordingProcessTree{
gracefulErr: gracefulErr,
forcefulErr: forcefulErr,
}
waitCh := make(chan error, 1)
waitCh <- waitErr
gotWaitErr, gotCleanupErr := terminateAndReap(tree, waitCh)
if !errors.Is(gotWaitErr, waitErr) {
t.Fatalf("wait error = %v, want %v", gotWaitErr, waitErr)
}
for _, want := range []error{gracefulErr, forcefulErr} {
if !errors.Is(gotCleanupErr, want) {
t.Fatalf("cleanup error = %v, want wrapped %v", gotCleanupErr, want)
}
}
if tree.gracefulCalls != 1 || tree.forcefulCalls != 1 {
t.Fatalf("termination calls = graceful %d, forceful %d; want 1 each", tree.gracefulCalls, tree.forcefulCalls)
}
}
type recordingProcessTree struct {
gracefulErr error
forcefulErr error
gracefulCalls int
forcefulCalls int
}
func (*recordingProcessTree) Start(*exec.Cmd) error { return nil }
func (tree *recordingProcessTree) TerminateGracefully() error {
tree.gracefulCalls++
return tree.gracefulErr
}
func (tree *recordingProcessTree) TerminateForcefully() error {
tree.forcefulCalls++
return tree.forcefulErr
}
func (*recordingProcessTree) Dispose() error { return nil }

View File

@@ -1,9 +1,7 @@
package subprocess
import (
"context"
"errors"
"fmt"
"os/exec"
"time"
)
@@ -22,39 +20,6 @@ type ownedProcessTree interface {
Dispose() error
}
func waitForOwnedCommand(ctx context.Context, tree ownedProcessTree, waitCh <-chan error, captureLimits <-chan *captureLimitError) (waitErr, ctxErr error, captureLimit *captureLimitError, cleanupErr error) {
select {
case waitErr = <-waitCh:
return waitErr, nil, nil, nil
case <-ctx.Done():
ctxErr = ctx.Err()
case captureLimit = <-captureLimits:
}
cleanupErr = tree.TerminateGracefully()
gracefulTimer := time.NewTimer(gracefulTerminationWait)
defer gracefulTimer.Stop()
select {
case waitErr = <-waitCh:
// The leader may exit before descendants finish graceful shutdown.
cleanupErr = joinErrors(cleanupErr, tree.TerminateForcefully())
return waitErr, ctxErr, captureLimit, cleanupErr
case <-gracefulTimer.C:
}
cleanupErr = joinErrors(cleanupErr, tree.TerminateForcefully())
forcefulTimer := time.NewTimer(forcefulTerminationWait)
defer forcefulTimer.Stop()
select {
case waitErr = <-waitCh:
return waitErr, ctxErr, captureLimit, cleanupErr
case <-forcefulTimer.C:
return nil, ctxErr, captureLimit, joinErrors(cleanupErr, fmt.Errorf("owned subprocess did not reap within %s after forceful termination", forcefulTerminationWait))
}
}
func joinErrors(errs ...error) error {
filtered := make([]error, 0, len(errs))
for _, err := range errs {

View File

@@ -1,4 +1,4 @@
//go:build linux || darwin || windows
//go:build linux || darwin
package subprocess

View File

@@ -1,10 +1,9 @@
//go:build !linux && !darwin && !windows
//go:build !linux && !darwin
package subprocess
import (
"fmt"
"runtime"
)
func newOwnedProcessTree() (ownedProcessTree, error) {

View File

@@ -1,122 +0,0 @@
//go:build windows
package subprocess
import (
"errors"
"fmt"
"os/exec"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
type windowsProcessTree struct {
job windows.Handle
}
func newOwnedProcessTree() (ownedProcessTree, error) {
return &windowsProcessTree{}, nil
}
func (tree *windowsProcessTree) Start(cmd *exec.Cmd) error {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return fmt.Errorf("create job object: %w", err)
}
limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil {
_ = windows.CloseHandle(job)
return fmt.Errorf("configure job object: %w", err)
}
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED}
if err := cmd.Start(); err != nil {
_ = windows.CloseHandle(job)
return err
}
process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
if err == nil {
err = windows.AssignProcessToJobObject(job, process)
_ = windows.CloseHandle(process)
}
if err == nil {
err = resumeInitialThread(uint32(cmd.Process.Pid))
}
if err != nil {
killErr := cmd.Process.Kill()
waitErr := cmd.Wait()
_ = windows.CloseHandle(job)
return joinErrors(fmt.Errorf("assign process to job object: %w", err), killErr, waitErr)
}
tree.job = job
return nil
}
func resumeInitialThread(processID uint32) error {
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
if err != nil {
return fmt.Errorf("snapshot initial thread: %w", err)
}
defer func() { _ = windows.CloseHandle(snapshot) }()
entry := windows.ThreadEntry32{Size: uint32(unsafe.Sizeof(windows.ThreadEntry32{}))}
if err := windows.Thread32First(snapshot, &entry); err != nil {
return fmt.Errorf("find initial thread: %w", err)
}
for {
if entry.OwnerProcessID != processID {
// Keep enumerating until the suspended process's only initial thread
// is found.
} else {
thread, openErr := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
if openErr != nil {
return fmt.Errorf("open initial thread: %w", openErr)
}
defer func() { _ = windows.CloseHandle(thread) }()
if _, resumeErr := windows.ResumeThread(thread); resumeErr != nil {
return fmt.Errorf("resume initial thread: %w", resumeErr)
}
return nil
}
if err := windows.Thread32Next(snapshot, &entry); err != nil {
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
break
}
return fmt.Errorf("find initial thread: %w", err)
}
}
return fmt.Errorf("find initial thread: no thread found for process %d", processID)
}
func (tree *windowsProcessTree) TerminateGracefully() error {
// Windows jobs have no portable graceful signal. Terminating the owned job
// is the safe fallback and prevents a descendant from escaping cleanup.
return tree.terminate()
}
func (tree *windowsProcessTree) TerminateForcefully() error {
return tree.terminate()
}
func (tree *windowsProcessTree) Dispose() error {
if tree.job == 0 {
return nil
}
err := windows.CloseHandle(tree.job)
tree.job = 0
return err
}
func (tree *windowsProcessTree) terminate() error {
if tree.job == 0 {
return nil
}
return windows.TerminateJobObject(tree.job, 1)
}

View File

@@ -7,9 +7,6 @@ import (
"os/exec"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gopkg.in/yaml.v3"
)
// RunRequest defines a subprocess invocation.
@@ -60,63 +57,23 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
}
defer logs.Close()
tree, err := newOwnedProcessTree()
if err != nil {
return RunResult{}, fmt.Errorf("prepare owned subprocess tree: %w", err)
}
cmd := exec.Command(req.Executable, req.Args...)
cmd.Dir = req.WorkingDir
cmd.Env = childEnv
cmd.Stdout = logs.Stdout
cmd.Stderr = logs.Stderr
// Streaming capture uses pipes. Bound their lifetime when a leader exits
// while a descendant still holds a stream descriptor.
cmd.WaitDelay = forcefulTerminationWait
started := time.Now().UTC()
result := RunResult{
ExitCode: -1,
StartedAt: started,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
}
if err := runCtx.Err(); err != nil {
result.CompletedAt = time.Now().UTC()
result.Duration = result.CompletedAt.Sub(result.StartedAt)
return result, fmt.Errorf("command was not started: %w", err)
}
if err := tree.Start(cmd); err != nil {
result.CompletedAt = time.Now().UTC()
result.Duration = result.CompletedAt.Sub(result.StartedAt)
return result, fmt.Errorf("start command %q with args %v: %w", req.Executable, req.Args, err)
}
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
waitErr, ctxErr, captureLimit, cleanupErr := waitForOwnedCommand(runCtx, tree, waitCh, logs.Limits())
cleanupErr = joinErrors(cleanupErr, tree.Dispose())
cleanupErr = joinErrors(cleanupErr, logs.Flush())
outcome := executeOwnedCommand(runCtx, cmd, logs.Limits())
cleanupErr := joinErrors(outcome.cleanupErr, logs.Flush())
captureLimit, _ := outcome.abortErr.(*captureLimitError)
if captureLimit == nil {
captureLimit = logs.Limit()
}
result.CompletedAt = time.Now().UTC()
result := RunResult{ExitCode: outcome.exitCode, StartedAt: outcome.startedAt, CompletedAt: outcome.completedAt, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath}
result.Duration = result.CompletedAt.Sub(result.StartedAt)
if cmd.ProcessState != nil {
result.ExitCode = cmd.ProcessState.ExitCode()
}
result.TimedOut = outcome.reason == executionDeadline
result.Canceled = outcome.reason == executionCanceled
if ctxErr == context.DeadlineExceeded {
result.TimedOut = true
}
if ctxErr == context.Canceled && !result.TimedOut {
result.Canceled = true
}
if waitErr == nil && ctxErr == nil && cleanupErr == nil {
if outcome.reason == executionNaturalExit && outcome.waitErr == nil && cleanupErr == nil {
return result, nil
}
@@ -124,48 +81,28 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
diagnostics := buildDiagnostics(req, result, stderrTail)
if captureLimit != nil {
if cause := joinErrors(waitErr, cleanupErr); cause != nil {
if cause := joinErrors(outcome.waitErr, cleanupErr); cause != nil {
return result, fmt.Errorf("%w (%s): %w", captureLimit, diagnostics, cause)
}
return result, fmt.Errorf("%w (%s)", captureLimit, diagnostics)
}
if result.TimedOut {
return result, fmt.Errorf("command timed out after %s (%s): %w", req.Timeout, diagnostics, joinErrors(ctxErr, waitErr, cleanupErr))
return result, fmt.Errorf("command timed out after %s (%s): %w", req.Timeout, diagnostics, joinErrors(outcome.contextErr, outcome.waitErr, cleanupErr))
}
if result.Canceled {
return result, fmt.Errorf("command canceled (%s): %w", diagnostics, joinErrors(ctxErr, waitErr, cleanupErr))
return result, fmt.Errorf("command canceled (%s): %w", diagnostics, joinErrors(outcome.contextErr, outcome.waitErr, cleanupErr))
}
if exitErr, ok := waitErr.(*exec.ExitError); ok {
return result, fmt.Errorf("command failed with exit code %d (%s): %w", exitErr.ExitCode(), diagnostics, joinErrors(waitErr, cleanupErr))
if exitErr, ok := outcome.waitErr.(*exec.ExitError); ok {
return result, fmt.Errorf("command failed with exit code %d (%s): %w", exitErr.ExitCode(), diagnostics, joinErrors(outcome.waitErr, cleanupErr))
}
if cleanupErr != nil {
return result, fmt.Errorf("command cleanup failed (%s): %w", diagnostics, joinErrors(waitErr, cleanupErr))
return result, fmt.Errorf("command cleanup failed (%s): %w", diagnostics, joinErrors(outcome.waitErr, cleanupErr))
}
return result, fmt.Errorf("command failed to run (%s): %w", diagnostics, joinErrors(waitErr, cleanupErr))
}
// WriteYAMLAtomic marshals value as YAML and atomically writes it to path.
func WriteYAMLAtomic(path string, value any, perm os.FileMode) error {
data, err := yaml.Marshal(value)
if err != nil {
return fmt.Errorf("marshal yaml for %q: %w", path, err)
if outcome.startErr != nil {
return result, fmt.Errorf("start command %q with args %v: %w", req.Executable, req.Args, outcome.startErr)
}
if err := WriteFileAtomic(path, data, perm); err != nil {
return fmt.Errorf("write yaml %q: %w", path, err)
}
return nil
}
// WriteFileAtomic writes bytes through the shared durable replacement primitive.
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("write file: path is required")
}
if err := fileops.WriteFileAtomic(path, data, perm); err != nil {
return fmt.Errorf("write file %q: %w", path, err)
}
return nil
return result, fmt.Errorf("command failed to run (%s): %w", diagnostics, joinErrors(outcome.waitErr, outcome.contextErr, outcome.abortErr, cleanupErr))
}
func buildDiagnostics(req RunRequest, result RunResult, stderrTail string) string {

View File

@@ -8,14 +8,11 @@ import (
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"testing"
"time"
"gopkg.in/yaml.v3"
)
func TestRunSuccessCapturesStdoutStderr(t *testing.T) {
@@ -139,9 +136,6 @@ func TestRunFailureRedactsSensitiveTail(t *testing.T) {
}
func TestRunRejectsSymlinkDiagnosticWithoutTruncatingTarget(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating symlinks requires privileges that are not available on every Windows runner")
}
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable() error = %v", err)
@@ -165,15 +159,15 @@ func TestRunRejectsSymlinkDiagnosticWithoutTruncatingTarget(t *testing.T) {
StdoutLogPath: stdoutPath,
StderrLogPath: filepath.Join(dir, "stderr.log"),
})
if err == nil || !strings.Contains(err.Error(), "symbolic link") {
t.Fatalf("Run() error = %v, want symbolic-link rejection", err)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
data, readErr := os.ReadFile(targetPath)
if readErr != nil {
t.Fatalf("ReadFile(target) error = %v", readErr)
}
if string(data) != original {
t.Fatalf("target content = %q, want %q", data, original)
if string(data) == original {
t.Fatal("diagnostic target was not replaced")
}
}
@@ -382,7 +376,7 @@ func TestStreamRedactorHandlesSplitAndOverlappingSecrets(t *testing.T) {
}
func TestDiagnosticWriterHonorsExactLimitAndCapPlusOne(t *testing.T) {
exactLogs := &logWriters{limits: make(chan *captureLimitError, 1)}
exactLogs := &logWriters{limits: make(chan error, 1)}
var exactOutput bytes.Buffer
exact := newDiagnosticWriter(exactLogs, "stdout", "test", &exactOutput, 5, nil)
if _, err := exact.Write([]byte("abcde")); err != nil {
@@ -398,7 +392,7 @@ func TestDiagnosticWriterHonorsExactLimitAndCapPlusOne(t *testing.T) {
t.Fatal("exact write recorded a capture limit")
}
cappedLogs := &logWriters{limits: make(chan *captureLimitError, 1)}
cappedLogs := &logWriters{limits: make(chan error, 1)}
var cappedOutput bytes.Buffer
capped := newDiagnosticWriter(cappedLogs, "stderr", "test", &cappedOutput, 5, nil)
if _, err := capped.Write([]byte("abcdef")); err == nil {
@@ -416,7 +410,7 @@ func TestDiagnosticWriterHonorsExactLimitAndCapPlusOne(t *testing.T) {
}
func TestDiagnosticWriterRetainsBoundedRedactedTail(t *testing.T) {
logs := &logWriters{limits: make(chan *captureLimitError, 1)}
logs := &logWriters{limits: make(chan error, 1)}
var output bytes.Buffer
secret := "credential-value"
writer := newDiagnosticWriter(logs, "stderr", "test", &output, 16*1024, []string{secret})
@@ -592,59 +586,6 @@ func TestRunSharedStdoutStderrLogPath(t *testing.T) {
}
}
func TestWriteYAMLAtomic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.generated.yml")
if err := WriteYAMLAtomic(path, map[string]any{"name": "narratio", "stage": "merge"}, 0o644); err != nil {
t.Fatalf("WriteYAMLAtomic() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read yaml: %v", err)
}
var got map[string]any
if err := yaml.Unmarshal(data, &got); err != nil {
t.Fatalf("yaml unmarshal: %v", err)
}
if got["name"] != "narratio" {
t.Fatalf("name = %#v, want narratio", got["name"])
}
}
func TestWriteYAMLAtomicOverwriteNoTempResidue(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.generated.yml")
if err := WriteYAMLAtomic(path, map[string]any{"value": "one"}, 0o644); err != nil {
t.Fatalf("first write: %v", err)
}
if err := WriteYAMLAtomic(path, map[string]any{"value": "two"}, 0o644); err != nil {
t.Fatalf("second write: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read yaml: %v", err)
}
if !strings.Contains(string(data), "two") {
t.Fatalf("yaml = %q, want overwritten value", string(data))
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir() error = %v", err)
}
for _, entry := range entries {
name := entry.Name()
if strings.Contains(name, ".tmp-") {
t.Fatalf("temp file residue found: %q", name)
}
}
}
func TestSubprocessHelper(t *testing.T) {
if os.Getenv("GO_WANT_SUBPROCESS_HELPER") != "1" {
return

View File

@@ -60,10 +60,14 @@ func resolveEffectiveArtifacts(cfg *config.Config, selected []string) (artifacts
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires pipeline.scriptorium.artifacts to be configured")
}
configured := artifacts.ConfiguredArtifactDefinitions(cfg.Pipeline.Scriptorium.Artifacts)
if len(selected) > 0 && len(configured) == 0 {
normalized, err := normalizeArtifactSelection(cfg, selected)
if err != nil {
return artifacts.EffectiveArtifactSet{}, err
}
if len(normalized) > 0 && len(configured) == 0 {
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires at least one configured artifact in pipeline.scriptorium.artifacts")
}
effective, err := artifacts.ResolveEffectiveArtifactSet(configured, selected)
effective, err := artifacts.ResolveEffectiveArtifactSet(configured, normalized)
if err != nil {
if strings.Contains(err.Error(), "is not configured") {
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts includes unknown artifact %q", selectedArtifactName(err))
@@ -73,7 +77,47 @@ func resolveEffectiveArtifacts(cfg *config.Config, selected []string) (artifacts
if err := validateEffectiveArtifactConfiguration(cfg.Pipeline.Scriptorium.Artifacts, effective); err != nil {
return artifacts.EffectiveArtifactSet{}, err
}
return effective, nil
return effective.WithOrigins(effectiveArtifactOrigins(cfg.Pipeline)), nil
}
func normalizeArtifactSelection(cfg *config.Config, selected []string) ([]string, error) {
if len(selected) == 0 {
return nil, nil
}
configured := cfg.Pipeline.Scriptorium.Artifacts
families := config.ArtifactFamilies(cfg.Pipeline).Families
set := make(map[string]struct{}, len(selected))
for _, raw := range selected {
key := strings.TrimSpace(raw)
if key == "" {
return nil, fmt.Errorf("artifact names must be non-empty")
}
if family, ok := families[key]; ok {
for _, member := range family.Members {
set[member] = struct{}{}
}
continue
}
if _, ok := configured[key]; !ok {
return nil, fmt.Errorf("--artifacts includes unknown artifact %q", key)
}
set[key] = struct{}{}
}
normalized := make([]string, 0, len(set))
for key := range set {
normalized = append(normalized, key)
}
sort.Strings(normalized)
return normalized, nil
}
func effectiveArtifactOrigins(pipeline *config.PipelineConfig) map[string]artifacts.EffectiveArtifactOrigin {
catalog := config.ArtifactFamilies(pipeline)
origins := make(map[string]artifacts.EffectiveArtifactOrigin, len(catalog.Members))
for key, member := range catalog.Members {
origins[key] = artifacts.EffectiveArtifactOrigin{Family: member.Family, CharacterID: member.CharacterID}
}
return origins
}
func selectedArtifactName(err error) string {

View File

@@ -11,7 +11,6 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
@@ -39,12 +38,8 @@ func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
var capturedStages []string
var capturedArtifacts []string
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
for _, s := range stages {
executor := func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
for _, s := range plan.Stages() {
capturedStages = append(capturedStages, s.Name())
}
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
@@ -53,7 +48,7 @@ func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
code := executeWithPipelineExecutor(
[]string{
"run-stage", "publish", "2026-05-03",
"--config", pipelinePath,
@@ -63,6 +58,7 @@ func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
},
&stdout,
&stderr,
executor,
)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -115,8 +111,8 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
if err != nil {
t.Fatalf("RunStage() error = %v", err)
}
if !strings.Contains(out.String(), "stage=analyze executed=0 skipped=1 force=false") {
t.Fatalf("output = %q, want analyze skip without force", out.String())
if !strings.Contains(out.String(), "stage=analyze executed=1 skipped=0 force=false") {
t.Fatalf("output = %q, want legacy analyze evidence rebuilt without implying force", out.String())
}
}
@@ -126,17 +122,28 @@ 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)
}
seed.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge", "polish", "normalize", "trim", "render")
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
t.Fatalf("save manifest: %v", err)
}
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,
@@ -144,8 +151,8 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "executed=1 skipped=11") {
t.Fatalf("output = %q, want all stages skipped", out.String())
if !strings.Contains(out.String(), "executed=5 skipped=9") {
t.Fatalf("output = %q, want extract reconsidered and legacy analyze plus delivery rebuilt", out.String())
}
}
@@ -155,12 +162,8 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
var capturedStages []string
var capturedForce bool
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
for _, s := range stages {
executor := func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
for _, s := range plan.Stages() {
capturedStages = append(capturedStages, s.Name())
}
capturedForce = opts.Force
@@ -172,10 +175,11 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
code := executeWithPipelineExecutor(
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
&stdout,
&stderr,
executor,
)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -196,18 +200,14 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
var capturedArtifacts []string
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(_ context.Context, _ *config.Config, _ []stage.Stage, opts RunOptions) (*RunSummary, error) {
executor := func(_ context.Context, _ *config.Config, _ BoundedPlan, opts RunOptions) (*RunSummary, error) {
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
code := executeWithPipelineExecutor(
[]string{
"analyze",
"2026-05-03",
@@ -218,6 +218,7 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
},
&stdout,
&stderr,
executor,
)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -271,13 +272,18 @@ func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) {
}
func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
deps, missingPath, executor := guardedMissingPipelineDiscovery(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"analyze", "2026-05-03"}, &stdout, &stderr)
code := executeWithDependencies([]string{"analyze", "2026-05-03"}, &stdout, &stderr, deps, executor)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "analyze: no pipeline config path provided and no default pipeline config found; searched:") {
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "analyze: no pipeline config path provided") ||
!strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
}
}
@@ -289,12 +295,8 @@ func TestExecutePublishForceRunsPublish(t *testing.T) {
var capturedStages []string
var capturedForce bool
var capturedArtifacts []string
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
for _, s := range stages {
executor := func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
for _, s := range plan.Stages() {
capturedStages = append(capturedStages, s.Name())
}
capturedForce = opts.Force
@@ -307,10 +309,11 @@ func TestExecutePublishForceRunsPublish(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
code := executeWithPipelineExecutor(
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
&stdout,
&stderr,
executor,
)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -373,13 +376,18 @@ func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) {
}
func TestExecutePublishMissingConfigUsesRunStageLoadingPath(t *testing.T) {
deps, missingPath, executor := guardedMissingPipelineDiscovery(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"publish", "2026-05-03"}, &stdout, &stderr)
code := executeWithDependencies([]string{"publish", "2026-05-03"}, &stdout, &stderr, deps, executor)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "publish: no pipeline config path provided and no default pipeline config found; searched:") {
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "publish: no pipeline config path provided") ||
!strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
}
}

View File

@@ -1,11 +1,71 @@
package app
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestResolveEffectiveArtifactsExpandsFamilySelections(t *testing.T) {
dir := t.TempDir()
write := func(name, body string) string {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
pipeline := write("pipeline.yml", `workspace: {root: /tmp/narratio-work}
whisperx: {transcribe_url: https://example.test/transcribe}
notification: {mode: noop}
scriptorium:
artifact_families:
character_meta:
enabled: false
for_each: party.characters
prompt_id: dnd.character_meta
output_path_pattern: artifacts/characters/{character_id}/meta.md
`)
campaign := write("campaign.yml", `campaign_id: campaign
inputs: {speakers_file: speakers.yml, autocorrect_file: autocorrect.yml, glossary_file: glossary.yml, party_file: party.yml}
`)
session := write("session.yml", `session_id: session
campaign: campaign
inputs: {audio_dir: audio}
`)
write("party.yml", `schema_version: narratio.party.v1
characters:
zeta: {player: {name: Z}, character: {name: Zeta, classes: [{name: wizard}]}}
alpha: {player: {name: A}, character: {name: Alpha, classes: [{name: ranger}]}}
`)
cfg, err := config.LoadWithSessionOptions(pipeline, campaign, session, config.SessionLoadOptions{})
if err != nil {
t.Fatal(err)
}
effective, err := resolveEffectiveArtifacts(cfg, []string{"character_meta", "character_meta_alpha"})
if err != nil {
t.Fatal(err)
}
if got, want := effective.Keys(), []string{"character_meta_alpha", "character_meta_zeta"}; !reflect.DeepEqual(got, want) {
t.Fatalf("keys = %#v, want %#v", got, want)
}
if origin, ok := effective.Origin("character_meta_alpha"); !ok || origin.Family != "character_meta" || origin.CharacterID != "alpha" {
t.Fatalf("origin = %#v, %t", origin, ok)
}
if _, err := resolveEffectiveArtifacts(cfg, []string{"unknown"}); err == nil || !strings.Contains(err.Error(), "unknown artifact") {
t.Fatalf("unknown selection error = %v", err)
}
if defaultEffective, err := resolveEffectiveArtifacts(cfg, nil); err != nil {
t.Fatal(err)
} else if len(defaultEffective.Keys()) != 0 {
t.Fatal("default selection should omit disabled family members")
}
}
func TestArtifactSelectionFlagNormalize(t *testing.T) {
tests := []struct {
name string

View File

@@ -0,0 +1,336 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestExecuteStagesRetriesFromLastAnalyzeCheckpoint(t *testing.T) {
cfg := testConfig(t)
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{Artifacts: checkpointRecoveryArtifacts("original")}
store := &manifest.LocalStore{}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
seed.Campaign = cfg.Session.Campaign
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
saveBoundedManifest(t, cfg, seed)
analyze := mustSelectStage(t, "analyze")
baselineRunner := &analyzeCheckpointRunner{}
if _, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
Env: &Env{ManifestStore: store, Scriptorium: baselineRunner},
}); err != nil {
t.Fatalf("baseline executeStages() error = %v", err)
}
baseline, err := store.Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatalf("load baseline manifest: %v", err)
}
baseline.MarkStageSucceeded("publish", time.Now().UTC(), nil)
baseline.MarkStageSucceeded("notify", time.Now().UTC(), nil)
if err := store.Save(context.Background(), manifestPathFor(cfg), baseline); err != nil {
t.Fatalf("save delivery baseline: %v", err)
}
upstream := cloneStageRecords(baseline, "prepare", "transcribe", "merge", "polish", "normalize", "trim")
unrelated := baseline.Stages["analyze"].AnalyzeArtifacts["unrelated"]
cfg.Pipeline.Scriptorium.Artifacts = checkpointRecoveryArtifacts("revised")
failure := errors.New("injected quest-log failure")
failingRunner := &analyzeCheckpointRunner{FailPrompt: "quest-log-revised", Err: failure}
if _, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
Env: &Env{ManifestStore: store, Scriptorium: failingRunner},
}); !errors.Is(err, failure) {
t.Fatalf("executeStages() error = %v, want quest-log failure", err)
}
if want := []string{"chronicle-revised", "encounters-revised", "quest-log-revised"}; !slices.Equal(failingRunner.Calls, want) {
t.Fatalf("failed-run calls = %#v, want %#v", failingRunner.Calls, want)
}
failed := loadAnalyzeRecoveryManifest(t, store, cfg)
failedAnalyze := failed.Stages["analyze"]
if failedAnalyze.Status != manifest.StatusFailed || len(failedAnalyze.Outputs) != 0 {
t.Fatalf("aggregate analyze = %#v, want failed without outputs", failedAnalyze)
}
assertAnalyzeArtifactStatus(t, failedAnalyze, "chronicle", manifest.AnalyzeArtifactCurrent)
assertAnalyzeArtifactStatus(t, failedAnalyze, "encounters", manifest.AnalyzeArtifactCurrent)
assertAnalyzeArtifactStatus(t, failedAnalyze, "quest_log", manifest.AnalyzeArtifactFailed)
assertAnalyzeArtifactStatus(t, failedAnalyze, "player_handout", manifest.AnalyzeArtifactStale)
gotUnrelated := failedAnalyze.AnalyzeArtifacts["unrelated"]
if diff := cmp.Diff(unrelated, gotUnrelated); diff != "" {
t.Fatalf("unrelated artifact changed (-want +got):\n%s", diff)
}
for _, name := range []string{"publish", "notify"} {
if got := failed.Stages[name]; got == nil || got.Status != manifest.StatusStale {
t.Fatalf("%s stage = %#v, want stale", name, got)
}
}
assertStageRecordsUnchanged(t, failed, upstream)
retryRunner := &analyzeCheckpointRunner{}
if _, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
Env: &Env{ManifestStore: store, Scriptorium: retryRunner},
}); err != nil {
t.Fatalf("retry executeStages() error = %v", err)
}
if want := []string{"quest-log-revised", "player-handout-revised"}; !slices.Equal(retryRunner.Calls, want) {
t.Fatalf("retry calls = %#v, want %#v", retryRunner.Calls, want)
}
completed := loadAnalyzeRecoveryManifest(t, store, cfg)
if completed.Stages["analyze"].Status != manifest.StatusSucceeded {
t.Fatalf("aggregate analyze = %#v, want succeeded", completed.Stages["analyze"])
}
for _, key := range []string{"chronicle", "encounters", "player_handout", "quest_log", "unrelated"} {
assertAnalyzeArtifactStatus(t, completed.Stages["analyze"], key, manifest.AnalyzeArtifactCurrent)
}
assertStageRecordsUnchanged(t, completed, upstream)
}
func TestExecuteStagesJoinsAnalyzeFailureWithTerminalSaveFailure(t *testing.T) {
cfg := testConfig(t)
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"chronicle": {Enabled: true, PromptID: "chronicle", OutputPath: "artifacts/chronicle.md"},
},
}
artifactErr := errors.New("injected analyze failure")
terminalErr := errors.New("injected terminal save failure")
store := &failedAnalyzeSaveStore{Store: &manifest.LocalStore{}, Err: terminalErr}
runner := &analyzeCheckpointRunner{FailPrompt: "chronicle", Err: artifactErr}
_, err := executeStages(context.Background(), cfg, []stage.Stage{mustSelectStage(t, "analyze")}, RunOptions{
Env: &Env{ManifestStore: store, Scriptorium: runner},
})
if !errors.Is(err, artifactErr) || !errors.Is(err, terminalErr) {
t.Fatalf("executeStages() error = %v, want analyze and terminal save failures", err)
}
if !store.Failed {
t.Fatal("terminal save failure was not injected")
}
}
func TestExecuteStagesPersistsAnalyzeCheckpointFailureRecovery(t *testing.T) {
cfg := testConfig(t)
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"chronicle": {Enabled: true, PromptID: "chronicle", OutputPath: "artifacts/chronicle.md"},
"encounters": {Enabled: true, PromptID: "encounters", OutputPath: "artifacts/encounters.md"},
"quest_log": {Enabled: true, PromptID: "quest-log", OutputPath: "artifacts/quest_log.md"},
},
}
baseStore := &manifest.LocalStore{}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
seed.Campaign = cfg.Session.Campaign
seed.MarkStageSucceeded("publish", time.Now().UTC(), nil)
seed.MarkStageSucceeded("notify", time.Now().UTC(), nil)
saveBoundedManifest(t, cfg, seed)
checkpointErr := errors.New("injected artifact checkpoint failure")
store := &currentAnalyzeCheckpointFailingStore{
Store: baseStore,
CurrentLimit: 2,
Err: checkpointErr,
}
runner := &analyzeCheckpointRunner{}
_, err := executeStages(context.Background(), cfg, []stage.Stage{mustSelectStage(t, "analyze")}, RunOptions{
Env: &Env{ManifestStore: store, Scriptorium: runner},
})
if !errors.Is(err, checkpointErr) {
t.Fatalf("executeStages() error = %v, want checkpoint failure", err)
}
if want := []string{"chronicle", "encounters"}; !slices.Equal(runner.Calls, want) {
t.Fatalf("adapter calls = %#v, want %#v", runner.Calls, want)
}
persisted := loadAnalyzeRecoveryManifest(t, baseStore, cfg)
if persisted.Stages["analyze"].Status != manifest.StatusFailed {
t.Fatalf("aggregate analyze = %#v, want failed", persisted.Stages["analyze"])
}
assertAnalyzeArtifactStatus(t, persisted.Stages["analyze"], "chronicle", manifest.AnalyzeArtifactCurrent)
assertAnalyzeArtifactStatus(t, persisted.Stages["analyze"], "encounters", manifest.AnalyzeArtifactFailed)
if _, exists := persisted.Stages["analyze"].AnalyzeArtifacts["quest_log"]; exists {
t.Fatalf("unattempted quest log appeared: %#v", persisted.Stages["analyze"].AnalyzeArtifacts["quest_log"])
}
for _, name := range []string{"publish", "notify"} {
if got := persisted.Stages[name]; got == nil || got.Status != manifest.StatusStale {
t.Fatalf("%s stage = %#v, want stale", name, got)
}
}
}
type analyzeCheckpointRunner struct {
Calls []string
FailPrompt string
Err error
}
type failedAnalyzeSaveStore struct {
manifest.Store
Err error
Failed bool
}
type currentAnalyzeCheckpointFailingStore struct {
manifest.Store
CurrentLimit int
Err error
Failed bool
}
func (s *currentAnalyzeCheckpointFailingStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
if !s.Failed {
current := 0
if record := m.Stages["analyze"]; record != nil && record.Status == manifest.StatusRunning {
for _, artifact := range record.AnalyzeArtifacts {
if artifact.Status == manifest.AnalyzeArtifactCurrent {
current++
}
}
}
if current >= s.CurrentLimit {
s.Failed = true
return s.Err
}
}
return s.Store.Save(ctx, path, m)
}
func (s *failedAnalyzeSaveStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
if record := m.Stages["analyze"]; !s.Failed && record != nil && record.Status == manifest.StatusFailed {
s.Failed = true
return s.Err
}
return s.Store.Save(ctx, path, m)
}
func (r *analyzeCheckpointRunner) RunArtifact(
ctx context.Context,
req scriptorium.RunArtifactRequest,
) (scriptorium.ArtifactResult, error) {
if err := ctx.Err(); err != nil {
return scriptorium.ArtifactResult{}, err
}
r.Calls = append(r.Calls, req.PromptID)
if req.PromptID == r.FailPrompt {
return scriptorium.ArtifactResult{}, r.Err
}
if err := os.MkdirAll(filepath.Dir(req.OutputPath), 0o755); err != nil {
return scriptorium.ArtifactResult{}, err
}
if err := os.WriteFile(req.OutputPath, []byte("generated "+req.PromptID+"\n"), 0o644); err != nil {
return scriptorium.ArtifactResult{}, err
}
return scriptorium.ArtifactResult{
OutputPath: req.OutputPath,
CommandMode: scriptorium.CommandModeRun,
PromptID: req.PromptID,
ProfileID: req.ProfileID,
}, nil
}
func (*analyzeCheckpointRunner) RenderArtifact(
context.Context,
scriptorium.RenderArtifactRequest,
) (scriptorium.ArtifactResult, error) {
return scriptorium.ArtifactResult{}, errors.New("unexpected render")
}
func checkpointRecoveryArtifacts(revision string) map[string]config.ScriptoriumArtifactConfig {
return map[string]config.ScriptoriumArtifactConfig{
"chronicle": {
Enabled: true, PromptID: "chronicle-" + revision, OutputPath: "artifacts/chronicle.md",
},
"encounters": {
Enabled: true, DependsOn: []string{"chronicle"}, PromptID: "encounters-" + revision,
OutputPath: "artifacts/encounters.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"chronicle": {Source: "narratio.artifact.chronicle", Required: true},
},
},
"quest_log": {
Enabled: true, DependsOn: []string{"encounters"}, PromptID: "quest-log-" + revision,
OutputPath: "artifacts/quest_log.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"encounters": {Source: "narratio.artifact.encounters", Required: true},
},
},
"player_handout": {
Enabled: true, DependsOn: []string{"quest_log"}, PromptID: "player-handout-" + revision,
OutputPath: "artifacts/player_handout.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"quest_log": {Source: "narratio.artifact.quest_log", Required: true},
},
},
"unrelated": {
Enabled: true, PromptID: "unrelated", OutputPath: "artifacts/unrelated.md",
},
}
}
func mustSelectStage(t *testing.T, name string) stage.Stage {
t.Helper()
selected, err := stage.Select(name)
if err != nil {
t.Fatalf("Select(%q) error = %v", name, err)
}
return selected
}
func loadAnalyzeRecoveryManifest(t *testing.T, store manifest.Store, cfg *config.Config) *manifest.Manifest {
t.Helper()
m, err := store.Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatalf("load session manifest: %v", err)
}
return m
}
func assertAnalyzeArtifactStatus(
t *testing.T,
record *manifest.StageRecord,
key string,
want manifest.AnalyzeArtifactStatus,
) {
t.Helper()
got, exists := record.AnalyzeArtifacts[key]
if !exists || got.Status != want {
t.Fatalf("artifact %q = %#v, want status %q", key, got, want)
}
if want != manifest.AnalyzeArtifactCurrent && got.Output != nil {
t.Fatalf("artifact %q retained output authority: %#v", key, got)
}
}
func cloneStageRecords(m *manifest.Manifest, names ...string) map[string]manifest.StageRecord {
clones := make(map[string]manifest.StageRecord, len(names))
for _, name := range names {
clones[name] = *m.Stages[name]
}
return clones
}
func assertStageRecordsUnchanged(t *testing.T, m *manifest.Manifest, want map[string]manifest.StageRecord) {
t.Helper()
for name, expected := range want {
got := m.Stages[name]
if got == nil {
t.Fatalf("upstream stage %q is missing", name)
}
if diff := cmp.Diff(expected, *got); diff != "" {
t.Fatalf("upstream stage %q changed (-want +got):\n%s", name, diff)
}
}
}

Some files were not shown because too many files have changed in this diff Show More