Add artifact provenance and stage skip outcomes

This commit is contained in:
2026-08-09 23:20:32 +00:00
parent df58595d1e
commit 951383226c
12 changed files with 1565 additions and 305 deletions

View File

@@ -0,0 +1,577 @@
# Notarius Extraction Implementation Plan
## Status And Audience
Proposed and not started.
This plan is written for a GPT-5.6 Terra coding agent. Implement the stages in
strict numerical order. Do not skip ahead, combine stages merely to reduce the
number of prompts, or describe unfinished behavior as current functionality.
The target state and acceptance criteria are owned by
[notarius-extract-stage.md](notarius-extract-stage.md). This document owns the
implementation sequence.
## Progress
| Stage | Status |
| --- | --- |
| Stage 1 | Complete |
| Stage 2 | Not started |
| Stage 3 | Not started |
| Stage 4 | Not started |
| Stage 5 | Not started |
| Stage 6 | Not started |
| Stage 7 | Not started |
| Stage 8 | Not started |
| Stage 9 | Not started |
After completing and validating a stage, update only that stage's row to
`Complete` and record any material deviation in the relevant stage section.
Do not mark a stage complete while required tests or exit criteria remain.
## Working Rules
Before Stage 1, read:
- `docs/development.md` and its task-specific references;
- `docs/roadmap/notarius-extract-stage.md` completely;
- `../notarius/docs/consumers/dnd-pipeline.md` and its linked subprocess,
receipt, JSON-output, and lane-contract documentation; and
- the focused Narratio documents and tests named by the current stage.
For every stage:
1. Inspect the current implementation before editing. Prefer the codebase
knowledge graph for code discovery as required by `AGENTS.md`.
2. Preserve unrelated worktree changes.
3. Implement the complete stage scope, including focused tests. Do not leave
TODO implementations, compatibility shims without owners, or knowingly
dead code for a later stage.
4. Use real internal collaborators in tests where fast and deterministic; fake
only subprocess, remote, clock, randomness, or other external boundaries.
5. Keep tests offline and independent of real Notarius, PromptKit, LLM
providers, credentials, and mutable services.
6. Run the focused package tests listed for the stage. Fix failures before
proceeding.
7. Run `go test ./...` after every stage. Also run `go vet ./...` and
`go build ./cmd/narratio` when the stage changes shared contracts,
composition, CLI behavior, or documentation examples.
8. Keep proposed behavior in `docs/roadmap/` until Stage 9. Intermediate code
is development work and must not cause current-behavior documentation to
claim that the end-to-end feature is complete.
Intermediate stages must compile and pass the repository test suite. The work
is not release-ready until Stage 9 is complete.
## Stage 1: Establish Shared Stage-Outcome And Artifact-Provenance Contracts
Introduce the reusable internal contracts needed by extraction without adding
Notarius configuration or a registered stage.
Implementation:
1. Under `internal/artifactmodel`, add optional neutral models for:
- contract metadata: `media_type`, `schema_id`, `schema_version`, and
optional `module_key`;
- external provenance: `system`, `run_id`, `pipeline_id`, and
`artifact_id`.
2. Extend `artifacts.Ref` with:
- optional explicit `SourceID`;
- optional contract metadata; and
- optional external provenance.
3. Extend `manifest.ArtifactRecord` with the same nested optional metadata.
Use backward-compatible `omitempty` JSON fields. Old manifests must load
and round-trip without fabricated metadata.
4. Refactor `internal/app.mapResultOutputs` to prefer `Ref.SourceID` when set,
copy both metadata structures, and retain existing output-kind and analyze
inference only as fallback behavior.
5. Add an explicit `StageResult` disposition with succeeded and skipped
values. Preserve zero-value success so existing stages do not need
mechanical edits.
6. Add a stable skip-reason field. Reject or fail safely if a skipped result
contains outputs.
7. Teach `executeStages` to persist a self-skipped stage as skipped in both
session and run manifests, apply bounded result metadata and diagnostics,
clear any older session-stage outputs, and continue to later stages.
Distinguish this from the runner's existing decision to skip an already
succeeded stage.
8. Keep successful and failed behavior unchanged for existing stages.
Tests:
- `internal/artifactmodel`: JSON behavior for complete and omitted metadata.
- `internal/manifest`: old manifest compatibility and self-skip clearing of
earlier outputs.
- `internal/app`: explicit source ID precedence, fallback inference,
provenance copying, self-skip persistence, continuation after self-skip,
and rejection of skipped results containing outputs.
Exit criteria:
- Existing stage outputs and manifests remain compatible.
- A synthetic stage can self-skip without being recorded as succeeded.
- A later invocation will reconsider a self-skipped stage.
- `go test ./...`, `go vet ./...`, and `go build ./cmd/narratio` pass.
## Stage 2: Add Notarius Configuration And Extraction Source Policy
Add strict configuration and stable source identity, but do not invoke
Notarius or register `extract` yet.
Implementation:
1. Add an optional `Notarius *NotariusConfig` field to `PipelineConfig`.
2. Define:
- `enabled` with false default;
- `binary` with `notarius` default;
- `config_path`;
- `pipeline_id`;
- `timeout` with `3h` default;
- optional `working_directory`; and
- `outputs map[string]NotariusOutputConfig`.
3. Define each output with required `lane_id`, `media_type`, `schema_id`, and
`schema_version`, plus optional `module_key`.
4. Apply defaults centrally. When enabled, resolve `config_path` to an
absolute path using the same configuration-source semantics as comparable
adapter paths. Default `working_directory` to the resolved config file's
directory, and resolve an explicitly configured working directory to an
absolute path.
5. Validate enabled configuration before stage execution:
- non-empty binary, config path, pipeline ID, and output map;
- positive duration;
- non-empty output contract fields;
- output keys accepted by the existing configured-artifact key policy;
- unique normalized extraction source IDs;
- unique lane IDs;
- no collision with built-in, configured Scriptorium, or previous-session
source identities.
6. Add `SourceKindExtraction` and helpers for the exact
`narratio.extraction.<key>` family in `internal/artifactpolicy` and
`internal/artifacts`. Do not accept arbitrary unconfigured extraction
sources at configuration validation boundaries.
7. Extend Scriptorium input and publish source validation to accept only
extraction keys declared by the same effective pipeline configuration.
8. Do not expose a Notarius session-ID setting, lane-selection flag, prompt,
profile, model, retry, or reference configuration.
Tests:
- strict YAML decoding and unknown-field rejection;
- omitted and disabled behavior;
- defaults and path resolution from pipeline-file location;
- enabled required fields and positive timeout;
- invalid/safe keys, normalized collisions, duplicate lanes, incomplete
contracts, and source-family collisions;
- valid and unknown extraction sources in Scriptorium inputs and publish
rules; and
- existing configurations and maintained examples continue to load.
Exit criteria:
- A complete Notarius configuration loads and normalizes deterministically.
- Invalid downstream extraction references fail during config validation.
- No runtime code invokes Notarius yet.
- `go test ./internal/config ./internal/artifactpolicy ./internal/artifacts`
and `go test ./...` pass.
## Stage 3: Add Safe Immutable Directory Promotion
Implement the filesystem primitive used to promote a validated external bundle
without exposing a partial or mixed durable directory.
Implementation:
1. Add a narrowly named directory-promotion operation to `internal/fileops`.
It accepts an existing source directory and a new destination directory.
2. Require a non-empty source and destination. The destination must not exist.
3. Create a temporary sibling of the destination so final installation uses a
same-filesystem rename.
4. Recursively walk with `Lstat` semantics:
- permit directories and regular files only;
- reject symlinks, devices, sockets, named pipes, and other special files;
- never follow a symlink;
- use deterministic lexical traversal;
- preserve relative layout; and
- use bounded safe permissions rather than copying unsafe mode bits.
5. Copy and sync each regular file, sync the completed temporary tree where
practical, and rename the temporary directory to the unique destination.
6. On any failure, remove only the operation's validated temporary sibling.
Never remove or overwrite the source, destination, workspace root, or a
broad caller-provided directory.
7. Return an error if a concurrent actor creates the destination before final
installation.
Tests using `t.TempDir()`:
- nested regular-tree success and byte-for-byte preservation;
- deterministic layout;
- destination already exists;
- source is not a directory;
- symlink to a file, symlink to a directory, and escaping symlink rejection;
- representative special-file rejection where portable;
- copy/install failure cleanup with no partial destination; and
- source preservation after success and failure.
Exit criteria:
- A promoted directory appears only as a complete unique tree.
- The operation cannot follow symlinks or replace an existing destination.
- `go test ./internal/fileops` and `go test ./...` pass.
## Stage 4: Implement The Notarius Adapter
Create the external subprocess boundary independently of stage policy.
Implementation:
1. Add `internal/adapters/notarius` with:
- a `Runner` interface;
- transport-neutral request and result models;
- receipt, index, lane-descriptor, and pipeline-wide-descriptor models;
- a production subprocess runner; and
- a small configurable fake for stage tests.
2. Build exactly:
```text
notarius run <pipeline-id> --config <path> --input <path>
--output-dir <path> --json
```
Use separate argument elements, absolute supplied paths, the configured
working directory, inherited environment, and Narratio's shared subprocess
timeout/cancellation behavior. Do not pass `--session-id`.
3. Write stdout directly to the configured receipt path and stderr to the
configured log path. Do not combine streams.
4. On nonzero exit, cancellation, or timeout, return the subprocess error and
do not parse stdout.
5. After exit zero, read receipt bytes with an explicit upper bound, decode
`notarius.run-result.v1`, tolerate unknown fields, and validate required
fields and matching pipeline ID.
6. Require an absolute `output_directory` and confine it beneath the absolute
output root from the request. Confine the relative `index_file` beneath the
returned directory using Narratio's root-scoped path helpers. Reject
absolute logical paths, traversal, symlinks at consumed paths, and
non-regular files.
7. Decode the supported production JSON index tolerantly. Validate required
management paths and lane descriptors, reject duplicate lane IDs, and
confine every lane and pipeline-wide descriptor file beneath the bundle
root.
8. Load `rejected.json` and `warnings.json` with explicit size bounds and
tolerant decoding. Return only their generic summaries, including lane ID
and reason code where present; do not read lane payload bodies or interpret
D&D fields. Keep free-form external messages bounded for immediate errors
and out of manifest metadata.
9. Return descriptors and paths without deciding which lanes are required or
whether their configured contracts match.
10. Do not implement automatic `notarius config validate` execution.
Tests:
- exact argument order and omission of `--session-id`;
- working directory, environment inheritance, stream separation, cancellation,
timeout, and nonzero exit;
- stdout ignored after failure;
- receipt size limit, malformed JSON, unsupported version, missing fields,
pipeline mismatch, and tolerated unknown fields;
- output-directory escape from the requested output root;
- index malformed/unsupported shapes, duplicate lanes, missing management
paths, and tolerated optional fields;
- malformed or oversized rejection/warning documents and tolerated unknown
summary fields;
- absolute logical paths, lexical traversal, root-prefix confusion, symlink
paths, and descriptor escape attempts; and
- valid lane plus chunk-map/evidence-context discovery.
Use a test helper subprocess or injected shared runner. Do not require a real
Notarius binary.
Exit criteria:
- The adapter fully enforces the generic Notarius subprocess and discovery
boundary without D&D policy.
- `go test ./internal/adapters/notarius ./internal/adapters/subprocess` and
`go test ./...` pass.
## Stage 5: Implement Extract-Stage Execution And Bundle Materialization
Implement the stage behind direct package tests, but do not yet add it to the
canonical `stage.All()` registry.
Implementation:
1. Add `Notarius notarius.Runner` to `stage.Env` and default production
composition in `internal/app/runner.go` when enabled extraction is selected.
2. Add canonical path helpers for:
- the run-local extract directory;
- receipt and stderr paths;
- the run-local Notarius output root; and
- immutable `artifacts/notarius/<narratio-run-id>` destinations.
3. Implement `extractStage` with name `extract` and declarations matching its
final-trimmed input and structured outputs.
4. When configuration is absent or disabled, return the explicit skipped
result with `notarius_disabled` and no outputs.
5. Resolve `narratio.transcript.final_trimmed` through the manifest-aware
resolver. Do not fall back to guessed filenames outside that resolver.
6. Invoke the adapter once with resolved absolute paths.
7. Apply required-output policy:
- find each configured output by exact lane ID;
- require exactly one descriptor;
- compare media type, schema ID/version, and optional module key;
- treat rejected or absent configured lanes as failure;
- tolerate unconfigured lanes;
- require each selected file to be regular, non-empty, and syntactically
valid JSON; and
- compute a staging checksum for later copy verification.
8. Validate the complete bundle tree for promotion, then use Stage 3's helper
to copy it to the unique immutable destination.
9. Re-resolve the promoted index and configured descriptor paths rather than
retaining staging paths. Compute authoritative SHA-256 checksums from the
promoted files and fail if promoted bytes differ from the validated source.
10. Return one `artifacts.Ref` per configured lane with explicit extraction
source ID, checksum, contract metadata, and Notarius external provenance.
11. Return the promoted `index.json` as a `notarius_index` output without a
selectable source ID.
12. Return bounded stage metadata containing durable bundle root, receipt and
diagnostic paths, receipt counts/status, rejection/warning summaries, the
producing Narratio run ID, and a deterministic fingerprint of the resolved
binary, config path, pipeline ID, timeout, working directory, and sorted
output-contract map.
13. Do not delete run-local diagnostics or failed bundles.
Tests:
- disabled self-skip;
- missing/invalid final-trimmed input before adapter invocation;
- exact adapter request construction;
- required, missing, rejected, duplicate, incompatible, and unconfigured
lanes;
- non-empty and JSON-syntax validation without D&D decoding;
- immutable promotion and promoted-path re-resolution;
- checksums, explicit source IDs, nested metadata, and external provenance;
- adapter and promotion failures do not return successful outputs; and
- complete bundle files are preserved while unknown files are not registered
as sources.
Exit criteria:
- Direct extract-stage execution produces a complete manifest-ready result
using only a fake Notarius runner.
- The stage is not yet in normal CLI plans.
- `go test ./internal/stage ./internal/artifacts ./internal/fileops` and
`go test ./...` pass.
## Stage 6: Integrate Canonical Ordering, CLI Lifecycle, Force, And Resume
Make `extract` a real pipeline stage and complete its orchestration semantics.
Implementation:
1. Register `extractStage` in `stage.All()` immediately after `trim` and before
`render`.
2. Update all hard-coded or user-visible stage inventories, stage-name
validation, CLI help, planning, prerequisite checks, and test fixtures.
3. Confirm full and single-stage commands can select `extract`. Do not add a
lane-selection flag and do not broaden `--artifacts` beyond Scriptorium
artifact selection.
4. Add an optional stage resume-validation contract. Use a result containing:
- resumable boolean; and
- bounded non-resumable reason.
Reserve an ordinary error for cases where a safe decision cannot be made.
5. Before skipping a succeeded stage, `executeStages` invokes the optional
validator. A non-resumable result marks the session stage stale with its
reason and schedules it to run. A validation error stops execution without
mutating the succeeded record.
6. Implement extraction resume validation against:
- enabled state;
- stored invocation/config-contract fingerprint;
- succeeded extract record;
- immutable bundle and index paths;
- exact configured source set;
- regular confined lane files;
- checksums;
- contracts; and
- Notarius external provenance.
Require the producing Narratio run ID recorded in extract metadata, output
records, and the immutable bundle path to agree. Do not compare it with the
session manifest's top-level `run_id`, which identifies the latest
invocation and may legitimately differ after resume.
7. Do not inspect or hash Notarius's transitive reference/profile closure.
8. Verify canonical downstream invalidation:
- force through `trim` stales extract and later succeeded stages;
- force `extract` stales render and later succeeded stages;
- force `render` does not stale extract.
9. Verify existing session manifests without an extract record normalize and
execute the new stage rather than failing migration.
Tests:
- exact canonical order and CLI stage inventory;
- full-plan and `run-stage extract` behavior;
- disabled skip followed by later enablement;
- successful resume skip;
- missing, tampered, incompatible, or config-mismatched outputs trigger stale
and rerun;
- unsafe validation errors stop without corrupting prior success;
- force and downstream invalidation in each direction named above; and
- old manifest compatibility.
Exit criteria:
- Normal execution includes `extract` in the agreed position.
- Disabled and resume behavior cannot make obsolete outputs current.
- `go test ./internal/stage ./internal/app ./internal/manifest`,
`go test ./...`, `go vet ./...`, and `go build ./cmd/narratio` pass.
## Stage 7: Integrate Extraction Sources With The Runtime Catalog And Analyze
Make succeeded extraction lanes selectable by configured Scriptorium
artifacts.
Implementation:
1. Extend `ArtifactCatalog` with extraction definitions registered from the
effective Notarius output map.
2. Add one shared manifest-backed hydration operation for extraction sources.
It accepts session paths, manifest, and configured extraction definitions
and marks entries available only after verifying the roadmap's full
successful-stage, source, path, checksum, contract, and provenance rules.
3. Add a distinct extraction provenance value such as
`manifest.current_extract_run`.
4. Extend `ResolveSessionArtifactWithCatalog` to resolve configured extraction
sources through catalog availability. It must never scan the Notarius
artifact directory or accept an incidental file.
5. Refactor catalog construction enough that extraction registration and
hydration are reusable by analyze and publish. Do not create a generic
workflow abstraction or move Scriptorium execution policy into artifacts.
6. Update analyze catalog creation to include effective extraction
definitions and current manifest state before planning Scriptorium inputs.
7. Preserve required/optional input behavior. Required unavailable extraction
inputs fail with actionable extract configuration/rerun guidance; optional
inputs are omitted normally.
8. Do not automatically add extraction inputs to any configured artifact.
Tests:
- registration, lookup, deterministic ordering, and source-family collision;
- successful current-manifest hydration;
- missing stage, skipped, failed, stale, interrupted, missing-source,
internally inconsistent producer identity, incompatible-contract,
missing-file, tampered-checksum, and unsafe-path rejection;
- required and optional analyze input behavior;
- selected Scriptorium artifact execution receives only explicitly configured
extraction inputs; and
- no object-store calls or directory scanning occur during analyze
extraction resolution.
Exit criteria:
- A fake successful extract run can feed selected JSON files into a fake
Scriptorium analysis run.
- No failed or incidental bundle can become available through the catalog.
- `go test ./internal/artifacts ./internal/stage ./internal/config` and
`go test ./...` pass.
## Stage 8: Integrate Extraction Sources With Publish And Operator Inspection
Complete downstream artifact handling without automatically publishing the
bundle.
Implementation:
1. Use Stage 7's shared extraction catalog registration and hydration in
publish planning and source resolution.
2. Permit explicit publish rules whose source is a configured extraction
source. Preserve existing destination policy, lock behavior, required
output policy, selected-Scriptorium behavior, and remote commit order.
3. Do not publish the complete Notarius bundle unless individual explicit
rules name selectable sources. `notarius_index` remains non-selectable in
the initial feature.
4. Ensure the published session manifest retains extraction artifact contract
and external provenance metadata through ordinary serialization.
5. Extend existing artifact/status/inspection output only where necessary so
configured extraction sources report planned, available, unavailable, and
published states consistently. Do not expose payload contents.
6. Confirm restore safely round-trips explicitly published extraction files
through existing manifest and confined-path behavior; add code only if a
real incompatibility is found.
Tests:
- successful explicit lane publication;
- required missing or invalid extraction source failure;
- optional/unselected behavior remains consistent;
- publish lock and commit-marker order remain unchanged;
- `--artifacts` does not partially select Notarius lanes;
- manifest metadata survives publish serialization and restore loading; and
- operator inspection never reads or prints extraction payload bodies.
Exit criteria:
- Explicit extraction publish rules work through the shared catalog.
- No implicit whole-bundle publication is introduced.
- `go test ./internal/stage ./internal/app ./internal/artifacts` and
`go test ./...` pass.
## Stage 9: Add Maintained Examples, Current Documentation, And Final Validation
Finish the public and maintainer contract only after the implementation is
complete.
Implementation:
1. Read Notarius's current published lane contracts and add exact media type,
schema ID, schema version, and module-key constraints for all ten lanes to
a maintained Narratio complete example. Do not guess or copy stale roadmap
placeholders.
2. Add a small maintained Scriptorium example that consumes a purpose-specific
subset of extraction sources rather than all ten.
3. Add `docs/integrations/notarius.md` as Narratio's external-consumer
contract. Link to Notarius's canonical documents instead of duplicating
complete schemas.
4. Add `docs/internal/stage-extract.md` for implemented stage mechanics,
lifecycle, resume validation, failure behavior, and focused tests.
5. Update the canonical owners listed in the roadmap's Documentation
Deliverables section. Keep configuration fields/defaults only in
`docs/config.md`, commands only in `docs/cli.md`, physical layout and force
procedures only in `docs/operations.md`, and implementation mechanics only
in internal documents.
6. Update every implemented stage inventory to show `extract` between `trim`
and `render`.
7. Add troubleshooting guidance for missing Notarius, nonzero exit,
receipt/index incompatibility, required-lane rejection, resume
invalidation, and the requirement to force extraction after changing
Notarius's transitive configuration inputs.
8. Review the roadmap status. If every acceptance criterion is implemented,
mark it complete or move completed planning material according to repository
convention without deleting useful decision context prematurely.
9. Check all changed links, examples, commands, field names, defaults, schemas,
and paths against the implementation.
Validation:
- focused example/configuration validation tests;
- `go test ./...`;
- `go vet ./...`;
- `go build ./cmd/narratio`;
- inspect `git diff --check`;
- confirm ordinary tests use no live Notarius, PromptKit, LLM provider,
credentials, network service, or mutable external state; and
- confirm no secrets or private campaign content were added.
Exit criteria:
- Every feature-roadmap acceptance criterion is satisfied.
- Maintained examples load under strict configuration validation.
- Current-behavior documentation accurately describes the shipped feature
without duplicating Notarius-owned schema definitions.
- The repository-wide validation suite passes.
## Open Questions
None. The target architecture, defaults, lifecycle behavior, compatibility
boundary, storage layout, source identity, downstream selection model, and
implementation order are decision-complete for implementation. If repository
inspection exposes a contradiction with an implemented invariant, stop at the
affected stage and document the concrete conflict rather than silently
changing this plan's target behavior.

View File

@@ -2,266 +2,497 @@
## Status ## Status
Proposed. Proposed. Implementation has not started.
The ordered implementation plan is maintained in
[implementation.md](implementation.md). Until that plan is complete, this
document is the only Narratio documentation that describes the proposed
`extract` stage, Notarius configuration, or `narratio.extraction.*` sources.
## Purpose ## Purpose
Add a first-class Narratio `extract` stage that runs Notarius against the Add a first-class Narratio `extract` stage that runs Notarius against the
session's final trimmed transcript, validates and collects the resulting session's final trimmed transcript and makes validated structured artifacts
structured D&D artifacts, and registers those artifacts for later use by the available to later analysis and publish work.
`analyze` and `publish` stages.
This feature should integrate Notarius through Narratio's existing stage, Notarius remains responsible for its D&D extraction pipeline, prompts,
adapter, manifest, workspace, and artifact-catalog boundaries. It must not turn references, LLM profiles, retries, validation, normalization, and published
Narratio into a generic workflow engine or a second configuration language for schemas. Narratio owns invocation, required-output policy, safe bundle
Notarius pipelines. ingestion, artifact identity, manifest state, resume, and downstream
availability.
The integration must preserve Narratio's explicit stage model. It must not
turn Narratio into a generic workflow engine or reproduce Notarius's
configuration language.
## User Outcome ## User Outcome
An operator can enable one configured Notarius pipeline for a Narratio An operator can enable one Notarius pipeline for a Narratio campaign. During a
campaign. During a normal run, Narratio will: normal run, Narratio will:
1. finish producing the session transcript tiers; 1. produce the final trimmed Seriatim JSON transcript;
2. invoke Notarius once with the final trimmed Seriatim JSON transcript; 2. invoke Notarius once with that transcript;
3. collect and validate the configured structured artifact lanes; 3. discover the exact run bundle through Notarius's machine-readable receipt;
4. record their exact files and provenance in the Narratio manifest; and 4. validate every output contract Narratio is configured to require;
5. make those artifacts selectable as inputs to Scriptorium artifacts in the 5. promote the complete validated bundle into immutable session storage;
later `analyze` stage. 6. record exact artifact paths, checksums, contracts, and external provenance;
7. make configured lanes available as `narratio.extraction.<key>` sources; and
8. allow individual Scriptorium artifacts and publish rules to select those
sources explicitly.
The maintained D&D example should demonstrate all ten lanes emitted by The maintained complete D&D example will require all ten lanes published by
Notarius's complete `dnd-session` pipeline. Notarius's `dnd-session` pipeline. Ordinary deployments may configure a
narrower required set.
## Target Stage Architecture ## Chosen Architecture
### First-Class Stage
Extraction is an independently observable and resumable pipeline stage. It is
not part of `analyze`, a Scriptorium artifact producer, or an implicit external
preprocessing requirement.
This boundary is required because Notarius work is expensive, produces
multiple durable outputs, has its own compatibility and diagnostic contracts,
and may be consumed by both `analyze` and `publish`.
### Canonical Order ### Canonical Order
The canonical stage order becomes: The canonical stage order becomes:
```text ```text
prepare -> transcribe -> merge -> polish -> normalize -> trim -> render prepare -> transcribe -> merge -> polish -> normalize -> trim
-> extract -> analyze -> publish -> notify -> extract -> render -> analyze -> publish -> notify
``` ```
`extract` is deliberately after all transcript-producing stages and before `extract` consumes `narratio.transcript.final_trimmed`, produced by `trim`. It
analysis. Its source document is the manifest-resolved does not consume rendered Markdown.
`narratio.transcript.final_trimmed` artifact, normally
`transcripts/final.trimmed.json`. It does not consume rendered Markdown.
Adding the stage must update full-plan construction, explicit stage selection, Narratio currently invalidates succeeded stages by canonical downstream order.
downstream invalidation, prerequisite checks, resume behavior, run manifests, Placing `extract` before `render` means forcing extraction may rerun the less
CLI stage validation and help, and every canonical-stage inventory. Forcing an expensive deterministic render stage, while forcing render does not rerun the
upstream transcript stage must stale a previously successful `extract` stage more expensive Notarius pipeline. This is preferable to placing extraction
and its downstream stages. Forcing `extract` must stale `analyze`, `publish`, after render and does not require dependency-aware scheduling or a DAG.
and `notify` according to existing rules.
Adding the stage must update every canonical-stage inventory, full-plan and
single-stage selection, prerequisite validation, downstream invalidation,
resume behavior, run manifests, CLI validation and help, and focused tests.
### Stage Boundary ### Stage Boundary
The stage owns Narratio policy and state transitions: The stage owns Narratio policy:
- resolve the final trimmed transcript through the runtime artifact catalog; - resolve the final trimmed transcript through the manifest-aware artifact
- build a Narratio-level Notarius request from validated configuration and resolver;
run-local paths; - allocate run-local receipt, log, and output-root paths;
- call a narrow Notarius adapter; - build a transport-neutral Notarius request from resolved configuration;
- apply the configured required-output policy; - call the configured Notarius adapter once;
- materialize the validated bundle into its canonical session location; - enforce Narratio's configured required-output policy;
- return manifest-ready artifact references and bounded metadata; and - validate selected payloads as regular, non-empty, syntactically valid JSON;
- fail without marking the stage successful when any required contract or - promote the validated bundle to immutable session artifact storage;
materialization step fails. - return explicit source IDs, checksums, contracts, and external provenance;
and
- report an explicit skipped outcome when Notarius is disabled.
The stage must not construct subprocess arguments, infer Notarius output The stage must not construct subprocess arguments, guess Notarius filenames,
filenames, parse provider logs, or decode individual D&D payload bodies. parse interactive output, decode D&D payload structures, or reproduce
Notarius pipeline configuration.
### Adapter Boundary ### Adapter Boundary
Add a dedicated Notarius adapter package with a small interface, production Add `internal/adapters/notarius` with a narrow runner interface, production
subprocess implementation, and test fake. Its request should contain only the subprocess implementation, and small fake.
resolved Notarius binary, configuration path, pipeline ID, transcript path,
output root, working directory, timeout, and process-log destinations needed The request contains only:
for one run.
- resolved executable path or name;
- absolute Notarius configuration path;
- pipeline ID;
- absolute transcript path;
- absolute output root;
- working directory;
- timeout; and
- stdout receipt and stderr log destinations.
The adapter owns: The adapter owns:
- optional `notarius config validate` preflight for the configured pipeline;
- exact `notarius run ... --json` argument construction; - exact `notarius run ... --json` argument construction;
- stdout and stderr separation; - stdout and stderr separation;
- context cancellation and timeout propagation through Narratio's shared - context cancellation and timeout through Narratio's shared subprocess
subprocess boundary; boundary;
- environment inheritance;
- exit-status handling; - exit-status handling;
- decoding the `notarius.run-result.v1` success receipt; - bounded receipt loading after exit status zero;
- receipt and index path-confinement checks; - tolerant decoding of supported `notarius.run-result.v1` documents;
- decoding `index.json` and resolving descriptor paths safely beneath the - validation of required receipt fields;
reported output directory; and - confinement of the receipt's absolute `output_directory` beneath the
- returning a transport-neutral result containing the bundle location, absolute output root Narratio supplied for this invocation;
receipt summary, lane descriptors, pipeline-wide descriptors, warnings and - confinement of `index_file` beneath the receipt's absolute
rejection locations, and diagnostic log paths. `output_directory`;
- tolerant decoding of the supported `index.json` contract;
- confinement of every index descriptor path beneath the bundle root; and
- bounded tolerant decoding of `rejected.json` and `warnings.json` into
transport-neutral summaries without reading lane payload bodies; summaries
persisted by Narratio contain structured scope, lane, and reason fields but
not unbounded free-form external messages; and
- returning transport-neutral receipt, descriptor, diagnostic, and bundle
information.
Only exit status zero permits receipt decoding. Receipt, index, or descriptor Only exit status zero permits receipt decoding. Unsupported schema versions,
paths that are absolute where a logical relative path is required, or that malformed documents, missing required fields, absolute logical paths, path
escape their owning root, are integration failures. Unknown fields in a escapes, symlinks at consumed paths, and incompatible structural metadata are
supported receipt or index schema should be tolerated. Unsupported schema integration failures.
versions and incompatible descriptor metadata should fail clearly.
The adapter must not write Narratio manifests, choose required lanes, decide The production runner will not execute `notarius config validate` before every
analysis inputs, or contain D&D domain logic. session. Notarius run-time validation remains authoritative, while operators
may use the separate validation command as deployment preflight. A second
automatic subprocess can be added later only if operational evidence warrants
it.
The adapter does not write Narratio manifests, decide required lanes, choose
analysis inputs, or interpret D&D payloads.
## External Contract Baseline
The integration consumes the contracts documented by Notarius in
`../notarius/docs/consumers/dnd-pipeline.md` and its linked canonical
integration documents.
The initial compatibility baseline is:
- `notarius run <pipeline-id> --config ... --input ... --output-dir ... --json`;
- successful receipt schema `notarius.run-result.v1`;
- an absolute receipt `output_directory`;
- logical `index_file` discovery beneath that directory;
- the production JSON `index.json` descriptor model; and
- the exact media type and schema identity configured for each required lane.
Compatibility is decided from these published contracts, not by parsing
`notarius --version`. Unknown fields in supported receipt and index versions
are tolerated. Unsupported versions or incompatible required descriptors fail
before any artifact becomes current in Narratio.
## Configuration Contract ## Configuration Contract
Add a strict optional `pipeline.notarius` configuration section. Omission or Add a strict optional `pipeline.notarius` section:
`enabled: false` keeps the current workflow usable and causes `extract` to
self-skip without outputs.
The section should provide: ```yaml
notarius:
- `enabled`: explicit opt-in; enabled: true
- `binary`: Notarius executable, defaulting to `notarius`; binary: notarius
- `config_path`: required when enabled; config_path: /absolute/path/to/notarius.yml
- `pipeline_id`: required when enabled; pipeline_id: dnd-session
- `timeout`: a positive stage timeout with a documented default; timeout: 3h
- `working_directory`: optional explicit subprocess working directory, working_directory: /absolute/path/to/deployment
defaulting to the directory containing `config_path`; and outputs:
- an `outputs` map defining the Notarius lane artifacts Narratio promises to npc_registry:
collect. lane_id: npc-registry
media_type: application/json
Each output-map key is a stable Narratio extraction key. Each value must define: schema_id: <published-schema-id>
schema_version: <published-schema-version>
- the exact Notarius `lane_id`;
- the expected `media_type`;
- the expected `schema_id`;
- the expected `schema_version`; and
- optionally an expected `module_key` when the operator needs to constrain the
producing module as part of compatibility.
Narratio derives the downstream source ID
`narratio.extraction.<output-key>` from the map key. Keys and lane IDs must be
non-empty, unique after normalization, path-safe under the existing artifact
policy, and collision-free with built-in and configured artifact identities.
Every configured output is required: a successful Notarius process that omits
one, rejects it, or reports incompatible descriptor metadata fails the
`extract` stage.
This explicit map keeps Narratio's consumer contract stable when a Notarius
lane ID or schema changes and avoids hard-coding the current D&D family into a
generic adapter. It also replaces a separate `required_lanes` list, which would
duplicate configuration.
Narratio should not reproduce Notarius lane selection, references, LLM
profiles, model settings, retries, concurrency, or prompt configuration. Those
remain in the referenced Notarius configuration. Narratio should not expose a
runtime lane-selection flag for `extract`; one stage invocation runs the
configured Notarius pipeline as a unit.
All configured paths should become absolute during Narratio configuration
resolution. The deterministic default working directory allows a Notarius
profile path relative to that directory, but operator documentation should
still recommend absolute deployment paths where practical. Notarius reference
paths continue to follow Notarius's own configuration-relative rules.
## Output And Artifact Model
### Canonical Bundle
Run Notarius against a run-local output root. After all configured descriptors
are validated, materialize the contents of the exact run-specific Notarius
bundle into a fixed canonical session directory:
```text
artifacts/notarius/
``` ```
Preserve its relative layout, including `index.json`, `manifest.json`, Fields:
`rejected.json`, `warnings.json`, `lanes/`, and any indexed `chunk-map.json` or
`evidence-context.json`. Materialize the complete directory as one narrow,
transactional replacement so a failed or interrupted rerun cannot mix files
from different Notarius runs.
The raw subprocess receipt and stderr log belong in the run-local `extract` - `enabled` is an explicit opt-in and defaults to false;
report and log directories. The raw receipt identifies the original run-local - `binary` defaults to `notarius`;
Notarius bundle and must not be rewritten to pretend that the canonical copy - `config_path` is required when enabled;
was its original `output_directory`. Narratio's manifest is the durable ledger - `pipeline_id` is required when enabled;
for the canonical materialized paths. - `timeout` defaults to `3h` and must be positive;
- `working_directory` is optional and defaults to the directory containing
`config_path`; and
- `outputs` maps stable Narratio extraction keys to required Notarius lane
contracts.
### Registered Artifact Sources All configured paths become absolute during configuration resolution. Notarius
reference paths continue to follow Notarius's configuration-relative rules,
while PromptKit profile paths remain relative to the chosen process working
directory where Notarius permits that behavior.
For each configured output, locate the lane through the canonical copy of Each output entry contains:
`index.json` and record a manifest artifact with:
- source ID `narratio.extraction.<output-key>`; - exact `lane_id`;
- canonical lane-file path discovered from the index; - exact `media_type`;
- exact `schema_id`;
- exact `schema_version`; and
- optional `module_key`.
The map key produces `narratio.extraction.<key>`. Keys use Narratio's existing
path-safe configured-artifact key grammar. Keys, normalized source IDs, and
lane IDs must be non-empty and unique. Extraction source IDs must not collide
with built-ins or configured Scriptorium sources.
Every configured output is required for extraction-stage success. Operators
who need only a subset configure only that subset. Narratio does not add a
second `required_lanes` list or a runtime lane-selection flag.
Narratio does not configure Notarius lane topology, references, prompts, LLM
profiles, model settings, concurrency, retry behavior, or session IDs. One
stage execution runs the configured Notarius pipeline as a unit. Narratio does
not pass Notarius's `--session-id` override.
## Stage Outcomes And Resume
### Explicit Self-Skip
Extend the stage-result contract with an explicit disposition whose zero value
remains successful for backward compatibility. A stage may return:
- succeeded; or
- skipped with a stable reason.
When Notarius is absent or disabled, `extract` returns skipped with reason
`notarius_disabled`, no outputs, and bounded metadata. The runner records a
skipped session-stage and run-stage outcome rather than a successful empty
stage. Skipped stages are reconsidered on later invocations, so subsequently
enabling Notarius causes extraction to run without requiring force.
A genuine self-skip clears any older outputs for that stage before persisting
the new skipped state. Downstream consumers cannot resolve artifacts retained
from an earlier extraction after the stage is disabled.
### Resume Validation
Add a small optional resume-validation interface implemented by `extract`.
Before skipping an already-succeeded extract stage, the runner asks it whether
the recorded result remains resumable.
The resume validator confirms:
- Notarius is still enabled;
- recorded configuration identity matches the current config path, pipeline
ID, and output-contract map;
- the immutable bundle and canonical index still exist;
- every configured source is present in the succeeded extract record;
- lane files remain confined regular files;
- stored checksums still match; and
- stored descriptor contracts and external provenance remain compatible.
An ordinary contract mismatch returns a non-resumable decision with a bounded
reason. The runner marks the stage stale and executes it. An environmental
error that prevents making a safe decision returns an error and stops the run.
Narratio does not recursively interpret Notarius configuration, PromptKit
profiles, or reference files. Changes to those external inputs are therefore
not automatically detectable. Operator documentation must require
`--force extract` after changing them.
The configuration fingerprint covers the resolved binary, config path,
pipeline ID, timeout, working directory, and the deterministically sorted
output-contract map. It identifies Narratio's invocation contract, not the
transitive content of files owned by Notarius.
### Force And Invalidation
- Forcing `trim` or an earlier stage stales succeeded `extract` and all later
stages.
- Forcing `extract` stales succeeded `render`, `analyze`, `publish`, and
`notify` under the existing canonical-order rule.
- Forcing `render` does not stale `extract` because extraction precedes it.
- A failed, skipped, stale, or interrupted extract stage never supplies current
extraction sources.
## Bundle Storage And Commit
### Run-Local Execution
Notarius runs against a run-local output root beneath the Narratio run's
`extract` directory. Receipt bytes and stderr remain run-local diagnostics.
Failed and malformed bundles remain outside durable artifact storage for
inspection and never become current merely because files exist.
### Immutable Durable Bundles
After complete validation, promote the exact Notarius bundle into:
```text
artifacts/notarius/<narratio-run-id>/
```
The destination is unique and must not already exist. Promotion uses a sibling
temporary directory on the same filesystem, recursively copies only regular
files and directories, rejects symlinks and special files, preserves relative
layout, and renames the completed temporary tree into place.
The promoted tree preserves `index.json`, `manifest.json`, `rejected.json`,
`warnings.json`, `lanes/`, and any emitted pipeline-wide artifacts such as
`chunk-map.json` and `evidence-context.json`. Unknown regular files may be
preserved because the complete bundle is provenance, but no unknown file is
registered as a stable Narratio source.
There is no mutable filesystem `current` directory or symlink. The atomically
saved Narratio session manifest selects the current successful bundle. Older
successful bundles remain immutable until explicit cleanup policy removes
them.
The raw Notarius receipt is retained without rewriting its original
`output_directory`. Narratio's manifest records promoted durable paths.
## Artifact And Manifest Model
### Explicit Source Identity
Extend `artifacts.Ref` with an optional explicit `SourceID`. The application
runner prefers that value and retains existing stage-specific inference only
as a backward-compatible fallback. `extract` must not require another
stage-name special case in output mapping.
Add neutral optional artifact metadata models under `internal/artifactmodel`:
- contract metadata: media type, schema ID, schema version, and optional
module key; and
- external provenance: system, external run ID, pipeline ID, and external
artifact ID.
Both `artifacts.Ref` and `manifest.ArtifactRecord` carry these nested models.
Existing manifests remain readable because the fields are optional and use
`omitempty` encoding.
For a Notarius lane, external provenance uses:
- system `notarius`;
- receipt run ID;
- receipt pipeline ID; and
- lane ID as the external artifact ID.
### Registered Sources
For each configured output, `extract` records one artifact with:
- source ID `narratio.extraction.<key>`;
- durable lane path discovered through the promoted canonical index;
- producer stage and Narratio run ID; - producer stage and Narratio run ID;
- checksum; - SHA-256 checksum;
- Notarius lane ID; and - configured and observed contract metadata; and
- descriptor media type, schema identity/version, and module key when present. - Notarius external provenance.
If the current manifest model cannot carry descriptor compatibility metadata, The canonical promoted `index.json` is also a stage output with kind
extend its artifact metadata in a backward-tolerant way rather than encoding `notarius_index`, but it is not a selectable extraction source. Extract-stage
that information in filenames or source IDs. metadata records the durable bundle root, receipt path, Notarius validation
status, counts, warnings/rejections paths and summaries, the producing Narratio
run ID, and the normalized configured-contract fingerprint used by resume
validation.
Also record the canonical Notarius index as a stage output or stage metadata so The producing Narratio run ID belongs to the successful extract result. It is
operators can discover the complete bundle, including non-lane artifacts. The not compared with the session manifest's top-level `run_id`, which advances on
configured lane sources are the stable interface for analysis; the index and later invocations even when extraction is validly resumed. Resume and catalog
bundle remain the provenance and inspection interface. checks instead require the extract outputs, immutable bundle path, and stored
extract-stage producer identity to agree with one another.
## Analysis And Publish Integration ## Artifact Catalog And Resolution
Extend the runtime artifact catalog and configured Scriptorium input validation Add extraction as a first-class artifact-policy and runtime-catalog family:
so an enabled analysis artifact can declare, for example:
- source kind `extraction`;
- canonical prefix `narratio.extraction.`;
- registration from `pipeline.notarius.outputs`; and
- manifest-backed availability from the current successful `extract` record.
Extraction availability is never inferred by scanning
`artifacts/notarius/`. A source is available only when:
- it is declared in current configuration;
- the session manifest records `extract` as succeeded and not stale;
- the exact matching source output is present;
- its durable path is confined and valid;
- its checksum matches; and
- its recorded contract and external provenance are compatible.
The catalog should expose one shared registration and hydration path used by
both `analyze` and `publish`. Avoid parallel extract-specific resolution logic
inside each stage.
## Analyze Integration
An enabled Scriptorium artifact may select an extraction source through the
existing input contract:
```yaml ```yaml
inputs: inputs:
npc_registry: npcs:
source: narratio.extraction.npc_registry source: narratio.extraction.npc_registry
required: true
``` ```
Resolution must remain manifest-first and verify that the recorded artifact Required missing extraction inputs fail with guidance to enable/configure or
was produced by a successful current `extract` stage. A required extraction rerun `extract`. Optional missing inputs follow the existing Scriptorium input
source that is unavailable must fail analysis with guidance to configure or contract.
rerun `extract`; an optional source may be omitted according to the existing
Scriptorium input contract.
Publish source resolution should accept configured Narratio never injects every extraction output into every analysis. Each
`narratio.extraction.<output-key>` sources through the same artifact catalog so Scriptorium artifact chooses the smallest useful set. This limits context
operators may publish selected structured artifacts without manually copying size, cost, and the risk of treating derived claims as transcript authority.
paths. The existing `--artifacts` flag remains scoped to Scriptorium artifact Analysis prompts should continue to treat the transcript as authoritative and
selection and must not partially execute the Notarius pipeline. Notarius artifacts as structured, cited, derived evidence.
No current-session analysis artifact should consume an incidental file from a The existing `--artifacts` selection remains scoped to Scriptorium artifacts.
failed, stale, skipped, or superseded extraction run. It does not select Notarius lanes or partially run the Notarius pipeline.
## Failure, Skip, Resume, And Diagnostics ## Publish Integration
- Missing or invalid enabled Notarius configuration fails configuration Publish source validation and resolution accept configured
validation before stage execution where statically discoverable. `narratio.extraction.<key>` sources through the shared runtime artifact
- A disabled or absent Notarius configuration makes `extract` skip with clear catalog. Operators may publish individual structured lanes without manually
stage metadata and no new outputs. copying files.
- A missing or invalid final trimmed transcript fails `extract` before starting
Notarius.
- Preflight failure, nonzero Notarius exit, cancellation, timeout, malformed or
unsupported receipt/index data, unsafe paths, incompatible descriptors,
rejected required outputs, or missing configured lanes fails the entire
stage.
- Process success does not override Narratio's required-output policy.
- A failed run retains bounded run-local receipt bytes, stderr, and the
unpublished Notarius bundle for diagnosis, subject to Narratio's existing
sensitive-data and cleanup policies.
- The canonical bundle and manifest artifacts are updated only after complete
validation and materialization.
- Resume skips a succeeded, non-stale `extract` stage only when its
manifest-recorded canonical index and configured lane outputs still validate.
- Force and staleness behavior follows the ordinary stage contract; it must not
depend on merely finding `artifacts/notarius/` on disk.
Transcripts, Notarius outputs, evidence context, manifests, receipts, and logs The Notarius bundle is not automatically published wholesale. Bundle files or
are private campaign material. Subprocess arguments and manifest metadata must lanes are published only through explicit configured publish rules. Existing
not contain secrets. Credentials remain in the environment or in mechanisms publish locking, destination safety, commit ordering, and required/unselected
owned by Notarius and PromptKit. artifact behavior remain unchanged.
## Maintained D&D Example ## Failure And Diagnostic Semantics
Add or update a Narratio example that enables Notarius's complete The stage fails before invoking Notarius when enabled configuration or the
`dnd-session` pipeline and maps these ten required lanes to stable extraction final trimmed transcript is invalid.
keys:
| Output key | Notarius lane ID | The stage fails after invocation for:
- cancellation or timeout;
- nonzero process exit;
- malformed, oversized, or unsupported receipt data;
- malformed or unsupported index data;
- unsafe receipt, descriptor, or filesystem paths;
- symlinks or special files in the promoted bundle;
- mismatched receipt pipeline identity;
- missing configured lanes;
- duplicate lane descriptors;
- rejected required lanes;
- incompatible media type, schema identity/version, or module key;
- empty or syntactically invalid required JSON payloads;
- checksum or promotion failure; or
- manifest persistence failure.
Process success alone does not establish consumer success. Notarius may exit
zero while omitting or rejecting a lane, and Narratio's configured required
set remains authoritative.
Failure retains bounded receipt and stderr diagnostics plus the run-local
bundle where available. Durable artifact storage and extraction source records
are updated only after complete validation and promotion. A promoted unique
bundle whose later manifest save fails is unreferenced and may be reclaimed by
explicit cleanup; it is never inferred as current.
## Security And Privacy
Transcripts, Notarius lanes, evidence context, manifests, receipts, warnings,
rejections, debug data, and logs are private campaign material.
- Secrets do not appear in command arguments, generated configuration,
artifact metadata, logs, examples, or documentation.
- Credentials continue to enter through Notarius and PromptKit's documented
environment or secret mechanisms.
- Receipt and index paths are untrusted external input until confined.
- Recursive promotion never follows symlinks or copies special files.
- Diagnostics remain bounded and do not echo payload bodies.
- Automatic cleanup follows Narratio's existing post-publish gates and path
safety rules; it does not silently remove immutable extraction bundles
outside an explicit covered policy.
## Maintained Complete D&D Example
Add a maintained example that enables Notarius's complete `dnd-session`
pipeline and maps these required lanes:
| Extraction key | Notarius lane ID |
| --- | --- | | --- | --- |
| `item_registry` | `item-registry` | | `item_registry` | `item-registry` |
| `npc_registry` | `npc-registry` | | `npc_registry` | `npc-registry` |
@@ -274,106 +505,100 @@ keys:
| `location_occurrences` | `location-occurrences` | | `location_occurrences` | `location-occurrences` |
| `enemy_events` | `enemy-events` | | `enemy_events` | `enemy-events` |
The example must include each lane's current media type and schema identity The example obtains exact media types and schema identities from Notarius's
from Notarius's published contracts. It should also demonstrate at least one published contracts at implementation time. It demonstrates at least one
Scriptorium analysis artifact consuming one or more Scriptorium artifact consuming a small, purpose-specific subset of extraction
`narratio.extraction.*` sources. The example must use placeholders and relative sources. It uses only placeholders and repository-relative example paths,
paths suitable for the example tree, contain no credentials, and pass the contains no credentials, and passes maintained example validation.
repository's configuration validation tests.
## Compatibility Policy ## Documentation Deliverables
The initial integration baseline is the public subprocess contract available When implementation lands, update current-behavior documentation in the same
in Notarius v0.3.0: change:
- successful JSON receipt schema `notarius.run-result.v1`; - add `docs/integrations/notarius.md` for the consumed CLI, receipt, index, and
- production JSON bundle discovery through `index.json`; and compatibility contract, linking to Notarius's canonical documentation;
- the schema IDs and versions explicitly configured for required lanes. - add `docs/internal/stage-extract.md` for stage flow, collaborators, state,
failures, resume validation, and focused tests;
Runtime compatibility should be decided from those published contracts, not - update `docs/internal/overview.md`, `docs/internal/adapters.md`,
from textual parsing of `notarius --version`. New optional receipt or index `docs/internal/artifacts.md`, `docs/internal/manifest.md`, and
fields must not break Narratio. An unsupported receipt version or lane schema `docs/internal/workspace.md` within their canonical scopes;
must fail before the artifact is registered for analysis. - update `docs/policy/architecture.md` for the Notarius boundary and explicit
skipped/resume-validation contracts;
## Documentation Deliverables When Implemented
Update current-behavior documentation in the same change that implements the
feature:
- add `docs/integrations/notarius.md` for the external CLI, receipt, bundle,
and adapter contract, linking to Notarius's canonical documentation;
- add `docs/internal/stage-extract.md` for stage inputs, outputs, collaborators,
state transitions, failures, and focused tests;
- update `docs/internal/adapters.md`, `docs/internal/artifacts.md`,
`docs/internal/manifest.md`, and the internal stage inventory;
- update `docs/policy/architecture.md` to list Notarius among isolated external
systems and preserve the adapter/stage boundary;
- update `docs/config.md`, `docs/cli.md`, `docs/operations.md`, - update `docs/config.md`, `docs/cli.md`, `docs/operations.md`,
`docs/troubleshooting.md`, `README.md`, and maintained examples only to the `docs/troubleshooting.md`, `README.md`, and maintained examples only within
extent their canonical scopes require; and their canonical scopes; and
- update `docs/development.md` only to the extent its canonical contributor - update `docs/development.md` only if its contributor routing changes.
routing scope requires.
Outside this roadmap, do not describe `extract`, Notarius configuration, or Outside this roadmap, do not describe the feature as implemented until its
`narratio.extraction.*` sources as implemented until the code exists. code and documentation are complete.
## Testing And Validation Expectations ## Testing Expectations
Implementation should provide focused tests for: Tests should protect contracts and meaningful risks rather than private helper
structure. The implementation plan assigns detailed ownership, with coverage
for:
- strict configuration decoding, defaults, required fields, path resolution, - strict configuration, defaults, normalization, cross-source validation, and
output-map validation, normalized-key collisions, and example loading; maintained examples;
- exact stage order, selection, downstream staleness, resume, force, and - stage result dispositions and clearing of self-skipped outputs;
prerequisite behavior; - backward-compatible artifact metadata serialization;
- adapter command construction, deterministic working directory, environment - exact adapter arguments, streams, cancellation, timeout, exit behavior,
inheritance, stdout/stderr separation, cancellation, timeout, and nonzero receipt/index compatibility, and every path-confinement boundary;
exits; - recursive promotion safety, atomic visibility, cleanup on failure, symlink
- supported and unsupported receipt versions, unknown optional fields, rejection, and immutable destination behavior;
malformed receipts, index decoding, and path escapes at every boundary; - required-lane policy, descriptor compatibility, JSON syntax, checksums, and
- descriptor lookup by lane ID rather than filename, expected metadata checks, provenance;
missing/rejected configured lanes, and tolerated unconfigured lanes; - canonical order, single-stage selection, force, staleness, resume
- run-local execution, transactional canonical-bundle replacement, checksums, validation, and enable-after-skip behavior;
failed-run preservation, and manifest recording; - manifest-backed extraction catalog resolution for required, optional,
- artifact-catalog resolution from `narratio.extraction.*` into analysis and missing, stale, skipped, incompatible, and tampered artifacts;
publish, including required, optional, missing, stale, and skipped cases; and - analyze and publish integration without automatic lane injection; and
- end-to-end stage execution with a fake Notarius adapter, without live LLM or - representative assembled execution with a fake Notarius runner and no live
external subprocess requirements in the ordinary test suite. LLM, credentials, or external subprocess in the ordinary test suite.
Run the repository-wide Go tests, vet, build, and maintained example validation Repository-wide tests, vet, build, and maintained-example validation are
after focused tests pass. required after focused tests pass.
## Acceptance Criteria ## Acceptance Criteria
- `extract` is a first-class transactional stage between `render` and - `extract` is a first-class stage between `trim` and `render` everywhere
`analyze` everywhere Narratio models stage order or state. Narratio models stage order and lifecycle.
- Narratio invokes Notarius only through a narrow, tested adapter. - Narratio invokes Notarius only through a narrow tested adapter.
- The stage consumes the manifest-resolved final trimmed Seriatim transcript. - Extraction consumes the manifest-resolved final trimmed Seriatim JSON.
- The Notarius configuration remains owned by Notarius; Narratio configures - Disabled extraction is recorded as skipped and runs normally if later
only invocation and its downstream consumer contract. enabled.
- Every configured output is discovered through the receipt and `index.json`, - Successful resume requires valid manifest-recorded immutable outputs rather
contract-checked, materialized transactionally, and recorded with a stable than filesystem presence alone.
`narratio.extraction.*` source ID. - The complete bundle is promoted to a unique immutable directory without
- The complete D&D example maps all ten current lanes and passes strict config following symlinks or exposing a partial destination.
validation. - Every configured lane is discovered by lane ID, contract-checked, checksummed,
- Analysis can consume extraction sources through the existing artifact input and recorded with explicit source identity and external provenance.
model, and publish can select them through the artifact catalog. - Analysis and publish resolve extraction sources only from a current
- Failed, partial, rejected, unsafe, stale, or incompatible output never becomes successful extract manifest record.
a current analysis input. - Failed, skipped, stale, partial, rejected, unsafe, incompatible, or tampered
- Resume and force behavior remains manifest-driven. output never becomes a current input.
- Documentation accurately describes the implemented stage, adapter, - The complete D&D example maps all ten current lanes and demonstrates curated
configuration, operations, and compatibility boundary without duplicating analysis inputs.
Notarius's canonical schemas. - Tests remain deterministic, offline, and independent of real Notarius,
PromptKit, LLM providers, and credentials.
- Current-behavior documentation is updated only as implementation becomes
complete.
## Non-Goals ## Non-Goals
- Reimplementing Notarius extraction, prompts, schemas, references, retries, - Reimplementing Notarius extraction, configuration, schemas, prompts,
profiles, or lane orchestration in Narratio. references, retries, profiles, validation, or lane orchestration.
- Allowing one Narratio run to invoke arbitrary extractor programs or multiple - Supporting arbitrary extractor programs or multiple Notarius pipelines in
Notarius pipelines. one Narratio run.
- Making `extract` a configurable DAG or folding it into the Scriptorium - Turning canonical stage execution into a DAG or generic workflow engine.
`analyze` stage. - Folding extraction into `analyze` or Scriptorium.
- Partially selecting Notarius lanes through Narratio's `--artifacts` flag. - Automatically injecting all structured outputs into every prompt.
- Decoding D&D payload bodies in the generic Notarius adapter. - Selecting Notarius lanes through Narratio's `--artifacts` flag.
- Supporting previous-session extraction artifacts in the initial feature. - Decoding D&D payload bodies in the generic adapter or stage.
- Automatically detecting changes throughout Notarius's referenced config,
PromptKit profile, and campaign-reference closure.
- Supporting previous-session extraction sources in the initial feature.
- Automatically publishing the complete Notarius bundle.
- Requiring live Notarius, PromptKit, an LLM provider, or external services in - Requiring live Notarius, PromptKit, an LLM provider, or external services in
the ordinary unit test suite. the ordinary test suite.

View File

@@ -195,6 +195,9 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "running", "path", manifestPath) env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "running", "path", manifestPath)
result, err := s.Run(ctx, stageEnv, m) result, err := s.Run(ctx, stageEnv, m)
if err == nil {
err = validateStageResult(result)
}
if err != nil { if err != nil {
failedAt := nowUTC() failedAt := nowUTC()
m.MarkStageFailed(s.Name(), failedAt, err.Error()) m.MarkStageFailed(s.Name(), failedAt, err.Error())
@@ -209,6 +212,25 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
env.Logger.Info("stage failed", "stage", s.Name(), "error", err) env.Logger.Info("stage failed", "stage", s.Name(), "error", err)
return nil, fmt.Errorf("stage %q failed: %w", s.Name(), err) return nil, fmt.Errorf("stage %q failed: %w", s.Name(), err)
} }
if result != nil && result.Disposition == stage.StageDispositionSkipped {
skipped = append(skipped, s.Name())
skippedAt := nowUTC()
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
clearStageResultDetails(m.Stages[s.Name()])
applyStageResultToManifest(m, s.Name(), result)
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
return nil, fmt.Errorf("save manifest after self-skip %q: %w", s.Name(), err)
}
runManifest.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
applyStageResultToRunManifest(runManifest, s.Name(), result)
syncRunManifestIdentityFromSession(m, runManifest)
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
return nil, fmt.Errorf("save run manifest after self-skip %q: %w", s.Name(), err)
}
env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "skipped", "path", manifestPath)
env.Logger.Info("stage skipped", "stage", s.Name(), "reason", result.SkipReason)
continue
}
outputs := mapResultOutputs(s.Name(), result, runID) outputs := mapResultOutputs(s.Name(), result, runID)
succeededAt := nowUTC() succeededAt := nowUTC()
@@ -407,26 +429,69 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
localPath = ref.RelativePath localPath = ref.RelativePath
} }
kind := ref.Kind kind := ref.Kind
sourceID := "" sourceID := strings.TrimSpace(ref.SourceID)
if stageName == "analyze" { if sourceID == "" {
sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind) if stageName == "analyze" {
kind = "scriptorium_artifact" sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind)
} else { kind = "scriptorium_artifact"
sourceID = sourceIDForOutputKind(kind) } else {
sourceID = sourceIDForOutputKind(kind)
}
} }
out = append(out, manifest.ArtifactRecord{ out = append(out, manifest.ArtifactRecord{
Kind: kind, Kind: kind,
SourceID: sourceID, SourceID: sourceID,
LocalPath: localPath, LocalPath: localPath,
ProducerRunID: runID, Contract: cloneContractMetadata(ref.Contract),
RemoteKey: ref.RemoteKey, ExternalProvenance: cloneExternalProvenance(ref.ExternalProvenance),
Checksum: ref.Checksum, ProducerRunID: runID,
RemoteKey: ref.RemoteKey,
Checksum: ref.Checksum,
}) })
} }
return out return out
} }
func validateStageResult(result *stage.StageResult) error {
if result == nil {
return nil
}
switch result.Disposition {
case stage.StageDispositionSucceeded:
if strings.TrimSpace(result.SkipReason) != "" {
return fmt.Errorf("successful result contains a skip reason")
}
return nil
case stage.StageDispositionSkipped:
if strings.TrimSpace(result.SkipReason) == "" {
return fmt.Errorf("skipped result requires a skip reason")
}
if len(result.Outputs) != 0 {
return fmt.Errorf("skipped result contains %d output(s)", len(result.Outputs))
}
return nil
default:
return fmt.Errorf("unsupported stage result disposition %q", result.Disposition)
}
}
func cloneContractMetadata(value *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func cloneExternalProvenance(value *artifactmodel.ExternalProvenance) *artifactmodel.ExternalProvenance {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func sourceIDForOutputKind(kind string) string { func sourceIDForOutputKind(kind string) string {
trimmed := strings.TrimSpace(kind) trimmed := strings.TrimSpace(kind)
if trimmed == "" { if trimmed == "" {
@@ -462,6 +527,15 @@ func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *
} }
} }
func clearStageResultDetails(sr *manifest.StageRecord) {
if sr == nil {
return
}
sr.Logs = nil
sr.GeneratedConfigs = nil
sr.Metadata = nil
}
func ensureManifestIdentity(cfg *config.Config, m *manifest.Manifest, runID string) (bool, error) { func ensureManifestIdentity(cfg *config.Config, m *manifest.Manifest, runID string) (bool, error) {
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil || m == nil { if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil || m == nil {
return false, nil return false, nil

View File

@@ -16,6 +16,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx" "gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
@@ -38,6 +39,25 @@ type countingStage struct {
runs *int runs *int
} }
type resultStage struct {
name string
result *stage.StageResult
runs *int
order *[]string
}
func (s resultStage) Name() string { return s.name }
func (s resultStage) Declares() stage.IODecl { return stage.IODecl{} }
func (s resultStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
if s.runs != nil {
*s.runs = *s.runs + 1
}
if s.order != nil {
*s.order = append(*s.order, s.name)
}
return s.result, nil
}
func (s countingStage) Name() string { return s.name } func (s countingStage) Name() string { return s.name }
func (s countingStage) Declares() stage.IODecl { return stage.IODecl{} } func (s countingStage) Declares() stage.IODecl { return stage.IODecl{} }
func (s countingStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) { func (s countingStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
@@ -199,6 +219,61 @@ func TestExecuteStagesAnalyzeOutputsPersistAsScriptoriumArtifacts(t *testing.T)
} }
} }
func TestMapResultOutputsPrefersExplicitSourceAndCopiesMetadata(t *testing.T) {
contract := &artifactmodel.ContractMetadata{
MediaType: "application/json",
SchemaID: "notarius.dnd.npc_registry",
SchemaVersion: "v1",
}
provenance := &artifactmodel.ExternalProvenance{
System: "notarius",
RunID: "external-run",
PipelineID: "dnd-session",
ArtifactID: "npc-registry",
}
result := &stage.StageResult{Outputs: []artifacts.Ref{{
Kind: "structured_data",
SourceID: "narratio.example.npcs",
RelativePath: "artifacts/npcs.json",
Contract: contract,
ExternalProvenance: provenance,
}}}
got := mapResultOutputs("analyze", result, "narratio-run")
if len(got) != 1 {
t.Fatalf("outputs len = %d, want 1", len(got))
}
if got[0].SourceID != "narratio.example.npcs" {
t.Fatalf("source_id = %q, want explicit source", got[0].SourceID)
}
if got[0].Kind != "structured_data" {
t.Fatalf("kind = %q, want explicit output kind preserved", got[0].Kind)
}
if got[0].Contract == nil || *got[0].Contract != *contract {
t.Fatalf("contract = %#v, want %#v", got[0].Contract, contract)
}
if got[0].ExternalProvenance == nil || *got[0].ExternalProvenance != *provenance {
t.Fatalf("external provenance = %#v, want %#v", got[0].ExternalProvenance, provenance)
}
if got[0].Contract == contract || got[0].ExternalProvenance == provenance {
t.Fatal("mapped metadata should not alias the stage result")
}
}
func TestMapResultOutputsRetainsFallbackInference(t *testing.T) {
transcript := mapResultOutputs("trim", &stage.StageResult{Outputs: []artifacts.Ref{{
Kind: artifactmodel.TranscriptOutputKindFinalTrimmed,
}}}, "run-id")
if len(transcript) != 1 || transcript[0].SourceID != artifacts.ArtifactTranscriptFinalTrimmed {
t.Fatalf("transcript fallback = %#v, want final-trimmed source", transcript)
}
analyze := mapResultOutputs("analyze", &stage.StageResult{Outputs: []artifacts.Ref{{Kind: "session_recap"}}}, "run-id")
if len(analyze) != 1 || analyze[0].SourceID != "narratio.artifact.session_recap" || analyze[0].Kind != "scriptorium_artifact" {
t.Fatalf("analyze fallback = %#v, want configured artifact inference", analyze)
}
}
func TestNeedsObjectStoreForRunPrepareWithPreviousRequirements(t *testing.T) { func TestNeedsObjectStoreForRunPrepareWithPreviousRequirements(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -738,6 +813,126 @@ func TestExecuteStagesRunManifestRecordsSkippedStage(t *testing.T) {
} }
} }
func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
cfg := testConfig(t)
store := &manifest.LocalStore{}
manifestPath := manifestPathFor(cfg)
seed := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
seed.MarkStageSucceeded("optional", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{
Kind: "old_output",
SourceID: "narratio.example.old",
LocalPath: "artifacts/old.json",
}})
seed.Stages["optional"].Logs = []string{"old.log"}
seed.Stages["optional"].GeneratedConfigs = []string{"old.yml"}
seed.Stages["optional"].Metadata = map[string]any{"old": true}
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
t.Fatalf("Save() seed manifest error = %v", err)
}
order := []string{}
optionalRuns := 0
stages := []stage.Stage{
resultStage{
name: "optional",
runs: &optionalRuns,
order: &order,
result: &stage.StageResult{
Disposition: stage.StageDispositionSkipped,
SkipReason: "integration_disabled",
Logs: []string{"runs/current/optional.log"},
GeneratedConfigs: []string{"runs/current/optional.yml"},
Metadata: map[string]any{"enabled": false},
},
},
resultStage{name: "later", order: &order, result: &stage.StageResult{}},
}
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{Force: true})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if strings.Join(order, ",") != "optional,later" {
t.Fatalf("execution order = %v, want optional then later", order)
}
if len(summary.Executed) != 2 || len(summary.Skipped) != 1 || summary.Skipped[0] != "optional" {
t.Fatalf("summary = %#v, want optional executed and self-skipped before later", summary)
}
sessionManifest, err := store.Load(context.Background(), summary.ManifestPath)
if err != nil {
t.Fatalf("Load() session manifest error = %v", err)
}
selfSkipped := sessionManifest.Stages["optional"]
if selfSkipped == nil || selfSkipped.Status != manifest.StatusSkipped {
t.Fatalf("optional stage = %#v, want skipped", selfSkipped)
}
if len(selfSkipped.Outputs) != 0 {
t.Fatalf("optional outputs = %#v, want old outputs cleared", selfSkipped.Outputs)
}
if selfSkipped.Error == nil || selfSkipped.Error.Message != "integration_disabled" {
t.Fatalf("optional skip reason = %#v, want integration_disabled", selfSkipped.Error)
}
if len(selfSkipped.Logs) != 1 || selfSkipped.Logs[0] != "runs/current/optional.log" ||
len(selfSkipped.GeneratedConfigs) != 1 || selfSkipped.GeneratedConfigs[0] != "runs/current/optional.yml" ||
selfSkipped.Metadata["enabled"] != false || selfSkipped.Metadata["old"] != nil {
t.Fatalf("optional result details = %#v, want current bounded diagnostics and metadata", selfSkipped)
}
if later := sessionManifest.Stages["later"]; later == nil || later.Status != manifest.StatusSucceeded {
t.Fatalf("later stage = %#v, want succeeded", later)
}
runManifest, err := store.LoadRun(context.Background(), summary.RunManifestPath)
if err != nil {
t.Fatalf("LoadRun() error = %v", err)
}
runStage := runManifest.Stages["optional"]
if runStage == nil || runStage.Action != manifest.RunStageActionRun || runStage.Status != manifest.StatusSkipped {
t.Fatalf("run optional stage = %#v, want run action with skipped status", runStage)
}
if len(runStage.Logs) != 1 || runStage.Metadata["enabled"] != false {
t.Fatalf("run optional stage details = %#v, want result diagnostics and metadata", runStage)
}
_, err = executeStages(context.Background(), cfg, []stage.Stage{stages[0]}, RunOptions{})
if err != nil {
t.Fatalf("second executeStages() error = %v", err)
}
if optionalRuns != 2 {
t.Fatalf("optional runs = %d, want self-skipped stage reconsidered", optionalRuns)
}
}
func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
cfg := testConfig(t)
invalid := resultStage{name: "optional", result: &stage.StageResult{
Disposition: stage.StageDispositionSkipped,
SkipReason: "integration_disabled",
Outputs: []artifacts.Ref{{Kind: "unexpected"}},
}}
summary, err := executeStages(context.Background(), cfg, []stage.Stage{invalid}, RunOptions{})
if err == nil {
t.Fatal("executeStages() error = nil, want invalid skipped result failure")
}
if summary != nil {
t.Fatalf("summary = %#v, want nil", summary)
}
if !strings.Contains(err.Error(), "skipped result contains 1 output") {
t.Fatalf("error = %q, want skipped-output validation", err)
}
loaded, loadErr := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
if loadErr != nil {
t.Fatalf("Load() session manifest error = %v", loadErr)
}
if got := loaded.Stages["optional"]; got == nil || got.Status != manifest.StatusFailed {
t.Fatalf("optional stage = %#v, want failed", got)
}
}
func TestExecuteStagesRunLocalArtifactsAndCanonicalSync(t *testing.T) { func TestExecuteStagesRunLocalArtifactsAndCanonicalSync(t *testing.T) {
cfg := testConfig(t) cfg := testConfig(t)
stages := []stage.Stage{ stages := []stage.Stage{

View File

@@ -0,0 +1,17 @@
package artifactmodel
// ContractMetadata identifies the data contract implemented by an artifact.
type ContractMetadata struct {
MediaType string `json:"media_type"`
SchemaID string `json:"schema_id"`
SchemaVersion string `json:"schema_version"`
ModuleKey string `json:"module_key,omitempty"`
}
// ExternalProvenance identifies an artifact produced by an external system.
type ExternalProvenance struct {
System string `json:"system"`
RunID string `json:"run_id"`
PipelineID string `json:"pipeline_id"`
ArtifactID string `json:"artifact_id"`
}

View File

@@ -0,0 +1,56 @@
package artifactmodel
import (
"encoding/json"
"strings"
"testing"
)
func TestArtifactMetadataJSON(t *testing.T) {
type envelope struct {
Contract *ContractMetadata `json:"contract,omitempty"`
ExternalProvenance *ExternalProvenance `json:"external_provenance,omitempty"`
}
complete, err := json.Marshal(envelope{
Contract: &ContractMetadata{
MediaType: "application/json",
SchemaID: "notarius.dnd.npc_registry",
SchemaVersion: "v1",
ModuleKey: "dnd/npc-registry",
},
ExternalProvenance: &ExternalProvenance{
System: "notarius",
RunID: "run-123",
PipelineID: "dnd-session",
ArtifactID: "npc-registry",
},
})
if err != nil {
t.Fatalf("Marshal() complete metadata error = %v", err)
}
wantComplete := `{"contract":{"media_type":"application/json","schema_id":"notarius.dnd.npc_registry","schema_version":"v1","module_key":"dnd/npc-registry"},"external_provenance":{"system":"notarius","run_id":"run-123","pipeline_id":"dnd-session","artifact_id":"npc-registry"}}`
if string(complete) != wantComplete {
t.Fatalf("complete metadata JSON = %s, want %s", complete, wantComplete)
}
omitted, err := json.Marshal(envelope{})
if err != nil {
t.Fatalf("Marshal() omitted metadata error = %v", err)
}
if string(omitted) != `{}` {
t.Fatalf("omitted metadata JSON = %s, want {}", omitted)
}
withoutModule, err := json.Marshal(envelope{Contract: &ContractMetadata{
MediaType: "application/json",
SchemaID: "notarius.dnd.npc_registry",
SchemaVersion: "v1",
}})
if err != nil {
t.Fatalf("Marshal() contract without module key error = %v", err)
}
if strings.Contains(string(withoutModule), "module_key") {
t.Fatalf("contract JSON unexpectedly contains omitted module_key: %s", withoutModule)
}
}

View File

@@ -1,16 +1,23 @@
package artifacts package artifacts
import "os" import (
"os"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
)
// Ref identifies a pipeline artifact and its local/remote coordinates. // Ref identifies a pipeline artifact and its local/remote coordinates.
type Ref struct { type Ref struct {
Kind string Kind string
Category string SourceID string
SessionID string Category string
RelativePath string SessionID string
AbsolutePath string RelativePath string
RemoteKey string AbsolutePath string
Checksum string RemoteKey string
Checksum string
Contract *artifactmodel.ContractMetadata
ExternalProvenance *artifactmodel.ExternalProvenance
} }
// Store is the local artifact/workdir abstraction used by orchestration code. // Store is the local artifact/workdir abstraction used by orchestration code.

View File

@@ -3,6 +3,8 @@ package manifest
import ( import (
"strings" "strings"
"time" "time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
) )
// ErrorRecord captures structured error metadata at run or stage scope. // ErrorRecord captures structured error metadata at run or stage scope.
@@ -28,9 +30,11 @@ type InputRecord struct {
// ArtifactRecord captures one produced artifact and optional remote metadata. // ArtifactRecord captures one produced artifact and optional remote metadata.
type ArtifactRecord struct { type ArtifactRecord struct {
Kind string `json:"kind"` Kind string `json:"kind"`
SourceID string `json:"source_id,omitempty"` SourceID string `json:"source_id,omitempty"`
LocalPath string `json:"local_path"` LocalPath string `json:"local_path"`
Contract *artifactmodel.ContractMetadata `json:"contract,omitempty"`
ExternalProvenance *artifactmodel.ExternalProvenance `json:"external_provenance,omitempty"`
// ProducerRunID identifies the run that produced this durable artifact. // ProducerRunID identifies the run that produced this durable artifact.
ProducerRunID string `json:"producer_run_id,omitempty"` ProducerRunID string `json:"producer_run_id,omitempty"`
RemoteKey string `json:"remote_key,omitempty"` RemoteKey string `json:"remote_key,omitempty"`
@@ -120,6 +124,7 @@ func (m *Manifest) MarkStageSkipped(name string, at time.Time, reason string) {
s.Status = StatusSkipped s.Status = StatusSkipped
s.CompletedAt = timePtr(at) s.CompletedAt = timePtr(at)
s.Error = &ErrorRecord{Message: strings.TrimSpace(reason), Code: "skipped", At: timePtr(at)} s.Error = &ErrorRecord{Message: strings.TrimSpace(reason), Code: "skipped", At: timePtr(at)}
s.Outputs = nil
s.UpdatedAt = at s.UpdatedAt = at
m.UpdatedAt = at m.UpdatedAt = at
} }

View File

@@ -110,3 +110,23 @@ func TestMarkStageSucceededClearsError(t *testing.T) {
t.Fatalf("error = %#v, want nil on success", stage.Error) t.Fatalf("error = %#v, want nil on success", stage.Error)
} }
} }
func TestMarkStageSkippedClearsEarlierOutputs(t *testing.T) {
m := New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
m.MarkStageSucceeded("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), []ArtifactRecord{
{Kind: "structured_data", SourceID: "narratio.example.characters", LocalPath: "artifacts/characters.json"},
})
m.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), "integration_disabled")
stage := m.Stages["extract"]
if stage == nil {
t.Fatal("missing stage record")
}
if stage.Status != StatusSkipped {
t.Fatalf("status = %q, want %q", stage.Status, StatusSkipped)
}
if len(stage.Outputs) != 0 {
t.Fatalf("outputs = %#v, want cleared", stage.Outputs)
}
}

View File

@@ -119,6 +119,7 @@ func (m *RunManifest) MarkStageSkipped(name string, at time.Time, reason string)
s.Status = StatusSkipped s.Status = StatusSkipped
s.CompletedAt = timePtr(at) s.CompletedAt = timePtr(at)
s.Error = &ErrorRecord{Message: strings.TrimSpace(reason), Code: "skipped", At: timePtr(at)} s.Error = &ErrorRecord{Message: strings.TrimSpace(reason), Code: "skipped", At: timePtr(at)}
s.Outputs = nil
s.UpdatedAt = at s.UpdatedAt = at
m.UpdatedAt = at m.UpdatedAt = at
} }

View File

@@ -8,6 +8,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
) )
func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) { func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
@@ -64,6 +66,76 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
} }
} }
func TestLocalStoreArtifactMetadataCompatibility(t *testing.T) {
store := &LocalStore{}
ctx := context.Background()
dir := t.TempDir()
oldPath := filepath.Join(dir, "old-manifest.json")
oldJSON := `{
"session_id": "2026-05-03",
"created_at": "2026-05-03T10:00:00Z",
"updated_at": "2026-05-03T10:01:00Z",
"stages": {
"analyze": {
"name": "analyze",
"status": "succeeded",
"created_at": "2026-05-03T10:00:00Z",
"updated_at": "2026-05-03T10:01:00Z",
"outputs": [{"kind":"scriptorium_artifact","local_path":"artifacts/recap.md"}]
}
}
}`
if err := os.WriteFile(oldPath, []byte(oldJSON), 0o644); err != nil {
t.Fatalf("WriteFile() old manifest error = %v", err)
}
loaded, err := store.Load(ctx, oldPath)
if err != nil {
t.Fatalf("Load() old manifest error = %v", err)
}
oldOutput := loaded.Stages["analyze"].Outputs[0]
if oldOutput.Contract != nil || oldOutput.ExternalProvenance != nil {
t.Fatalf("old output metadata = %#v, %#v; want nil", oldOutput.Contract, oldOutput.ExternalProvenance)
}
if err := store.Save(ctx, oldPath, loaded); err != nil {
t.Fatalf("Save() old manifest error = %v", err)
}
roundTripped, err := os.ReadFile(oldPath)
if err != nil {
t.Fatalf("ReadFile() round-tripped old manifest error = %v", err)
}
if strings.Contains(string(roundTripped), `"contract"`) || strings.Contains(string(roundTripped), `"external_provenance"`) {
t.Fatalf("old manifest gained fabricated metadata:\n%s", roundTripped)
}
loaded.Stages["analyze"].Outputs[0].Contract = &artifactmodel.ContractMetadata{
MediaType: "application/json",
SchemaID: "example.recap",
SchemaVersion: "v1",
}
loaded.Stages["analyze"].Outputs[0].ExternalProvenance = &artifactmodel.ExternalProvenance{
System: "example",
RunID: "external-run",
PipelineID: "pipeline",
ArtifactID: "recap",
}
metadataPath := filepath.Join(dir, "metadata-manifest.json")
if err := store.Save(ctx, metadataPath, loaded); err != nil {
t.Fatalf("Save() metadata manifest error = %v", err)
}
withMetadata, err := store.Load(ctx, metadataPath)
if err != nil {
t.Fatalf("Load() metadata manifest error = %v", err)
}
got := withMetadata.Stages["analyze"].Outputs[0]
if got.Contract == nil || got.Contract.SchemaID != "example.recap" {
t.Fatalf("contract = %#v, want persisted contract", got.Contract)
}
if got.ExternalProvenance == nil || got.ExternalProvenance.RunID != "external-run" {
t.Fatalf("external provenance = %#v, want persisted provenance", got.ExternalProvenance)
}
}
func TestLocalStoreSaveUpdatesTimestamp(t *testing.T) { func TestLocalStoreSaveUpdatesTimestamp(t *testing.T) {
store := &LocalStore{} store := &LocalStore{}
ctx := context.Background() ctx := context.Background()

View File

@@ -44,8 +44,19 @@ type Stage interface {
Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error)
} }
// StageDisposition describes the outcome of a stage that returned without an error.
type StageDisposition string
const (
// StageDispositionSucceeded is the zero value so existing stages remain successful.
StageDispositionSucceeded StageDisposition = ""
StageDispositionSkipped StageDisposition = "skipped"
)
// StageResult is the declared output of a stage execution. // StageResult is the declared output of a stage execution.
type StageResult struct { type StageResult struct {
Disposition StageDisposition
SkipReason string
Outputs []artifacts.Ref Outputs []artifacts.Ref
Logs []string Logs []string
GeneratedConfigs []string GeneratedConfigs []string