12 Commits

51 changed files with 4182 additions and 264 deletions

View File

@@ -60,7 +60,10 @@ remote state with an unsafe legacy identity must be migrated before use.
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
- Pipeline defaults are applied before validation.
- Campaign and session identities must agree.
- Stable files (`speakers_file`, `autocorrect_file`, `glossary_file`, `players_file`, `party_file`) resolve from session overrides when provided, otherwise from campaign defaults.
- 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.
- Exactly one audio mode must be configured in session input:
- local (`audio_dir` or `audio_files`), or
- S3 (`audio_s3.prefix`).
@@ -227,6 +230,7 @@ Rules:
| `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.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` |
| `pipeline.render.format` | string | No | `markdown` (only supported value) |
@@ -241,6 +245,44 @@ Rules:
| `pipeline.scriptorium.artifacts` | map | No | empty |
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
### Notarius Reference Bindings
`pipeline.notarius.references` maps a Notarius CLI selector to a prepared
Narratio source, not to a filesystem path:
```yaml
notarius:
references:
glossary: narratio.input.glossary
party: narratio.input.party
players: narratio.input.players
spell_catalog: narratio.input.spell_catalog
```
Supported sources are `narratio.input.party`, `narratio.input.players`,
`narratio.input.glossary`, and `narratio.input.spell_catalog`. Each map entry is
required by its presence: omit a binding when the selected Notarius pipeline
does not need it. A spell-catalog binding additionally requires an effective
campaign or session `spell_catalog_file`.
Selectors accept Notarius's `slot`, `chunk.slot`, `lane.slot`,
`lane.extract.slot`, `lane.merge.slot`, and `lane.normalize.slot` forms.
Narratio trims whitespace around
selectors and their dot-separated components, rejects empty components and
`=`, rejects duplicate normalized selectors, and limits the map to 256 entries.
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,
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
[Notarius integration contract](./integrations/notarius.md) for the subprocess
boundary and the [complete example](../examples/pipeline.full.annotated.yml)
for a copyable configuration.
### Notarius Output Entries
For each `pipeline.notarius.outputs.<name>`:
@@ -318,6 +360,7 @@ integration.
| `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.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
### Session
@@ -333,6 +376,7 @@ integration.
| `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 |
| `inputs.audio_s3.prefix` | string | Conditional | S3 audio mode |

View File

@@ -7,12 +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 references:
Canonical Notarius v0.6.0 references:
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/consumers/subprocess.md)
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/consumers/dnd-pipeline.md)
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/integrations/run-result.md)
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/integrations/json-output.md)
- [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)
The [complete Narratio example](../../examples/pipeline.full.annotated.yml)
records the exact current constraints for all ten D&D lanes. Treat the linked
@@ -23,12 +25,43 @@ 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 and invokes:
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`:
```text
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> --json
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> [--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
binding takes precedence over a matching external path in Notarius
configuration. Narratio never emits `--without-reference`.
The maintained D&D boundary binds only the four campaign-owned external slots:
```text
notarius run dnd-session \
--config <absolute config path> \
--input <absolute trimmed transcript path> \
--output-dir <absolute staging directory> \
--reference glossary=<absolute verified glossary snapshot> \
--reference party=<absolute verified party snapshot> \
--reference players=<absolute verified players snapshot> \
--reference spell_catalog=<absolute verified spell catalog snapshot> \
--json
```
The spell-catalog binding is omitted when the campaign does not maintain that
optional overlay. Registry, scene-description, combat-turn, and occurrence
handoffs generated during the same Notarius run remain in Notarius pipeline
composition and must not be emitted as CLI references. The linked CLI and D&D
consumer documents own selector targeting, declared slots, media compatibility,
and generated-handoff collision rules.
Standard output is reserved for the JSON receipt. Standard error is captured
separately as diagnostic output. Narratio applies the configured timeout and
does not interpret stdout as a receipt unless the subprocess exits successfully.
@@ -38,24 +71,31 @@ environment apply to the subprocess.
## Accepted Result
Narratio currently accepts receipt schema `notarius.run-result.v1`. The receipt
Narratio's supported invocation baseline is Notarius v0.6.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
`index.json` beneath the reported bundle root. The production index must name
the management files exactly as `manifest.json`, `rejected.json`, and
`warnings.json`. All receipt, index, and lane paths must stay inside that
bundle; symlinks and non-regular lane payloads are rejected.
the management files exactly as `manifest.json`, `rejected.json`,
`warnings.json`, and `diagnostics.json`. All receipt, index, and lane paths must
stay inside that bundle; symlinks and non-regular lane payloads are rejected.
Supported receipt and index shapes tolerate unknown fields for forward
compatibility, while required identity, validation, count, manifest,
rejection, warning, and lane-list fields remain mandatory. Narratio applies
bounded reads to the receipt, index, rejection, and warning documents. Optional
chunk-map and evidence-context descriptors must carry their complete generic
contract metadata when present.
rejection, warning, diagnostic, and lane-list fields remain mandatory.
Narratio applies bounded reads to the receipt, index, rejection, warning, and
diagnostic documents. Warning and diagnostic envelopes, group counts,
occurrence counts, truncation state, framework-owned origins, and
receipt-to-bundle counts must be internally consistent. Optional chunk-map and
evidence-context descriptors must carry their complete generic contract
metadata when present.
For every entry in `pipeline.notarius.outputs`, Narratio requires exactly one
index descriptor with the configured lane ID, media type, schema ID, schema
version, and, when configured, module key. Missing, duplicate, rejected, or
incompatible required lanes fail extraction even if Notarius exited zero.
incompatible required lanes fail extraction even if Notarius exited zero. A
configured lane whose v2 validation summary is `rejected` or `incomplete` also
fails extraction.
Unconfigured lanes may remain in the preserved bundle but do not become
selectable Narratio sources.
@@ -72,10 +112,13 @@ only explicitly named lane sources; `--artifacts` never selects Notarius lanes.
staged bundle is promoted to durable storage.
- Contract and external provenance metadata are preserved on lane artifact
records and through explicit publication.
- Undeclared selectors, incompatible reference files, and external/generated
reference collisions are Notarius errors and fail extraction normally.
Rejection and warning summaries retain structured stage, scope, lane, and
reason-code fields for diagnostics without exposing free-form external messages
or reading lane payload bodies.
Rejection, validation, warning, and diagnostic summaries retain bounded stable
identity, category, origin, reason-code, status, and occurrence fields without
copying free-form external messages into Narratio manifest metadata or reading
lane payload bodies.
Configuration fields and defaults are in [Configuration](../config.md).
Operator paths, rerun procedures, and bundle retention are in

View File

@@ -46,7 +46,9 @@ Adapters do not own:
- Object store only when required by selected stages/config.
Notarius is composed only when extraction is enabled; the extract stage owns
receipt, bundle, and configured-lane policy rather than the adapter.
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`.
Object-store construction goes through `newCommandObjectStore`, which loads
configured filesystem secrets before adapter initialization.

View File

@@ -36,6 +36,11 @@ unrecognized token into a valid source. Extraction sources are registered only
from `pipeline.notarius.outputs`; the Notarius index has no selectable source
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.
## Runtime Catalog
`ArtifactCatalog` tracks:
@@ -72,6 +77,15 @@ Configured sources (`narratio.artifact.*`):
- resolve only through runtime catalog availability.
Prepared stable sources (`narratio.input.*`):
- resolve only from the current manifest's exact prepared-input record;
- require the policy-owned canonical path below the session root, a confined
non-symlink regular file, a non-empty payload, and a matching SHA-256
checksum; and
- return an immutable source/path/checksum/size identity shared by extract and
analyze rather than falling back to campaign/session source paths.
Extraction sources (`narratio.extraction.*`):
- use the shared typed bundle evidence inspection in `extraction_evidence.go`;
@@ -199,7 +213,8 @@ physical layout.
`internal/artifacts/catalog.go`, `internal/artifacts/transcripts.go`,
`internal/artifacts/extraction_catalog.go`,
`internal/artifacts/extraction_evidence.go`,
`internal/artifacts/extraction_input.go`
`internal/artifacts/extraction_input.go`,
`internal/artifacts/prepared_input.go`
- Current state: `internal/artifacts/current_state.go`,
`internal/artifacts/current_state_commit.go`,
`internal/artifacts/current_state_legacy.go`

View File

@@ -14,7 +14,7 @@ Execute selected configured Scriptorium artifacts in dependency order and materi
Supported source families:
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`
- prepared stable inputs: `narratio.input.players`, `narratio.input.party`,
`narratio.input.glossary`
`narratio.input.glossary`, `narratio.input.spell_catalog`
- configured artifacts: `narratio.artifact.<key>`
- extraction lanes: `narratio.extraction.<key>`
- previous-session cache: `narratio.previous_session.artifact.<key>`
@@ -42,7 +42,9 @@ Supported source families:
- 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.
- resolves prepared stable input sources from `inputs/*.yml` materialized by `prepare`.
- 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.
- 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.

View File

@@ -17,15 +17,21 @@ procedures belong in [Operations](../operations.md).
`internal/stage/extract.go`:
1. resolves the final trimmed transcript from the shared artifact catalog;
2. resolves and fingerprints the Notarius invocation contract;
3. creates a run-local staging directory and invokes the injected
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;
5. creates a run-local staging directory and invokes the injected
`notarius.Runner`;
4. validates the successful receipt, confined index, configured required lane
descriptors, and regular payload files;
5. atomically promotes the complete bundle to its immutable durable location;
6. records one non-selectable `notarius_index` output and one selectable
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;
7. atomically promotes the complete bundle to its immutable durable location;
8. records one non-selectable `notarius_index` output and one selectable
`notarius_lane` output per configured lane; and
7. registers each lane as `narratio.extraction.<output_key>` for downstream
9. registers each lane as `narratio.extraction.<output_key>` for downstream
Scriptorium and publish resolution.
Lane records retain checksum, contract, producer run ID, and Notarius system,
@@ -34,6 +40,12 @@ root, receipt, diagnostic paths, rejection/warning summaries, producing
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
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.
@@ -46,11 +58,16 @@ with no outputs is stable and does not repeatedly invalidate downstream stages.
`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, and the current
direct trimmed-transcript identity. The same identity is resolved again for
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.
A valid prepared-reference change makes extraction non-resumable. Missing,
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
with prepare-force guidance because an immediate extract rerun cannot succeed.
The validator then checks the producing run identity, canonical immutable
bundle root, path confinement and absence of symlink components, receipt
identity, exactly one canonical index, the exact configured source set,
@@ -65,8 +82,10 @@ Operators must force extraction after changing any such input.
## Failure Behavior
Adapter startup, timeout, nonzero exit, receipt decoding, path confinement,
index compatibility, required-lane rejection, payload inspection, checksum, or
promotion errors fail the stage through ordinary manifest transition handling.
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
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.

View File

@@ -8,6 +8,7 @@ Materialize canonical current-session inputs before processing stages.
- resolved campaign, session, and pipeline configuration
- stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`)
- optional spell-catalog overlay
- one resolved local or S3 audio source
- enabled configured artifact input requirements for previous-session sources
@@ -21,6 +22,7 @@ Materialize canonical current-session inputs before processing stages.
- `inputs/glossary.yml`
- `inputs/players.yml`
- `inputs/party.yml`
- optional `inputs/spell_catalog.json`
- `audio/*.flac`
- optional `previous/manifest.json`
- optional `previous/artifacts/**`
@@ -34,6 +36,9 @@ Materialize canonical current-session inputs before processing stages.
- gives distinct local source paths with the same basename deterministic unique
prepared filenames so neither source is overwritten.
- materializes S3 audio through spool/cache-aware logic.
- 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.
- 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;

View File

@@ -36,7 +36,12 @@ narratio session init 2026-04-04 --remote --force
If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values.
Campaigns must provide stable input files for speakers, autocorrect, glossary, players, and party. Session files may override those paths for one session. The `prepare` stage materializes them under `inputs/`; configured Scriptorium artifacts can reference prepared `players`, `party`, and `glossary` files with `narratio.input.players`, `narratio.input.party`, and `narratio.input.glossary`.
Campaigns must provide stable input files for speakers, autocorrect, glossary,
players, and party, and may provide an optional spell-catalog overlay. Session
files may override those paths for one session. The `prepare` stage
materializes them under `inputs/`; configured consumers use the prepared files,
never the original campaign or session source paths. Field definitions and
source IDs are in [Configuration](./config.md#notarius-reference-bindings).
## Standard Session Workflow
@@ -135,6 +140,31 @@ The directory is immutable once promoted. Configured lanes become
the bundle and `index.json` are retained for audit and resume validation but
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.
Inspect the effective stable-input inventory and
prepared-file readiness with:
```bash
narratio session status 2026-04-04
narratio session validate 2026-04-04
```
Reference metadata records selector, source ID, session-relative path,
checksum, and byte size, but never payload contents. Changing a prepared
reference changes extraction identity: ordinary continuation rejects the old
result, reruns Notarius, and marks successful downstream stages stale. If the
prepared file is missing or inconsistent with its manifest checksum, repair
the source configuration and refresh prepared state first:
```bash
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
@@ -181,11 +211,12 @@ To intentionally replace the current extraction result, run:
narratio run-stage extract 2026-04-04 --force
```
Narratio automatically reruns extraction when its recorded invocation contract
or durable output validation changes. It cannot fingerprint configuration
files, profiles, prompts, modules, or references loaded transitively by
Notarius. Force extraction after changing any of those inputs, even when the
top-level Narratio and Notarius config paths remain the same. A forced extract
Narratio automatically reruns extraction when its recorded invocation contract,
prepared Narratio reference identities, or durable output validation changes.
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
config paths remain the same. A forced extract
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.

View File

@@ -0,0 +1,546 @@
# 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

@@ -0,0 +1,274 @@
# 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

@@ -158,6 +158,66 @@ is expected audit state, not a signal to relink the old bundle manually.
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
## Prepared Notarius reference missing or inconsistent
Symptom:
- extraction or resume validation reports that a configured reference source is
unavailable, unsafe, empty, or checksum-inconsistent and recommends
`prepare --force`.
Likely causes:
- `prepare` has not run since the campaign/session stable input changed;
- the configured source file is missing;
- a prepared `inputs/` file or its manifest record was modified independently;
- a spell-catalog binding exists without an effective `spell_catalog_file`.
Diagnostics:
```bash
narratio session status 2026-04-04
narratio session validate 2026-04-04
```
Safe fix:
- correct the campaign/session input path, then refresh canonical prepared
evidence before extraction:
```bash
narratio run-stage prepare 2026-04-04 --force
```
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 selector or generated-handoff collision
Symptom:
- Notarius exits nonzero with an undeclared reference-slot, incompatible media,
or external/generated reference collision error.
Likely causes:
- a selector does not identify a slot declared by the selected Notarius target;
- a prepared file does not satisfy that slot's Notarius media contract; or
- a CLI binding attempts to replace a same-run generated D&D handoff.
Safe fix:
- compare external bindings with the selected Notarius pipeline's canonical
consumer documentation;
- keep only campaign-owned external slots on the CLI; and
- leave registry, scene, combat, and occurrence handoffs to Notarius pipeline
composition.
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:
@@ -198,7 +258,7 @@ Safe fix:
- compare installed Notarius output with the canonical Notarius contracts,
including receipt `index_file: index.json` and index management names
`manifest.json`, `rejected.json`, and `warnings.json`; align
`manifest.json`, `rejected.json`, `warnings.json`, and `diagnostics.json`; align
`pipeline.notarius` constraints and rerun. Do not bypass confinement or schema
checks.
@@ -230,6 +290,8 @@ Likely causes:
- the executable/config path, pipeline ID, timeout, working directory, or
configured output contracts changed;
- a configured prepared reference selector, source, path, checksum, or byte
size changed;
- the durable bundle, index, lane set, provenance, regular-file status, or
checksum no longer validates.
@@ -252,8 +314,9 @@ Safe fix:
narratio run-stage extract 2026-04-04 --force
```
Narratio fingerprints its invocation contract, not the contents of transitive
Notarius inputs. Always force extraction after changing them; downstream
Narratio fingerprints its invocation contract and prepared Narratio reference
identities, not the contents of other transitive Notarius inputs. Always force
extraction after changing those external inputs; downstream
successful stages are then marked stale normally.
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).

View File

@@ -39,7 +39,9 @@ with the sample campaign and a compatible local- or S3-audio session.
[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.
[party](campaigns/sample-campaign/party.yml) fixtures, plus an optional
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
follows the Notarius v0.6 contract.
- [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.

View File

@@ -6,3 +6,4 @@ inputs:
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
spell_catalog_file: ./spell_catalog.json

View File

@@ -0,0 +1,18 @@
{
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
"catalogs": [
{
"id": "narratio.sample-campaign",
"ruleset": "dnd-5e-2014",
"source": {
"title": "Narratio sample campaign spell names"
},
"spells": [
{
"name": "Aegis of Emberfall",
"aliases": ["Emberfall Aegis"]
}
]
}
]
}

View File

@@ -14,6 +14,11 @@ notarius:
config_path: /usr/local/etc/notarius/config.yml
pipeline_id: dnd-session
timeout: 3h
references:
glossary: narratio.input.glossary
party: narratio.input.party
players: narratio.input.players
spell_catalog: narratio.input.spell_catalog
outputs:
npc_registry:
lane_id: npc-registry
@@ -52,4 +57,3 @@ scriptorium:
scenes:
source: narratio.extraction.scene_descriptions
required: true

View File

@@ -136,6 +136,13 @@ notarius:
pipeline_id: dnd-session
timeout: 3h
working_directory: /usr/local/etc/notarius
# External campaign references use prepared Narratio source IDs. Omit an
# optional binding when the selected Notarius pipeline does not need it.
references:
glossary: narratio.input.glossary
party: narratio.input.party
players: narratio.input.players
spell_catalog: narratio.input.spell_catalog
# Each key creates source narratio.extraction.<key>. These constraints match
# the current Notarius D&D lane contracts; update them with Notarius.
outputs:

View File

@@ -14,7 +14,9 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error)
if err := ctx.Err(); err != nil {
return RunResult{}, err
}
f.Requests = append(f.Requests, req)
copyRequest := req
copyRequest.References = append([]ReferenceBinding(nil), req.References...)
f.Requests = append(f.Requests, copyRequest)
if f.Err != nil {
return RunResult{}, f.Err
}

View File

@@ -6,13 +6,20 @@ import (
"time"
)
const ReceiptSchemaVersion = "notarius.run-result.v1"
const ReceiptSchemaVersion = "notarius.run-result.v2"
// Runner is the adapter boundary for a complete Notarius pipeline invocation.
type Runner interface {
Run(ctx context.Context, req RunRequest) (RunResult, error)
}
// ReferenceBinding maps one normalized Notarius selector to an absolute
// external reference path.
type ReferenceBinding struct {
Selector string
Path string
}
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
type RunRequest struct {
Binary string
@@ -24,6 +31,7 @@ type RunRequest struct {
ReceiptPath string
LogPath string
Timeout time.Duration
References []ReferenceBinding
}
// Receipt is the transport-neutral successful run receipt.
@@ -35,11 +43,31 @@ type Receipt struct {
IndexFile string
NormalizedOutputCount int
RejectedOutputCount int
WarningCount int
WarningGroupCount int
WarningOccurrenceCount int
DiagnosticGroupCount int
DiagnosticOccurrenceCount int
DiagnosticsTruncated bool
ValidationStatus string
ValidationSummaries []ValidationSummary
DebugDirectory string
}
// ValidationSummary retains the bounded outcome of one Notarius producer result.
type ValidationSummary struct {
Stage string
StepID string
LaneID string
ModuleKey string
ChunkID string
Status string
RejectingValidators []string
ReasonCodes []string
IncompleteValidators []string
ProducerAttemptCount int
TerminalAction string
}
// LaneDescriptor identifies one normalized lane payload discovered through the index.
type LaneDescriptor struct {
LaneID string
@@ -72,6 +100,8 @@ type Index struct {
RejectedPath string
WarningsFile string
WarningsPath string
DiagnosticsFile string
DiagnosticsPath string
Lanes []LaneDescriptor
ChunkMap *PipelineDescriptor
EvidenceContext *PipelineDescriptor
@@ -90,8 +120,29 @@ type RejectionSummary struct {
// WarningSummary retains structured warning identity without free-form messages.
type WarningSummary struct {
Scope string
Disposition string
Category string
ReasonCode string
Origin DiagnosticOrigin
OccurrenceCount int
}
// DiagnosticOrigin identifies the framework-owned pipeline location of a finding.
type DiagnosticOrigin struct {
Stage string
StepID string
LaneID string
ModuleKey string
ValidatorKey string
}
// DiagnosticSummary retains bounded advisory or observation group metadata.
type DiagnosticSummary struct {
Disposition string
Category string
ReasonCode string
Origin DiagnosticOrigin
OccurrenceCount int
}
// RunResult describes a successfully decoded and validated Notarius bundle.
@@ -105,4 +156,5 @@ type RunResult struct {
Duration time.Duration
Rejections []RejectionSummary
Warnings []WarningSummary
Diagnostics []DiagnosticSummary
}

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
@@ -22,6 +23,12 @@ const (
canonicalManifestFile = "manifest.json"
canonicalRejectedFile = "rejected.json"
canonicalWarningsFile = "warnings.json"
canonicalDiagnosticsFile = "diagnostics.json"
warningsSchemaVersion = "notarius.warnings.v2"
diagnosticsSchemaVersion = "notarius.diagnostics.v1"
maxWarningGroups = 128
maxDiagnosticGroups = 256
maxFindingSamples = 3
)
type subprocessRun func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error)
@@ -41,7 +48,8 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
if r == nil || r.run == nil {
return RunResult{}, fmt.Errorf("notarius subprocess runner is nil")
}
if err := validateRunRequest(req); err != nil {
references, err := validateRunRequest(req)
if err != nil {
return RunResult{}, err
}
@@ -50,8 +58,11 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
"--config", req.ConfigPath,
"--input", req.InputPath,
"--output-dir", req.OutputRoot,
"--json",
}
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,
@@ -95,24 +106,42 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
if err != nil {
return baseResult, err
}
diagnostics, diagnosticOccurrences, diagnosticsTruncated, err := loadDiagnostics(index.DiagnosticsPath)
if err != nil {
return baseResult, err
}
if receipt.NormalizedOutputCount != len(index.Lanes) || receipt.RejectedOutputCount != len(rejections) ||
receipt.WarningGroupCount != len(warnings) || receipt.DiagnosticGroupCount != len(diagnostics) {
return baseResult, fmt.Errorf("notarius receipt counts do not match published bundle")
}
warningOccurrences, err := sumWarningOccurrences(warnings)
if err != nil {
return baseResult, err
}
if receipt.WarningOccurrenceCount != warningOccurrences ||
receipt.DiagnosticOccurrenceCount != diagnosticOccurrences ||
receipt.DiagnosticsTruncated != diagnosticsTruncated {
return baseResult, fmt.Errorf("notarius receipt occurrence counts do not match published bundle")
}
baseResult.Receipt = receipt
baseResult.Index = index
baseResult.BundleRoot = bundleRoot
baseResult.Rejections = rejections
baseResult.Warnings = warnings
baseResult.Diagnostics = diagnostics
return baseResult, nil
}
func validateRunRequest(req RunRequest) error {
func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) {
if strings.TrimSpace(req.Binary) == "" {
return fmt.Errorf("notarius binary is required")
return nil, fmt.Errorf("notarius binary is required")
}
if strings.TrimSpace(req.PipelineID) == "" {
return fmt.Errorf("notarius pipeline id is required")
return nil, fmt.Errorf("notarius pipeline id is required")
}
if req.Timeout <= 0 {
return fmt.Errorf("notarius timeout must be positive")
return nil, fmt.Errorf("notarius timeout must be positive")
}
for label, path := range map[string]string{
"config": req.ConfigPath,
@@ -123,34 +152,53 @@ func validateRunRequest(req RunRequest) error {
"log": req.LogPath,
} {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("notarius %s path is required", label)
return nil, fmt.Errorf("notarius %s path is required", label)
}
if !filepath.IsAbs(path) {
return fmt.Errorf("notarius %s path must be absolute", label)
return nil, fmt.Errorf("notarius %s path must be absolute", label)
}
}
if filepath.Clean(req.ReceiptPath) == filepath.Clean(req.LogPath) {
return fmt.Errorf("notarius receipt and log paths must be different")
return nil, fmt.Errorf("notarius receipt and log paths must be different")
}
references := make([]ReferenceBinding, 0, len(req.References))
selectors := make(map[string]struct{}, len(req.References))
for index, binding := range req.References {
selector, err := notariusref.NormalizeSelector(binding.Selector)
if err != nil {
return nil, fmt.Errorf("notarius reference %d selector: %w", index, err)
}
if _, duplicate := selectors[selector]; duplicate {
return nil, fmt.Errorf("notarius reference selector %q is duplicated", selector)
}
selectors[selector] = struct{}{}
if strings.TrimSpace(binding.Path) == "" {
return nil, fmt.Errorf("notarius reference %q path is required", selector)
}
if !filepath.IsAbs(binding.Path) {
return nil, fmt.Errorf("notarius reference %q path must be absolute", selector)
}
references = append(references, ReferenceBinding{Selector: selector, Path: binding.Path})
}
if err := requireRegularFile(req.ConfigPath); err != nil {
return fmt.Errorf("validate notarius config path: %w", err)
return nil, fmt.Errorf("validate notarius config path: %w", err)
}
if err := requireRegularFile(req.InputPath); err != nil {
return fmt.Errorf("validate notarius input path: %w", err)
return nil, fmt.Errorf("validate notarius input path: %w", err)
}
if err := requireDirectory(req.OutputRoot); err != nil {
return fmt.Errorf("validate notarius output root: %w", err)
return nil, fmt.Errorf("validate notarius output root: %w", err)
}
if err := requireDirectory(req.WorkingDirectory); err != nil {
return fmt.Errorf("validate notarius working directory: %w", err)
return nil, fmt.Errorf("validate notarius working directory: %w", err)
}
if err := validateLogDestination(req.ReceiptPath); err != nil {
return fmt.Errorf("validate notarius receipt path: %w", err)
return nil, fmt.Errorf("validate notarius receipt path: %w", err)
}
if err := validateLogDestination(req.LogPath); err != nil {
return fmt.Errorf("validate notarius log path: %w", err)
return nil, fmt.Errorf("validate notarius log path: %w", err)
}
return nil
return references, nil
}
type receiptDocument struct {
@@ -161,11 +209,69 @@ type receiptDocument struct {
IndexFile string `json:"index_file"`
NormalizedOutputCount *int `json:"normalized_output_count"`
RejectedOutputCount *int `json:"rejected_output_count"`
WarningCount *int `json:"warning_count"`
WarningGroupCount *int `json:"warning_group_count"`
WarningOccurrenceCount *int `json:"warning_occurrence_count"`
DiagnosticGroupCount *int `json:"diagnostic_group_count"`
DiagnosticOccurrenceCount *int `json:"diagnostic_occurrence_count"`
DiagnosticsTruncated *bool `json:"diagnostics_truncated"`
ValidationStatus string `json:"validation_status"`
ValidationSummaries []validationSummaryDocument `json:"validation_summaries"`
DebugDirectory string `json:"debug_directory"`
}
type validationSummaryDocument struct {
Stage string `json:"stage"`
StepID string `json:"step_id"`
LaneID string `json:"lane_id"`
ModuleKey string `json:"module_key"`
ChunkID string `json:"chunk_id"`
Status string `json:"status"`
RejectingValidators []string `json:"rejecting_validators"`
ReasonCodes []string `json:"reason_codes"`
IncompleteValidators []string `json:"incomplete_validators"`
ProducerAttemptCount *int `json:"producer_attempt_count"`
TerminalAction string `json:"terminal_action"`
}
func validValidationStatus(value string) bool {
switch value {
case "approved", "rejected", "incomplete":
return true
default:
return false
}
}
func validateValidationSummaries(documents []validationSummaryDocument) ([]ValidationSummary, error) {
summaries := make([]ValidationSummary, 0, len(documents))
for _, document := range documents {
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) {
return nil, fmt.Errorf("notarius validation summary is missing required fields")
}
summaries = append(summaries, ValidationSummary{
Stage: document.Stage, StepID: document.StepID, LaneID: document.LaneID,
ModuleKey: document.ModuleKey, ChunkID: document.ChunkID, Status: document.Status,
RejectingValidators: append([]string(nil), document.RejectingValidators...),
ReasonCodes: append([]string(nil), document.ReasonCodes...),
IncompleteValidators: append([]string(nil), document.IncompleteValidators...),
ProducerAttemptCount: *document.ProducerAttemptCount, TerminalAction: document.TerminalAction,
})
}
return summaries, nil
}
func validTerminalAction(value string) bool {
switch value {
case "accepted", "reject_output", "warn_continue", "fail_run":
return true
default:
return false
}
}
func loadReceipt(path, pipelineID string) (Receipt, error) {
var document receiptDocument
if err := decodeBoundedJSON(path, maxReceiptBytes, &document); err != nil {
@@ -177,7 +283,9 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
if strings.TrimSpace(document.RunID) == "" || strings.TrimSpace(document.PipelineID) == "" ||
strings.TrimSpace(document.OutputDirectory) == "" || strings.TrimSpace(document.ValidationStatus) == "" ||
document.NormalizedOutputCount == nil ||
document.RejectedOutputCount == nil || document.WarningCount == nil {
document.RejectedOutputCount == nil || document.WarningGroupCount == nil ||
document.WarningOccurrenceCount == nil || document.DiagnosticGroupCount == nil ||
document.DiagnosticOccurrenceCount == nil || document.DiagnosticsTruncated == nil {
return Receipt{}, fmt.Errorf("notarius receipt is missing required fields")
}
if document.IndexFile != canonicalIndexFile {
@@ -186,9 +294,18 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
if document.PipelineID != pipelineID {
return Receipt{}, fmt.Errorf("notarius receipt pipeline id %q does not match requested pipeline %q", document.PipelineID, pipelineID)
}
if *document.NormalizedOutputCount < 0 || *document.RejectedOutputCount < 0 || *document.WarningCount < 0 {
if *document.NormalizedOutputCount < 0 || *document.RejectedOutputCount < 0 ||
*document.WarningGroupCount < 0 || *document.WarningOccurrenceCount < 0 ||
*document.DiagnosticGroupCount < 0 || *document.DiagnosticOccurrenceCount < 0 {
return Receipt{}, fmt.Errorf("notarius receipt counts must be non-negative")
}
if !validValidationStatus(document.ValidationStatus) {
return Receipt{}, fmt.Errorf("notarius receipt validation_status %q is invalid", document.ValidationStatus)
}
validationSummaries, err := validateValidationSummaries(document.ValidationSummaries)
if err != nil {
return Receipt{}, err
}
if !filepath.IsAbs(document.OutputDirectory) {
return Receipt{}, fmt.Errorf("notarius receipt output directory must be absolute")
}
@@ -203,8 +320,13 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
IndexFile: document.IndexFile,
NormalizedOutputCount: *document.NormalizedOutputCount,
RejectedOutputCount: *document.RejectedOutputCount,
WarningCount: *document.WarningCount,
WarningGroupCount: *document.WarningGroupCount,
WarningOccurrenceCount: *document.WarningOccurrenceCount,
DiagnosticGroupCount: *document.DiagnosticGroupCount,
DiagnosticOccurrenceCount: *document.DiagnosticOccurrenceCount,
DiagnosticsTruncated: *document.DiagnosticsTruncated,
ValidationStatus: document.ValidationStatus,
ValidationSummaries: validationSummaries,
DebugDirectory: document.DebugDirectory,
}, nil
}
@@ -214,6 +336,7 @@ type indexDocument struct {
OutputFiles *[]laneDocument `json:"output_files"`
RejectedFile string `json:"rejected_file"`
WarningsFile string `json:"warnings_file"`
DiagnosticsFile string `json:"diagnostics_file"`
ChunkMap *pipelineDocument `json:"chunk_map"`
EvidenceContext *pipelineDocument `json:"evidence_context"`
}
@@ -250,6 +373,7 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
{name: "manifest_file", got: document.ManifestFile, want: canonicalManifestFile},
{name: "rejected_file", got: document.RejectedFile, want: canonicalRejectedFile},
{name: "warnings_file", got: document.WarningsFile, want: canonicalWarningsFile},
{name: "diagnostics_file", got: document.DiagnosticsFile, want: canonicalDiagnosticsFile},
} {
if field.got != field.want {
return Index{}, fmt.Errorf("notarius index %s %q is incompatible; want %q", field.name, field.got, field.want)
@@ -264,6 +388,7 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
ManifestFile: document.ManifestFile,
RejectedFile: document.RejectedFile,
WarningsFile: document.WarningsFile,
DiagnosticsFile: document.DiagnosticsFile,
}
var err error
if index.ManifestPath, err = resolveRegularFile(bundleRoot, index.ManifestFile); err != nil {
@@ -275,6 +400,9 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
if index.WarningsPath, err = resolveRegularFile(bundleRoot, index.WarningsFile); err != nil {
return Index{}, fmt.Errorf("resolve notarius warning file: %w", err)
}
if index.DiagnosticsPath, err = resolveRegularFile(bundleRoot, index.DiagnosticsFile); err != nil {
return Index{}, fmt.Errorf("resolve notarius diagnostics file: %w", err)
}
seenLanes := make(map[string]struct{}, len(*document.OutputFiles))
for _, lane := range *document.OutputFiles {
@@ -362,12 +490,34 @@ func loadRejections(path string) ([]RejectionSummary, error) {
return summaries, nil
}
type warningDocument struct {
Warnings *[]struct {
Scope string `json:"scope"`
type findingGroupDocument struct {
Disposition string `json:"disposition"`
Category string `json:"category"`
ReasonCode string `json:"reason_code"`
Origin diagnosticOriginDocument `json:"origin"`
OccurrenceCount *int `json:"occurrence_count"`
Samples *[]struct {
Scope string `json:"scope"`
Message string `json:"message"`
} `json:"warnings"`
ChunkID string `json:"chunk_id"`
ChunkIndex *int `json:"chunk_index"`
} `json:"samples"`
OmittedSampleCount *int `json:"omitted_sample_count"`
}
type diagnosticOriginDocument struct {
Stage string `json:"stage"`
StepID string `json:"step_id"`
LaneID string `json:"lane_id"`
ModuleKey string `json:"module_key"`
ValidatorKey string `json:"validator_key"`
}
type warningDocument struct {
SchemaVersion string `json:"schema_version"`
GroupCount *int `json:"group_count"`
OccurrenceCount *int `json:"occurrence_count"`
Groups *[]findingGroupDocument `json:"groups"`
}
func loadWarnings(path string) ([]WarningSummary, error) {
@@ -375,19 +525,151 @@ func loadWarnings(path string) ([]WarningSummary, error) {
if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil {
return nil, fmt.Errorf("decode notarius warnings: %w", err)
}
if document.Warnings == nil {
return nil, fmt.Errorf("notarius warning document is missing warnings array")
if document.SchemaVersion != warningsSchemaVersion || document.GroupCount == nil ||
document.OccurrenceCount == nil || document.Groups == nil {
return nil, fmt.Errorf("notarius warning document is missing or incompatible required fields")
}
summaries := make([]WarningSummary, 0, len(*document.Warnings))
for _, item := range *document.Warnings {
if strings.TrimSpace(item.ReasonCode) == "" || strings.TrimSpace(item.Message) == "" {
return nil, fmt.Errorf("notarius warning entries require reason_code and message")
if *document.GroupCount < 0 || *document.GroupCount > maxWarningGroups || *document.OccurrenceCount < 0 ||
*document.GroupCount != len(*document.Groups) {
return nil, fmt.Errorf("notarius warning document counts are inconsistent")
}
summaries = append(summaries, WarningSummary{Scope: item.Scope, ReasonCode: item.ReasonCode})
summaries := make([]WarningSummary, 0, len(*document.Groups))
occurrences := 0
for _, group := range *document.Groups {
if err := validateFindingGroup(group); err != nil {
return nil, fmt.Errorf("notarius warning group: %w", err)
}
if group.Disposition != "warning" {
return nil, fmt.Errorf("notarius warning group disposition %q is invalid", group.Disposition)
}
if *group.OccurrenceCount > int(^uint(0)>>1)-occurrences {
return nil, fmt.Errorf("notarius warning occurrence count overflows")
}
occurrences += *group.OccurrenceCount
summaries = append(summaries, WarningSummary{
Disposition: group.Disposition, Category: group.Category, ReasonCode: group.ReasonCode,
Origin: diagnosticOrigin(group.Origin), OccurrenceCount: *group.OccurrenceCount,
})
}
if occurrences != *document.OccurrenceCount {
return nil, fmt.Errorf("notarius warning document occurrence count is inconsistent")
}
return summaries, nil
}
type diagnosticDocument struct {
SchemaVersion string `json:"schema_version"`
GroupCount *int `json:"group_count"`
OccurrenceCount *int `json:"occurrence_count"`
Truncated *bool `json:"truncated"`
UnrepresentedOccurrenceCount *int `json:"unrepresented_occurrence_count"`
Groups *[]findingGroupDocument `json:"groups"`
}
func loadDiagnostics(path string) ([]DiagnosticSummary, int, bool, error) {
var document diagnosticDocument
if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil {
return nil, 0, false, fmt.Errorf("decode notarius diagnostics: %w", err)
}
if document.SchemaVersion != diagnosticsSchemaVersion || document.GroupCount == nil ||
document.OccurrenceCount == nil || document.Truncated == nil ||
document.UnrepresentedOccurrenceCount == nil || document.Groups == nil {
return nil, 0, false, fmt.Errorf("notarius diagnostics document is missing or incompatible required fields")
}
if *document.GroupCount < 0 || *document.GroupCount > maxDiagnosticGroups || *document.OccurrenceCount < 0 ||
*document.UnrepresentedOccurrenceCount < 0 || *document.GroupCount != len(*document.Groups) {
return nil, 0, false, fmt.Errorf("notarius diagnostics document counts are inconsistent")
}
if !*document.Truncated && *document.UnrepresentedOccurrenceCount != 0 {
return nil, 0, false, fmt.Errorf("notarius diagnostics document has unrepresented occurrences without truncation")
}
summaries := make([]DiagnosticSummary, 0, len(*document.Groups))
representedOccurrences := 0
for _, group := range *document.Groups {
if err := validateFindingGroup(group); err != nil {
return nil, 0, false, fmt.Errorf("notarius diagnostic group: %w", err)
}
if group.Disposition != "advisory" && group.Disposition != "observation" {
return nil, 0, false, fmt.Errorf("notarius diagnostic group disposition %q is invalid", group.Disposition)
}
if *group.OccurrenceCount > int(^uint(0)>>1)-representedOccurrences {
return nil, 0, false, fmt.Errorf("notarius diagnostic occurrence count overflows")
}
representedOccurrences += *group.OccurrenceCount
summaries = append(summaries, DiagnosticSummary{
Disposition: group.Disposition, Category: group.Category, ReasonCode: group.ReasonCode,
Origin: diagnosticOrigin(group.Origin), OccurrenceCount: *group.OccurrenceCount,
})
}
if *document.UnrepresentedOccurrenceCount > int(^uint(0)>>1)-representedOccurrences ||
representedOccurrences+*document.UnrepresentedOccurrenceCount != *document.OccurrenceCount {
return nil, 0, false, fmt.Errorf("notarius diagnostics document occurrence count is inconsistent")
}
return summaries, *document.OccurrenceCount, *document.Truncated, nil
}
func validateFindingGroup(group findingGroupDocument) error {
if strings.TrimSpace(group.Disposition) == "" || strings.TrimSpace(group.Category) == "" ||
strings.TrimSpace(group.ReasonCode) == "" || !validDiagnosticOriginStage(group.Origin.Stage) ||
!validDiagnosticCategory(group.Disposition, group.Category) ||
group.OccurrenceCount == nil || *group.OccurrenceCount <= 0 || group.Samples == nil ||
group.OmittedSampleCount == nil || *group.OmittedSampleCount < 0 {
return fmt.Errorf("missing required fields")
}
if len(*group.Samples) == 0 || len(*group.Samples) > maxFindingSamples ||
*group.OmittedSampleCount != *group.OccurrenceCount-len(*group.Samples) {
return fmt.Errorf("sample counts are inconsistent")
}
for _, sample := range *group.Samples {
if strings.TrimSpace(sample.Scope) == "" || strings.TrimSpace(sample.Message) == "" ||
(sample.ChunkIndex != nil && *sample.ChunkIndex < 0) {
return fmt.Errorf("samples require scope and message")
}
}
return nil
}
func validDiagnosticCategory(disposition, category string) bool {
switch disposition {
case "warning":
return category == "configuration" || category == "degradation" ||
category == "validation_incomplete" || category == "fallback"
case "advisory":
return category == "data_quality"
case "observation":
return category == "normalization"
default:
return false
}
}
func validDiagnosticOriginStage(stage string) bool {
switch stage {
case "references", "chunk", "extract", "merge", "normalize":
return true
default:
return false
}
}
func diagnosticOrigin(document diagnosticOriginDocument) DiagnosticOrigin {
return DiagnosticOrigin{
Stage: document.Stage, StepID: document.StepID, LaneID: document.LaneID,
ModuleKey: document.ModuleKey, ValidatorKey: document.ValidatorKey,
}
}
func sumWarningOccurrences(values []WarningSummary) (int, error) {
total := 0
for _, value := range values {
if value.OccurrenceCount > int(^uint(0)>>1)-total {
return 0, fmt.Errorf("notarius warning occurrence count overflows")
}
total += value.OccurrenceCount
}
return total, nil
}
func decodeBoundedJSON(path string, limit int64, destination any) error {
data, err := fileops.ReadRegularFile(path, limit)
if err != nil {

View File

@@ -52,6 +52,10 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
if result.Receipt.SchemaVersion != ReceiptSchemaVersion || result.Receipt.RunID != "notarius-run-1" {
t.Fatalf("receipt = %#v", result.Receipt)
}
if len(result.Receipt.ValidationSummaries) != 1 || result.Receipt.ValidationSummaries[0].LaneID != "npc-registry" ||
result.Receipt.ValidationSummaries[0].Status != "complete" {
t.Fatalf("validation summaries = %#v", result.Receipt.ValidationSummaries)
}
if len(result.Index.Lanes) != 1 || result.Index.Lanes[0].LaneID != "npc-registry" {
t.Fatalf("lanes = %#v", result.Index.Lanes)
}
@@ -64,9 +68,107 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
if len(result.Rejections) != 1 || result.Rejections[0].LaneID != "spells" || result.Rejections[0].ReasonCode != "invalid_spell" {
t.Fatalf("rejections = %#v", result.Rejections)
}
if len(result.Warnings) != 1 || result.Warnings[0].Scope != "lane:npc-registry" || result.Warnings[0].ReasonCode != "normalized_name" {
if len(result.Warnings) != 1 || result.Warnings[0].Category != "degradation" || result.Warnings[0].ReasonCode != "normalized_name" {
t.Fatalf("warnings = %#v", result.Warnings)
}
if len(result.Diagnostics) != 1 || result.Diagnostics[0].Category != "data_quality" || result.Diagnostics[0].ReasonCode != "low_confidence" {
t.Fatalf("diagnostics = %#v", result.Diagnostics)
}
}
func TestSubprocessRunnerBuildsOrderedReferenceArguments(t *testing.T) {
req := validRunRequest(t)
referenceRoot := t.TempDir()
req.References = []ReferenceBinding{
{Selector: " party ", Path: filepath.Join(referenceRoot, "party context=primary.json")},
{Selector: " npc-registry . extract . glossary ", Path: filepath.Join(referenceRoot, "glossary.json")},
}
originalReferences := append([]ReferenceBinding(nil), req.References...)
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)
}
wantArgs := []string{
"run", req.PipelineID,
"--config", req.ConfigPath,
"--input", req.InputPath,
"--output-dir", req.OutputRoot,
"--reference", "party=" + req.References[0].Path,
"--reference", "npc-registry.extract.glossary=" + req.References[1].Path,
"--json",
}
if !reflect.DeepEqual(captured.Args, wantArgs) {
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs)
}
if !reflect.DeepEqual(req.References, originalReferences) {
t.Fatalf("Run() mutated caller references = %#v, want %#v", req.References, originalReferences)
}
}
func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) {
tests := []struct {
name string
references func(string) []ReferenceBinding
wantErr string
}{
{
name: "invalid selector",
references: func(root string) []ReferenceBinding {
return []ReferenceBinding{{Selector: "lane.prepare.party", Path: filepath.Join(root, "party.json")}}
},
wantErr: "selector",
},
{
name: "duplicate normalized selector",
references: func(root string) []ReferenceBinding {
return []ReferenceBinding{
{Selector: "lane.party", Path: filepath.Join(root, "party.json")},
{Selector: " lane . party ", Path: filepath.Join(root, "party-2.json")},
}
},
wantErr: "duplicated",
},
{
name: "empty path",
references: func(string) []ReferenceBinding {
return []ReferenceBinding{{Selector: "party", Path: " "}}
},
wantErr: "path is required",
},
{
name: "relative path",
references: func(string) []ReferenceBinding {
return []ReferenceBinding{{Selector: "party", Path: "references/party.json"}}
},
wantErr: "path must be absolute",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := validRunRequest(t)
req.References = tt.references(t.TempDir())
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(), tt.wantErr) {
t.Fatalf("Run() error = %v, want containing %q", err, tt.wantErr)
}
if started {
t.Fatal("subprocess started after request validation failure")
}
})
}
}
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
@@ -171,8 +273,10 @@ func TestLoadReceiptValidation(t *testing.T) {
valid := map[string]any{
"schema_version": ReceiptSchemaVersion, "run_id": "run-1", "pipeline_id": "pipeline-1",
"output_directory": filepath.Join(root, "outputs", "run-1"), "index_file": "index.json",
"normalized_output_count": 1, "rejected_output_count": 0, "warning_count": 0,
"validation_status": "approved", "future_field": true,
"normalized_output_count": 1, "rejected_output_count": 0,
"warning_group_count": 0, "warning_occurrence_count": 0,
"diagnostic_group_count": 0, "diagnostic_occurrence_count": 0,
"diagnostics_truncated": false, "validation_status": "approved", "future_field": true,
}
tests := []struct {
name string
@@ -183,11 +287,12 @@ func TestLoadReceiptValidation(t *testing.T) {
}{
{name: "unknown fields tolerated", wantOK: true},
{name: "malformed", raw: []byte("{")},
{name: "unsupported version", mutate: func(v map[string]any) { v["schema_version"] = "notarius.run-result.v2" }},
{name: "unsupported version", mutate: func(v map[string]any) { v["schema_version"] = "notarius.run-result.v1" }},
{name: "missing field", mutate: func(v map[string]any) { delete(v, "run_id") }},
{name: "pipeline mismatch", mutate: func(v map[string]any) { v["pipeline_id"] = "other" }},
{name: "relative output", mutate: func(v map[string]any) { v["output_directory"] = "run-1" }},
{name: "negative count", mutate: func(v map[string]any) { v["warning_count"] = -1 }},
{name: "negative count", mutate: func(v map[string]any) { v["warning_group_count"] = -1 }},
{name: "invalid validation status", mutate: func(v map[string]any) { v["validation_status"] = "valid" }},
{
name: "nested index", mutate: func(v map[string]any) { v["index_file"] = "nested/index.json" },
wantError: `index_file "nested/index.json"`,
@@ -369,22 +474,26 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
root := t.TempDir()
rejectedPath := filepath.Join(root, "rejected.json")
warningsPath := filepath.Join(root, "warnings.json")
diagnosticsPath := filepath.Join(root, "diagnostics.json")
writeJSONFile(t, rejectedPath, map[string]any{"rejected": []any{map[string]any{
"stage": "validate", "lane_id": "spells", "reason_code": "invalid", "message": "do not retain this", "future": true,
}}, "future": true})
writeJSONFile(t, warningsPath, map[string]any{"warnings": []any{map[string]any{
"scope": "lane:spells", "reason_code": "bounded", "message": "do not retain this", "future": true,
}}, "future": true})
writeJSONFile(t, warningsPath, findingEnvelope(warningsSchemaVersion, []any{findingGroup("warning", "degradation", "bounded", "normalize", 2)}, 2, false, 0))
writeJSONFile(t, diagnosticsPath, findingEnvelope(diagnosticsSchemaVersion, []any{findingGroup("advisory", "data_quality", "low_confidence", "normalize", 3)}, 4, true, 1))
rejections, err := loadRejections(rejectedPath)
if err != nil || len(rejections) != 1 || rejections[0].ReasonCode != "invalid" {
t.Fatalf("loadRejections() = %#v, %v", rejections, err)
}
warnings, err := loadWarnings(warningsPath)
if err != nil || len(warnings) != 1 || warnings[0].Scope != "lane:spells" {
if err != nil || len(warnings) != 1 || warnings[0].Category != "degradation" || warnings[0].OccurrenceCount != 2 {
t.Fatalf("loadWarnings() = %#v, %v", warnings, err)
}
diagnostics, occurrences, truncated, err := loadDiagnostics(diagnosticsPath)
if err != nil || len(diagnostics) != 1 || occurrences != 4 || !truncated || diagnostics[0].Category != "data_quality" {
t.Fatalf("loadDiagnostics() = %#v, %d, %t, %v", diagnostics, occurrences, truncated, err)
}
for name, path := range map[string]string{"rejections": rejectedPath, "warnings": warningsPath} {
for name, path := range map[string]string{"rejections": rejectedPath, "warnings": warningsPath, "diagnostics": diagnosticsPath} {
t.Run("malformed "+name, func(t *testing.T) {
if err := os.WriteFile(path, []byte("{"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
@@ -392,8 +501,10 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
var err error
if name == "rejections" {
_, err = loadRejections(path)
} else {
} else if name == "warnings" {
_, err = loadWarnings(path)
} else {
_, _, _, err = loadDiagnostics(path)
}
if err == nil {
t.Fatal("summary decoder error = nil")
@@ -410,16 +521,23 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
if _, err := loadRejections(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("loadRejections(oversized) error = %v", err)
}
if _, _, _, err := loadDiagnostics(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("loadDiagnostics(oversized) error = %v", err)
}
}
func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) {
req := RunRequest{PipelineID: "pipeline"}
req := RunRequest{PipelineID: "pipeline", References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}}
want := RunResult{BundleRoot: "/bundle"}
fake := &FakeRunner{Result: want}
got, err := fake.Run(context.Background(), req)
if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(fake.Requests, []RunRequest{req}) {
t.Fatalf("Run() = %#v, %v; requests = %#v", got, err, fake.Requests)
}
req.References[0].Path = "/references/changed.json"
if fake.Requests[0].References[0].Path != "/references/party.json" {
t.Fatalf("fake retained aliased request references: %#v", fake.Requests[0].References)
}
wantErr := errors.New("configured failure")
fake.Err = wantErr
@@ -478,13 +596,12 @@ func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown boo
}
}
rejection := map[string]any{"stage": "validate", "lane_id": "spells", "reason_code": "invalid_spell", "message": strings.Repeat("external detail", 20)}
warning := map[string]any{"scope": "lane:npc-registry", "reason_code": "normalized_name", "message": strings.Repeat("external warning", 20)}
if includeUnknown {
rejection["future"] = true
warning["future"] = true
}
writeJSONFile(t, filepath.Join(bundle, "rejected.json"), map[string]any{"rejected": []any{rejection}, "future": true})
writeJSONFile(t, filepath.Join(bundle, "warnings.json"), map[string]any{"warnings": []any{warning}, "future": true})
writeJSONFile(t, filepath.Join(bundle, "warnings.json"), findingEnvelope(warningsSchemaVersion, []any{findingGroup("warning", "degradation", "normalized_name", "normalize", 2)}, 2, false, 0))
writeJSONFile(t, filepath.Join(bundle, "diagnostics.json"), findingEnvelope(diagnosticsSchemaVersion, []any{findingGroup("advisory", "data_quality", "low_confidence", "normalize", 3)}, 4, true, 1))
index := validIndexValue([]any{map[string]any{
"lane_id": "npc-registry", "file": "lanes/npc.json", "media_type": "application/json",
"module_key": "dnd/npc-registry", "schema_id": "notarius.dnd.npc_registry",
@@ -503,7 +620,13 @@ func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown boo
receipt := map[string]any{
"schema_version": ReceiptSchemaVersion, "run_id": "notarius-run-1", "pipeline_id": req.PipelineID,
"output_directory": bundle, "index_file": "index.json", "normalized_output_count": 1,
"rejected_output_count": 1, "warning_count": 1, "validation_status": "rejected",
"rejected_output_count": 1, "warning_group_count": 1, "warning_occurrence_count": 2,
"diagnostic_group_count": 1, "diagnostic_occurrence_count": 4,
"diagnostics_truncated": true, "validation_status": "rejected",
"validation_summaries": []any{map[string]any{
"stage": "normalize", "lane_id": "npc-registry", "status": "complete",
"producer_attempt_count": 1, "terminal_action": "accepted",
}},
}
if includeUnknown {
receipt["future"] = true
@@ -517,7 +640,7 @@ func createBundleSkeleton(t *testing.T) string {
if err := os.MkdirAll(filepath.Join(bundle, "lanes"), 0o755); err != nil {
t.Fatalf("MkdirAll(bundle) error = %v", err)
}
for _, name := range []string{"manifest.json", "rejected.json", "warnings.json", "lanes/npc.json", "chunk-map.json"} {
for _, name := range []string{"manifest.json", "rejected.json", "warnings.json", "diagnostics.json", "lanes/npc.json", "chunk-map.json"} {
if err := os.WriteFile(filepath.Join(bundle, filepath.FromSlash(name)), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", name, err)
}
@@ -528,10 +651,31 @@ func createBundleSkeleton(t *testing.T) string {
func validIndexValue(lanes []any) map[string]any {
return map[string]any{
"manifest_file": "manifest.json", "output_files": lanes,
"rejected_file": "rejected.json", "warnings_file": "warnings.json",
"rejected_file": "rejected.json", "warnings_file": "warnings.json", "diagnostics_file": "diagnostics.json",
}
}
func findingGroup(disposition, category, reasonCode, origin string, occurrences int) map[string]any {
return map[string]any{
"disposition": disposition, "category": category, "reason_code": reasonCode,
"origin": map[string]any{"stage": origin, "lane_id": "npc-registry"}, "occurrence_count": occurrences,
"samples": []any{map[string]any{"scope": "lane:npc-registry", "message": "external detail"}},
"omitted_sample_count": occurrences - 1,
}
}
func findingEnvelope(schema string, groups []any, occurrences int, truncated bool, unrepresented int) map[string]any {
value := map[string]any{
"schema_version": schema, "group_count": len(groups), "occurrence_count": occurrences,
"groups": groups,
}
if schema == diagnosticsSchemaVersion {
value["truncated"] = truncated
value["unrepresented_occurrence_count"] = unrepresented
}
return value
}
func writeJSONFile(t *testing.T, path string, value any) {
t.Helper()
data, err := json.Marshal(value)

View File

@@ -7,12 +7,14 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
@@ -25,6 +27,37 @@ type materializingNotariusRunner struct {
failuresRemaining int
}
type assertExtractionSourcesStage struct {
keys []string
runs *int
}
func (s assertExtractionSourcesStage) Name() string { return "analyze" }
func (s assertExtractionSourcesStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
definitions := artifacts.ExtractionDefinitionsFromConfig(env.Config.Pipeline.Notarius)
catalog, err := artifacts.BootstrapRuntimeCatalog(nil, env.EffectiveArtifacts, definitions)
if err != nil {
return nil, err
}
paths, err := env.ArtifactStore.EnsureLayoutFor(env.Config.Session.Campaign, env.Config.Session.SessionID)
if err != nil {
return nil, err
}
catalog.HydrateExtractionArtifacts(paths, m, definitions)
for _, key := range s.keys {
sourceID := artifacts.ExtractionArtifactSourceID(key)
entry, ok := catalog.Lookup(sourceID)
if !ok || !entry.Available || entry.SourceID != sourceID || entry.Path == "" {
return nil, fmt.Errorf("extraction source %q unavailable: %#v, present=%v", sourceID, entry, ok)
}
}
if s.runs != nil {
*s.runs = *s.runs + 1
}
return &stage.StageResult{}, nil
}
func (r *materializingNotariusRunner) Run(_ context.Context, req notarius.RunRequest) (notarius.RunResult, error) {
r.requests = append(r.requests, req)
if r.failuresRemaining > 0 {
@@ -41,29 +74,44 @@ func (r *materializingNotariusRunner) Run(_ context.Context, req notarius.RunReq
filepath.Join(bundle, "index.json"): `{"manifest_file":"manifest.json"}`,
filepath.Join(bundle, "manifest.json"): `{}`,
filepath.Join(bundle, "rejected.json"): `{"rejected":[]}`,
filepath.Join(bundle, "warnings.json"): `{"warnings":[]}`,
filepath.Join(lanesDir, "npcs.json"): `{"npcs":[]}`,
filepath.Join(bundle, "warnings.json"): `{"schema_version":"notarius.warnings.v2","group_count":0,"occurrence_count":0,"groups":[]}`,
filepath.Join(bundle, "diagnostics.json"): `{"schema_version":"notarius.diagnostics.v1","group_count":0,"occurrence_count":0,"truncated":false,"unrepresented_occurrence_count":0,"groups":[]}`,
} {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return notarius.RunResult{}, err
}
}
output := r.cfg.Outputs["npc_registry"]
keys := make([]string, 0, len(r.cfg.Outputs))
for key := range r.cfg.Outputs {
keys = append(keys, key)
}
sort.Strings(keys)
lanes := make([]notarius.LaneDescriptor, 0, len(keys))
for _, key := range keys {
output := r.cfg.Outputs[key]
filename := key + ".json"
path := filepath.Join(lanesDir, filename)
if err := os.WriteFile(path, []byte(`{"records":[]}`), 0o644); err != nil {
return notarius.RunResult{}, err
}
lanes = append(lanes, notarius.LaneDescriptor{
LaneID: output.LaneID, File: filepath.ToSlash(filepath.Join("lanes", filename)), Path: path,
MediaType: output.MediaType, SchemaID: output.SchemaID,
SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
})
}
return notarius.RunResult{
Receipt: notarius.Receipt{
SchemaVersion: notarius.ReceiptSchemaVersion, RunID: externalRunID,
PipelineID: req.PipelineID, OutputDirectory: bundle, IndexFile: "index.json",
NormalizedOutputCount: 1, ValidationStatus: "valid",
NormalizedOutputCount: len(lanes), ValidationStatus: "approved",
},
BundleRoot: bundle,
Index: notarius.Index{
Path: filepath.Join(bundle, "index.json"), RejectedPath: filepath.Join(bundle, "rejected.json"),
WarningsPath: filepath.Join(bundle, "warnings.json"),
Lanes: []notarius.LaneDescriptor{{
LaneID: output.LaneID, File: "lanes/npcs.json", Path: filepath.Join(lanesDir, "npcs.json"),
MediaType: output.MediaType, SchemaID: output.SchemaID,
SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
}},
DiagnosticsPath: filepath.Join(bundle, "diagnostics.json"),
Lanes: lanes,
},
}, nil
}
@@ -100,6 +148,182 @@ func TestExtractLifecycleDisabledThenEnabled(t *testing.T) {
}
}
func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true)
originalPaths := configureLifecycleReferences(t, cfg)
summary, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if len(summary.Executed) != 2 || len(runner.requests) != 1 {
t.Fatalf("summary = %#v requests=%d", summary, len(runner.requests))
}
paths, err := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if err != nil {
t.Fatalf("EnsureLayoutFor() error = %v", err)
}
want := []struct {
selector string
sourceID string
filename string
}{
{selector: "glossary", sourceID: artifactpolicy.SourceInputGlossary, filename: "glossary.yml"},
{selector: "party", sourceID: artifactpolicy.SourceInputParty, filename: "party.yml"},
{selector: "players", sourceID: artifactpolicy.SourceInputPlayers, filename: "players.yml"},
{selector: "spells", sourceID: artifactpolicy.SourceInputSpellCatalog, filename: "spell_catalog.json"},
}
request := runner.requests[0]
if len(request.References) != len(want) {
t.Fatalf("references = %#v", request.References)
}
loaded := loadLifecycleManifest(t, cfg)
for index, expected := range want {
binding := request.References[index]
canonical := filepath.Join(paths.InputsDir, expected.filename)
snapshot := filepath.Join(
artifacts.SessionRunNotariusReferencesDirForCampaign(
cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, loaded.RunID,
),
expected.filename,
)
if binding.Selector != expected.selector || binding.Path != snapshot || binding.Path == canonical || binding.Path == originalPaths[expected.sourceID] {
t.Fatalf("reference[%d] = %#v, want selector %q snapshot %q and not prepared/source paths", index, binding, expected.selector, snapshot)
}
}
extract := loaded.Stages["extract"]
if extract == nil || extract.Status != manifest.StatusSucceeded || extract.Metadata["reference_count"] != float64(len(want)) {
t.Fatalf("extract record = %#v", extract)
}
references, ok := extract.Metadata["references"].([]any)
if !ok || len(references) != len(want) || len(references) > config.MaxNotariusReferenceBindings {
t.Fatalf("reference metadata = %#v", extract.Metadata["references"])
}
for index, raw := range references {
entry, ok := raw.(map[string]any)
if !ok || len(entry) != 5 || entry["selector"] != want[index].selector || entry["source_id"] != want[index].sourceID {
t.Fatalf("reference metadata[%d] = %#v", index, raw)
}
}
}
func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDownstream(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true)
originalPaths := configureLifecycleReferences(t, cfg)
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
t.Fatalf("initial executeStages() error = %v", err)
}
before := loadLifecycleManifest(t, cfg)
beforeChecksum := lifecycleInputChecksum(t, before, "party")
for _, name := range []string{"render", "analyze", "publish"} {
before.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), before); err != nil {
t.Fatalf("Save(downstream success) error = %v", err)
}
if err := os.WriteFile(originalPaths[artifactpolicy.SourceInputParty], []byte("changed party bytes\n"), 0o644); err != nil {
t.Fatalf("WriteFile(party source) error = %v", err)
}
prepared := loadLifecycleManifest(t, cfg)
prepare, err := stage.Select("prepare")
if err != nil {
t.Fatalf("stage.Select(prepare) error = %v", err)
}
if _, err := prepare.Run(context.Background(), env, prepared); err != nil {
t.Fatalf("prepare.Run() error = %v", err)
}
if lifecycleInputChecksum(t, prepared, "party") == beforeChecksum {
t.Fatal("prepared party checksum did not change")
}
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), prepared); err != nil {
t.Fatalf("Save(reprepared manifest) error = %v", err)
}
plan, err := BuildSingleStagePlan("extract")
if err != nil {
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
}
run, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
if err != nil {
t.Fatalf("rerun executeStages() error = %v", err)
}
if len(run.Executed) != 1 || len(run.Skipped) != 0 || len(runner.requests) != 2 {
t.Fatalf("rerun summary = %#v requests=%d", run, len(runner.requests))
}
after := loadLifecycleManifest(t, cfg)
if after.Stages["extract"].Status != manifest.StatusSucceeded {
t.Fatalf("extract status = %#v", after.Stages["extract"])
}
for _, name := range []string{"render", "analyze", "publish"} {
if after.Stages[name] == nil || after.Stages[name].Status != manifest.StatusStale {
t.Fatalf("%s status = %#v, want stale", name, after.Stages[name])
}
}
}
func TestExtractLifecycleSessionOverrideBytesReachCanonicalReference(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true)
configureLifecycleReferences(t, cfg)
overridePath := filepath.Join(filepath.Dir(cfg.SessionPath), "session-party.yml")
if err := os.WriteFile(overridePath, []byte("session override party\n"), 0o644); err != nil {
t.Fatalf("WriteFile(session override) error = %v", err)
}
cfg.StableInputs.PartyFile = config.ResolvedInputFile{
Path: "./session-party.yml", ConfigPath: cfg.SessionPath, Source: "session_config",
}
cfg.Session.Inputs.PartyFile = "./session-party.yml"
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if len(runner.requests) != 1 {
t.Fatalf("requests = %d", len(runner.requests))
}
var partyPath string
for _, binding := range runner.requests[0].References {
if binding.Selector == "party" {
partyPath = binding.Path
}
}
contents, err := os.ReadFile(partyPath)
if err != nil {
t.Fatalf("ReadFile(prepared party) error = %v", err)
}
if string(contents) != "session override party\n" || partyPath == overridePath {
t.Fatalf("prepared party path=%q contents=%q override=%q", partyPath, contents, overridePath)
}
}
func TestExtractLifecycleEmptyReferencesPreserveAllDndExtractionSources(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true)
cfg.Pipeline.Notarius.Outputs = lifecycleDndOutputs()
keys := make([]string, 0, len(cfg.Pipeline.Notarius.Outputs))
for key := range cfg.Pipeline.Notarius.Outputs {
keys = append(keys, key)
}
sort.Strings(keys)
analyzeRuns := 0
extractPlan, err := BuildSingleStagePlan("extract")
if err != nil {
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
}
plan := append(extractPlan, assertExtractionSourcesStage{keys: keys, runs: &analyzeRuns})
summary, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if len(summary.Executed) != 2 || len(runner.requests) != 1 || len(runner.requests[0].References) != 0 || analyzeRuns != 1 {
t.Fatalf("summary=%#v requests=%#v analyze=%d", summary, runner.requests, analyzeRuns)
}
loaded := loadLifecycleManifest(t, cfg)
if got := len(loaded.Stages["extract"].Outputs); got != len(keys)+1 {
t.Fatalf("extract outputs = %d, want %d lanes plus index", got, len(keys))
}
}
func TestExtractLifecycleChangedOutcomeRerunsSucceededDownstream(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, false)
analyzeRuns := 0
@@ -394,6 +618,73 @@ func markLifecycleStageSucceeded(t *testing.T, cfg *config.Config, name string)
}
}
func prepareExtractLifecyclePlan(t *testing.T) []stage.Stage {
t.Helper()
prepare, err := BuildSingleStagePlan("prepare")
if err != nil {
t.Fatalf("BuildSingleStagePlan(prepare) error = %v", err)
}
extract, err := BuildSingleStagePlan("extract")
if err != nil {
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
}
return append(prepare, extract...)
}
func configureLifecycleReferences(t *testing.T, cfg *config.Config) map[string]string {
t.Helper()
cfg.Pipeline.Notarius.References = map[string]string{
"party": artifactpolicy.SourceInputParty,
"players": artifactpolicy.SourceInputPlayers,
"glossary": artifactpolicy.SourceInputGlossary,
"spells": artifactpolicy.SourceInputSpellCatalog,
}
spellPath := filepath.Join(filepath.Dir(cfg.CampaignPath), "spells.json")
if err := os.WriteFile(spellPath, []byte(`{"spells":[]}`+"\n"), 0o644); err != nil {
t.Fatalf("WriteFile(spell catalog) error = %v", err)
}
cfg.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
Path: "./spells.json", ConfigPath: cfg.CampaignPath, Source: "campaign_config",
}
cfg.Session.Inputs.SpellCatalogFile = "./spells.json"
return map[string]string{
artifactpolicy.SourceInputParty: filepath.Join(filepath.Dir(cfg.CampaignPath), "party.yml"),
artifactpolicy.SourceInputPlayers: filepath.Join(filepath.Dir(cfg.CampaignPath), "players.yml"),
artifactpolicy.SourceInputGlossary: filepath.Join(filepath.Dir(cfg.CampaignPath), "glossary.yml"),
artifactpolicy.SourceInputSpellCatalog: spellPath,
}
}
func lifecycleInputChecksum(t *testing.T, m *manifest.Manifest, kind string) string {
t.Helper()
for _, input := range m.Inputs {
if input.Kind == kind {
if strings.TrimSpace(input.Checksum) == "" {
t.Fatalf("input %q has no checksum: %#v", kind, input)
}
return input.Checksum
}
}
t.Fatalf("manifest input %q not found: %#v", kind, m.Inputs)
return ""
}
func lifecycleDndOutputs() map[string]config.NotariusOutputConfig {
return map[string]config.NotariusOutputConfig{
"item_registry": {LaneID: "item-registry", MediaType: "application/json", SchemaID: "notarius.dnd.item_registry", SchemaVersion: "v1", ModuleKey: "dnd/item-registry"},
"npc_registry": {LaneID: "npc-registry", MediaType: "application/json", SchemaID: "notarius.dnd.npc_registry", SchemaVersion: "v1", ModuleKey: "dnd/npc-registry"},
"location_registry": {LaneID: "location-registry", MediaType: "application/json", SchemaID: "notarius.dnd.location_registry", SchemaVersion: "v1", ModuleKey: "dnd/location-registry"},
"scene_descriptions": {LaneID: "scene-descriptions", MediaType: "application/json", SchemaID: "notarius.dnd.scene_descriptions", SchemaVersion: "v1", ModuleKey: "dnd/scene-descriptions"},
"item_occurrences": {LaneID: "item-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.item_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/item-occurrences"},
"spells": {LaneID: "spells", MediaType: "application/json", SchemaID: "notarius.dnd.spells", SchemaVersion: "v1", ModuleKey: "dnd/spells"},
"combat_turns": {LaneID: "combat-turns", MediaType: "application/json", SchemaID: "notarius.dnd.combat_turns", SchemaVersion: "v1", ModuleKey: "dnd/combat-turns"},
"npc_occurrences": {LaneID: "npc-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.npc_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/npc-occurrences"},
"location_occurrences": {LaneID: "location-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.location_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/location-occurrences"},
"enemy_events": {LaneID: "enemy-events", MediaType: "application/json", SchemaID: "notarius.dnd.enemy_events", SchemaVersion: "v1", ModuleKey: "dnd/enemy-events"},
}
}
func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *stage.Env, *materializingNotariusRunner) {
t.Helper()
cfg := testConfig(t)

View File

@@ -458,6 +458,63 @@ inputs:
}
}
func TestOperatorCommandsReportConfiguredSpellCatalog(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
campaignBytes, err := os.ReadFile(campaignPath)
if err != nil {
t.Fatalf("read campaign: %v", err)
}
campaignYAML := strings.Replace(string(campaignBytes), " party_file: ./party.yml\n", " party_file: ./party.yml\n spell_catalog_file: ./spells.json\n", 1)
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign: %v", err)
}
spellPath := filepath.Join(filepath.Dir(campaignPath), "spells.json")
mustWriteTestFile(t, spellPath, "{\"spells\":[]}\n")
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
commonArgs := []string{
"2026-05-03",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}
var stdout bytes.Buffer
var stderr bytes.Buffer
validateArgs := append([]string{"session", "validate"}, commonArgs...)
if code := Execute(validateArgs, &stdout, &stderr); code != 0 {
t.Fatalf("validate exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "OK inputs spell_catalog: "+spellPath) {
t.Fatalf("validate stdout = %q, want spell catalog finding", stdout.String())
}
stdout.Reset()
stderr.Reset()
statusArgs := append([]string{"session", "status"}, commonArgs...)
if code := Execute(statusArgs, &stdout, &stderr); code != 0 {
t.Fatalf("status exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "Stable input spell_catalog: "+spellPath) {
t.Fatalf("status stdout = %q, want spell catalog inventory", stdout.String())
}
if err := os.Remove(spellPath); err != nil {
t.Fatalf("remove spell catalog: %v", err)
}
stdout.Reset()
stderr.Reset()
if code := Execute(validateArgs, &stdout, &stderr); code == 0 {
t.Fatalf("validate missing spell catalog exit code = 0; stdout=%q", stdout.String())
}
if !strings.Contains(stdout.String(), "ERROR inputs spell_catalog missing:") {
t.Fatalf("validate stdout = %q, want missing spell catalog finding", stdout.String())
}
}
func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)

View File

@@ -55,21 +55,26 @@ func inspectStableInputs(cfg *config.Config) []stableInputCheck {
items := []struct {
name string
in config.ResolvedInputFile
optional bool
}{
{name: "speakers", in: cfg.StableInputs.SpeakersFile},
{name: "autocorrect", in: cfg.StableInputs.AutocorrectFile},
{name: "glossary", in: cfg.StableInputs.GlossaryFile},
{name: "players", in: cfg.StableInputs.PlayersFile},
{name: "party", in: cfg.StableInputs.PartyFile},
{name: "spell_catalog", in: cfg.StableInputs.SpellCatalogFile, optional: true},
}
out := make([]stableInputCheck, 0, len(items))
for _, item := range items {
if item.optional && strings.TrimSpace(item.in.Path) == "" {
continue
}
path, err := resolveHelperConfigRelativePath(item.in)
if err != nil {
out = append(out, stableInputCheck{Name: item.name, Err: err})
continue
}
if _, err := os.Stat(path); err != nil {
if err := requireInspectionFile(path, item.name); err != nil {
out = append(out, stableInputCheck{Name: item.name, Path: path, Err: err})
continue
}
@@ -271,8 +276,8 @@ func requireInspectionFile(path, label string) error {
}
return fmt.Errorf("stat %s %q: %w", label, path, err)
}
if info.IsDir() {
return fmt.Errorf("%s %q is a directory", label, path)
if !info.Mode().IsRegular() {
return fmt.Errorf("%s %q is not a regular file", label, path)
}
return nil
}

View File

@@ -0,0 +1,34 @@
//go:build unix
package app
import (
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"golang.org/x/sys/unix"
)
func TestInspectStableInputsRejectsSpellCatalogFIFO(t *testing.T) {
root := t.TempDir()
sourcePath := filepath.Join(root, "spells.fifo")
if err := unix.Mkfifo(sourcePath, 0o644); err != nil {
t.Fatalf("Mkfifo() error = %v", err)
}
cfg := &config.Config{StableInputs: config.ResolvedStableInputs{
SpellCatalogFile: config.ResolvedInputFile{Path: sourcePath, ConfigPath: filepath.Join(root, "campaign.yml")},
}}
for _, check := range inspectStableInputs(cfg) {
if check.Name != "spell_catalog" {
continue
}
if check.Err == nil || !strings.Contains(check.Err.Error(), "not a regular file") {
t.Fatalf("spell catalog check = %#v, want regular-file rejection", check)
}
return
}
t.Fatal("spell catalog inspection result was not reported")
}

View File

@@ -16,6 +16,7 @@ const (
SourceInputPlayers = "narratio.input.players"
SourceInputParty = "narratio.input.party"
SourceInputGlossary = "narratio.input.glossary"
SourceInputSpellCatalog = "narratio.input.spell_catalog"
configuredSourcePrefix = "narratio.artifact."
extractionSourcePrefix = "narratio.extraction."
@@ -55,6 +56,14 @@ type ScriptoriumInputSourceDescriptor struct {
PreviousSession *PreviousSessionSourceDescriptor
}
// PreparedInputSourceDescriptor describes a prepared stable input's source,
// manifest kind, and canonical staged filename.
type PreparedInputSourceDescriptor struct {
SourceID string
ManifestKind string
Filename string
}
// PreviousSessionSourceDescriptor describes one canonical previous-session input source.
type PreviousSessionSourceDescriptor struct {
SourceID string
@@ -158,9 +167,9 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
if trimmed == "" {
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
}
if IsStableInputSource(trimmed) {
if prepared, ok := DescribePreparedInputSource(trimmed); ok {
return ScriptoriumInputSourceDescriptor{
Source: Source{ID: trimmed, Kind: SourceKindStableInput},
Source: Source{ID: prepared.SourceID, Kind: SourceKindStableInput},
}, nil
}
if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") {
@@ -185,15 +194,34 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
return ScriptoriumInputSourceDescriptor{Source: classified}, nil
}
// IsStableInputSource reports whether source is a prepared stable input source
// available only to Scriptorium input resolution.
func IsStableInputSource(source string) bool {
switch strings.TrimSpace(source) {
case SourceInputPlayers, SourceInputParty, SourceInputGlossary:
return true
default:
return false
}
var preparedInputSources = map[string]PreparedInputSourceDescriptor{
SourceInputPlayers: {
SourceID: SourceInputPlayers,
ManifestKind: "players",
Filename: "players.yml",
},
SourceInputParty: {
SourceID: SourceInputParty,
ManifestKind: "party",
Filename: "party.yml",
},
SourceInputGlossary: {
SourceID: SourceInputGlossary,
ManifestKind: "glossary",
Filename: "glossary.yml",
},
SourceInputSpellCatalog: {
SourceID: SourceInputSpellCatalog,
ManifestKind: "spell_catalog",
Filename: "spell_catalog.json",
},
}
// DescribePreparedInputSource returns the canonical descriptor for a prepared
// stable input source.
func DescribePreparedInputSource(source string) (PreparedInputSourceDescriptor, bool) {
descriptor, ok := preparedInputSources[strings.TrimSpace(source)]
return descriptor, ok
}
// DescribePreviousSessionSource validates a canonical previous-session source id

View File

@@ -170,6 +170,7 @@ func TestDescribeScriptoriumInputSource(t *testing.T) {
{name: "prepared players input", source: "narratio.input.players", wantKind: SourceKindStableInput},
{name: "prepared party input", source: "narratio.input.party", wantKind: SourceKindStableInput},
{name: "prepared glossary input", source: "narratio.input.glossary", wantKind: SourceKindStableInput},
{name: "prepared spell catalog input", source: "narratio.input.spell_catalog", wantKind: SourceKindStableInput},
{name: "configured", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"},
{name: "previous", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap", wantPrev: true},
{name: "invalid previous", source: "narratio.previous_session.artifact.", wantErr: ErrInvalidPreviousSessionSource},
@@ -211,6 +212,35 @@ func TestDescribeScriptoriumInputSource(t *testing.T) {
}
}
func TestDescribePreparedInputSource(t *testing.T) {
tests := []struct {
source string
manifestKind string
filename string
}{
{source: SourceInputPlayers, manifestKind: "players", filename: "players.yml"},
{source: SourceInputParty, manifestKind: "party", filename: "party.yml"},
{source: SourceInputGlossary, manifestKind: "glossary", filename: "glossary.yml"},
{source: SourceInputSpellCatalog, manifestKind: "spell_catalog", filename: "spell_catalog.json"},
}
for _, tt := range tests {
t.Run(tt.manifestKind, func(t *testing.T) {
descriptor, ok := DescribePreparedInputSource(" " + tt.source + " ")
if !ok {
t.Fatalf("DescribePreparedInputSource(%q) ok = false", tt.source)
}
if descriptor.SourceID != tt.source || descriptor.ManifestKind != tt.manifestKind || descriptor.Filename != tt.filename {
t.Fatalf("DescribePreparedInputSource(%q) = %#v", tt.source, descriptor)
}
})
}
if _, ok := DescribePreparedInputSource("narratio.input.unknown"); ok {
t.Fatal("DescribePreparedInputSource(unknown) ok = true")
}
}
func TestValidateInputConfiguredReference(t *testing.T) {
configured := map[string]struct{}{"session_recap": {}}

View File

@@ -113,6 +113,12 @@ func SessionRunNotariusLogPathForCampaign(rootDir, campaign, sessionID, runID st
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius.stderr.log")
}
// SessionRunNotariusReferencesDirForCampaign returns the invocation-local
// directory containing verified reference snapshots supplied to Notarius.
func SessionRunNotariusReferencesDirForCampaign(rootDir, campaign, sessionID, runID string) string {
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "references")
}
// SessionRunNotariusOutputRootForCampaign returns the invocation-local Notarius output root.
func SessionRunNotariusOutputRootForCampaign(rootDir, campaign, sessionID, runID string) string {
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius-output")

View File

@@ -64,6 +64,7 @@ func TestSessionNotariusPathsForCampaign(t *testing.T) {
{name: "extract directory", got: SessionRunExtractDirForCampaign(root, campaign, sessionID, runID), want: extractDir},
{name: "receipt", got: SessionRunNotariusReceiptPathForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius.receipt.json")},
{name: "stderr", got: SessionRunNotariusLogPathForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius.stderr.log")},
{name: "references", got: SessionRunNotariusReferencesDirForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "references")},
{name: "output root", got: SessionRunNotariusOutputRootForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius-output")},
{name: "durable bundle", got: SessionNotariusBundleDirForCampaign(root, campaign, sessionID, runID), want: filepath.Join(root, "work", campaign, sessionID, "artifacts", "notarius", runID)},
}

View File

@@ -0,0 +1,191 @@
package artifacts
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// ErrPreparedInputAbsent reports that the current manifest has no record for a
// supported prepared stable input.
var ErrPreparedInputAbsent = errors.New("prepared input absent")
// PreparedInputAbsentError identifies the prepared source absent from the
// current manifest.
type PreparedInputAbsentError struct {
SourceID string
}
func (e *PreparedInputAbsentError) Error() string {
return fmt.Sprintf("%s: %q", ErrPreparedInputAbsent, e.SourceID)
}
func (e *PreparedInputAbsentError) Unwrap() error {
return ErrPreparedInputAbsent
}
// PreparedInputIdentity is the verified identity of one canonical prepared
// session input.
type PreparedInputIdentity struct {
SourceID string
ManifestKind string
Path string
RelativePath string
Checksum string
Size int64
}
// ResolvePreparedInput resolves a prepared stable source exclusively from its
// current manifest record and verifies the canonical file's identity.
func ResolvePreparedInput(paths SessionPaths, m *manifest.Manifest, sourceID string) (PreparedInputIdentity, error) {
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
if !ok {
return PreparedInputIdentity{}, fmt.Errorf("unsupported prepared input source %q", sourceID)
}
rootPath, canonicalPath, relativePath, err := preparedInputCanonicalPaths(paths, descriptor)
if err != nil {
return PreparedInputIdentity{}, err
}
matching := make([]manifest.InputRecord, 0, 1)
if m != nil {
for _, record := range m.Inputs {
if strings.TrimSpace(record.Kind) == descriptor.ManifestKind {
matching = append(matching, record)
}
}
}
if len(matching) == 0 {
if m != nil {
for _, record := range m.Inputs {
recordedPath, pathErr := resolvePreparedManifestPath(paths, record.Path, rootPath)
if pathErr == nil && recordedPath == canonicalPath {
return PreparedInputIdentity{}, fmt.Errorf(
"prepared input source %q canonical path is recorded with manifest kind %q, want %q",
descriptor.SourceID,
strings.TrimSpace(record.Kind),
descriptor.ManifestKind,
)
}
}
}
return PreparedInputIdentity{}, &PreparedInputAbsentError{SourceID: descriptor.SourceID}
}
if len(matching) != 1 {
return PreparedInputIdentity{}, fmt.Errorf(
"prepared input source %q has %d manifest records for kind %q; want exactly one",
descriptor.SourceID,
len(matching),
descriptor.ManifestKind,
)
}
record := matching[0]
recordedPath, err := resolvePreparedManifestPath(paths, record.Path, rootPath)
if err != nil {
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q manifest path: %w", descriptor.SourceID, err)
}
if recordedPath != canonicalPath {
return PreparedInputIdentity{}, fmt.Errorf(
"prepared input source %q manifest path %q does not match canonical path %q",
descriptor.SourceID,
recordedPath,
canonicalPath,
)
}
declaredChecksum := strings.TrimSpace(record.Checksum)
if declaredChecksum == "" {
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q manifest checksum is required", descriptor.SourceID)
}
file, err := fileops.OpenConfinedRegularFile(rootPath, relativePath)
if err != nil {
return PreparedInputIdentity{}, fmt.Errorf("open prepared input source %q: %w", descriptor.SourceID, err)
}
digest := sha256.New()
size, readErr := io.Copy(digest, file)
closeErr := file.Close()
if readErr != nil {
return PreparedInputIdentity{}, fmt.Errorf("checksum prepared input source %q: %w", descriptor.SourceID, readErr)
}
if closeErr != nil {
return PreparedInputIdentity{}, fmt.Errorf("close prepared input source %q: %w", descriptor.SourceID, closeErr)
}
if size == 0 {
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q is empty", descriptor.SourceID)
}
checksum := hex.EncodeToString(digest.Sum(nil))
if !strings.EqualFold(checksum, declaredChecksum) {
return PreparedInputIdentity{}, fmt.Errorf(
"prepared input source %q checksum mismatch: manifest=%q actual=%q",
descriptor.SourceID,
declaredChecksum,
checksum,
)
}
return PreparedInputIdentity{
SourceID: descriptor.SourceID,
ManifestKind: descriptor.ManifestKind,
Path: canonicalPath,
RelativePath: filepath.ToSlash(relativePath),
Checksum: checksum,
Size: size,
}, nil
}
func preparedInputCanonicalPaths(
paths SessionPaths,
descriptor artifactpolicy.PreparedInputSourceDescriptor,
) (rootPath, canonicalPath, relativePath string, err error) {
rootPath, err = filepath.Abs(strings.TrimSpace(paths.Root))
if err != nil || strings.TrimSpace(paths.Root) == "" {
if err == nil {
err = fmt.Errorf("session root is required")
}
return "", "", "", err
}
canonicalPath, err = filepath.Abs(filepath.Join(paths.InputsDir, descriptor.Filename))
if err != nil {
return "", "", "", fmt.Errorf("resolve prepared input canonical path: %w", err)
}
relativePath, err = filepath.Rel(rootPath, canonicalPath)
if err != nil || relativePath == "." || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) {
if err != nil {
return "", "", "", fmt.Errorf("resolve prepared input below session root: %w", err)
}
return "", "", "", fmt.Errorf("prepared input canonical path %q is outside session root %q", canonicalPath, rootPath)
}
return filepath.Clean(rootPath), filepath.Clean(canonicalPath), filepath.Clean(relativePath), nil
}
func resolvePreparedManifestPath(paths SessionPaths, recordedPath, rootPath string) (string, error) {
if strings.TrimSpace(recordedPath) == "" {
return "", fmt.Errorf("recorded path is required")
}
resolved := ResolveSessionLocalPathForRead(paths, recordedPath)
if strings.TrimSpace(resolved) == "" {
return "", fmt.Errorf("recorded path is required")
}
absolute, err := filepath.Abs(resolved)
if err != nil {
return "", fmt.Errorf("resolve recorded path: %w", err)
}
relative, err := filepath.Rel(rootPath, absolute)
if err != nil {
return "", fmt.Errorf("resolve recorded path below session root: %w", err)
}
if relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("recorded path %q is outside session root %q", absolute, rootPath)
}
return filepath.Clean(absolute), nil
}

View File

@@ -0,0 +1,222 @@
package artifacts
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestResolvePreparedInputReturnsVerifiedIdentity(t *testing.T) {
paths, m, canonicalPath, checksum := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n"))
m.Inputs[0].Path = filepath.ToSlash(filepath.Join("inputs", "spell_catalog.json"))
identity, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog)
if err != nil {
t.Fatalf("ResolvePreparedInput() error = %v", err)
}
wantAbsolute, err := filepath.Abs(canonicalPath)
if err != nil {
t.Fatalf("filepath.Abs() error = %v", err)
}
if identity.SourceID != artifactpolicy.SourceInputSpellCatalog ||
identity.ManifestKind != "spell_catalog" ||
identity.Path != wantAbsolute ||
identity.RelativePath != "inputs/spell_catalog.json" ||
identity.Checksum != checksum ||
identity.Size != int64(len("{\"spells\":[]}\n")) {
t.Fatalf("ResolvePreparedInput() = %#v", identity)
}
}
func TestResolvePreparedInputRequiresCurrentManifestRecord(t *testing.T) {
paths, _, _, _ := preparedInputFixture(t, artifactpolicy.SourceInputPlayers, []byte("- Alice\n"))
for _, m := range []*manifest.Manifest{nil, manifest.New("session", time.Now().UTC())} {
_, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputPlayers)
if !errors.Is(err, ErrPreparedInputAbsent) {
t.Fatalf("ResolvePreparedInput() error = %v, want ErrPreparedInputAbsent", err)
}
var absent *PreparedInputAbsentError
if !errors.As(err, &absent) || absent.SourceID != artifactpolicy.SourceInputPlayers {
t.Fatalf("ResolvePreparedInput() error = %#v, want typed players absence", err)
}
}
}
func TestResolvePreparedInputRejectsInvalidManifestEvidence(t *testing.T) {
tests := []struct {
name string
mutate func(*testing.T, SessionPaths, *manifest.Manifest, string)
wantErr string
}{
{
name: "duplicate record",
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
m.Inputs = append(m.Inputs, m.Inputs[0])
},
wantErr: "2 manifest records",
},
{
name: "wrong kind",
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
m.Inputs[0].Kind = "players"
},
wantErr: "recorded with manifest kind",
},
{
name: "wrong canonical path",
mutate: func(t *testing.T, paths SessionPaths, m *manifest.Manifest, _ string) {
wrong := filepath.Join(paths.InputsDir, "other.json")
if err := os.WriteFile(wrong, []byte("other\n"), 0o644); err != nil {
t.Fatalf("WriteFile(wrong) error = %v", err)
}
m.Inputs[0].Path = wrong
},
wantErr: "does not match canonical path",
},
{
name: "traversal path",
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
m.Inputs[0].Path = filepath.Join("..", "..", "outside.json")
},
wantErr: "outside session root",
},
{
name: "missing checksum",
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
m.Inputs[0].Checksum = " "
},
wantErr: "manifest checksum is required",
},
{
name: "checksum mismatch",
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
m.Inputs[0].Checksum = strings.Repeat("0", 64)
},
wantErr: "checksum mismatch",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
paths, m, canonicalPath, _ := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n"))
tt.mutate(t, paths, m, canonicalPath)
_, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ResolvePreparedInput() error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
func TestResolvePreparedInputRejectsInvalidCanonicalFile(t *testing.T) {
tests := []struct {
name string
mutate func(*testing.T, string)
wantErr string
}{
{
name: "missing",
mutate: func(t *testing.T, path string) {
if err := os.Remove(path); err != nil {
t.Fatalf("Remove() error = %v", err)
}
},
wantErr: "open prepared input source",
},
{
name: "symlink",
mutate: func(t *testing.T, path string) {
outside := filepath.Join(t.TempDir(), "outside.json")
if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil {
t.Fatalf("WriteFile(outside) error = %v", err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("Remove() error = %v", err)
}
if err := os.Symlink(outside, path); err != nil {
t.Fatalf("Symlink() error = %v", err)
}
},
wantErr: "not a regular file",
},
{
name: "directory",
mutate: func(t *testing.T, path string) {
if err := os.Remove(path); err != nil {
t.Fatalf("Remove() error = %v", err)
}
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("Mkdir() error = %v", err)
}
},
wantErr: "not a regular file",
},
{
name: "empty",
mutate: func(t *testing.T, path string) {
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatalf("WriteFile(empty) error = %v", err)
}
},
wantErr: "is empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
paths, m, canonicalPath, _ := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n"))
tt.mutate(t, canonicalPath)
_, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ResolvePreparedInput() error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
func TestResolvePreparedInputRejectsUnsupportedSource(t *testing.T) {
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
_, err := ResolvePreparedInput(paths, nil, "narratio.input.unknown")
if err == nil || errors.Is(err, ErrPreparedInputAbsent) || !strings.Contains(err.Error(), "unsupported prepared input source") {
t.Fatalf("ResolvePreparedInput() error = %v", err)
}
}
func preparedInputFixture(
t *testing.T,
sourceID string,
payload []byte,
) (SessionPaths, *manifest.Manifest, string, string) {
t.Helper()
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
if !ok {
t.Fatalf("DescribePreparedInputSource(%q) ok = false", sourceID)
}
canonicalPath := filepath.Join(paths.InputsDir, descriptor.Filename)
if err := os.MkdirAll(paths.InputsDir, 0o755); err != nil {
t.Fatalf("MkdirAll(inputs) error = %v", err)
}
if err := os.WriteFile(canonicalPath, payload, 0o644); err != nil {
t.Fatalf("WriteFile(canonical) error = %v", err)
}
checksum, err := SHA256File(canonicalPath)
if err != nil {
t.Fatalf("SHA256File() error = %v", err)
}
m := manifest.New("session", time.Now().UTC())
m.Inputs = []manifest.InputRecord{{
Kind: descriptor.ManifestKind,
Path: canonicalPath,
Checksum: checksum,
Source: "campaign_config",
}}
return paths, m, canonicalPath, checksum
}

View File

@@ -138,6 +138,74 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config")
}
func TestCampaignSessionMergeSpellCatalog(t *testing.T) {
tests := []struct {
name string
campaignValue string
sessionValue string
wantPath string
wantSource string
}{
{name: "omitted", wantPath: "", wantSource: "campaign_config"},
{name: "campaign inherited", campaignValue: "./campaign-spells.json", wantPath: "./campaign-spells.json", wantSource: "campaign_config"},
{name: "session override", campaignValue: "./campaign-spells.json", sessionValue: "./session-spells.json", wantPath: "./session-spells.json", wantSource: "session_config"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
campaignSpell := ""
if tt.campaignValue != "" {
campaignSpell = " spell_catalog_file: " + tt.campaignValue + "\n"
}
sessionSpell := ""
if tt.sessionValue != "" {
sessionSpell = " spell_catalog_file: " + tt.sessionValue + "\n"
}
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n"+campaignSpell,
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+sessionSpell,
)
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
wantConfigPath := campaignPath
if tt.wantSource == "session_config" {
wantConfigPath = sessionPath
}
assertResolvedStableInput(t, cfg.StableInputs.SpellCatalogFile, tt.wantPath, wantConfigPath, tt.wantSource)
if cfg.Session.Inputs.SpellCatalogFile != tt.wantPath {
t.Fatalf("session spell_catalog_file = %q, want %q", cfg.Session.Inputs.SpellCatalogFile, tt.wantPath)
}
})
}
}
func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) {
tests := []struct {
name string
campaignLine string
sessionLine string
wantErr string
}{
{name: "campaign", campaignLine: " spell_catalog_file: ' '\n", wantErr: "campaign.inputs.spell_catalog_file"},
{name: "session", sessionLine: " spell_catalog_file: ' '\n", wantErr: "session.inputs.spell_catalog_file"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n"+tt.campaignLine,
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+tt.sessionLine,
)
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("LoadWithSessionOptions() error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",

View File

@@ -54,6 +54,7 @@ type CampaignInputsConfig struct {
GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"`
SpellCatalogFile string `yaml:"spell_catalog_file"`
}
// SessionConfig contains per-session inputs and metadata.
@@ -266,6 +267,7 @@ type NotariusConfig struct {
PipelineID string `yaml:"pipeline_id"`
Timeout string `yaml:"timeout"`
WorkingDirectory string `yaml:"working_directory"`
References map[string]string `yaml:"references"`
Outputs map[string]NotariusOutputConfig `yaml:"outputs"`
}
@@ -293,6 +295,7 @@ type SessionInputsConfig struct {
GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"`
SpellCatalogFile string `yaml:"spell_catalog_file"`
}
// SessionAudioS3Input configures S3 session-audio input discovery.
@@ -308,6 +311,7 @@ type ResolvedStableInputs struct {
GlossaryFile ResolvedInputFile
PlayersFile ResolvedInputFile
PartyFile ResolvedInputFile
SpellCatalogFile ResolvedInputFile
}
// ResolvedInputFile records one merged config path and its source config file.

View File

@@ -35,6 +35,9 @@ const (
DefaultAuditaReport = true
DefaultNotariusBinary = "notarius"
DefaultNotariusTimeout = "3h"
// MaxNotariusReferenceBindings is the maximum number of CLI reference
// bindings accepted for one Notarius invocation.
MaxNotariusReferenceBindings = 256
DefaultScriptoriumBinary = "scriptorium"
DefaultScriptoriumTimeout = "10m"

View File

@@ -209,6 +209,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
if sessionCfg == nil {
return ResolvedStableInputs{}, fmt.Errorf("session config is required")
}
if campaignCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(campaignCfg.Inputs.SpellCatalogFile) == "" {
return ResolvedStableInputs{}, fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided")
}
if sessionCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(sessionCfg.Inputs.SpellCatalogFile) == "" {
return ResolvedStableInputs{}, fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
}
campaignName := CampaignID(campaignCfg)
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
@@ -254,6 +260,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
campaignPath,
sessionPath,
),
SpellCatalogFile: selectStableInput(
campaignCfg.Inputs.SpellCatalogFile,
sessionCfg.Inputs.SpellCatalogFile,
campaignPath,
sessionPath,
),
}
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
@@ -261,6 +273,7 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path
sessionCfg.Inputs.PartyFile = stable.PartyFile.Path
sessionCfg.Inputs.SpellCatalogFile = stable.SpellCatalogFile.Path
return stable, nil
}

View File

@@ -1,6 +1,7 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -108,6 +109,7 @@ func TestNotariusStrictYAML(t *testing.T) {
{name: "unknown output field", yaml: "notarius:\n outputs:\n npc_registry:\n lane_id: npc-registry\n unknown: true\n"},
{name: "unsupported session id", yaml: "notarius:\n session_id: forbidden\n"},
{name: "unsupported model", yaml: "notarius:\n model: forbidden\n"},
{name: "duplicate reference selector", yaml: "notarius:\n references:\n party: narratio.input.party\n party: narratio.input.players\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -122,6 +124,134 @@ func TestNotariusStrictYAML(t *testing.T) {
}
}
func TestNotariusReferenceValidationAndNormalization(t *testing.T) {
cfg := validNotariusConfig()
cfg.References = map[string]string{
" party ": " narratio.input.party ",
" chunk . players ": "narratio.input.players",
" npc-registry . extract . glossary ": "narratio.input.glossary",
"spells": "narratio.input.spell_catalog",
}
if err := validateNotarius(cfg, nil); err != nil {
t.Fatalf("validateNotarius() error = %v", err)
}
want := map[string]string{
"party": "narratio.input.party",
"chunk.players": "narratio.input.players",
"npc-registry.extract.glossary": "narratio.input.glossary",
"spells": "narratio.input.spell_catalog",
}
if len(cfg.References) != len(want) {
t.Fatalf("normalized references = %#v, want %#v", cfg.References, want)
}
for selector, source := range want {
if cfg.References[selector] != source {
t.Fatalf("references[%q] = %q, want %q", selector, cfg.References[selector], source)
}
}
}
func TestNotariusReferenceValidationRejectsInvalidBindings(t *testing.T) {
tests := []struct {
name string
references map[string]string
wantErr string
}{
{name: "empty selector", references: map[string]string{" ": "narratio.input.party"}, wantErr: "selector is required"},
{name: "equals in selector", references: map[string]string{"party=x": "narratio.input.party"}, wantErr: "must not contain"},
{name: "invalid stage", references: map[string]string{"lane.prepare.party": "narratio.input.party"}, wantErr: "middle component"},
{name: "empty source", references: map[string]string{"party": " "}, wantErr: "source is required"},
{name: "unsupported source", references: map[string]string{"party": "narratio.input.unknown"}, wantErr: "not a supported prepared input source"},
{
name: "normalized collision",
references: map[string]string{
"chunk.party": "narratio.input.party",
" chunk . party ": "narratio.input.players",
},
wantErr: "normalize to",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := validNotariusConfig()
cfg.References = tt.references
err := validateNotarius(cfg, nil)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("validateNotarius() error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
func TestNotariusReferenceLimit(t *testing.T) {
for _, count := range []int{MaxNotariusReferenceBindings, MaxNotariusReferenceBindings + 1} {
t.Run(fmt.Sprintf("count_%d", count), func(t *testing.T) {
cfg := validNotariusConfig()
cfg.References = make(map[string]string, count)
for i := 0; i < count; i++ {
cfg.References[fmt.Sprintf("lane-%03d.party", i)] = "narratio.input.party"
}
err := validateNotarius(cfg, nil)
if count == MaxNotariusReferenceBindings {
if err != nil {
t.Fatalf("validateNotarius() at limit error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), "at most 256 bindings") {
t.Fatalf("validateNotarius() above limit error = %v", err)
}
})
}
}
func TestNotariusNilAndEmptyReferencesAreValid(t *testing.T) {
for _, references := range []map[string]string{nil, {}} {
cfg := validNotariusConfig()
cfg.References = references
if err := validateNotarius(cfg, nil); err != nil {
t.Fatalf("validateNotarius(%#v) error = %v", references, err)
}
}
}
func TestNotariusSpellCatalogReferenceRequiresEffectiveInput(t *testing.T) {
cfg := loadedValidConfig(t)
cfg.Pipeline.Notarius = validNotariusConfig()
cfg.Pipeline.Notarius.References = map[string]string{"spells": "narratio.input.spell_catalog"}
err := Validate(cfg)
if err == nil || !strings.Contains(err.Error(), "requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file") {
t.Fatalf("Validate() error = %v, want missing spell catalog input", err)
}
cfg.StableInputs.SpellCatalogFile = ResolvedInputFile{Path: "./spells.json", Source: "campaign_config"}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() with spell catalog error = %v", err)
}
}
func validNotariusConfig() *NotariusConfig {
return &NotariusConfig{
Enabled: true,
Binary: "notarius",
ConfigPath: "./notarius.yml",
PipelineID: "dnd-session",
Timeout: "45m",
WorkingDirectory: ".",
Outputs: map[string]NotariusOutputConfig{
"npc_registry": {
LaneID: "npc-registry",
MediaType: "application/json",
SchemaID: "notarius.dnd.npc_registry",
SchemaVersion: "v1",
},
},
}
}
func TestNotariusEnabledValidation(t *testing.T) {
valid := `notarius:
enabled: true

View File

@@ -12,6 +12,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
@@ -39,7 +40,7 @@ func Validate(cfg *Config) error {
if err := validateSession(cfg.Session); err != nil {
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
}
if err := validateCrossConfig(cfg.Pipeline, cfg.Session); err != nil {
if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil {
return fmt.Errorf("pipeline/session config invalid: %w", err)
}
@@ -71,6 +72,9 @@ func validateCampaign(cfg *CampaignConfig) error {
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("campaign.inputs.party_file is required")
}
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
return fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided")
}
return nil
}
@@ -547,6 +551,39 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
if strings.TrimSpace(cfg.WorkingDirectory) == "" {
return fmt.Errorf("pipeline.notarius.working_directory is required when pipeline.notarius.enabled is true")
}
if len(cfg.References) > MaxNotariusReferenceBindings {
return fmt.Errorf("pipeline.notarius.references must contain at most %d bindings", MaxNotariusReferenceBindings)
}
referenceKeys := make([]string, 0, len(cfg.References))
for selector := range cfg.References {
referenceKeys = append(referenceKeys, selector)
}
sort.Strings(referenceKeys)
var normalizedReferences map[string]string
if cfg.References != nil {
normalizedReferences = make(map[string]string, len(cfg.References))
}
referenceOwners := make(map[string]string, len(cfg.References))
for _, rawSelector := range referenceKeys {
selector, err := notariusref.NormalizeSelector(rawSelector)
if err != nil {
return fmt.Errorf("pipeline.notarius.references selector %q is invalid: %w", rawSelector, err)
}
if previous, ok := referenceOwners[selector]; ok {
return fmt.Errorf("pipeline.notarius.references selectors %q and %q normalize to %q", previous, rawSelector, selector)
}
referenceOwners[selector] = rawSelector
source := strings.TrimSpace(cfg.References[rawSelector])
if source == "" {
return fmt.Errorf("pipeline.notarius.references.%s source is required", selector)
}
if _, ok := artifactpolicy.DescribePreparedInputSource(source); !ok {
return fmt.Errorf("pipeline.notarius.references.%s source %q is not a supported prepared input source", selector, source)
}
normalizedReferences[selector] = source
}
reservedSources := map[string]string{}
for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() {
@@ -613,6 +650,7 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
cfg.PipelineID = strings.TrimSpace(cfg.PipelineID)
cfg.Timeout = strings.TrimSpace(cfg.Timeout)
cfg.WorkingDirectory = filepath.Clean(cfg.WorkingDirectory)
cfg.References = normalizedReferences
cfg.Outputs = normalizedOutputs
return nil
}
@@ -762,6 +800,9 @@ func validateSession(cfg *SessionConfig) error {
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("session.inputs.party_file is required")
}
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
}
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
@@ -797,10 +838,17 @@ func validateSessionIdentifier(fieldName, value string, required bool) error {
return nil
}
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error {
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig, stableInputs ResolvedStableInputs) error {
if pipeline == nil || session == nil {
return nil
}
if pipeline.Notarius != nil && pipeline.Notarius.Enabled {
for selector, source := range pipeline.Notarius.References {
if source == artifactpolicy.SourceInputSpellCatalog && strings.TrimSpace(stableInputs.SpellCatalogFile.Path) == "" {
return fmt.Errorf("pipeline.notarius.references.%s requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file", selector)
}
}
}
audioS3Enabled := session.Inputs.AudioS3 != nil
publishUploadEnabled := publishUploadConfiguredForS3(pipeline)

View File

@@ -22,6 +22,32 @@ func RemoveAllUnderRoot(rootPath, target string) error {
return removeConfinedEntry(root, targetName)
}
// RemoveFileUnderRoot removes an exact regular-file target below root without
// following symlinked ancestors or the leaf. A missing target is successful;
// directories, symlinks, and other non-regular entries are rejected.
func RemoveFileUnderRoot(rootPath, target string) error {
root, targetName, err := openConfinedCleanupTarget(rootPath, target)
if err != nil {
return err
}
defer func() { _ = root.Close() }()
info, err := root.Lstat(targetName)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("inspect cleanup file %q: %w", targetName, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("refusing to delete non-regular file path %q", targetName)
}
if err := root.Remove(targetName); err != nil {
return fmt.Errorf("remove cleanup file %q: %w", targetName, err)
}
return nil
}
func openConfinedCleanupTarget(rootPath, target string) (*os.Root, string, error) {
if strings.TrimSpace(rootPath) == "" {
return nil, "", fmt.Errorf("cleanup root is required")

View File

@@ -1,8 +1,10 @@
package fileops
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -77,3 +79,47 @@ func TestRemoveAllUnderRootRejectsSymlinkInTree(t *testing.T) {
t.Fatalf("outside sentinel was changed: %v", err)
}
}
func TestRemoveFileUnderRootRemovesOnlyRegularFile(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "inputs", "spell_catalog.json")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(target, []byte("{}\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := RemoveFileUnderRoot(root, target); err != nil {
t.Fatalf("RemoveFileUnderRoot() error = %v", err)
}
if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Lstat() error = %v, want not exist", err)
}
if err := RemoveFileUnderRoot(root, target); err != nil {
t.Fatalf("RemoveFileUnderRoot(missing) error = %v", err)
}
for _, tt := range []struct {
name string
setup func(string) error
}{
{name: "directory", setup: func(path string) error { return os.Mkdir(path, 0o755) }},
{name: "symlink", setup: func(path string) error { return os.Symlink(filepath.Join(root, "outside"), path) }},
} {
t.Run(tt.name, func(t *testing.T) {
if err := tt.setup(target); err != nil {
t.Fatalf("setup target: %v", err)
}
err := RemoveFileUnderRoot(root, target)
if err == nil || !strings.Contains(err.Error(), "non-regular") {
t.Fatalf("RemoveFileUnderRoot() error = %v, want non-regular rejection", err)
}
if _, err := os.Lstat(target); err != nil {
t.Fatalf("ambiguous target was removed: %v", err)
}
if err := os.Remove(target); err != nil {
t.Fatalf("cleanup target: %v", err)
}
})
}
}

View File

@@ -67,17 +67,29 @@ func CopyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, erro
}
defer func() { _ = in.Close() }()
return WriteReaderAtomicWithChecksum(dst, in, perm)
}
// WriteReaderAtomicWithChecksum streams src through the durable replacement
// sequence and returns the SHA-256 checksum of the installed bytes. The caller
// retains ownership of src.
func WriteReaderAtomicWithChecksum(dst string, src io.Reader, perm os.FileMode) (string, error) {
if strings.TrimSpace(dst) == "" {
return "", fmt.Errorf("destination path is required")
}
if src == nil {
return "", fmt.Errorf("source reader is required")
}
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
digest := sha256.New()
err = replaceFileFromReaderConfined(
if err := replaceFileFromReaderConfined(
dst,
io.TeeReader(in, digest),
io.TeeReader(src, digest),
ReplaceFileOptions{Mode: perm},
)
if err != nil {
); err != nil {
return "", err
}
return hex.EncodeToString(digest.Sum(nil)), nil

View File

@@ -1,6 +1,8 @@
package fileops
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
@@ -74,6 +76,28 @@ func TestCopyFileAtomicWithChecksumMatchesDestination(t *testing.T) {
assertNoMatchingTempFiles(t, filepath.Dir(dst), ".copied.txt.tmp-")
}
func TestWriteReaderAtomicWithChecksumMatchesDestination(t *testing.T) {
root := t.TempDir()
dst := filepath.Join(root, "nested", "snapshot.yml")
payload := "verified reference bytes\n"
checksum, err := WriteReaderAtomicWithChecksum(dst, strings.NewReader(payload), WorkspaceFileMode)
if err != nil {
t.Fatalf("WriteReaderAtomicWithChecksum() error = %v", err)
}
wantChecksum := sha256.Sum256([]byte(payload))
if checksum != hex.EncodeToString(wantChecksum[:]) {
t.Fatalf("checksum = %q, want %q", checksum, hex.EncodeToString(wantChecksum[:]))
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != payload {
t.Fatalf("destination = %q, want %q", data, payload)
}
}
func TestCopyFileAtomicCleansTempFileOnInstallFailure(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source.txt")

View File

@@ -0,0 +1,43 @@
// Package notariusref owns Narratio's Notarius CLI reference-selector
// vocabulary without depending on Notarius implementation packages.
package notariusref
import (
"fmt"
"strings"
)
// NormalizeSelector validates and normalizes a Notarius v0.6 reference
// selector. It intentionally validates selector structure only; Notarius owns
// target, slot, and media compatibility.
func NormalizeSelector(value string) (string, error) {
selector := strings.TrimSpace(value)
if selector == "" {
return "", fmt.Errorf("reference selector is required")
}
if strings.Contains(selector, "=") {
return "", fmt.Errorf("reference selector must not contain '='")
}
parts := strings.Split(selector, ".")
for index := range parts {
parts[index] = strings.TrimSpace(parts[index])
if parts[index] == "" {
return "", fmt.Errorf("reference selector components must not be empty")
}
}
switch len(parts) {
case 1, 2:
return strings.Join(parts, "."), nil
case 3:
switch parts[1] {
case "extract", "merge", "normalize":
return strings.Join(parts, "."), nil
default:
return "", fmt.Errorf("three-component reference selector must use extract, merge, or normalize as its middle component")
}
default:
return "", fmt.Errorf("reference selector must use slot, chunk.slot, lane.slot, or lane.stage.slot")
}
}

View File

@@ -0,0 +1,58 @@
package notariusref
import (
"strings"
"testing"
)
func TestNormalizeSelectorAcceptsDocumentedForms(t *testing.T) {
tests := []struct {
name string
value string
want string
}{
{name: "pipeline", value: "party", want: "party"},
{name: "chunk", value: "chunk.party", want: "chunk.party"},
{name: "lane", value: "npc-registry.party", want: "npc-registry.party"},
{name: "extract", value: "npc-registry.extract.party", want: "npc-registry.extract.party"},
{name: "merge", value: "npc-registry.merge.party", want: "npc-registry.merge.party"},
{name: "normalize", value: "npc-registry.normalize.party", want: "npc-registry.normalize.party"},
{name: "whitespace", value: " npc-registry . extract . party ", want: "npc-registry.extract.party"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := NormalizeSelector(test.value)
if err != nil {
t.Fatalf("NormalizeSelector(%q) error = %v", test.value, err)
}
if got != test.want {
t.Fatalf("NormalizeSelector(%q) = %q, want %q", test.value, got, test.want)
}
})
}
}
func TestNormalizeSelectorRejectsInvalidForms(t *testing.T) {
tests := []struct {
name string
value string
wantErr string
}{
{name: "empty", value: "", wantErr: "required"},
{name: "whitespace", value: " ", wantErr: "required"},
{name: "empty first", value: ".party", wantErr: "components"},
{name: "empty middle", value: "lane..party", wantErr: "components"},
{name: "empty final", value: "lane.", wantErr: "components"},
{name: "equals", value: "party=/tmp/party.yml", wantErr: "must not contain"},
{name: "invalid stage", value: "lane.chunk.party", wantErr: "extract, merge, or normalize"},
{name: "too many components", value: "lane.extract.party.extra", wantErr: "must use"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := NormalizeSelector(test.value)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("NormalizeSelector(%q) error = %v, want containing %q", test.value, err, test.wantErr)
}
})
}
}

View File

@@ -627,17 +627,26 @@ func resolveScriptoriumInput(inputCfg config.ScriptoriumInputConfig, execution a
return analyzeInputFailure(describeErr)
}
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, execution.Paths)
if err != nil {
identity, err := artifacts.ResolvePreparedInput(execution.Paths, execution.Manifest, descriptor.Source.ID)
if err == nil {
return analyzeInputFound(identity.Path, nil)
}
if errors.Is(err, artifacts.ErrPreparedInputAbsent) {
if inputCfg.Required {
return analyzeInputFailure(err)
return analyzeInputFailure(fmt.Errorf(
"required prepared input source %q is unavailable; run narratio run-stage prepare %s --force",
descriptor.Source.ID,
execution.SessionID,
))
}
return analyzeInputMissing()
}
if !ok {
return analyzeInputMissing()
}
return analyzeInputFound(resolvedPath, nil)
return analyzeInputFailure(fmt.Errorf(
"prepared input source %q is invalid; run narratio run-stage prepare %s --force: %w",
descriptor.Source.ID,
execution.SessionID,
err,
))
}
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog)
@@ -710,36 +719,6 @@ func requiredBuiltInInputError(source string, execution analyzeExecutionContext)
)
}
func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) {
filename, ok := preparedStableInputFilename(sourceID)
if !ok {
return "", false, fmt.Errorf("unsupported prepared input source %q", sourceID)
}
path := filepath.Join(paths.InputsDir, filename)
if err := requireNonEmptyFile(path, "prepared input "+sourceID); err != nil {
return "", false, fmt.Errorf(
"prepared input source %q is unavailable; run narratio run-stage prepare %s --force: %w",
sourceID,
paths.SessionID,
err,
)
}
return path, true, nil
}
func preparedStableInputFilename(sourceID string) (string, bool) {
switch strings.TrimSpace(sourceID) {
case artifactpolicy.SourceInputPlayers:
return "players.yml", true
case artifactpolicy.SourceInputParty:
return "party.yml", true
case artifactpolicy.SourceInputGlossary:
return "glossary.yml", true
default:
return "", false
}
}
func buildAnalyzeRuntimeArtifactCatalog(
paths artifacts.SessionPaths,
m *manifest.Manifest,

View File

@@ -14,6 +14,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
@@ -1099,9 +1100,11 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
playersPath := filepath.Join(paths.InputsDir, "players.yml")
partyPath := filepath.Join(paths.InputsDir, "party.yml")
glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml")
writeAnalyzeFile(t, playersPath, "- Eric\n")
writeAnalyzeFile(t, partyPath, "- Arannis\n")
writeAnalyzeFile(t, glossaryPath, "- term: Ten Towns\n")
spellCatalogPath := filepath.Join(paths.InputsDir, "spell_catalog.json")
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputPlayers, playersPath, "- Eric\n")
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputParty, partyPath, "- Arannis\n")
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputGlossary, glossaryPath, "- term: Ten Towns\n")
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputSpellCatalog, spellCatalogPath, "{\"spells\":[]}\n")
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
@@ -1116,6 +1119,10 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
Source: "narratio.input.glossary",
Required: true,
}
artifact.Inputs["spells"] = config.ScriptoriumInputConfig{
Source: "narratio.input.spell_catalog",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
@@ -1134,6 +1141,9 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
if fake.RunRequests[0].InputPaths["glossary"] != glossaryPath {
t.Fatalf("glossary input = %q, want %q", fake.RunRequests[0].InputPaths["glossary"], glossaryPath)
}
if fake.RunRequests[0].InputPaths["spells"] != spellCatalogPath {
t.Fatalf("spells input = %q, want %q", fake.RunRequests[0].InputPaths["spells"], spellCatalogPath)
}
}
func TestAnalyzeMissingRequiredPreparedStableInputFailsWithPrepareGuidance(t *testing.T) {
@@ -1182,6 +1192,28 @@ func TestAnalyzeMissingOptionalPreparedStableInputIsOmitted(t *testing.T) {
}
}
func TestAnalyzeInvalidOptionalPreparedStableInputFailsWithPrepareGuidance(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
playersPath := filepath.Join(paths.InputsDir, "players.yml")
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputPlayers, playersPath, "- Eric\n")
m.Inputs[len(m.Inputs)-1].Checksum = strings.Repeat("0", 64)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
Source: "narratio.input.players",
Required: false,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "prepared input source \"narratio.input.players\" is invalid") ||
!strings.Contains(err.Error(), "run narratio run-stage prepare 2026-05-03 --force") {
t.Fatalf("Run() error = %v, want invalid prepared input guidance", err)
}
}
func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
@@ -1650,6 +1682,24 @@ func writeAnalyzeFile(t *testing.T, path, contents string) {
}
}
func recordPreparedAnalyzeInput(t *testing.T, m *manifest.Manifest, sourceID, path, contents string) {
t.Helper()
writeAnalyzeFile(t, path, contents)
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
if !ok {
t.Fatalf("DescribePreparedInputSource(%q) ok = false", sourceID)
}
checksum, err := artifacts.SHA256File(path)
if err != nil {
t.Fatalf("SHA256File(%q) error = %v", path, err)
}
m.Inputs = append(m.Inputs, manifest.InputRecord{
Kind: descriptor.ManifestKind,
Path: path,
Checksum: checksum,
})
}
func writeAnalyzeFileNoTest(path, contents string) {
if strings.TrimSpace(path) == "" {
return

View File

@@ -80,6 +80,10 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil {
return nil, fmt.Errorf("extract: resolve final-trimmed transcript identity: %w", err)
}
references, err := resolveExtractReferences(paths, m, notariusConfig, sessionID)
if err != nil {
return nil, fmt.Errorf("extract: resolve Notarius references: %w", err)
}
timeout, err := time.ParseDuration(strings.TrimSpace(notariusConfig.Timeout))
if err != nil || timeout <= 0 {
@@ -119,20 +123,32 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil {
return nil, fmt.Errorf("extract: resolve durable bundle path: %w", err)
}
for _, directory := range []string{filepath.Dir(receiptPath), outputRoot, filepath.Dir(durableBundle)} {
referenceSnapshotRoot, err := absolutePath(artifacts.SessionRunNotariusReferencesDirForCampaign(workspaceRoot, campaign, sessionID, runID))
if err != nil {
return nil, fmt.Errorf("extract: resolve reference snapshot path: %w", err)
}
directories := []string{filepath.Dir(receiptPath), outputRoot, filepath.Dir(durableBundle)}
if len(references.Bindings) > 0 {
directories = append(directories, referenceSnapshotRoot)
}
for _, directory := range directories {
if err := fileops.EnsureWorkspaceDirectory(directory); err != nil {
return nil, fmt.Errorf("extract: create directory %q: %w", directory, err)
}
}
referenceBindings, err := materializeExtractReferenceSnapshots(paths, references, referenceSnapshotRoot)
if err != nil {
return nil, fmt.Errorf("extract: materialize Notarius reference snapshots: %w", err)
}
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, notariusConfig, timeout, workingDirectory, input)
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, notariusConfig, timeout, workingDirectory, input, references.Identities)
if err != nil {
return nil, fmt.Errorf("extract: build configuration fingerprint: %w", err)
}
request := notarius.RunRequest{
Binary: resolvedBinary, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory,
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout,
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, References: referenceBindings,
}
adapterResult, err := env.Notarius.Run(ctx, request)
if err != nil {
@@ -144,6 +160,9 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if adapterResult.Receipt.RunID == "" || adapterResult.Receipt.PipelineID != notariusConfig.PipelineID {
return nil, fmt.Errorf("extract: notarius receipt identity is missing or incompatible")
}
if err := verifyExtractReferenceSnapshots(referenceSnapshotRoot, references); err != nil {
return nil, fmt.Errorf("extract: verify Notarius reference snapshots: %w", err)
}
selected, err := selectRequiredNotariusLanes(env.ArtifactStore, notariusConfig.Outputs, adapterResult)
if err != nil {
@@ -161,6 +180,10 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil {
return nil, fmt.Errorf("extract: resolve staging warnings relative path: %w", err)
}
diagnosticsRelative, err := pathsafe.SlashRelativeFromRoot(adapterResult.BundleRoot, adapterResult.Index.DiagnosticsPath)
if err != nil {
return nil, fmt.Errorf("extract: resolve staging diagnostics relative path: %w", err)
}
stagingIndexChecksum, err := checksumRegularFile(adapterResult.Index.Path, false)
if err != nil {
return nil, fmt.Errorf("extract: validate staging index: %w", err)
@@ -188,6 +211,10 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil {
return nil, fmt.Errorf("extract: resolve promoted warnings: %w", err)
}
promotedDiagnosticsPath, err := pathsafe.JoinSlashRelativeUnderRoot(durableBundle, diagnosticsRelative)
if err != nil {
return nil, fmt.Errorf("extract: resolve promoted diagnostics: %w", err)
}
outputs := make([]artifacts.Ref, 0, len(selected)+1)
for _, lane := range selected {
@@ -237,18 +264,27 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
"diagnostic_path": logPath,
"rejections_path": promotedRejectionsPath,
"warnings_path": promotedWarningsPath,
"diagnostics_path": promotedDiagnosticsPath,
"narratio_run_id": runID,
"configuration_fingerprint": fingerprint,
"direct_input": input.Metadata(),
"reference_count": len(references.Identities),
"references": extractReferenceMetadata(references.Identities),
"receipt": map[string]any{
"run_id": adapterResult.Receipt.RunID, "pipeline_id": adapterResult.Receipt.PipelineID,
"normalized_output_count": adapterResult.Receipt.NormalizedOutputCount,
"rejected_output_count": adapterResult.Receipt.RejectedOutputCount,
"warning_count": adapterResult.Receipt.WarningCount,
"warning_group_count": adapterResult.Receipt.WarningGroupCount,
"warning_occurrence_count": adapterResult.Receipt.WarningOccurrenceCount,
"diagnostic_group_count": adapterResult.Receipt.DiagnosticGroupCount,
"diagnostic_occurrence_count": adapterResult.Receipt.DiagnosticOccurrenceCount,
"diagnostics_truncated": adapterResult.Receipt.DiagnosticsTruncated,
"validation_status": adapterResult.Receipt.ValidationStatus,
},
"rejections": boundedRejectionMetadata(adapterResult.Rejections),
"warnings": boundedWarningMetadata(adapterResult.Warnings),
"diagnostics": boundedDiagnosticMetadata(adapterResult.Diagnostics),
"validation_summaries": boundedValidationMetadata(adapterResult.Receipt.ValidationSummaries),
}
return &StageResult{
Outputs: outputs,
@@ -282,6 +318,11 @@ func selectRequiredNotariusLanes(
return nil, fmt.Errorf("extract: required lane %q was rejected (reason_code=%q)", expected.LaneID, rejection.ReasonCode)
}
}
for _, validation := range result.Receipt.ValidationSummaries {
if validation.LaneID == expected.LaneID && validation.Status != "complete" {
return nil, fmt.Errorf("extract: required lane %q validation is %q", expected.LaneID, validation.Status)
}
}
matches := make([]notarius.LaneDescriptor, 0, 1)
for _, descriptor := range result.Index.Lanes {
if descriptor.LaneID == expected.LaneID {
@@ -359,6 +400,7 @@ type fingerprintDocument struct {
Timeout string `json:"timeout"`
WorkingDirectory string `json:"working_directory"`
Input artifacts.ExtractionInputIdentity `json:"input"`
References []extractReferenceIdentity `json:"references"`
Outputs []fingerprintOutput `json:"outputs"`
}
@@ -368,6 +410,7 @@ func extractionFingerprint(
timeout time.Duration,
workingDirectory string,
input artifacts.ExtractionInputIdentity,
references []extractReferenceIdentity,
) (string, error) {
keys := make([]string, 0, len(cfg.Outputs))
for key := range cfg.Outputs {
@@ -382,9 +425,20 @@ func extractionFingerprint(
SchemaID: output.SchemaID, SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
})
}
sortedReferences := append([]extractReferenceIdentity(nil), references...)
sort.Slice(sortedReferences, func(i, j int) bool {
if sortedReferences[i].Selector != sortedReferences[j].Selector {
return sortedReferences[i].Selector < sortedReferences[j].Selector
}
if sortedReferences[i].SourceID != sortedReferences[j].SourceID {
return sortedReferences[i].SourceID < sortedReferences[j].SourceID
}
return sortedReferences[i].Path < sortedReferences[j].Path
})
payload, err := json.Marshal(fingerprintDocument{
Binary: binary, ConfigPath: configPath, PipelineID: cfg.PipelineID,
Timeout: timeout.String(), WorkingDirectory: workingDirectory, Input: input, Outputs: outputs,
Timeout: timeout.String(), WorkingDirectory: workingDirectory, Input: input,
References: sortedReferences, Outputs: outputs,
})
if err != nil {
return "", err
@@ -441,7 +495,52 @@ func boundedWarningMetadata(values []notarius.WarningSummary) []map[string]any {
}
result := make([]map[string]any, 0, limit)
for _, value := range values[:limit] {
result = append(result, map[string]any{"scope": value.Scope, "reason_code": value.ReasonCode})
result = append(result, map[string]any{
"disposition": value.Disposition, "category": value.Category,
"reason_code": value.ReasonCode, "origin": diagnosticOriginMetadata(value.Origin),
"occurrence_count": value.OccurrenceCount,
})
}
return result
}
func boundedDiagnosticMetadata(values []notarius.DiagnosticSummary) []map[string]any {
limit := len(values)
if limit > maxDiagnosticSummaries {
limit = maxDiagnosticSummaries
}
result := make([]map[string]any, 0, limit)
for _, value := range values[:limit] {
result = append(result, map[string]any{
"disposition": value.Disposition, "category": value.Category,
"reason_code": value.ReasonCode, "origin": diagnosticOriginMetadata(value.Origin),
"occurrence_count": value.OccurrenceCount,
})
}
return result
}
func boundedValidationMetadata(values []notarius.ValidationSummary) []map[string]any {
limit := len(values)
if limit > maxDiagnosticSummaries {
limit = maxDiagnosticSummaries
}
result := make([]map[string]any, 0, limit)
for _, value := range values[:limit] {
result = append(result, map[string]any{
"stage": value.Stage, "step_id": value.StepID, "lane_id": value.LaneID,
"module_key": value.ModuleKey, "chunk_id": value.ChunkID, "status": value.Status,
"rejecting_validators": value.RejectingValidators, "reason_codes": value.ReasonCodes,
"incomplete_validators": value.IncompleteValidators,
"producer_attempt_count": value.ProducerAttemptCount, "terminal_action": value.TerminalAction,
})
}
return result
}
func diagnosticOriginMetadata(value notarius.DiagnosticOrigin) map[string]any {
return map[string]any{
"stage": value.Stage, "step_id": value.StepID, "lane_id": value.LaneID,
"module_key": value.ModuleKey, "validator_key": value.ValidatorKey,
}
}

View File

@@ -0,0 +1,193 @@
package stage
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
)
type extractReferenceIdentity struct {
Selector string `json:"selector"`
SourceID string `json:"source_id"`
Path string `json:"path"`
Checksum string `json:"checksum"`
SizeBytes int64 `json:"size_bytes"`
}
func materializeExtractReferenceSnapshots(
paths artifacts.SessionPaths,
references resolvedExtractReferences,
snapshotRoot string,
) ([]notarius.ReferenceBinding, error) {
if len(references.Bindings) != len(references.Identities) {
return nil, fmt.Errorf("reference bindings and identities have different lengths")
}
bindings := make([]notarius.ReferenceBinding, len(references.Bindings))
copied := make(map[string]string, len(references.Bindings))
destinationOwners := make(map[string]string, len(references.Bindings))
for index, binding := range references.Bindings {
identity := references.Identities[index]
destination, ok := copied[identity.SourceID]
if !ok {
filename := filepath.Base(filepath.FromSlash(identity.Path))
if filename == "" || filename == "." || filename == string(filepath.Separator) {
return nil, fmt.Errorf("reference %q source %q has invalid prepared filename", identity.Selector, identity.SourceID)
}
destination = filepath.Join(snapshotRoot, filename)
if owner, duplicate := destinationOwners[destination]; duplicate && owner != identity.SourceID {
return nil, fmt.Errorf("reference sources %q and %q resolve to the same snapshot path", owner, identity.SourceID)
}
destinationOwners[destination] = identity.SourceID
file, err := fileops.OpenConfinedRegularFile(paths.Root, identity.Path)
if err != nil {
return nil, fmt.Errorf("open reference %q source %q for snapshot: %w", identity.Selector, identity.SourceID, err)
}
checksum, copyErr := fileops.WriteReaderAtomicWithChecksum(destination, file, fileops.WorkspaceFileMode)
closeErr := file.Close()
if copyErr != nil {
return nil, fmt.Errorf("snapshot reference %q source %q: %w", identity.Selector, identity.SourceID, copyErr)
}
if closeErr != nil {
return nil, fmt.Errorf("close reference %q source %q: %w", identity.Selector, identity.SourceID, closeErr)
}
info, err := os.Lstat(destination)
if err != nil {
return nil, fmt.Errorf("inspect reference %q snapshot: %w", identity.Selector, err)
}
if !info.Mode().IsRegular() || !strings.EqualFold(checksum, identity.Checksum) || info.Size() != identity.SizeBytes {
return nil, fmt.Errorf("reference %q source %q changed while its verified snapshot was created", identity.Selector, identity.SourceID)
}
copied[identity.SourceID] = destination
}
bindings[index] = notarius.ReferenceBinding{Selector: binding.Selector, Path: destination}
}
return bindings, nil
}
func verifyExtractReferenceSnapshots(snapshotRoot string, references resolvedExtractReferences) error {
verified := make(map[string]struct{}, len(references.Identities))
for _, identity := range references.Identities {
if _, ok := verified[identity.SourceID]; ok {
continue
}
filename := filepath.Base(filepath.FromSlash(identity.Path))
file, err := fileops.OpenConfinedRegularFile(snapshotRoot, filename)
if err != nil {
return fmt.Errorf("reopen reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, err)
}
digest := sha256.New()
size, readErr := io.Copy(digest, file)
closeErr := file.Close()
if readErr != nil {
return fmt.Errorf("revalidate reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, readErr)
}
if closeErr != nil {
return fmt.Errorf("close reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, closeErr)
}
checksum := hex.EncodeToString(digest.Sum(nil))
if !strings.EqualFold(checksum, identity.Checksum) || size != identity.SizeBytes {
return fmt.Errorf("reference %q source %q snapshot changed while Notarius was running", identity.Selector, identity.SourceID)
}
verified[identity.SourceID] = struct{}{}
}
return nil
}
type resolvedExtractReferences struct {
Bindings []notarius.ReferenceBinding
Identities []extractReferenceIdentity
}
func resolveExtractReferences(
paths artifacts.SessionPaths,
m *manifest.Manifest,
cfg *config.NotariusConfig,
sessionID string,
) (resolvedExtractReferences, error) {
if cfg == nil || len(cfg.References) == 0 {
return resolvedExtractReferences{
Bindings: []notarius.ReferenceBinding{},
Identities: []extractReferenceIdentity{},
}, nil
}
type configuredReference struct {
selector string
sourceID string
}
configured := make([]configuredReference, 0, len(cfg.References))
seen := make(map[string]struct{}, len(cfg.References))
for rawSelector, rawSourceID := range cfg.References {
selector, err := notariusref.NormalizeSelector(rawSelector)
if err != nil {
return resolvedExtractReferences{}, fmt.Errorf("reference selector %q is invalid: %w", rawSelector, err)
}
if _, duplicate := seen[selector]; duplicate {
return resolvedExtractReferences{}, fmt.Errorf("reference selector %q is configured more than once after normalization", selector)
}
seen[selector] = struct{}{}
configured = append(configured, configuredReference{selector: selector, sourceID: strings.TrimSpace(rawSourceID)})
}
sort.Slice(configured, func(i, j int) bool { return configured[i].selector < configured[j].selector })
resolved := resolvedExtractReferences{
Bindings: make([]notarius.ReferenceBinding, 0, len(configured)),
Identities: make([]extractReferenceIdentity, 0, len(configured)),
}
for _, reference := range configured {
selector := reference.selector
sourceID := reference.sourceID
identity, err := artifacts.ResolvePreparedInput(paths, m, sourceID)
if err != nil {
return resolvedExtractReferences{}, fmt.Errorf(
"reference %q source %q is unavailable or invalid; run narratio run-stage prepare %s --force: %w",
selector,
sourceID,
sessionID,
err,
)
}
resolved.Bindings = append(resolved.Bindings, notarius.ReferenceBinding{
Selector: selector,
Path: identity.Path,
})
resolved.Identities = append(resolved.Identities, extractReferenceIdentity{
Selector: selector,
SourceID: identity.SourceID,
Path: identity.RelativePath,
Checksum: identity.Checksum,
SizeBytes: identity.Size,
})
}
return resolved, nil
}
func extractReferenceMetadata(identities []extractReferenceIdentity) []map[string]any {
limit := len(identities)
if limit > config.MaxNotariusReferenceBindings {
limit = config.MaxNotariusReferenceBindings
}
metadata := make([]map[string]any, 0, limit)
for _, identity := range identities[:limit] {
metadata = append(metadata, map[string]any{
"selector": identity.Selector,
"source_id": identity.SourceID,
"path": identity.Path,
"checksum": identity.Checksum,
"size_bytes": identity.SizeBytes,
})
}
return metadata
}

View File

@@ -49,7 +49,11 @@ func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
if err != nil {
return ResumeValidation{}, fmt.Errorf("extract resume: resolve final-trimmed transcript identity: %w", err)
}
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, cfg, timeout, workingDirectory, input)
references, err := resolveExtractReferences(paths, m, cfg, sessionID)
if err != nil {
return ResumeValidation{}, fmt.Errorf("extract resume: resolve Notarius references: %w", err)
}
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, cfg, timeout, workingDirectory, input, references.Identities)
if err != nil {
return ResumeValidation{}, fmt.Errorf("extract resume: build configuration fingerprint: %w", err)
}

View File

@@ -15,6 +15,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
@@ -75,11 +76,176 @@ func TestExtractStageResolvesManifestInputAndBuildsExactRequest(t *testing.T) {
if !filepath.IsAbs(req.Binary) {
t.Fatalf("binary = %q, want absolute resolved path", req.Binary)
}
if len(req.References) != 0 {
t.Fatalf("references = %#v, want none", req.References)
}
if result.Metadata["reference_count"] != 0 || len(result.Metadata["references"].([]map[string]any)) != 0 {
t.Fatalf("reference metadata = count %#v references %#v", result.Metadata["reference_count"], result.Metadata["references"])
}
if len(result.Outputs) != 2 {
t.Fatalf("outputs = %#v, want lane and index", result.Outputs)
}
}
func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T) {
env, m, fake := setupExtractEnv(t)
configured := []struct {
selector string
sourceID string
contents string
}{
{selector: " players ", sourceID: artifactpolicy.SourceInputPlayers, contents: "players-secret\n"},
{selector: "spells.spell_catalog", sourceID: artifactpolicy.SourceInputSpellCatalog, contents: "spell-secret\n"},
{selector: "glossary", sourceID: artifactpolicy.SourceInputGlossary, contents: "glossary-secret\n"},
{selector: "party", sourceID: artifactpolicy.SourceInputParty, contents: "party-secret\n"},
}
env.Config.Pipeline.Notarius.References = make(map[string]string, len(configured))
preparedPaths := make(map[string]string, len(configured))
wantSources := make(map[string]string, len(configured))
for _, reference := range configured {
env.Config.Pipeline.Notarius.References[reference.selector] = reference.sourceID
selector := strings.TrimSpace(reference.selector)
preparedPaths[selector] = recordPreparedExtractInput(t, env, m, reference.sourceID, reference.contents)
wantSources[selector] = reference.sourceID
}
result, err := (extractStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
wantSelectors := []string{"glossary", "party", "players", "spells.spell_catalog"}
if len(fake.Requests) != 1 || len(fake.Requests[0].References) != len(wantSelectors) {
t.Fatalf("requests = %#v", fake.Requests)
}
for index, selector := range wantSelectors {
binding := fake.Requests[0].References[index]
wantPath := filepath.Join(
artifacts.SessionRunNotariusReferencesDirForCampaign(
env.Config.Pipeline.Workspace.Root, m.Campaign, m.SessionID, m.RunID,
),
filepath.Base(preparedPaths[selector]),
)
if binding.Selector != selector || binding.Path != wantPath || binding.Path == preparedPaths[selector] || !filepath.IsAbs(binding.Path) {
t.Fatalf("reference[%d] = %#v, want selector %q verified snapshot %q", index, binding, selector, wantPath)
}
}
if result.Metadata["reference_count"] != len(wantSelectors) {
t.Fatalf("reference_count = %#v", result.Metadata["reference_count"])
}
metadata, ok := result.Metadata["references"].([]map[string]any)
if !ok || len(metadata) != len(wantSelectors) {
t.Fatalf("references metadata = %#v", result.Metadata["references"])
}
for index, selector := range wantSelectors {
entry := metadata[index]
if len(entry) != 5 || entry["selector"] != selector || entry["source_id"] != wantSources[selector] {
t.Fatalf("reference metadata[%d] = %#v", index, entry)
}
if path, _ := entry["path"].(string); !strings.HasPrefix(path, "inputs/") || filepath.IsAbs(path) {
t.Fatalf("reference metadata path = %#v", entry["path"])
}
if entry["checksum"] == "" || entry["size_bytes"] == int64(0) {
t.Fatalf("reference identity metadata = %#v", entry)
}
}
encoded, err := json.Marshal(result.Metadata)
if err != nil {
t.Fatalf("json.Marshal(metadata) error = %v", err)
}
for _, reference := range configured {
if strings.Contains(string(encoded), strings.TrimSpace(reference.contents)) {
t.Fatalf("metadata contains reference payload %q: %s", reference.contents, encoded)
}
}
}
type referenceMutationRunner struct {
delegate notarius.Runner
preparedPath string
preparedBytes []byte
mutateSnapshot bool
snapshotBytes []byte
}
func (r *referenceMutationRunner) Run(ctx context.Context, req notarius.RunRequest) (notarius.RunResult, error) {
if err := os.WriteFile(r.preparedPath, r.preparedBytes, 0o664); err != nil {
return notarius.RunResult{}, err
}
if len(req.References) != 1 {
return notarius.RunResult{}, fmt.Errorf("got %d references, want one", len(req.References))
}
data, err := os.ReadFile(req.References[0].Path)
if err != nil {
return notarius.RunResult{}, err
}
r.snapshotBytes = data
if r.mutateSnapshot {
if err := os.WriteFile(req.References[0].Path, []byte("tampered snapshot\n"), 0o664); err != nil {
return notarius.RunResult{}, err
}
}
return r.delegate.Run(ctx, req)
}
func TestExtractStageInvokesNotariusWithVerifiedReferenceSnapshot(t *testing.T) {
env, m, fake := setupExtractEnv(t)
env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputParty}
preparedPath := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "verified party\n")
runner := &referenceMutationRunner{
delegate: fake, preparedPath: preparedPath, preparedBytes: []byte("changed after snapshot\n"),
}
env.Notarius = runner
if _, err := (extractStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("Run() error = %v", err)
}
if string(runner.snapshotBytes) != "verified party\n" {
t.Fatalf("Notarius reference bytes = %q, want verified snapshot", runner.snapshotBytes)
}
if len(fake.Requests) != 1 || fake.Requests[0].References[0].Path == preparedPath {
t.Fatalf("adapter requests = %#v, want a run-local reference snapshot", fake.Requests)
}
}
func TestExtractStageRejectsReferenceSnapshotChangedDuringNotariusRun(t *testing.T) {
env, m, fake := setupExtractEnv(t)
env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputParty}
preparedPath := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "verified party\n")
env.Notarius = &referenceMutationRunner{
delegate: fake, preparedPath: preparedPath, preparedBytes: []byte("changed after snapshot\n"), mutateSnapshot: true,
}
result, err := (extractStage{}).Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "snapshot changed while Notarius was running") || result != nil {
t.Fatalf("Run() result = %#v error = %v, want changed-snapshot failure", result, err)
}
}
func TestExtractStageRejectsUnavailableReferenceBeforeInvocationOrRunDirectoryCreation(t *testing.T) {
env, m, fake := setupExtractEnv(t)
env.Config.Pipeline.Notarius.References = map[string]string{
"spell_catalog": artifactpolicy.SourceInputSpellCatalog,
}
runRoot := artifacts.SessionRunRootForCampaign(
env.Config.Pipeline.Workspace.Root, m.Campaign, m.SessionID, m.RunID,
)
if err := os.RemoveAll(runRoot); err != nil {
t.Fatalf("RemoveAll(run root) error = %v", err)
}
result, err := (extractStage{}).Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "run narratio run-stage prepare ") || !strings.Contains(err.Error(), "--force") {
t.Fatalf("Run() result = %#v error = %v", result, err)
}
if len(fake.Requests) != 0 {
t.Fatalf("adapter requests = %#v", fake.Requests)
}
if _, statErr := os.Stat(runRoot); !os.IsNotExist(statErr) {
t.Fatalf("run root stat error = %v, want not exist", statErr)
}
}
func TestExtractStageProducesImmutableManifestReadyOutputs(t *testing.T) {
env, m, fake := setupExtractEnv(t)
fixture := extractFixtureFromEnv(t, env, m)
@@ -99,7 +265,8 @@ func TestExtractStageProducesImmutableManifestReadyOutputs(t *testing.T) {
if result.Metadata["receipt_path"] != artifacts.SessionRunNotariusReceiptPathForCampaign(fixture.workspace, fixture.campaign, fixture.sessionID, fixture.runID) ||
result.Metadata["diagnostic_path"] != artifacts.SessionRunNotariusLogPathForCampaign(fixture.workspace, fixture.campaign, fixture.sessionID, fixture.runID) ||
result.Metadata["rejections_path"] != filepath.Join(durableBundle, "rejected.json") ||
result.Metadata["warnings_path"] != filepath.Join(durableBundle, "warnings.json") {
result.Metadata["warnings_path"] != filepath.Join(durableBundle, "warnings.json") ||
result.Metadata["diagnostics_path"] != filepath.Join(durableBundle, "diagnostics.json") {
t.Fatalf("diagnostic metadata = %#v", result.Metadata)
}
if len(result.Outputs) != 2 {
@@ -133,7 +300,7 @@ func TestExtractStageProducesImmutableManifestReadyOutputs(t *testing.T) {
t.Fatalf("lane path was not re-resolved after promotion: %q", lane.AbsolutePath)
}
for _, relative := range []string{
"index.json", "manifest.json", "rejected.json", "warnings.json", "lanes/npc.json",
"index.json", "manifest.json", "rejected.json", "warnings.json", "diagnostics.json", "lanes/npc.json",
"lanes/unconfigured.json", "chunk-map.json", "evidence-context.json", "unknown/private-debug.json",
} {
if _, err := os.Stat(filepath.Join(durableBundle, filepath.FromSlash(relative))); err != nil {
@@ -156,6 +323,81 @@ func TestExtractStageProducesImmutableManifestReadyOutputs(t *testing.T) {
}
}
func TestExtractStageConsumesAllDefaultDndPipelineArtifacts(t *testing.T) {
env, m, fake := setupExtractEnv(t)
contracts := map[string]config.NotariusOutputConfig{
"item_registry": {LaneID: "item-registry", MediaType: "application/json", SchemaID: "notarius.dnd.item_registry", SchemaVersion: "v1", ModuleKey: "dnd/item-registry"},
"npc_registry": {LaneID: "npc-registry", MediaType: "application/json", SchemaID: "notarius.dnd.npc_registry", SchemaVersion: "v1", ModuleKey: "dnd/npc-registry"},
"location_registry": {LaneID: "location-registry", MediaType: "application/json", SchemaID: "notarius.dnd.location_registry", SchemaVersion: "v1", ModuleKey: "dnd/location-registry"},
"scene_descriptions": {LaneID: "scene-descriptions", MediaType: "application/json", SchemaID: "notarius.dnd.scene_descriptions", SchemaVersion: "v1", ModuleKey: "dnd/scene-descriptions"},
"item_occurrences": {LaneID: "item-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.item_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/item-occurrences"},
"spells": {LaneID: "spells", MediaType: "application/json", SchemaID: "notarius.dnd.spells", SchemaVersion: "v1", ModuleKey: "dnd/spells"},
"combat_turns": {LaneID: "combat-turns", MediaType: "application/json", SchemaID: "notarius.dnd.combat_turns", SchemaVersion: "v1", ModuleKey: "dnd/combat-turns"},
"npc_occurrences": {LaneID: "npc-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.npc_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/npc-occurrences"},
"location_occurrences": {LaneID: "location-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.location_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/location-occurrences"},
"enemy_events": {LaneID: "enemy-events", MediaType: "application/json", SchemaID: "notarius.dnd.enemy_events", SchemaVersion: "v1", ModuleKey: "dnd/enemy-events"},
}
env.Config.Pipeline.Notarius.Outputs = contracts
fake.Result.Index.Lanes = nil
fake.Result.Rejections = nil
fake.Result.Warnings = nil
fake.Result.Diagnostics = nil
fake.Result.Receipt.NormalizedOutputCount = len(contracts)
fake.Result.Receipt.RejectedOutputCount = 0
fake.Result.Receipt.WarningGroupCount = 0
fake.Result.Receipt.WarningOccurrenceCount = 0
fake.Result.Receipt.DiagnosticGroupCount = 0
fake.Result.Receipt.DiagnosticOccurrenceCount = 0
fake.Result.Receipt.ValidationStatus = "approved"
indexDescriptors := make([]map[string]any, 0, len(contracts))
for _, contract := range contracts {
relative := "lanes/" + contract.LaneID + ".json"
path := filepath.Join(fake.Result.BundleRoot, filepath.FromSlash(relative))
if err := os.WriteFile(path, []byte(`{"records":[]}`), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
fake.Result.Index.Lanes = append(fake.Result.Index.Lanes, notarius.LaneDescriptor{
LaneID: contract.LaneID, File: relative, Path: path, MediaType: contract.MediaType,
ModuleKey: contract.ModuleKey, SchemaID: contract.SchemaID, SchemaVersion: contract.SchemaVersion,
})
indexDescriptors = append(indexDescriptors, map[string]any{
"lane_id": contract.LaneID, "file": relative, "media_type": contract.MediaType,
"module_key": contract.ModuleKey, "schema_id": contract.SchemaID,
"schema_version": contract.SchemaVersion,
})
}
if err := writeJSONFile(fake.Result.Index.Path, map[string]any{
"manifest_file": "manifest.json", "output_files": indexDescriptors,
"rejected_file": "rejected.json", "warnings_file": "warnings.json",
"diagnostics_file": "diagnostics.json",
}); err != nil {
t.Fatalf("write complete D&D index: %v", err)
}
result, err := (extractStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(result.Outputs) != len(contracts)+1 {
t.Fatalf("output count = %d, want %d", len(result.Outputs), len(contracts)+1)
}
got := make(map[string]*artifactmodel.ContractMetadata, len(contracts))
for _, output := range result.Outputs {
if output.Kind == extractLaneOutputKind {
got[output.SourceID] = output.Contract
}
}
for key, contract := range contracts {
sourceID := artifacts.ExtractionArtifactSourceID(key)
actual := got[sourceID]
if actual == nil || actual.MediaType != contract.MediaType || actual.SchemaID != contract.SchemaID ||
actual.SchemaVersion != contract.SchemaVersion || actual.ModuleKey != contract.ModuleKey {
t.Fatalf("source %q contract = %#v, want %#v", sourceID, actual, contract)
}
}
}
func TestExtractStageRejectsMissingOrInvalidFinalTrimmedInputBeforeInvocation(t *testing.T) {
tests := []struct {
name string
@@ -197,6 +439,9 @@ func TestExtractStageEnforcesRequiredLanePolicy(t *testing.T) {
{name: "rejected", mutate: func(result *notarius.RunResult) {
result.Rejections = append(result.Rejections, notarius.RejectionSummary{LaneID: "npc-registry", ReasonCode: "invalid_npc"})
}, want: "was rejected"},
{name: "incomplete validation", mutate: func(result *notarius.RunResult) {
result.Receipt.ValidationSummaries = []notarius.ValidationSummary{{LaneID: "npc-registry", Status: "incomplete"}}
}, want: `validation is "incomplete"`},
{name: "duplicate", mutate: func(result *notarius.RunResult) {
result.Index.Lanes = append(result.Index.Lanes, result.Index.Lanes[0])
}, want: "2 descriptors"},
@@ -281,11 +526,15 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
second := &config.NotariusConfig{PipelineID: "pipeline", Outputs: map[string]config.NotariusOutputConfig{
"alpha": first.Outputs["alpha"], "zeta": first.Outputs["zeta"],
}}
one, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", first, time.Minute, "/work", input)
references := []extractReferenceIdentity{
{Selector: "zeta", SourceID: artifactpolicy.SourceInputPlayers, Path: "inputs/players.yml", Checksum: "players-checksum", SizeBytes: 12},
{Selector: "alpha", SourceID: artifactpolicy.SourceInputParty, Path: "inputs/party.yml", Checksum: "party-checksum", SizeBytes: 34},
}
one, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", first, time.Minute, "/work", input, references)
if err != nil {
t.Fatalf("extractionFingerprint(first) error = %v", err)
}
two, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", input)
two, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", input, []extractReferenceIdentity{references[1], references[0]})
if err != nil {
t.Fatalf("extractionFingerprint(second) error = %v", err)
}
@@ -305,7 +554,7 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
changedInput := input
test.mutate(&changedInput)
changed, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", changedInput)
changed, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", changedInput, references)
if err != nil {
t.Fatalf("extractionFingerprint(changed input) error = %v", err)
}
@@ -314,10 +563,36 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
}
})
}
for _, test := range []struct {
name string
mutate func(*extractReferenceIdentity)
}{
{name: "selector", mutate: func(identity *extractReferenceIdentity) { identity.Selector = "changed" }},
{name: "source", mutate: func(identity *extractReferenceIdentity) { identity.SourceID = artifactpolicy.SourceInputGlossary }},
{name: "path", mutate: func(identity *extractReferenceIdentity) { identity.Path = "inputs/changed.yml" }},
{name: "checksum", mutate: func(identity *extractReferenceIdentity) { identity.Checksum = "changed-checksum" }},
{name: "size", mutate: func(identity *extractReferenceIdentity) { identity.SizeBytes++ }},
} {
t.Run("reference "+test.name, func(t *testing.T) {
changedReferences := append([]extractReferenceIdentity(nil), references...)
test.mutate(&changedReferences[0])
changed, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", input, changedReferences)
if err != nil {
t.Fatalf("extractionFingerprint(changed reference) error = %v", err)
}
if one == changed {
t.Fatalf("fingerprint did not change with reference %s", test.name)
}
})
}
}
func TestExtractStageResumeValidationAcceptsCurrentImmutableResult(t *testing.T) {
env, m, _ := setupExtractEnv(t)
env.Config.Pipeline.Notarius.References = map[string]string{
"party": artifactpolicy.SourceInputParty,
}
recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "party-reference\n")
seedSucceededExtractResult(t, env, m)
m.RunID = "20260810T020304Z-fedcba98"
@@ -330,6 +605,93 @@ func TestExtractStageResumeValidationAcceptsCurrentImmutableResult(t *testing.T)
}
}
func TestExtractStageResumeValidationRejectsChangedPreparedReferenceIdentity(t *testing.T) {
env, m, _ := setupExtractEnv(t)
env.Config.Pipeline.Notarius.References = map[string]string{
"party": artifactpolicy.SourceInputParty,
}
path := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "original-party\n")
seedSucceededExtractResult(t, env, m)
if err := os.WriteFile(path, []byte("changed-party\n"), 0o644); err != nil {
t.Fatalf("WriteFile(reference) error = %v", err)
}
checksum, err := artifacts.SHA256File(path)
if err != nil {
t.Fatalf("SHA256File(reference) error = %v", err)
}
descriptor, _ := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputParty)
for index := range m.Inputs {
if m.Inputs[index].Kind == descriptor.ManifestKind {
m.Inputs[index].Checksum = checksum
}
}
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatalf("ValidateResume() error = %v", err)
}
if validation.Resumable || !strings.Contains(validation.Reason, "invocation contract changed") {
t.Fatalf("validation = %#v, want changed invocation contract", validation)
}
}
func TestExtractStageResumeValidationErrorsForUnavailablePreparedReference(t *testing.T) {
for _, test := range []struct {
name string
mutate func(*testing.T, string, *manifest.Manifest)
}{
{name: "missing", mutate: func(t *testing.T, path string, _ *manifest.Manifest) {
if err := os.Remove(path); err != nil {
t.Fatalf("Remove(reference) error = %v", err)
}
}},
{name: "unsafe manifest path", mutate: func(t *testing.T, _ string, m *manifest.Manifest) {
descriptor, _ := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputParty)
for index := range m.Inputs {
if m.Inputs[index].Kind == descriptor.ManifestKind {
m.Inputs[index].Path = filepath.Join(t.TempDir(), "outside.yml")
}
}
}},
{name: "stale checksum", mutate: func(t *testing.T, path string, _ *manifest.Manifest) {
if err := os.WriteFile(path, []byte("tampered-party\n"), 0o644); err != nil {
t.Fatalf("WriteFile(reference) error = %v", err)
}
}},
} {
t.Run(test.name, func(t *testing.T) {
env, m, _ := setupExtractEnv(t)
env.Config.Pipeline.Notarius.References = map[string]string{
"party": artifactpolicy.SourceInputParty,
}
path := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "original-party\n")
seedSucceededExtractResult(t, env, m)
test.mutate(t, path, m)
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "run narratio run-stage prepare ") || !strings.Contains(err.Error(), "--force") {
t.Fatalf("ValidateResume() validation = %#v error = %v", validation, err)
}
})
}
}
func TestExtractStageResumeValidationDoesNotResolveReferencesWhenDisabled(t *testing.T) {
env, m, _ := setupExtractEnv(t)
env.Config.Pipeline.Notarius.Enabled = false
env.Config.Pipeline.Notarius.References = map[string]string{
"missing": artifactpolicy.SourceInputSpellCatalog,
}
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatalf("ValidateResume() error = %v", err)
}
if validation.Resumable || !strings.Contains(validation.Reason, "disabled") {
t.Fatalf("validation = %#v", validation)
}
}
func TestExtractStageAdapterResultIsImmediatelyReusableAndCatalogVisible(t *testing.T) {
env, m, fake := setupExtractEnv(t)
producerRunID := m.RunID
@@ -338,7 +700,10 @@ func TestExtractStageAdapterResultIsImmediatelyReusableAndCatalogVisible(t *test
"schema_version": notarius.ReceiptSchemaVersion,
"run_id": "notarius-run-1", "pipeline_id": "dnd-session",
"output_directory": fake.Result.BundleRoot, "index_file": "index.json",
"normalized_output_count": 2, "rejected_output_count": 0, "warning_count": 0,
"normalized_output_count": 2, "rejected_output_count": 0,
"warning_group_count": 0, "warning_occurrence_count": 0,
"diagnostic_group_count": 0, "diagnostic_occurrence_count": 0,
"diagnostics_truncated": false,
"validation_status": "approved", "future_field": true,
})
if err != nil {
@@ -540,6 +905,29 @@ func extractFixtureFromEnv(t *testing.T, env *Env, m *manifest.Manifest) extract
}
}
func recordPreparedExtractInput(t *testing.T, env *Env, m *manifest.Manifest, sourceID, contents string) string {
t.Helper()
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
if !ok {
t.Fatalf("DescribePreparedInputSource(%q) ok = false", sourceID)
}
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, descriptor.Filename)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(prepared input) error = %v", err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatalf("WriteFile(prepared input) error = %v", err)
}
checksum, err := artifacts.SHA256File(path)
if err != nil {
t.Fatalf("SHA256File(prepared input) error = %v", err)
}
m.Inputs = append(m.Inputs, manifest.InputRecord{
Kind: descriptor.ManifestKind, Path: path, Checksum: checksum,
})
return path
}
func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunner) {
t.Helper()
workspace := t.TempDir()
@@ -594,10 +982,11 @@ func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunn
t.Fatalf("MkdirAll(bundle unknown) error = %v", err)
}
files := map[string]string{
"index.json": `{"manifest_file":"manifest.json","output_files":[{"lane_id":"npc-registry","file":"lanes/npc.json","media_type":"application/json","module_key":"dnd/npc-registry","schema_id":"notarius.dnd.npc_registry","schema_version":"v1","future_field":true},{"lane_id":"unconfigured","file":"lanes/unconfigured.json"}],"rejected_file":"rejected.json","warnings_file":"warnings.json","future_field":true}`,
"index.json": `{"manifest_file":"manifest.json","output_files":[{"lane_id":"npc-registry","file":"lanes/npc.json","media_type":"application/json","module_key":"dnd/npc-registry","schema_id":"notarius.dnd.npc_registry","schema_version":"v1","future_field":true},{"lane_id":"unconfigured","file":"lanes/unconfigured.json"}],"rejected_file":"rejected.json","warnings_file":"warnings.json","diagnostics_file":"diagnostics.json","future_field":true}`,
"manifest.json": `{}`,
"rejected.json": `{"rejected":[]}`,
"warnings.json": `{"warnings":[]}`,
"warnings.json": `{"schema_version":"notarius.warnings.v2","group_count":0,"occurrence_count":0,"groups":[]}`,
"diagnostics.json": `{"schema_version":"notarius.diagnostics.v1","group_count":0,"occurrence_count":0,"truncated":false,"unrepresented_occurrence_count":0,"groups":[]}`,
"lanes/npc.json": `{"npcs":[]}`,
"lanes/unconfigured.json": `{"spells":[]}`,
"chunk-map.json": `{"chunks":[]}`,
@@ -613,12 +1002,14 @@ func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunn
Receipt: notarius.Receipt{
SchemaVersion: notarius.ReceiptSchemaVersion, RunID: "notarius-run-1", PipelineID: "dnd-session",
OutputDirectory: bundle, IndexFile: "index.json", NormalizedOutputCount: 2,
RejectedOutputCount: 1, WarningCount: 1, ValidationStatus: "rejected",
RejectedOutputCount: 1, WarningGroupCount: 1, WarningOccurrenceCount: 1,
DiagnosticGroupCount: 1, DiagnosticOccurrenceCount: 1, ValidationStatus: "rejected",
},
BundleRoot: bundle,
Index: notarius.Index{
Path: filepath.Join(bundle, "index.json"), RejectedPath: filepath.Join(bundle, "rejected.json"),
WarningsPath: filepath.Join(bundle, "warnings.json"),
DiagnosticsPath: filepath.Join(bundle, "diagnostics.json"),
Lanes: []notarius.LaneDescriptor{
{
LaneID: "npc-registry", File: "lanes/npc.json", Path: filepath.Join(bundle, "lanes", "npc.json"),
@@ -629,7 +1020,14 @@ func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunn
},
},
Rejections: []notarius.RejectionSummary{{LaneID: "optional", ReasonCode: "optional_rejected"}},
Warnings: []notarius.WarningSummary{{Scope: "lane:npc-registry", ReasonCode: "normalized_name"}},
Warnings: []notarius.WarningSummary{{
Disposition: "warning", Category: "degradation", ReasonCode: "normalized_name",
Origin: notarius.DiagnosticOrigin{Stage: "normalize", LaneID: "npc-registry"}, OccurrenceCount: 1,
}},
Diagnostics: []notarius.DiagnosticSummary{{
Disposition: "advisory", Category: "data_quality", ReasonCode: "low_confidence",
Origin: notarius.DiagnosticOrigin{Stage: "normalize", ValidatorKey: "validator"}, OccurrenceCount: 1,
}},
}}
env := &Env{
Config: &config.Config{

View File

@@ -12,6 +12,7 @@ import (
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/audio"
"gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -64,6 +65,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc)
playersInput := stableInputSource(env.Config.StableInputs.PlayersFile, env.Config.Session.Inputs.PlayersFile, sessionSrc)
partyInput := stableInputSource(env.Config.StableInputs.PartyFile, env.Config.Session.Inputs.PartyFile, sessionSrc)
spellCatalogInput := stableInputSource(env.Config.StableInputs.SpellCatalogFile, env.Config.Session.Inputs.SpellCatalogFile, sessionSrc)
speakersSrc, err := resolveConfigRelativePath(speakersInput)
if err != nil {
@@ -85,6 +87,17 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil {
return nil, fmt.Errorf("prepare: party path: %w", err)
}
spellCatalogConfigured := strings.TrimSpace(spellCatalogInput.Path) != ""
spellCatalogSrc := ""
if spellCatalogConfigured {
spellCatalogSrc, err = resolveConfigRelativePath(spellCatalogInput)
if err != nil {
return nil, fmt.Errorf("prepare: spell catalog path: %w", err)
}
if err := requireRegularReadableFile(spellCatalogSrc, "spell catalog"); err != nil {
return nil, fmt.Errorf("prepare: %w", err)
}
}
for _, required := range []struct {
path string
@@ -106,7 +119,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err)
}
inputs := make([]manifest.InputRecord, 0, 8+len(resolvedLocalAudio))
inputs := make([]manifest.InputRecord, 0, 9+len(resolvedLocalAudio))
registerInput := func(kind, path, checksum string) {
inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum})
}
@@ -155,18 +168,51 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
}
registerInput("pipeline_resolved", pipelineDst, pipelineChecksum)
for _, cfgFile := range []struct {
type preparedConfigFile struct {
kind string
src string
dst string
source string
}{
}
preparedConfigFiles := []preparedConfigFile{
{kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source},
{kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source},
{kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml"), source: glossaryInput.Source},
{kind: "players", src: playersSrc, dst: filepath.Join(paths.InputsDir, "players.yml"), source: playersInput.Source},
{kind: "party", src: partySrc, dst: filepath.Join(paths.InputsDir, "party.yml"), source: partyInput.Source},
}
for _, prepared := range []struct {
sourceID string
input config.ResolvedInputFile
src string
}{
{sourceID: artifactpolicy.SourceInputGlossary, input: glossaryInput, src: glossarySrc},
{sourceID: artifactpolicy.SourceInputPlayers, input: playersInput, src: playersSrc},
{sourceID: artifactpolicy.SourceInputParty, input: partyInput, src: partySrc},
} {
descriptor, ok := artifactpolicy.DescribePreparedInputSource(prepared.sourceID)
if !ok {
return nil, fmt.Errorf("prepare: prepared-input descriptor %q is unavailable", prepared.sourceID)
}
preparedConfigFiles = append(preparedConfigFiles, preparedConfigFile{
kind: descriptor.ManifestKind, src: prepared.src,
dst: filepath.Join(paths.InputsDir, descriptor.Filename), source: prepared.input.Source,
})
}
spellDescriptor, ok := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputSpellCatalog)
if !ok {
return nil, fmt.Errorf("prepare: spell catalog prepared-input descriptor is unavailable")
}
spellCatalogDst := filepath.Join(paths.InputsDir, spellDescriptor.Filename)
if spellCatalogConfigured {
preparedConfigFiles = append(preparedConfigFiles, preparedConfigFile{
kind: spellDescriptor.ManifestKind,
src: spellCatalogSrc,
dst: spellCatalogDst,
source: spellCatalogInput.Source,
})
} else if err := fileops.RemoveFileUnderRoot(paths.Root, spellCatalogDst); err != nil {
return nil, fmt.Errorf("prepare: remove obsolete spell catalog: %w", err)
}
for _, cfgFile := range preparedConfigFiles {
checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst)
if err != nil {
return nil, fmt.Errorf("prepare: materialize %s: %w", cfgFile.kind, err)
@@ -585,6 +631,40 @@ func requireFile(path string, label string) error {
return nil
}
func requireRegularReadableFile(path string, label string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("%s path is required", label)
}
declared, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("inspect %s %q: %w", label, path, err)
}
if declared.Mode()&os.ModeSymlink != 0 || !declared.Mode().IsRegular() {
return fmt.Errorf("%s %q is not a regular file without symlinks", label, path)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s %q: %w", label, path, err)
}
info, statErr := file.Stat()
closeErr := file.Close()
if statErr != nil {
return fmt.Errorf("stat %s %q: %w", label, path, statErr)
}
current, currentErr := os.Lstat(path)
if currentErr != nil {
return fmt.Errorf("reinspect %s %q: %w", label, path, currentErr)
}
if !info.Mode().IsRegular() || current.Mode()&os.ModeSymlink != 0 || !current.Mode().IsRegular() ||
!os.SameFile(info, declared) || !os.SameFile(info, current) {
return fmt.Errorf("%s %q changed while being opened", label, path)
}
if closeErr != nil {
return fmt.Errorf("close %s %q: %w", label, path, closeErr)
}
return nil
}
func isFlac(path string) bool {
return strings.EqualFold(filepath.Ext(path), ".flac")
}

View File

@@ -0,0 +1,45 @@
//go:build unix
package stage
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"golang.org/x/sys/unix"
)
func TestPrepareStageRejectsSpellCatalogFIFOWithoutBlocking(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
sourcePath := filepath.Join(root, "spells.fifo")
if err := unix.Mkfifo(sourcePath, 0o644); err != nil {
t.Fatalf("Mkfifo() error = %v", err)
}
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
Path: "./spells.fifo",
ConfigPath: env.Config.CampaignPath,
Source: "campaign_config",
}
done := make(chan error, 1)
go func() {
_, err := (prepareStage{}).Run(context.Background(), env, m)
done <- err
}()
select {
case err := <-done:
if err == nil || !strings.Contains(err.Error(), "not a regular file") {
t.Fatalf("prepare.Run() error = %v, want regular-file rejection", err)
}
case <-time.After(time.Second):
t.Fatal("prepare.Run() blocked while inspecting a FIFO spell catalog")
}
}

View File

@@ -2,6 +2,8 @@ package stage
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
@@ -154,6 +156,167 @@ func TestPrepareStageIdempotent(t *testing.T) {
}
}
func TestPrepareStageMaterializesSpellCatalogWithProvenance(t *testing.T) {
tests := []struct {
name string
configPath func(*config.Config) string
source string
}{
{name: "campaign", configPath: func(cfg *config.Config) string { return cfg.CampaignPath }, source: "campaign_config"},
{name: "session", configPath: func(cfg *config.Config) string { return cfg.SessionPath }, source: "session_config"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
payload := []byte(`{"spells":[{"name":"Fireball"}]}` + "\n")
sourcePath := filepath.Join(filepath.Dir(tt.configPath(env.Config)), tt.name+"-spells.json")
writeFile(t, sourcePath, string(payload))
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
Path: filepath.Base(sourcePath),
ConfigPath: tt.configPath(env.Config),
Source: tt.source,
}
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("prepare.Run() error = %v", err)
}
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
got, err := os.ReadFile(destination)
if err != nil {
t.Fatalf("ReadFile(destination) error = %v", err)
}
if string(got) != string(payload) {
t.Fatalf("destination bytes = %q, want %q", got, payload)
}
record := findManifestInput(t, m.Inputs, "spell_catalog")
digest := sha256.Sum256(payload)
wantChecksum := hex.EncodeToString(digest[:])
if record.Path != destination || record.Source != tt.source || record.Checksum != wantChecksum {
t.Fatalf("spell catalog record = %#v, want path=%q source=%q checksum=%q", record, destination, tt.source, wantChecksum)
}
assertManifestInputsSorted(t, m.Inputs)
})
}
}
func TestPrepareStageSpellCatalogOptionalOmission(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("prepare.Run() error = %v", err)
}
for _, input := range m.Inputs {
if input.Kind == "spell_catalog" {
t.Fatalf("unexpected spell catalog record: %#v", input)
}
}
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
t.Fatalf("Lstat(destination) error = %v, want not exist", err)
}
}
func TestPrepareStageSpellCatalogMissingOrNonRegularSource(t *testing.T) {
tests := []struct {
name string
setup func(string) error
}{
{name: "missing", setup: func(string) error { return nil }},
{name: "directory", setup: func(path string) error { return os.Mkdir(path, 0o755) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
sourcePath := filepath.Join(root, "spells.json")
if err := tt.setup(sourcePath); err != nil {
t.Fatalf("setup source: %v", err)
}
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
Path: "./spells.json",
ConfigPath: env.Config.CampaignPath,
Source: "campaign_config",
}
_, err := (prepareStage{}).Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "spell catalog") {
t.Fatalf("prepare.Run() error = %v, want spell catalog source error", err)
}
})
}
}
func TestPrepareStageReplacesAndRemovesSpellCatalog(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
sourcePath := filepath.Join(root, "spells.json")
writeFile(t, sourcePath, "first\n")
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
Path: "./spells.json",
ConfigPath: env.Config.CampaignPath,
Source: "campaign_config",
}
stage := prepareStage{}
if _, err := stage.Run(context.Background(), env, m); err != nil {
t.Fatalf("first prepare.Run() error = %v", err)
}
firstChecksum := findManifestInput(t, m.Inputs, "spell_catalog").Checksum
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
writeFile(t, sourcePath, "second\n")
if _, err := stage.Run(context.Background(), env, m); err != nil {
t.Fatalf("replacement prepare.Run() error = %v", err)
}
secondChecksum := findManifestInput(t, m.Inputs, "spell_catalog").Checksum
if secondChecksum == firstChecksum {
t.Fatalf("replacement checksum = %q, want different from %q", secondChecksum, firstChecksum)
}
mustReadFileEquals(t, destination, "second\n")
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{}
env.Config.Session.Inputs.SpellCatalogFile = ""
if _, err := stage.Run(context.Background(), env, m); err != nil {
t.Fatalf("removal prepare.Run() error = %v", err)
}
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
t.Fatalf("Lstat(destination) error = %v, want removed", err)
}
for _, input := range m.Inputs {
if input.Kind == "spell_catalog" {
t.Fatalf("stale spell catalog record remains: %#v", input)
}
}
}
func TestPrepareStageRefusesAmbiguousObsoleteSpellCatalog(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
writeFile(t, filepath.Join(destination, "keep.txt"), "keep")
_, err := (prepareStage{}).Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "remove obsolete spell catalog") || !strings.Contains(err.Error(), "non-regular") {
t.Fatalf("prepare.Run() error = %v, want ambiguous destination rejection", err)
}
mustReadFileEquals(t, filepath.Join(destination, "keep.txt"), "keep")
}
func TestPrepareStageRecordsLocalSessionProvenance(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
@@ -719,6 +882,17 @@ func findManifestInput(t *testing.T, inputs []manifest.InputRecord, kind string)
return manifest.InputRecord{}
}
func assertManifestInputsSorted(t *testing.T, inputs []manifest.InputRecord) {
t.Helper()
for index := 1; index < len(inputs); index++ {
previous := inputs[index-1]
current := inputs[index]
if previous.Kind > current.Kind || (previous.Kind == current.Kind && previous.Path > current.Path) {
t.Fatalf("manifest inputs are not sorted at %d: %#v before %#v", index, previous, current)
}
}
}
func mustReadFileEquals(t *testing.T, path, want string) {
t.Helper()
data, err := os.ReadFile(path)