324 Commits

Author SHA1 Message Date
7b077c269d Clarify normalize fallback ownership 2026-07-26 02:44:29 +00:00
80ec939383 Validate normalize retry diagnostics 2026-07-26 02:43:28 +00:00
d3c4d6f133 Simplify NPC normalization prompt guidance 2026-07-26 02:40:08 +00:00
5ad661f95f Redact NPC normalization context errors 2026-07-26 02:38:03 +00:00
d63e5c6852 Plan NPC normalization follow-up fixes 2026-07-25 21:34:42 -05:00
fbb8e0d241 Document NPC semantic normalization 2026-07-26 01:46:23 +00:00
8d9a496935 Integrate NPC semantic normalization 2026-07-26 01:41:35 +00:00
d1c48db4bc Add LLM-assisted NPC normalization 2026-07-26 01:36:32 +00:00
6bd781d344 Add NPC normalization prompt context 2026-07-26 01:27:34 +00:00
8a12c56971 Add retryable normalization fallbacks 2026-07-26 01:20:28 +00:00
26bd59a5a2 Plan LLM-assisted NPC normalization 2026-07-25 20:13:12 -05:00
927a7beb88 Clarify and streamline D&D item events 2026-07-25 23:59:56 +00:00
f7059607af Document D&D item event support 2026-07-25 22:16:29 +00:00
63de44c347 Add D&D item event integration coverage 2026-07-25 22:10:53 +00:00
f0ede9dacc Register D&D item event pipeline 2026-07-25 22:05:52 +00:00
5711f8b9e3 Add D&D item event normalization 2026-07-25 22:00:32 +00:00
da83510234 Add D&D item event validators 2026-07-25 21:52:59 +00:00
f51b22bea7 Add D&D item event extractor 2026-07-25 21:49:24 +00:00
f320c2fcee Add D&D item event artifact contract 2026-07-25 21:41:17 +00:00
4ba1e50a89 Plan D&D item event tracking 2026-07-25 21:33:43 +00:00
2a7e025251 Improve scene-aware combat gating 2026-07-25 21:08:24 +00:00
3da20e9d6a Document scene-aware combat extraction 2026-07-25 19:34:33 +00:00
b1c0faa748 Test scene-aware combat handoff behavior 2026-07-25 19:29:15 +00:00
989f2c220b Gate combat extraction on scene descriptions 2026-07-25 19:23:19 +00:00
7e35915b3e Add scene eligibility registry 2026-07-25 19:15:33 +00:00
24238d249e Plan scene-aware combat extraction 2026-07-25 18:59:57 +00:00
9614469b45 Consolidate the D&D configuration examples 2026-07-25 15:48:48 +00:00
29ee68824d Finish the D&D module cleanup 2026-07-25 13:30:23 +00:00
aeaaf44ae0 Remove unused NPC registry and identity aliases 2026-07-25 13:06:40 +00:00
d752c51aec Centralize D&D candidate JSON codecs 2026-07-25 13:05:02 +00:00
8199d95dc1 Reuse document indexes in D&D normalization 2026-07-25 13:01:31 +00:00
97cdb01357 Reuse document indexes in D&D validators 2026-07-25 12:57:31 +00:00
7a66095912 Reuse indexes for D&D citation validation 2026-07-25 12:53:48 +00:00
9d1356a20e Add D&D refactoring plan and isolate merger results 2026-07-25 12:51:36 +00:00
e4471fc300 Fix D&D extraction issues and retire the completed audit 2026-07-25 12:30:42 +00:00
84a2854b5e Close D&D extraction audit documentation 2026-07-24 14:48:13 +00:00
a1b76093ce Document D&D extractor contract 2026-07-24 14:46:19 +00:00
1aa30a73db Protect D&D citation prompt ordering 2026-07-24 14:44:10 +00:00
dc7c0e2f9e Move scene semantics to deterministic validation 2026-07-24 14:40:54 +00:00
e2cb0d901a Centralize D&D extraction request preparation 2026-07-24 14:38:42 +00:00
8e0b029f5f Order combat and NPC interaction extraction by document position 2026-07-24 14:33:34 +00:00
9bbf2535dd Order spell and NPC extraction by document position 2026-07-24 14:29:19 +00:00
83fde83a58 Preserve literal ordering for foreign source references 2026-07-24 14:25:02 +00:00
bef3d1359d Fingerprint D&D extractor mapping policies 2026-07-24 14:23:35 +00:00
1ff449435f Centralize D&D source reference ordering 2026-07-24 14:20:10 +00:00
6e21c83fd8 Add D&D module code audit implementation plan 2026-07-24 14:08:36 +00:00
6dc9d522b1 Audit the D&D modules 2026-07-24 13:43:53 +00:00
8adcf6840d Clean up completed D&D scene chunking work 2026-07-24 03:14:34 +00:00
f5ed30e455 Retire annotated D&D scene plans 2026-07-24 00:40:53 +00:00
cacf3f24e7 Simplify D&D scene chunking responses 2026-07-24 00:35:10 +00:00
f08ca4ddfa Plan simpler D&D scene chunking 2026-07-24 00:29:46 +00:00
e1c2f3c202 Harden D&D scene description extraction 2026-07-23 23:03:37 +00:00
9614eb540d Format shared D&D prompt assets 2026-07-23 20:30:47 +00:00
1b46596a39 Document D&D scene description artifacts 2026-07-23 20:29:01 +00:00
e043d61a99 Register D&D scene description pipeline 2026-07-23 20:22:05 +00:00
ad89782c9b Add D&D scene description validation 2026-07-23 20:17:18 +00:00
cd29265d5d Add D&D scene description extractor 2026-07-23 20:09:59 +00:00
2f36b7c3b6 Add D&D scene description codec 2026-07-23 20:02:37 +00:00
b8b3f3abfa Plan D&D scene description extraction 2026-07-23 18:59:34 +00:00
b490297cde Harden chunk map export and retire completed plans 2026-07-23 17:38:04 +00:00
06148074a2 Document accepted chunk map export 2026-07-23 15:06:33 +00:00
16a998055c Add opt-in JSON chunk map export 2026-07-23 15:02:01 +00:00
97c9a8e5ce Carry accepted chunk maps through the runner 2026-07-23 14:58:14 +00:00
66415fd1fa Add accepted chunk map contract 2026-07-23 14:52:25 +00:00
bfe25609a7 Tighten NPC interaction validation and consistency 2026-07-23 14:25:32 +00:00
36e0512454 Complete D&D NPC interaction integration 2026-07-23 13:59:02 +00:00
b02f667107 Register NPC interaction production lane 2026-07-23 13:49:34 +00:00
ed2b6f4580 Add NPC interaction normalization 2026-07-23 13:42:37 +00:00
cb7f145c76 Add NPC interaction validators 2026-07-23 13:35:15 +00:00
2b9d2eaeaa Add grounded NPC interaction extractor 2026-07-23 13:26:27 +00:00
61016671ab Add D&D NPC interaction artifact codec 2026-07-23 13:18:38 +00:00
b2c076946b Plan chunk map exports and scene descriptions 2026-07-23 04:33:18 +00:00
250c5c22b8 Align the spell response schema 2026-07-23 03:57:55 +00:00
4b0b166143 Complete minimal D&D extraction cutover 2026-07-22 19:23:10 +00:00
90481a0e4b Simplify D&D combat turn extraction contract 2026-07-22 19:14:14 +00:00
2cbaf20e55 Minimize D&D spell extraction contracts 2026-07-22 19:01:58 +00:00
a263a0840c Minimize D&D NPC extraction contracts 2026-07-22 18:51:26 +00:00
14991cf58b Plan simpler D&D extraction contracts 2026-07-22 18:35:25 +00:00
ab0b4e350c Relax private D&D response schemas 2026-07-22 14:38:35 +00:00
7b2fb0880d Align D&D validator ordering 2026-07-22 14:30:20 +00:00
748e02db80 Harden checkpoint reuse and combat validation 2026-07-22 14:24:03 +00:00
23c55f8925 Add implementation roadmap to clean up D&D extract module validation issues 2026-07-22 09:19:13 -05:00
906d97b391 Reconcile ordered pipeline documentation 2026-07-22 02:40:36 +00:00
f15fd4f9c1 Add recompute recovery acceptance coverage 2026-07-22 02:35:44 +00:00
7aadb088a6 Hydrate accepted producer checkpoints 2026-07-22 02:27:19 +00:00
9de399432e Add stable checkpoint decision diagnostics 2026-07-22 02:13:50 +00:00
5bdd56cfb1 Decompose pipeline runner orchestration 2026-07-22 02:03:27 +00:00
64ea23c21f Audit the implementation and add a roadmap with cleanup items and bugfixes 2026-07-22 01:30:17 +00:00
7071102ab7 Document ordered pipeline operations and retire sequential examples 2026-07-21 22:25:11 +00:00
9184072839 Implement operation-time D&D NPC artifact handoff 2026-07-21 22:12:01 +00:00
c437682407 Implement selective checkpoint recomputation 2026-07-21 21:59:48 +00:00
22d4f29670 Implement generated artifact handoff and provenance 2026-07-21 21:39:23 +00:00
afb7ed3cf1 Execute ordered pipeline steps with barriers 2026-07-21 21:19:00 +00:00
f846f252c0 Implement ordered pipeline step resolution 2026-07-21 21:05:28 +00:00
f5618d1f0c Plan the ordered pipeline steps implementation 2026-07-21 20:28:04 +00:00
f94ab0a6bf Remove completed roadmap documentation and add future work to the long-term roadmap 2026-07-21 20:01:46 +00:00
41b52aae74 Put stable D&D prompt content before transcripts 2026-07-21 18:59:59 +00:00
ed36f7d7fd Finish the D&D audit fixes 2026-07-21 16:08:28 +00:00
3ba2bfd7f6 Finalize D&D prompt integration and documentation 2026-07-21 14:58:26 +00:00
b344d16dc1 Clarify D&D registration composition 2026-07-21 14:48:05 +00:00
6e12c09952 Align D&D validator policies and diagnostics 2026-07-21 14:43:20 +00:00
07460341e3 Share D&D cited source traversal 2026-07-21 14:37:12 +00:00
732b13669f Centralize D&D chunk prompt material preparation 2026-07-21 14:27:39 +00:00
3a8a82ebc9 Reorder shared D&D extraction prompt messages 2026-07-21 14:22:46 +00:00
1c9819f08e Make D&D prompt asset manifests exact 2026-07-21 14:13:11 +00:00
447c4f73f9 Protect deterministic D&D prompt inputs 2026-07-21 14:04:52 +00:00
e01b8d1b6d Use shared assets instead of prompt prefix change detectors 2026-07-21 13:59:28 +00:00
3d70920f3d Add the D&D harmonization roadmap and implementation plan 2026-07-21 13:53:04 +00:00
110593ece1 Close out the D&D combat turn extraction roadmap 2026-07-21 07:49:57 -05:00
a1f5dce405 Compose production D&D combat pipeline 2026-07-21 05:42:33 +00:00
50aa60e0b8 Add combat turn normalization and invariants 2026-07-21 05:22:12 +00:00
2fbb3813aa Add standalone D&D combat turn extraction 2026-07-21 05:08:02 +00:00
6dd695611c Add durable D&D combat turn artifacts 2026-07-21 04:48:55 +00:00
92acb45775 Share the D&D NPC registry and prompt grounding 2026-07-21 04:34:56 +00:00
d3e171aa82 Add feature roadmap and implementation plan for a D&D combat turn extraction module 2026-07-20 23:22:03 -05:00
c6f330eb06 Implement NPC extraction follow-up fixes 2026-07-20 23:10:25 -05:00
20cfbfd311 Add NPC registry grounding for spell extraction 2026-07-21 03:12:03 +00:00
fb043325e1 Add production NPC pipeline composition 2026-07-21 02:50:37 +00:00
06c0259788 Add NPC normalization and identity validation 2026-07-21 02:36:59 +00:00
3d5fd9dc05 Add D&D NPC extraction and validation 2026-07-21 02:24:26 +00:00
3ba2c62cc1 Add D&D NPC artifact contract and identity codec 2026-07-21 02:08:46 +00:00
e2ab01f9d2 Add feature roadmap and implementation plan for a D&D NPC extraction module 2026-07-20 20:58:29 -05:00
5186e061a8 Minor additional improvements to the D&D spell extraction prompt. 2026-07-20 20:34:59 -05:00
c4c907d421 Hardened D&D spell extraction prompts 2026-07-20 20:25:23 -05:00
fa5076f5f1 Updated .gitignore to ignore local workspace for testing 2026-07-20 20:25:14 -05:00
8b5a4e0efd Apply spell normalization follow-up fixes 2026-07-20 19:45:00 -05:00
3eb68baca6 Document D&D spell normalization behavior 2026-07-20 21:25:02 +00:00
ae97adb8b0 Add assembled spell pipeline and checkpoint coverage 2026-07-20 21:16:08 +00:00
79b9fffcaf Register the D&D spell normalizer defaults 2026-07-20 21:06:30 +00:00
be22852daa Collapse duplicate D&D spell casts during normalization 2026-07-20 21:00:22 +00:00
f5107045c3 Add deterministic D&D spell normalizer foundation 2026-07-20 20:52:27 +00:00
2c98763b9b Add feature roadmap and implementation plan for D&D spell normalization module 2026-07-20 15:43:42 -05:00
d2eb763b9b Add feature roadmap and implementation plan for D&D spell normalization module 2026-07-20 15:31:57 -05:00
0f25e7339f Add a framework mechanism for prepared modules and validators to contribute checkpoint identity fingerprints 2026-07-20 15:14:41 -05:00
87c57681f6 Document completed spell catalog baseline 2026-07-20 19:44:31 +00:00
3d0d79360e Verify spell catalog provenance and checkpoint identity 2026-07-20 19:38:57 +00:00
f08b407b72 Enforce spell catalog validation in the D&D pipeline 2026-07-20 19:30:17 +00:00
4ff2c7795f Ground spell extraction with the effective catalog 2026-07-20 19:20:56 +00:00
3bfe05ab56 Add spell catalog overlay resolution 2026-07-20 19:10:44 +00:00
7806dba509 Deliver target references during pipeline preparation 2026-07-20 18:57:44 +00:00
ac53f83ac8 Add feature roadmap and implementation plan for D&D spell extraction and validation upgrades 2026-07-20 13:47:43 -05:00
385e4593f4 Implement ADR-0007 2026-07-19 10:15:28 -05:00
f64bb7c883 Update to upstream scriptorium v0.11.1 2026-07-19 08:40:15 -05:00
9d3175d36a Complete the test suite review documentation 2026-07-19 00:32:17 +00:00
4f96abf42c Make composition and boundary tests extension-friendly 2026-07-18 23:50:24 +00:00
d88bcb6070 Rewrite brittle validation and schema tests 2026-07-18 23:44:27 +00:00
0cca3b1f5d Consolidate configuration and resolver tests 2026-07-18 23:38:59 +00:00
bbc83ab042 Remove obsolete and misleading tests 2026-07-18 23:32:26 +00:00
2cba6d4512 Add filesystem checkpoint compatibility tests 2026-07-18 23:27:36 +00:00
e70450c401 Add testing policy documentation 2026-07-18 17:06:04 -05:00
4d3351c774 Implement finalized test suite for the CLI and configuration code 2026-07-18 12:14:46 -05:00
a586257d5e Harden CLI and configuration contract coverage 2026-07-18 16:14:24 +00:00
b8163091cc Add production and example composition contract tests 2026-07-18 16:09:58 +00:00
c7b3af82b4 Add CLI cache and resume contract tests 2026-07-18 15:59:57 +00:00
a42b06ba20 Add CLI reference selector contract tests 2026-07-18 15:51:57 +00:00
8d62973627 Add run control contract tests 2026-07-18 15:46:19 +00:00
8cdefc72a1 Add CLI command contract tests 2026-07-18 15:35:58 +00:00
d3a8dc7930 Add configuration validation and resolution contracts 2026-07-18 15:31:05 +00:00
86ebb62f84 Add version 3 configuration contract tests 2026-07-18 15:18:37 +00:00
50191ee694 Add implementation plan for CLI and configuration test coverage 2026-07-18 10:14:02 -05:00
3c35124db4 Complete run state hardening audit 2026-07-18 14:36:47 +00:00
e4ec521bed Write terminal debug reports for failed runs 2026-07-18 14:31:46 +00:00
2111e01142 Make run identities collision-resistant and outputs exclusive 2026-07-18 14:21:26 +00:00
a39eea7ed6 Enforce redaction for resolved pipeline summaries 2026-07-18 14:13:27 +00:00
7bcce9953e Harden debug bundle filesystem collaborators 2026-07-18 14:07:14 +00:00
9746a42e04 Reject backslashes in confined file paths 2026-07-18 14:07:14 +00:00
47bacc7abb Add an implementation plan to address gaps and fixes for the ADR-0006 refactor 2026-07-18 08:33:02 -05:00
8824948910 Document output cache and debug state model 2026-07-18 13:08:27 +00:00
8cb11e60e4 Harden output cache and debug state integration 2026-07-18 13:00:04 +00:00
26142f0e05 Remove legacy workspace and diagnostics implementation 2026-07-18 12:48:21 +00:00
1542a12497 Cut configuration and CLI over to output cache and debug surfaces 2026-07-18 12:42:10 +00:00
a9250206d5 Add internal debug bundle collaborators 2026-07-18 04:57:50 +00:00
5bd0ba7a72 Decouple checkpoint storage from workspace state 2026-07-18 04:55:33 +00:00
286fb9dce7 Add ADR, roadmap, and staged implementation plan for filesystem surface refactoring 2026-07-17 23:48:14 -05:00
9fa9154dda Implement final fixes and close out the implemetation roadmap 2026-07-17 23:16:16 -05:00
604c7a7945 Mark chunk plan implementation complete 2026-07-18 02:16:20 +00:00
a0f5e6e2b9 Document chunk plan remediation 2026-07-18 02:12:28 +00:00
561d65a505 Deep-clone source metadata during chunk materialization 2026-07-18 02:10:05 +00:00
205e2a9908 Confine chunk plan storage to its cache root 2026-07-18 02:03:58 +00:00
8c59b6af14 Redact invalid chunk plan lookup diagnostics 2026-07-18 01:59:10 +00:00
b3328b93e5 Update implementation plan to incorporate follow-up fixes 2026-07-17 20:55:39 -05:00
96a49bb7cd Document chunk plan caching and provenance 2026-07-18 00:57:51 +00:00
6d0a19c94c Harden chunk plan cache storage and reuse 2026-07-18 00:49:50 +00:00
6fc6ce0adb Expose chunk plan provenance and safe diagnostics 2026-07-18 00:45:01 +00:00
8ba5228c01 Wire persistent chunk plan caching into the CLI 2026-07-18 00:31:53 +00:00
51a36efb6b Integrate chunk plan caching into the runner 2026-07-18 00:20:56 +00:00
ebd449d847 Add chunk plan cache configuration and storage 2026-07-18 00:07:53 +00:00
7844c0a93f Generate and materialize canonical chunk plans 2026-07-17 23:57:59 +00:00
3bfac14397 Add canonical chunk plan source model 2026-07-17 23:47:22 +00:00
1c13e1d64a Add ADR, roadmap, and staged implementation plan for input chunk caching 2026-07-17 18:38:08 -05:00
60b86dc40c Finish the domain pipeline cleanup 2026-07-17 11:29:52 -05:00
68481804a7 Enforce production module import boundaries 2026-07-17 15:27:17 +00:00
236ccc62ad Record every retry attempt outcome 2026-07-17 15:23:27 +00:00
35bffdf336 Validate artifact candidates before final encoding 2026-07-17 15:13:19 +00:00
3772b308e9 Update implementation plan to address additional clean-up items 2026-07-17 10:06:18 -05:00
ef6926322d Remove obsolete sequential extraction path 2026-07-17 14:05:21 +00:00
2df7084d5d Enforce module family import boundaries 2026-07-17 13:58:50 +00:00
3d3cc0c08e Make typed module spec lookup artifact-aware 2026-07-17 13:53:55 +00:00
fbc3d9add6 Add retry-scoped merge and normalize debugging 2026-07-17 13:43:25 +00:00
35e45f0914 Include validator policy in pipeline identity 2026-07-17 13:33:16 +00:00
3e4fa923eb Update implementation plan to address remaining clean-up items 2026-07-17 08:28:18 -05:00
3013ee044d Finalize bounded typed pipeline implementation 2026-07-17 08:49:08 +00:00
adfd3bd052 Add bounded concurrent pipeline execution 2026-07-17 08:37:52 +00:00
4023c66508 Add extract worker configuration controls 2026-07-17 08:21:24 +00:00
adfe3825ee Remove legacy raw pipeline contracts 2026-07-17 08:13:08 +00:00
814fcdc6ba Serialize typed artifacts at durable boundaries 2026-07-17 07:33:49 +00:00
66de1a5520 Run D&D spell lanes through typed artifacts 2026-07-17 07:19:58 +00:00
52e6b31408 Add typed spell validation strategies 2026-07-17 07:02:30 +00:00
142ba36695 Introduce typed D&D spell artifacts 2026-07-17 06:50:08 +00:00
b949e9bbc0 Construct universal modules with decoded options 2026-07-17 06:30:56 +00:00
ce3a07512f Prepare pipelines before source execution 2026-07-17 06:18:46 +00:00
1c84d19e5f Add type-safe artifact lane resolution 2026-07-17 05:57:31 +00:00
fc1b57bde2 Add typed artifact codec foundation 2026-07-17 05:41:20 +00:00
075888c97f Move chunks into the canonical source model 2026-07-17 05:32:11 +00:00
40709e4ad8 Add canonical source unit provenance 2026-07-17 05:19:57 +00:00
15c369c509 Organize D&D extensions by domain 2026-07-17 05:05:48 +00:00
a81b9f1e1f Organize generic and Seriatim modules by domain 2026-07-17 04:57:47 +00:00
0327659355 Compose production modules through family registrars 2026-07-17 04:53:09 +00:00
c99bad19ae Add domain pipeline compatibility baselines 2026-07-17 04:45:01 +00:00
35f9446ed8 Add documentation and roadmap for a significant refactor around domain-focused module packages 2026-07-16 23:36:46 -05:00
21888d625f Minor documentation cleanup 2026-07-16 22:31:14 -05:00
feb03c3f8e Remove completed roadmap documentation 2026-07-16 22:25:39 -05:00
3b07b64a0f Consolidate documentation ownership 2026-07-17 03:12:35 +00:00
6e6375521d Refocus developer and internal documentation 2026-07-17 03:06:23 +00:00
b1fe9dc5a7 Reorganize user and operator documentation 2026-07-17 02:56:31 +00:00
6db2dc8d2a Refactor and clean up documentation 2026-07-16 21:42:24 -05:00
98b03a4629 Refactor the D&D spells module to apply deterministic fields where appropriate 2026-07-08 10:43:39 -05:00
610bdb4fea Enhance debug output to include response content files and update related metadata handling 2026-07-08 08:52:19 -05:00
68ec69f2e4 Rewrite the debug path to provide raw LLM prompt and response artifacts 2026-07-08 08:37:26 -05:00
451f6c0bb9 Stop base64 encoding LLM responses in debug output 2026-07-07 23:14:17 -05:00
3011dd91ca Update the debug workflow to provide raw LLM output 2026-07-07 23:08:14 -05:00
ae65b95374 Document completed workspace behavior 2026-07-08 03:18:50 +00:00
a5bbfea9b9 Write workspace debug artifacts during runs 2026-07-08 03:14:59 +00:00
ae9c2e1d5e Reuse valid workspace checkpoints on request 2026-07-08 03:02:50 +00:00
1d3a444df8 Write workspace checkpoints during runs 2026-07-08 02:46:38 +00:00
f044c00a7c Add checkpoint identity and manifest types 2026-07-08 02:31:07 +00:00
7d89c2702b Route diagnostics through workspace settings 2026-07-08 02:25:58 +00:00
93653cccb8 Add workspace filesystem helpers 2026-07-08 02:23:20 +00:00
a024492dbf Add workspace configuration support 2026-07-08 02:18:59 +00:00
304c68f9fc Add a planning roadmap and a staged implementation plan for the workspace configuration 2026-07-07 21:08:52 -05:00
c5f2b14ff4 Cleanup and complete the validator refactor 2026-07-07 19:36:48 -05:00
fc8e03f98c Document validation defaults and cleanup roadmap 2026-07-07 21:48:19 +00:00
16de4b6437 Register production validators and defaults 2026-07-07 21:43:32 +00:00
0f30888b00 Add D&D spell raw output validators 2026-07-07 21:37:58 +00:00
3e67be6ac3 Add generic raw output validators 2026-07-07 21:32:56 +00:00
5ef027b6f0 Wire resolved validator chains into runner 2026-07-07 21:27:28 +00:00
666b4bf801 Add validator chain config overrides 2026-07-07 21:21:12 +00:00
d593bfee0a Add validator chain provenance 2026-07-07 21:10:29 +00:00
b7ad66f0e0 Replace validator contracts with raw output validation 2026-07-07 20:55:49 +00:00
249e49c928 Update planning roadmap and add a staged imnplementation plan for the validator registry 2026-07-07 15:50:00 -05:00
e54e74ed88 Remove completed pipeline refactoring roadmap docs 2026-07-07 15:32:56 -05:00
582c5dceed Cleanup and complete the pipeline refactor 2026-07-07 15:32:35 -05:00
a9d8505cdb Document raw pipeline completion 2026-07-07 19:32:15 +00:00
7c95791e94 Update raw output files and manifests 2026-07-07 19:27:28 +00:00
aa14faa3cb Migrate production modules to raw outputs 2026-07-07 19:23:35 +00:00
cc6b050367 Implement runner retries and raw validation 2026-07-07 19:19:04 +00:00
bcedf19a08 Add merge references and retry config 2026-07-07 19:10:55 +00:00
c05ecb58d8 Implement raw module output contracts 2026-07-07 18:58:23 +00:00
9e3f8809b3 Implement integer source units and chunk payloads 2026-07-07 18:34:23 +00:00
4f057b99ac Add planning roadmaps for large-scale refactors of the pipleine and the validator registry, and add a staged imnplementation plan for the pipeline refactor 2026-07-07 13:20:10 -05:00
aec807fcb0 Update D&D schemas to require integer unit_id values 2026-07-06 14:41:24 -05:00
79a585d17e Remove a duplicated helper function 2026-07-06 14:26:40 -05:00
671ff6d132 Document completed DnD shared helper refactor 2026-07-06 17:26:38 +00:00
9b2d0297b7 Clean up DnD shared prompt asset tests 2026-07-06 17:24:15 +00:00
f91e643932 Use shared DnD prompt helpers in modules 2026-07-06 17:22:21 +00:00
35fe405448 Add DnD reference prompt helpers 2026-07-06 17:18:38 +00:00
524f2ffb8e Move DnD shared prompt assets into helper package 2026-07-06 17:15:27 +00:00
68b426cdb0 Make shared prompt filesystem composition generic 2026-07-06 17:10:59 +00:00
223f3751e8 Add reference slot clone helper 2026-07-06 17:06:24 +00:00
7861d040df Add a feature roadmap and staged implementation plan to refactor shared D&D module assets 2026-07-06 12:03:49 -05:00
47cf7e76ec Adjust the prompt FS layout and test strategy 2026-07-06 11:26:21 -05:00
8cafa64174 Added shared helper for module prompt FS 2026-07-06 07:29:41 -05:00
ecba0ad725 Add production prompt registry coverage 2026-07-06 01:36:55 +00:00
b3757dcf7b Remove legacy shared prompt package and update docs 2026-07-06 01:34:04 +00:00
3e456ec4d4 Update D&D prompt definitions to shallow asset paths 2026-07-06 01:32:27 +00:00
5b1efc89f6 Flatten D&D module prompt asset trees 2026-07-06 01:27:02 +00:00
aee48d011e Add shared D&D prompt assets package 2026-07-06 01:17:13 +00:00
3217bb3e12 Add a roadmap to refactor prompt assets 2026-07-05 20:13:26 -05:00
3df686f474 Bugfix in the built-in prompt references definition 2026-07-05 18:48:45 -05:00
7d4c027d09 Finish implementation of the scriptorium migration 2026-07-05 18:26:31 -05:00
31d70a2dd7 Finish Scriptorium runtime documentation 2026-07-05 18:34:21 +00:00
c9fbb331e2 Cut modules over to Scriptorium prompts 2026-07-05 18:27:07 +00:00
f6224dcbee Add Scriptorium-backed LLM runtime 2026-07-05 18:21:37 +00:00
de6689bc1d Add Scriptorium prompt assets 2026-07-05 18:13:10 +00:00
0fc740470f Switch config to Scriptorium profiles 2026-07-05 18:03:23 +00:00
49d94cc2e9 Add prompt input materials and session IDs 2026-07-05 17:51:36 +00:00
291298cf7b Add Scriptorium API grounding 2026-07-05 17:46:05 +00:00
9532ae8121 Add a feature roadmap and implementation plan to import the scriptorium LLM library 2026-07-05 12:42:15 -05:00
7601731a2c Clean up completed reference roadmap docs 2026-07-05 11:51:36 -05:00
3aa88ab9d3 Document completed LLM and reference expansion 2026-07-05 16:42:15 +00:00
c1ba94192d Cover reference prompts for chunk and normalize usage 2026-07-05 16:39:41 +00:00
22032dfd6d Deliver references to eligible runtime targets 2026-07-05 16:38:17 +00:00
4cafde2502 Materialize references for all eligible targets 2026-07-05 16:35:24 +00:00
8c623b7ad8 Support target-aware reference selectors 2026-07-05 16:31:23 +00:00
43dc954440 Resolve references across eligible pipeline targets 2026-07-05 16:24:49 +00:00
51053d390d Add stage-local reference config bindings 2026-07-05 16:18:00 +00:00
9278797aa9 Introduce target-aware resolved reference storage 2026-07-05 16:12:38 +00:00
39e49d7f77 Expand module contracts for references and normalizer LLM access 2026-07-05 16:06:47 +00:00
84c4c06712 Add a staged implementation plan to extend first-class LLM access and reference support across all chunk, extract, and normalize stages 2026-07-05 11:01:32 -05:00
eab640aa21 Update .gitignore to ignore build and testing output artifacts 2026-07-05 10:54:16 -05:00
a516944086 Finish the references implementation for the extraction module and update roadmap documentation 2026-07-05 10:53:16 -05:00
be6803ffa1 Document extraction reference support 2026-07-05 14:52:52 +00:00
ef4bdd4f9f Use references in D&D spell extraction 2026-07-05 14:49:17 +00:00
2f97895732 Record reference provenance in manifests 2026-07-05 14:44:44 +00:00
9e89b88efc Add prompt reference template functions 2026-07-05 14:37:39 +00:00
a57c6397e3 Materialize extraction reference files 2026-07-05 14:32:35 +00:00
39e071f5ca Add CLI reference binding flags 2026-07-05 14:27:23 +00:00
70d733edaf Resolve extraction reference bindings from config 2026-07-05 14:21:36 +00:00
1c31f56af1 Add reference contracts to extractor metadata 2026-07-05 14:13:48 +00:00
f9999a73df Add a staged implementation plan for background context references 2026-07-05 09:08:31 -05:00
11d8187052 Update documentation to reflect the implemented chunking module 2026-07-05 08:40:24 -05:00
86bff552c1 Update .gitgnore to ignore build artifacts 2026-07-05 08:39:34 -05:00
d3f790095e Trim and validate scene caveats 2026-07-05 13:30:32 +00:00
95218218e2 Canonicalize chunk units before extraction 2026-07-05 13:28:09 +00:00
e700df82d8 Record top-level module metadata in run manifests 2026-07-05 13:23:56 +00:00
e19cc02c4d Add a staged implementation plan to address gaps from the initial implementation of the scene chunking module 2026-07-05 08:15:46 -05:00
8a5419448f Update feature roadmaps to reflect future work 2026-07-05 08:11:54 -05:00
c8217549a8 Verify D&D scene chunker run output 2026-07-04 13:08:45 +00:00
2130414899 Register D&D scene chunker 2026-07-04 13:05:39 +00:00
7f83a20fa6 Implement D&D scene chunker 2026-07-04 13:02:02 +00:00
317ab0472d Add D&D scene chunking assets 2026-07-04 12:57:04 +00:00
e5eb0ba5c8 Make chunk validation a framework contract 2026-07-04 12:52:16 +00:00
b95af4f87d Add a roadmap to implement a D&D-specific chunk module 2026-07-04 07:48:36 -05:00
11073b613c Update AGENTS.md 2026-07-04 07:05:06 -05:00
540 changed files with 73007 additions and 15753 deletions

6
.gitignore vendored
View File

@@ -1,3 +1,8 @@
# build and testing artifacts
notarius
notarius-output
workspace/
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
@@ -49,6 +54,7 @@ go.work.sum
# Icon must end with two \r
Icon
# Thumbnails
._*

View File

@@ -1,3 +1,2 @@
Please carefully review the documents in `docs/policy` before making any changes to this repository.
- `architecture.md` provides the canonical high-level architecture policy for this repository.
- `documentation.md` provides the canonical documentation policy for this repository.
Please review `docs/development.md` for initial orientation in this repository
and follow its task-specific reading guide.

View File

@@ -4,33 +4,31 @@ Notarius is a Go CLI for extracting structured artifacts from source material
with explicit, configurable pipeline modules.
The current implementation reads Seriatim transcript JSON, chunks the source
units, extracts D&D spell-cast artifacts with an OpenAI-compatible LLM, and
writes JSON output plus diagnostics for each run.
units, extracts D&D spell-cast artifacts with a Scriptorium-backed LLM runtime,
and writes JSON output. Add `--debug` when a per-run inspection bundle is
needed.
```sh
NOTARIUS_LLM_DEFAULT_BASE_URL=http://127.0.0.1:8080/v1 \
NOTARIUS_LLM_DEFAULT_MODEL=your-model \
OPENROUTER_API_KEY=... \
go run ./cmd/notarius run dnd-session \
--config examples/dnd-spells.config.yml \
--config examples/dnd-minimal.config.yml \
--input examples/seriatim-minimal-transcript.json
```
If the provider requires authentication, set
`NOTARIUS_LLM_DEFAULT_API_KEY` in the environment before running the command.
Outputs are written under `./notarius-output/<run-id>/` unless `--output-dir`
is provided.
This invocation uses the maintained example configuration and input. See the
configuration and operations references for profile selection, credentials, and
run artifacts.
Useful references:
- [CLI reference](docs/cli.md)
- [Configuration reference](docs/config.md)
- [Operations](docs/operations.md)
- [Troubleshooting](docs/troubleshooting.md)
- [Seriatim input contract](docs/integrations/seriatim.md)
- [OpenAI-compatible provider contract](docs/integrations/openai-compatible.md)
- [JSON output contract](docs/integrations/json-output.md)
- [D&D spell artifact contract](docs/integrations/dnd-spell-artifacts.md)
- [Developer workflow](docs/policy/development.md)
- [Internal architecture docs](docs/internal/overview.md)
- [Maintained example config](examples/dnd-spells.config.yml)
- [Developer guide](docs/development.md)
- [Internal implementation docs](docs/internal/overview.md)
- [Minimal D&D configuration](examples/dnd-minimal.config.yml)
- [Complete D&D configuration](examples/dnd-complete.config.yml)
- [Maintained example input](examples/seriatim-minimal-transcript.json)

View File

@@ -0,0 +1,23 @@
# ADR-0001: Record architecture decisions as ADRs
**Status:** Accepted
**Date:** 2026-07-13
## Context
Architectural reasoning made during design (pattern choices, rejected
alternatives, trigger conditions for revisiting) is lost if only the final
state is documented.
## Decision
We keep a living overview in docs/policy/architecture.md describing current
intended state, and immutable, numbered ADRs (Nygard format) in docs/adr/
recording each significant decision, its alternatives, and its consequences.
Changed decisions get a new ADR that marks the old one Superseded.
## Alternatives considered
- Overview doc only: loses the "why" and the rejected options.
- arc42 / RFC-style design docs: heavier than warranted for a solo repo.
## Consequences
Small ongoing writing cost; durable reasoning trail; cheap onboarding for
future contributors (including future-us).

View File

@@ -0,0 +1,51 @@
# ADR-0002: Linear pipes-and-filters pipeline, not a general DAG
**Status:** Accepted
**Date:** 2026-07-13
## Context
Notarius processes source material through one known workflow:
```text
input -> chunk -> extract -> merge -> normalize -> output
```
Input and chunking apply to the source as a whole. Each selected artifact lane
then performs extract, merge, and normalize, after which output aggregates the
lane outcomes. Chunk extraction has a natural scatter-gather shape, but no
current use case requires arbitrary branches, joins, or user-defined stage
topology.
## Decision
Notarius implements a fixed six-stage pipes-and-filters pipeline. Configuration
selects implementations for these stages but cannot add stages, reorder them,
or define an arbitrary graph.
The framework owns stage sequencing and the scatter-gather boundary between
chunk, extract, and merge. Extract results are handed to merge in deterministic
source-chunk order regardless of execution strategy. Each artifact lane remains
logically linear. Output runs after every selected lane has either produced an
accepted normalized artifact or reached a recorded rejection. A framework
execution failure aborts the pipeline.
The runner's concrete internal representation and stage-specific scheduling
policies are implementation details. Concurrency must preserve the pipeline's
deterministic handoffs, validation behavior, and provenance, and all execution
strategies must continue to honor context cancellation.
## Alternatives considered
- Build a general DAG engine now. This would support hypothetical branching
topologies, but would add scheduling, topology validation, configuration, and
state-management complexity without a current consumer. Revisit this choice
only when a concrete workflow requires a topology the fixed pipeline cannot
express.
## Consequences
The runner, configuration model, and operator mental model remain small. Stage
ownership stays visible, and general chunking, merging, or normalization cannot
be hidden inside extractors. A future DAG requirement will require an explicit
architectural change rather than incremental exceptions to the fixed pipeline.

View File

@@ -0,0 +1,119 @@
# ADR-0003: Strongly typed stage interfaces with a two-zone data model
**Status:** Accepted
**Date:** 2026-07-13
## Context
Pipeline stages must exchange source data and extracted artifacts. Universal
source data has one engine-wide meaning, while extracted artifacts have
domain-specific shapes. Passing opaque bytes or `any` between all stages would
make invalid wiring and merge behavior runtime concerns. Requiring JSON at
every handoff would preserve interoperability but discard useful Go type safety
while all modules are in-process.
The framework must also support multiple configured artifact domains, durable
checkpoints, diagnostics, and output encoders without making those consumers
depend on every domain's Go types.
## Decision
Notarius uses two typed data zones followed by one serialized boundary.
### Source zone
Input and chunk stages use conservative, engine-owned document, segment, chunk,
and source-reference types. Their exact Go names are implementation details.
Every segment carries engine-owned source provenance identifying the source
location from which it was produced. Chunks preserve the ordered provenance of
their segments.
Source-format-specific fields remain in input modules or explicitly namespaced
metadata; they do not become framework contracts.
### Domain artifact zone
Each artifact lane has one domain-owned Go artifact type `T`. Its extract,
merge, normalize, and domain-aware validation implementations use generic,
strongly typed contracts over the same `T`. Raw JSON, opaque bytes, and `any`
are not stage-handoff contracts within a lane.
Each registered domain artifact type supplies a codec for `T`. The codec owns:
- stable schema identity and an explicit schema version;
- JSON serialization and deserialization;
- the media type and schema metadata required at serialized boundaries; and
- rejection of data that cannot be represented by the declared artifact
schema.
An artifact type's JSON representation is a maintained domain contract.
Changing it incompatibly requires a new schema version.
Extract, merge, and normalize may change the contents of `T`, but they do not
change the lane's canonical Go artifact type or artifact schema identity. An
extractor maps any provider- or prompt-specific response type into `T` before
returning. A future lane that requires different artifact types at different
stages requires a new architectural decision.
### Serialized boundary
After normalization, each typed artifact is converted into an engine-owned
serialized artifact containing bytes, media type, and schema metadata. Output
aggregation and output encoders consume this type-erased form. Intermediate
checkpoint and debug encodings do not become stage-handoff contracts.
LLM transport, checkpoints, and opt-in debug recording are also explicit
serialization boundaries. They may encode or decode a typed artifact through
its domain codec, but they do not change the in-memory type used between
extract, merge, normalize, and typed validators. Checkpoint reuse requires a
compatible schema identity and version.
An LLM structured-response schema is a module transport contract and may differ
from the domain artifact schema. The calling module owns the response type and
maps it into the canonical `T`; the artifact codec remains authoritative for
artifact checkpoints and output serialization.
The framework may use private type-erased adapters to store heterogeneous lane
registrations and execute configured domains. Such an adapter must assemble a
type-consistent lane before execution and must not expose `any` or raw payloads
as module-facing handoffs inside the domain artifact zone.
### Construction and dependencies
Every module operation accepts `context.Context`. Modules receive stable runtime
collaborators through an injected dependency set at construction time. In
particular, LLM-using modules receive the application-provided structured LLM
client and do not construct provider clients or bypass shared scheduling.
The application boundary enforces one configurable global upper bound on
in-flight LLM calls across all stages, lanes, retries, and validators.
Configuration options are parsed and validated while a module is constructed,
before that module executes. Per-run data such as source material, references,
session identity, and lane identity remains operation input rather than a
construction dependency.
## Alternatives considered
- Pass raw bytes between stages. This maximizes decoupling but moves wiring,
parsing, and merge errors to runtime and prevents domain types from being the
canonical in-process contract.
- Require JSON plus schemas at every stage boundary. This is appropriate for an
out-of-process boundary, but adds serialization and parsing inside the current
in-process pipeline. The stable codec contract preserves this upgrade path if
remote plugins are introduced.
- Use a uniform `Process(any) (any, error)` contract. This simplifies a fully
dynamic engine but turns incompatible module composition into type assertions
and runtime failures. The fixed topology does not require that tradeoff.
## Consequences
Domain pipelines gain compile-time handoff safety and explicit merge semantics.
Serialization, schema compatibility, checkpoint decoding, and output erasure
have named owners. Dynamic registration requires a small erased adapter around
each typed lane, and generic stage implementations must be instantiated for a
specific artifact type or behavior rather than manipulating arbitrary JSON.
The engine-owned source model becomes a long-lived contract and must evolve
conservatively. Domain authors must maintain a codec and versioned schema in
addition to their Go artifact type.

View File

@@ -0,0 +1,81 @@
# ADR-0004: Package modules by domain, not by stage
**Status:** Accepted
**Date:** 2026-07-13
## Context
Module packages can be grouped first by pipeline stage, such as
`modules/chunk/dnd/scenes`, or first by domain, such as
`modules/dnd/chunk/scenes`. A domain's extract, merge, normalize, validation,
schema, prompt, and artifact-codec implementations collaborate around the same
artifact types and are likely to evolve together.
Go package dependencies also constrain registration. If shared types live in a
domain root package, that package cannot import child implementation packages
to register them because the children already import the root types.
## Decision
Production extensions are grouped by domain under:
```text
internal/modules/<domain>/<stage>/<name>
```
Shared artifact types live at the domain root, for example
`internal/modules/dnd/types.go`. Domain-specific validators, prompt fragments,
schemas, reference helpers, and codecs also live within that domain tree.
Each domain exposes one production registration entry point from a sibling
registrar package, for example `internal/modules/dnd/register`. The registrar
may import the domain root and its child implementations; the domain root does
not import its registrar or child packages. This keeps shared types available
as `dnd.SpellList` without creating a Go import cycle.
The `generic` tree is a peer extension family for reusable implementations that
contain no concrete source-format or artifact-domain knowledge. Source-format
and output-format families, such as Seriatim and JSON output, follow the same
domain-first organization even when they do not define a type in the
[domain artifact zone](0003-typed-interfaces-with-two-zone-data-model.md#domain-artifact-zone).
Concrete domain implementation packages do not import another concrete domain.
Generic extension packages never import concrete domains. A domain registrar
may import domain-neutral generic extension packages to instantiate a reusable
strategy for that domain's artifact type; the generic implementation remains
unaware of the concrete type's domain semantics. Reuse needed directly by a
domain implementation lives in a domain-neutral framework or helper package,
not in a peer extension package.
The application composition root may import multiple registrar packages, and
black-box integration tests may compose multiple domains. Other cross-domain
reuse occurs through engine contracts and composition-time registration rather
than concrete peer-domain imports.
A domain registrar owns registration of that domain's modules, validators,
default validator chains, artifact codecs, schemas, and prompt assets. It does
not take ownership of application execution or process behavior.
## Alternatives considered
- Group modules by stage. This keeps interchangeable strategies side by side,
but scatters a domain's shared artifact model and collaborating extensions
across the repository. It is preferable when generic strategy libraries
dominate or when the project is primarily a stage-extension framework rather
than an application composed from domain suites.
- Put both shared types and `Register` in the domain root. This gives the
shortest import path but creates an import cycle once child implementations
import the root artifact types.
## Consequences
The repository layout makes supported domains immediately visible, and adding
or extracting a domain affects one cohesive subtree. The CLI composition root
depends on a small set of domain registrars instead of every leaf package.
Package moves must preserve user-visible module and validator keys unless a
separate compatibility decision changes them. Shared behavior that cannot be
expressed through framework contracts may need to move into a domain-neutral
framework package rather than creating a concrete peer-domain import. Registrar
packages become explicit composition points for instantiating generic typed
strategies, in addition to registering domain-owned implementations.

View File

@@ -0,0 +1,141 @@
# ADR-0005: Cache one canonical chunk plan per source
**Status:** Accepted
**Date:** 2026-07-17
## Context
Notarius may run several extraction passes over the same source. A D&D
transcript, for example, may first produce NPC artifacts and later produce
spell or combat artifacts, with output from an earlier pass supplied as a
reference to a later pass.
An LLM-backed chunker may process an entire, potentially large source in one
expensive request. Recomputing boundaries for every pipeline or pass repeats
that cost and can make otherwise comparable extraction runs use different
source partitions. Stable chunk material also gives later extraction requests
a better opportunity to benefit from provider-side prompt caching.
Chunk boundaries can affect extraction quality. Evidence may span a boundary,
overlap may produce duplicates, and different partitions may change the context
available to a model. Merge and normalization should remove structural signs
of chunking from durable output, but they cannot guarantee recovery of evidence
that an extractor did not receive.
Notarius therefore needs an explicit policy for choosing between automatically
applying the latest chunking configuration and preserving one stable partition
for repeated work on the same source.
## Decision
Notarius assigns one active canonical chunk plan to a source and reuses that
plan by default across pipelines and invocations.
The canonical source identity is derived from the validated generic source
document and covers the source-unit identity, order, and content needed to
interpret plan boundaries. Input-adapter and chunk-producer identities are
recorded as provenance, but the active-plan lookup does not vary with:
- pipeline identity or selected artifact lanes;
- the configured chunk module or its options;
- references;
- LLM provider, model, profile, prompt, or response schema; or
- configuration for later pipeline stages.
When an active plan exists, Notarius uses it even if the current pipeline
configures a different chunk module or different chunk-module settings. The
configured chunk module generates a plan only when none exists or when the
operator explicitly requests recomputation.
The framework-owned minimum plan contract is an ordered, non-empty set of
source-unit ranges. Each range identifies the inclusive start and end unit for
one chunk. A chunk module may also provide namespaced, domain-specific
annotations at plan or range scope. Those annotations are stored with the plan
and passed through the pipeline when present, but they remain optional.
Downstream stages must not assume that annotations associated with the
currently configured chunk module are present on a reused plan produced by a
different module.
The cache stores the plan rather than fully materialized chunks. The framework
validates a reused plan against the current source and deterministically
materializes its ranges into chunks. The same source and plan must produce
byte-stable chunk input for later stages.
Canonical plan storage is a distinct cache surface with an independently
configurable location. It is not coupled to the roots or lifecycles of
invocation checkpoints, diagnostics, debug artifacts, or durable output. This
allows per-user and system-service deployments to apply cache-specific
ownership, permissions, placement, and cleanup policy without relocating other
Notarius state.
One mutable active plan is stored under the canonical source identity and
retains provenance for the module and relevant runtime inputs that produced it.
Refreshing the active plan atomically replaces that one mutable record; readers
must observe either the previous complete plan or the replacement complete
plan, never a partial update.
The effective plan producer is reported separately from the chunk module
requested by the current pipeline; reuse must not attribute cached boundaries
or annotations to a module that did not produce them.
Reuse is enabled by default. Operators can explicitly:
- bypass cached plans for an invocation without changing the active plan; or
- recompute a plan with the configured chunk module and make it active for
later work.
Exact storage layout, configuration fields, CLI syntax, publication mechanics,
recovery behavior, and diagnostics are implementation and operational
contracts rather than part of this decision.
## Alternatives considered
- Recompute chunks on every invocation. This always applies the current
chunking configuration, but repeats the most expensive stage and weakens
provider-side caching and cross-pass comparability.
- Cache every distinct chunking request by including module options,
references, prompts, profiles, and other runtime inputs in its identity. This
closely associates a cached result with its producing request, but reduces
reuse and permits boundary drift across operationally different passes.
- Key plans by source plus chunk module and options. This shares plans across
pipelines using the same strategy, but changing the configured strategy
silently selects a different partition rather than preserving one canonical
partition for the source.
- Require operators to name or supply a plan for every run. Explicit selection
is reproducible and may be useful as an advanced operation, but adds friction
to the default workflow and does not provide automatic reuse.
- Store fully materialized chunks. This simplifies loading, but duplicates
source content and couples durable state to the current chunk representation
rather than the stable boundary decision.
- Store canonical plans beneath the general workspace root. This would reuse an
existing location setting, but it couples a reusable application cache to
checkpoint, diagnostic, and debug state that have different ownership,
sensitivity, retention, and deployment requirements.
## Consequences
Independent pipelines and passes over the same source use stable boundaries by
default. This reduces repeated LLM work, improves cross-pass comparability, and
increases the opportunity for cached provider reads.
The configured chunk module may not execute, and its settings may have no
effect, when an active plan already exists. Domain-specific annotations reflect
the plan's original producer and may be absent or differ from those the current
module would produce. User-visible provenance must make the effective plan
clear.
A poor or outdated partition remains active until an operator replaces it.
This can preserve suboptimal context boundaries and affect extraction recall or
duplication even when merge and normalization hide the partition structure in
durable output. Stable reuse is an intentional priority over automatically
incorporating later chunk-strategy changes.
The framework gains a durable minimal chunk-plan contract and deterministic
materialization responsibility. Chunk modules must separate required boundary
output from optional annotations, and downstream modules may rely only on the
minimal boundary contract unless a future decision introduces an explicit plan
compatibility mechanism.
Operators must configure and secure canonical plan storage independently from
other workspace state when the per-user default is not appropriate. Removing
that cache remains recoverable because Notarius can regenerate it from the
source, but doing so may repeat an expensive LLM operation.

View File

@@ -0,0 +1,114 @@
# ADR-0006: Separate output, cache, and debug state
**Status:** Superseded by [ADR-0007](0007-separate-checkpoint-recording-from-reuse.md)
**Date:** 2026-07-17
## Context
Notarius currently exposes a workspace as a shared parent for checkpoints,
debug artifacts, and preferred diagnostics settings. Diagnostics are a second
inspection surface with their own enablement, directory, retention, and legacy
configuration. Durable output uses a separate CLI-selected root, while the
canonical chunk-plan cache introduced by ADR-0005 correctly uses an independent
cache root.
These concepts reflect implementation history more than operator intent. A user
must understand differences among workspace state, diagnostics, debug artifacts,
checkpoints, and chunk plans before deciding where Notarius may write. Some of
those distinctions are important internally: a redacted run summary has a
different sensitivity from a trace containing source material, prompts, and
model responses. They do not require separate public filesystem categories.
Notarius needs a smaller state model that communicates why data exists, how it
may be treated, and whether it is reconstructible.
## Decision
Notarius exposes three filesystem surfaces: output, cache, and debug. The
public workspace concept and diagnostics as a separate output surface are
removed.
### Output
Output is the durable result of a run and the only surface intended for normal
consumption. It contains the logical files produced by the output stage,
including the maintained result, manifest, warning, and rejection contracts.
Output is not cache or inspection state.
### Cache
Cache contains reconstructible state used to avoid repeated work or resume an
interrupted workflow. Canonical chunk plans and invocation checkpoints are
distinct cache families with independent identities, compatibility rules,
enablement policies, locations, and cleanup lifecycles.
ADR-0005 continues to govern canonical chunk-plan selection and reuse. Grouping
chunk plans and checkpoints under the public cache category does not permit a
checkpoint to compete with canonical plan reuse or couple their storage roots.
Checkpointing is an invocation policy rather than a prerequisite hidden in
persistent workspace configuration. An explicit resume invocation may read
compatible checkpoints and record replacement checkpoint state for work it
executes. Runs that do not request resume perform no checkpoint I/O.
### Debug
Debug is an explicitly requested per-run inspection bundle intended for
developers and troubleshooting. It is off by default. When enabled, one bundle
contains both redacted run summaries and detailed stage and LLM traces. The
internal distinction between a safe summary and a sensitive trace remains, but
there is one public enablement and location model.
Debug data is never a cache input and has no automatic retention policy.
Notarius does not create a debug directory unless debug is requested, and it
does not automatically delete a requested bundle. Credentials remain redacted
at every level, while the bundle as a whole is treated as potentially sensitive
because traces may contain source, reference, prompt, model-response, and
intermediate artifact content.
Concise progress, warnings, and failures continue to use stdout or stderr. A
run without debug may fail without producing a filesystem inspection record.
Exact configuration fields, CLI flags, default paths, layouts, compatibility
handling, and migration mechanics are configuration and operational contracts
rather than part of this decision.
## Alternatives considered
- Keep workspace, diagnostics, checkpoints, debug, and chunk-plan cache as
separate public concepts. This preserves compatibility and the current safe
default-on failure records, but retains overlapping configuration and asks
operators to reason about implementation-specific categories.
- Keep diagnostics as an always-available redacted operational surface and use
debug only for sensitive traces. This distinction is useful for a daemon or
managed service with an operational logging contract, but the current CLI can
report concise failures on stderr and provide inspection data when explicitly
requested.
- Put all non-output state beneath one physical root. This minimizes path
configuration, but couples reconstructible caches to per-run inspection data
and couples cache families whose identity, sensitivity, and cleanup policies
differ.
- Treat checkpoints as durable run state rather than cache. This emphasizes
resumability, but checkpoints are derived, compatibility-checked data that may
be deleted and recomputed. Cache more accurately describes their lifecycle.
## Consequences
The operator model becomes smaller: normal runs produce output and may use
cache; developers explicitly request debug. Public configuration no longer
exposes a workspace or overlapping diagnostics and debug systems.
The implementation retains separate collaborators and serializers where their
security or lifecycle boundaries differ. Redacted summaries remain useful as
the index to a debug bundle, and chunk plans and checkpoints retain separate
stores even though both are cache.
Existing configuration, environment variables, flags, examples, and
documentation require a deliberate compatibility transition. Default-on
diagnostic directories disappear, so failures without debug are inspectable
only through stderr and any durable output completed before the failure.
Debug becomes easier to request and substantially more complete, but enabling
it creates sensitive files that the operator must protect and remove. Cache
cleanup is recoverable but may repeat expensive work, while deleting output is
data loss from the user's perspective.

View File

@@ -0,0 +1,50 @@
# ADR-0007: Separate checkpoint recording from reuse
**Status:** Accepted
**Date:** 2026-07-19
## Context
ADR-0006 made checkpoint I/O conditional on an explicit `--resume` invocation.
That policy requires an operator to anticipate the need for recovery before a
run begins. A failed ordinary run cannot reuse completed work because it did not
record checkpoints.
Recording reconstructible state and authorizing reuse are separate operational
decisions. Recording consumes storage and retains sensitive derived application
data, while reuse may change which module operations execute during a run.
## Decision
ADR-0006's separation of output, cache, and debug surfaces remains in effect;
this decision supersedes only its checkpoint invocation policy.
Checkpoint recording is controlled by an explicit persistent Boolean
configuration setting and remains disabled by default. When recording is
enabled, every run records checkpoint transitions and reusable approved stage
results.
Checkpoint loading remains an invocation policy. Only a run with `--resume`
loads and reuses compatible completed work. A recording-enabled run without
`--resume` executes every stage normally and never loads checkpoints. A resume
request while recording is disabled is rejected.
The existing checkpoint identities, compatibility rules, payload format,
filesystem root behavior, and pipeline collaborator contracts remain unchanged.
## Alternatives considered
- Continue coupling reads and writes to `--resume`. This is safe by default but
prevents recovery unless resume was anticipated on the earlier run.
- Always record checkpoints. This maximizes recovery but creates potentially
sensitive state without explicit operator consent.
- Add a multi-value recording policy. This preserves the old behavior as an
option but adds configuration complexity without a current need.
## Consequences
Operators can opt into recovery-ready runs while keeping checkpoint reuse
explicit. Enabled successful, rejected, and failed runs may all leave sensitive
checkpoint state, so operators remain responsible for access and retention.
Disabled configurations perform no checkpoint I/O, and `--resume` requires the
operator to enable recording first.

View File

@@ -0,0 +1,50 @@
# ADR-0008: Bounded ordered pipeline steps and explicit artifact references
**Status:** Accepted
**Date:** 2026-07-21
## Context
Notarius currently models one pipeline-wide input, chunking plan, artifact
lanes, and output boundary. Some workflows need a deterministic handoff from
one set of normalized artifacts to a later set of artifacts, such as using
extracted NPC records while grounding later combat events. The workflow needs
an explicit topology without turning the pipeline into a general-purpose
workflow engine.
## Decision
Add an ordered collection of pipeline steps. Each step owns one or more
artifact lanes, and lanes within a step retain the existing independent
execution model. The pipeline continues to have one input, chunk plan, output,
and failure boundary. Steps are barriers: a later step may consume only
normalized artifacts from an earlier step.
Generated references use an explicit step-and-lane selector. Reference slots
declare the generated artifact kinds and media types they accept. The resolver
validates the topology, ordering, lane identity, artifact kind, schema, and
codec compatibility before execution. External references remain supported as
path sources, and the legacy top-level artifact map is interpreted as an
implicit `default` step.
Pipeline-level references may not select generated artifacts. General DAGs,
branches, loops, conditional execution, joins, and inferred dependencies are
not part of this model.
## Alternatives considered
- A general DAG would provide more flexibility but would also require a new
scheduler, lifecycle model, failure semantics, and provenance model.
- Separate pipeline runs connected through filesystem paths would lose the
static topology and typed compatibility checks.
- Inferring dependencies from module or lane names would make ordering and
configuration errors difficult to detect reliably.
## Consequences
The resolved pipeline has a deterministic, inspectable topology and can
include it in its identity digest. Configuration validation can reject invalid
generated bindings before any work begins. Existing single-step profiles keep
their behavior through the implicit `default` step. Execution handoff and
multi-step scheduling require follow-up work in the runner and checkpoint
layers.

View File

@@ -0,0 +1,98 @@
# ADR-0009: Prefer minimal evidence-grounded extraction artifacts
**Status:** Accepted
**Date:** 2026-07-22
## Context
Notarius is intended to extract structured facts from source material. Several
early D&D artifacts grew to include descriptive prose, inferred relationships,
immediate outcomes, summaries, and other enrichment alongside the facts that
identify an event or entity. Those fields make one model call responsible for
both extraction and synthesis.
In practice, the richer contracts have produced overlapping or weakly grounded
fields and have made structurally valid, semantically coherent output harder for
cost-effective smaller models. They also increase prompt size, validation and
normalization policy, durable schema surface, downstream coupling, and the
number of claims whose provenance must be evaluated.
The application needs a consistent rule for deciding what belongs in an
extractor before redesigning the current D&D spell, NPC, and combat-turn
contracts or adding new artifact families.
## Decision
An extraction module answers one narrowly stated question and returns the
smallest durable structured artifact that usefully answers it.
Every model-produced field in an extraction artifact must:
- be necessary to answer the extractor's stated question or serve a known
downstream consumer;
- represent a fact or bounded classification that can be supported directly by
cited source ranges;
- remain independently meaningful without model-generated explanatory prose;
and
- justify the additional prompt, schema, validation, normalization, and
compatibility surface it creates.
Source references are required provenance for extracted records. Auxiliary
references may disambiguate identities or canonical names, but they do not
establish source facts and are not copied into evidence.
Extraction artifacts do not include narrative summaries, general analysis,
speculative enrichment, inferred biography or relationships, or redundant
free-text descriptions by default. When such output has a demonstrated use, it
belongs in an explicitly named extraction, classification, enrichment, or
analysis module with its own contract and evidence policy.
Occurrence-level facts are not forced into entity-level attributes. A fact
that can change between encounters, such as an NPC's role in a scene, belongs
on an occurrence artifact rather than as one scalar property of a normalized
NPC registry entry.
Deterministic mapping and normalization may assign application-owned
identifiers, canonicalize known catalog values, order and deduplicate evidence,
and collapse records under an explicit identity rule. They must not manufacture
removed descriptive fields or synthesize missing claims to satisfy an older
contract.
This is a default design rule, not a prohibition on rich artifacts. A richer
field is appropriate when its consumer, evidence semantics, and ownership are
explicit.
## Alternatives considered
- Keep rich schemas and improve prompts or use larger models. This retains
potentially convenient prose but does not resolve overlapping field
responsibilities, weak provenance, higher cost, or unnecessary downstream
coupling.
- Make enrichment fields optional. This reduces rejection pressure but leaves
ambiguous artifact semantics and inconsistent records, and many strict
structured-output providers still require nullable placeholders.
- Keep minimal private LLM schemas while preserving rich durable artifacts.
Deterministic code would have to invent, default, or separately derive the
missing fields, hiding synthesis behind the extraction boundary.
- Use one broad session-analysis module. This reduces the number of lanes but
couples unrelated facts, schemas, retries, evaluation, and downstream
consumers into one model call.
## Consequences
Extraction prompts and response schemas become smaller, more focused, and more
suitable for lower-cost models. Artifacts carry fewer unsupported claims, and
their evidence and validation policies become easier to explain and evaluate.
Independent extractors can evolve, retry, and be consumed without requiring
unrelated enrichment.
Some descriptive convenience fields will disappear from primary artifacts.
Consumers that genuinely need them may require a separate module and explicit
pipeline step. Entity registries may no longer resolve aliases or relationships
unless a dedicated, evidence-grounded capability supplies them.
Removing durable fields is a schema compatibility change. Each affected
artifact requires an explicit version and reference policy; private prompt
changes alone are insufficient. Current-behavior integration and internal
documentation must change with implementation, while the roadmap owns the
proposed contract until then.

View File

@@ -3,26 +3,15 @@
This is the canonical reference for the implemented Notarius command-line
interface.
## Quick Run
```sh
NOTARIUS_LLM_DEFAULT_BASE_URL=http://127.0.0.1:8080/v1 \
NOTARIUS_LLM_DEFAULT_MODEL=your-model \
go run ./cmd/notarius run dnd-session \
--config examples/dnd-spells.config.yml \
--input examples/seriatim-minimal-transcript.json
```
Set `NOTARIUS_LLM_DEFAULT_API_KEY` if the OpenAI-compatible provider requires
a bearer token.
For the minimal end-to-end invocation, see the [README](../README.md).
## Commands
```text
notarius help
notarius run <pipeline-id> --input path/to/source.json [--config path/to/config.yml] [--only lane-a,lane-b]
notarius config validate --config path/to/config.yml [--pipeline pipeline-id] [--only lane-a,lane-b]
notarius pipelines list --config path/to/config.yml [--json]
notarius run <pipeline-id> --input path/to/source.json [--config path/to/config.yml] [--only lane-a,lane-b] [--chunk_cache auto|bypass|refresh] [--output-dir path] [--resume] [--recompute-step step-id] [--debug [--debug-dir path]] [--llm-profile id] [--session-id id] [--reference selector=path] [--without-reference selector]
notarius config validate [--config path/to/config.yml] [--pipeline pipeline-id] [--only lane-a,lane-b]
notarius pipelines list [--config path/to/config.yml] [--json]
```
Running `notarius` with no arguments, `notarius help`, `notarius --help`, or
@@ -36,26 +25,198 @@ file.
Flags:
- `--input path`: required source input file.
- `--config path`: config file path. If omitted, Notarius checks
`NOTARIUS_CONFIG`, then `/usr/local/etc/notarius/config.yml`.
- `--config path`: config file path. If omitted, Notarius uses the discovery
rules in [Configuration](config.md#discovery).
- `--only lane-a,lane-b`: run only the named artifact lanes. Values are
comma-separated and must be non-empty.
- `--output-dir path`: output root. The run writes to `<path>/<run-id>/`.
Defaults to `./notarius-output`.
- `--diagnostics-dir path`: diagnostics work directory override for this
invocation.
- `--llm-profile id`: override every effective module binding to use one LLM
profile.
comma-separated and must be non-empty. This retains its existing behavior for
implicit single-step pipelines; explicit multi-step pipelines reject it
rather than inferring dependency closure.
- `--resume`: request checkpoint reuse for this invocation. Checkpoint recording
must be enabled in configuration. See
[Operations](operations.md#checkpoint-cache) for prerequisites and reuse
behavior.
- `--recompute-step step-id`: with `--resume` and checkpoint recording enabled,
force the named ordered step and every transitive dependent lane to execute.
Compatible required predecessors and unrelated lanes remain reusable. The
value may identify an explicit step or the implicit single-step ID `default`;
it cannot be combined with `--only`.
- `--chunk_cache auto|bypass|refresh`: select chunk-plan reuse for this
invocation. `auto` reuses a valid plan by canonical source digest, `bypass`
performs no plan-cache I/O, and `refresh` regenerates and replaces a valid
plan only after chunk validation succeeds. See
[Configuration](config.md#state-surfaces) for the persistent setting, precedence,
and cache-root selection.
- `--output-dir path`: output root. Defaults to `./notarius-output`.
- `--debug`: allocate and retain one debug bundle for this invocation.
- `--debug-dir path`: debug-bundle root override. This flag requires `--debug`.
- `--llm-profile id`: override every effective LLM-capable pipeline module
binding with one Scriptorium profile ID. Validator-specific profiles are not
overridden.
- `--session-id id`: pass a stable prompt session identifier through LLM-backed
module calls.
- `--reference selector=path`: bind a reference path to a chunk, extractor,
merger, or normalizer reference slot. Repeatable.
- `--without-reference selector`: remove a configured optional reference binding.
Repeatable. It accepts the same selector forms as `--reference`, without
`=path`.
On success, the command prints the completed pipeline ID, approved and rejected
artifact counts, and the output directory. If the run completes with warnings,
the warning count is printed to stderr.
On success, the command prints the completed pipeline ID, normalized output and
rejected output counts, and the output directory. A debug-enabled run also
prints `debug=<bundle-path>`. If the run completes with warnings, the warning
count is printed to stderr.
For durable output, diagnostics, retention, and failure inspection, see
[Operations](operations.md).
Reference flags are external file bindings resolved against selected chunk,
extractor, merger, and normalizer targets before the run starts. Generated
artifact bindings are configured in ordered steps and cannot be introduced by a
CLI path flag. Flat slot names are accepted only
when exactly one selected target declares that slot. For configured reference
bindings, precedence, path resolution, and validation, see
[Configuration](config.md#pipelines).
The current `run` command requires the resolved pipeline to use exactly one
distinct LLM profile after defaults and overrides are applied.
`--reference` binds or replaces one slot for one selected target. Selectors are:
- `slot=path`: valid when exactly one selected target declares `slot`;
- `chunk.slot=path`: target the chunker;
- `merge.slot=path`: valid when exactly one selected merger declares `slot`;
- `lane.slot=path`: valid when exactly one selected extractor, merger, or
normalizer in that lane declares `slot`;
- `lane.extract.slot=path`: target a lane extractor;
- `lane.merge.slot=path`: target a lane merger;
- `lane.normalize.slot=path`: target a lane normalizer.
Use `slot=path` when the selected targets declare the slot unambiguously:
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-minimal.config.yml \
--input examples/seriatim-minimal-transcript.json \
--reference roster=./campaign-roster.txt
```
Use an explicit selector when multiple selected targets declare the same slot or
when you want to target a specific target:
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-minimal.config.yml \
--input examples/seriatim-minimal-transcript.json \
--reference spells.extract.glossary=./campaign-glossary.txt
```
For the maintained ordered D&D workflow, use the explicit pipeline. The first
step produces normalized NPC and scene-description artifacts; the second step
receives the NPC artifact in memory for spell extraction, combat extraction, and
combat normalization, and receives the required scene-description artifact for
combat eligibility:
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-complete.config.yml \
--input examples/seriatim-minimal-transcript.json \
--output-dir ./npc-grounded-output
```
The generated NPC content remains contextual grounding, not spell or combat
evidence. It is represented in manifests and debug summaries by bounded
identity and producer provenance, not by payload content or a filesystem path.
The scene-description artifact is control context: combat extraction calls its
LLM only for an exact `combat` scene match. See the
[D&D combat-turn artifact contract](integrations/dnd-combat-turn-artifacts.md)
for the resulting empty-output and warning behavior.
The same grammar can target chunk, merge, and normalize slots when the configured
modules declare them:
```sh
go run ./cmd/notarius run dnd-session \
--config path/to/config.yml \
--input examples/seriatim-minimal-transcript.json \
--reference chunk.scene_guide=./campaign-scenes.txt \
--reference spells.merge.merge_notes=./merge-notes.txt \
--reference spells.normalize.normalization_notes=./normalization-notes.txt
```
Use `--without-reference` to remove a configured optional binding for a run:
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-minimal.config.yml \
--input examples/seriatim-minimal-transcript.json \
--without-reference glossary
```
Use `--session-id` when an external orchestrator needs all prompt calls from one
run to share an identifier:
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-minimal.config.yml \
--input examples/seriatim-minimal-transcript.json \
--session-id campaign-17-session-04
```
When `cache.checkpoints.enabled` is `true`, runs record checkpoints whether or
not `--resume` is present. Add the resume flag to load and reuse compatible
recorded work; using it while checkpoint recording is disabled is an error:
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-minimal.config.yml \
--input examples/seriatim-minimal-transcript.json \
--resume
```
To selectively rerun one ordered step and its dependent lanes, use the step ID
from the configuration. The selected step and dependents are reported as
`forced_recompute`; reusable predecessors are reported as `reused`:
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-complete.config.yml \
--input examples/seriatim-minimal-transcript.json \
--resume --recompute-step extract-events
```
Checkpoint decisions use these categories: `reused`, `executed`,
`forced_recompute`, and `dependency_invalidated`. The reason code and bounded
detail identify the decision without exposing reference content, local paths,
or secrets. `--recompute-step` requires checkpoint recording and `--resume`;
unknown step IDs, empty values, and combinations with `--only` are rejected.
The operator meanings of checkpoint reason codes are maintained in
[Operations](operations.md#resume-and-selective-recompute).
Use `--debug` to retain the redacted summary and trace bundle for one run. The
bundle is allocated before pipeline resolution; once allocated, its path is
also printed to stderr if the command fails. Debug-write failures cause exit
code `1`.
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-minimal.config.yml \
--input examples/seriatim-minimal-transcript.json \
--debug --debug-dir ./notarius-debug
```
Use `refresh` when intentionally replacing the cached plan for the same source:
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-minimal.config.yml \
--input examples/seriatim-minimal-transcript.json \
--chunk_cache refresh
```
Use `bypass` for a one-off run that must not inspect or create plan-cache state:
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-minimal.config.yml \
--input examples/seriatim-minimal-transcript.json \
--chunk_cache bypass
```
`--diagnostics-dir` has been removed. For checkpoint behavior, durable output,
debug-bundle lifecycle, and failure inspection, see [Operations](operations.md).
## `config validate`
@@ -63,8 +224,8 @@ distinct LLM profile after defaults and overrides are applied.
Flags:
- `--config path`: config file path. If omitted, discovery uses
`NOTARIUS_CONFIG`, then `/usr/local/etc/notarius/config.yml`.
- `--config path`: config file path. If omitted, Notarius uses the discovery
rules in [Configuration](config.md#discovery).
- `--pipeline pipeline-id`: additionally resolve one configured pipeline against
the production module catalog.
- `--only lane-a,lane-b`: validate resolution for selected artifact lanes. This
@@ -74,10 +235,10 @@ Examples:
```sh
go run ./cmd/notarius config validate \
--config examples/dnd-spells.config.yml
--config examples/dnd-minimal.config.yml
go run ./cmd/notarius config validate \
--config examples/dnd-spells.config.yml \
--config examples/dnd-minimal.config.yml \
--pipeline dnd-session \
--only spells
```
@@ -88,18 +249,18 @@ go run ./cmd/notarius config validate \
Flags:
- `--config path`: config file path. If omitted, discovery uses
`NOTARIUS_CONFIG`, then `/usr/local/etc/notarius/config.yml`.
- `--config path`: config file path. If omitted, Notarius uses the discovery
rules in [Configuration](config.md#discovery).
- `--json`: print `{"pipelines":[...]}` instead of one ID per line.
Examples:
```sh
go run ./cmd/notarius pipelines list \
--config examples/dnd-spells.config.yml
--config examples/dnd-minimal.config.yml
go run ./cmd/notarius pipelines list \
--config examples/dnd-spells.config.yml \
--config examples/dnd-minimal.config.yml \
--json
```
@@ -107,23 +268,11 @@ go run ./cmd/notarius pipelines list \
- `0`: command succeeded.
- `1`: command syntax was valid, but loading config, resolving modules, running
the pipeline, calling the provider, writing output, or writing diagnostics
failed.
the pipeline, calling the provider, writing output, or writing a requested
debug bundle failed.
- `2`: command syntax was invalid, a command was unknown, a required argument
was missing, or a flag value was malformed.
## Implemented Production Pipeline Modules
The production CLI currently registers these module keys:
- input: `seriatim`
- chunk: `generic`
- extract: `dnd/spells`
- merge: `appendorder`
- normalize: `noop`
- output: `json`
The production CLI does not currently register validator modules.
For YAML structure, defaults, environment overrides, and module binding syntax,
see [Configuration](config.md).
For YAML structure, defaults, Scriptorium profile sources, environment
overrides, and selectable module and validator keys, see
[Configuration](config.md).

View File

@@ -2,137 +2,303 @@
This is the canonical reference for implemented Notarius configuration.
Notarius reads YAML config files with `version: 1`. File config is applied over
built-in defaults, then environment overrides are applied.
Notarius reads YAML config files with `version: 3`. File configuration is
applied over built-in defaults, then environment overrides are applied. Explicit
CLI overrides are applied last where the command supports them.
## Discovery
Commands that accept `--config` load configuration in this order:
Commands that load configuration use this order:
1. the `--config` path, when provided;
1. an explicit path supplied through the CLI, when provided;
2. `NOTARIUS_CONFIG`, when set to a non-empty path;
3. `/usr/local/etc/notarius/config.yml`.
If none is available, the command fails with a config file not found error.
The explicit-path option is defined in the [CLI reference](cli.md).
## Minimal Example
## Maintained Examples
```yaml
version: 1
llm_profiles:
default:
provider: openai-compatible
base_url: http://127.0.0.1:8080/v1
model: your-model
pipelines:
dnd-session:
input: seriatim
chunk:
module: generic
options:
max_units: 50
artifacts:
spells:
extract: dnd/spells
```
- [Minimal D&D configuration](../examples/dnd-minimal.config.yml) shows the
shortest useful single-step, single-lane pipeline.
- [Complete D&D configuration](../examples/dnd-complete.config.yml) shows scene
chunking, ordered steps, all six D&D artifact lanes, generated NPC and
scene-description handoffs, campaign and spell-catalog references, checkpoint
storage, and chunk-map output.
The maintained fixture is [examples/dnd-spells.config.yml](../examples/dnd-spells.config.yml).
Both are complete version 3 files. The fragments below illustrate individual
fields and are not alternate complete configurations.
## Top-Level Fields
- `version`: required. The only supported value is `1`.
- `llm_profiles`: optional map of LLM profile IDs to profile settings.
- `version`: required. The only supported value is `3`.
- `scriptorium`: optional Scriptorium profile source settings.
- `pipelines`: optional map of pipeline IDs to pipeline definitions.
- `concurrency`: optional global concurrency settings.
- `diagnostics`: optional diagnostics settings.
- `output`: optional durable output placement.
- `cache`: optional chunk-plan and checkpoint cache placement.
- `debug`: optional debug-bundle placement. It does not enable debug capture.
Unknown YAML fields are rejected.
Unknown YAML fields are rejected. The removed top-level `llm_profiles` field is
rejected; execution profiles now come from Scriptorium.
## Defaults
Built-in defaults:
```yaml
llm_profiles:
default:
provider: openai-compatible
timeout: 600
max_retries: 3
max_concurrency: 1
concurrency:
total_llm: 1
diagnostics:
work_dir: /tmp/notarius
retention: auto
```
- `concurrency.total_llm`: `1`
- `concurrency.stage_workers.extract`: effective `concurrency.total_llm`
- `output.directory`: `./notarius-output`
- `cache.chunk_plans.mode`: `auto`
- `cache.chunk_plans.directory`: unset, selecting
`<os.UserCacheDir>/notarius/chunk-plans`
- `cache.checkpoints.enabled`: `false`
- `cache.checkpoints.directory`: unset, selecting
`<os.UserCacheDir>/notarius/checkpoints`
- `debug.directory`: `./notarius-debug`
No pipelines are built in. A run requires a configured pipeline.
## LLM Profiles
If `scriptorium` is omitted, Notarius uses Scriptorium's built-in profile
catalog. Prompt definitions may also name default profile IDs. The current D&D
scene, extraction, and NPC-normalization prompts default to `gemini-2-flash`
when a module binding does not set `llm_profile`. The selected built-in profile
reads its credentials from its Scriptorium profile definition.
Each `llm_profiles` entry may contain:
## Scriptorium Profiles
- `provider`: optional provider key. Empty means `openai-compatible`; any other
non-empty value must be `openai-compatible`.
- `base_url`: provider base URL. Required for actual LLM calls.
- `model`: provider model name. Required for actual LLM calls.
- `api_key_env`: environment variable name to read for the API key.
- `timeout`: request timeout as whole seconds or a Go-style duration string such
as `10m`.
- `max_retries`: retry count for provider calls. Must be zero or greater.
- `max_concurrency`: per-profile LLM concurrency. Must be zero or greater; when
zero, Notarius uses `concurrency.total_llm`.
`scriptorium` fields:
Raw API keys are not accepted as file config fields. Use `api_key_env` or an
environment override.
- `profile_dir`: optional directory containing Scriptorium profile YAML files.
- `profile_file`: optional Scriptorium profile YAML file.
`profile_dir` and `profile_file` are mutually exclusive. Custom profiles
overlay Scriptorium built-in profiles by profile ID.
Scriptorium profile files use Scriptorium's profile schema. A minimal profile
looks like:
```yaml
id: local-fast
endpoint: http://127.0.0.1:8080/v1
model: your-model
api_key_env: SCRIPTORIUM_API_KEY
timeout_seconds: 180
```
Notarius does not accept raw API keys in Notarius config. For file-backed
Scriptorium profiles, store the environment variable name in `api_key_env` and
set that variable in the run environment. Scriptorium rejects raw `api_key`
fields in profile YAML.
## Environment Overrides
These environment variables are applied after the config file:
- `NOTARIUS_CONFIG`: config discovery path.
- `NOTARIUS_LLM_DEFAULT_API_KEY`: API key for the `default` LLM profile.
- `NOTARIUS_LLM_DEFAULT_BASE_URL`: base URL for the `default` LLM profile.
- `NOTARIUS_LLM_DEFAULT_MODEL`: model for the `default` LLM profile.
- `NOTARIUS_LLM_DEFAULT_TIMEOUT_SECONDS`: integer timeout seconds for the
`default` LLM profile.
- `NOTARIUS_LLM_DEFAULT_MAX_RETRIES`: integer retry count for the `default` LLM
profile.
- `NOTARIUS_LLM_DEFAULT_MAX_CONCURRENCY`: integer max concurrency for the
`default` LLM profile.
- `NOTARIUS_TOTAL_LLM_CONCURRENCY`: integer global LLM concurrency.
- `NOTARIUS_WORK_DIR`: diagnostics work directory.
- `NOTARIUS_DIAGNOSTICS_RETENTION`: diagnostics retention mode.
- `NOTARIUS_STAGE_WORKERS_EXTRACT`: integer extract worker limit.
- `NOTARIUS_OUTPUT_DIR`: durable output root.
- `NOTARIUS_CACHE_CHUNK_PLANS_MODE`: chunk-plan cache mode.
- `NOTARIUS_CACHE_CHUNK_PLANS_DIR`: chunk-plan cache root.
- `NOTARIUS_CACHE_CHECKPOINTS_DIR`: checkpoint cache root.
- `NOTARIUS_DEBUG_DIR`: debug-bundle root.
Integer environment values must parse as base-10 integers.
Integer environment values must parse as base-10 integers. Directory overrides
must be non-empty after trimming. Cache-directory fields in a file may be
empty, which deliberately selects the corresponding per-user default.
The removed `NOTARIUS_LLM_DEFAULT_*` variables are not read. Configure provider
endpoint, model, and credential environment variable names through Scriptorium
profiles.
## Concurrency
`concurrency` fields:
- `total_llm`: positive integer ceiling on concurrent provider calls.
- `stage_workers`: optional map of framework worker limits. The only supported
key is `extract`.
`stage_workers.extract` defaults to the effective `total_llm` value after file
and environment precedence. It must be between `1` and `total_llm`, inclusive.
Unknown or empty stage-worker keys are rejected. The environment override
`NOTARIUS_STAGE_WORKERS_EXTRACT` takes precedence over the file value, as does
`NOTARIUS_TOTAL_LLM_CONCURRENCY` for the global ceiling.
The worker value is present in effective and redacted configuration. It bounds
the fixed run-wide extract pool and its bounded dispatch queue. Extract jobs are
submitted by source chunk and then resolved lane; `total_llm` independently
bounds actual provider calls made by extracts, retries, and validators.
## Pipelines
A pipeline defines the fixed Notarius workflow:
```text
input -> chunk -> extract -> merge -> normalize -> output
```
A pipeline selects implementations for the fixed workflow defined by
[Architecture](policy/architecture.md#system-shape).
Pipeline fields:
- `input`: required module binding.
- `chunk`: optional module binding. Default module is `generic`.
- `artifacts`: required for pipeline resolution. It maps artifact lane IDs to
lane definitions.
- `artifacts`: the artifact lane map for a single-step pipeline. It is treated
as an implicit step with the stable ID `default`.
- `steps`: an ordered, non-empty list of step definitions. A pipeline may use
`steps` or `artifacts`, but not both. Step IDs must be unique after trimming.
- `output`: optional module binding. Default module is `json`.
- `references`: optional map of reference slot names to reference paths. These
bindings are defaults for eligible pipeline targets that declare the matching
slot.
Each explicit step contains an `id`, an optional `references` map, and a
non-empty `artifacts` map. Steps share the pipeline input, chunk plan, worker
budget, output encoder, manifest, and failure boundary. Lanes within a step
retain the fixed extract, validate, merge, validate, normalize, and validate
workflow; the next step starts only after the current step is terminal.
Generated references use the structured `artifact` source form to identify one
accepted normalized lane from an earlier step:
```yaml
pipelines:
dnd-npc-grounded:
input: seriatim
steps:
- id: identify-npcs
artifacts:
npcs:
extract: dnd/npcs
normalize: dnd/npcs
scene-descriptions:
extract: dnd/scene-descriptions
normalize: dnd/scene-descriptions
- id: grounded-events
references:
npcs:
artifact:
step: identify-npcs
lane: npcs
scene_descriptions:
artifact:
step: identify-npcs
lane: scene-descriptions
artifacts:
spells:
extract: dnd/spells
normalize: dnd/spells
combat:
extract: dnd/combat-turns
normalize: dnd/combat-turns
```
The generated binding is explicit and typed; it is not inferred from module,
lane, or slot names. It may be declared at step scope, applying to every
selected target in that step that declares the slot, or at one target's
`references` map. A producer may fan out to compatible target slots, but a
slot accepts only one producer. A producer must be in an earlier step, and a
configured generated dependency is required even when the consumer slot is
otherwise optional. Aggregating several producer artifacts is unsupported.
The producer codec supplies the artifact kind, complete schema identity, media
type, canonical content digest, and size used for compatibility and checkpoint
dependency checks. Only one accepted normalized artifact crosses the boundary;
raw extraction results, rejected output, intermediate values, and validator
diagnostics do not. Generated content is supplied in memory and is never
represented by a filesystem path.
Artifact lane fields:
- `extract`: required module binding.
- `merge`: optional module binding. Default module is `appendorder`.
- `normalize`: optional module binding. Default module is `noop`.
- `validators`: optional list of module bindings. The production CLI currently
does not register validator modules.
- `validators`: deprecated lane-level validator list. Non-empty lists are
rejected; use `extract.validators`, `merge.validators`, or
`normalize.validators`.
- `references`: optional compatibility alias for extractor reference bindings.
Lane bindings override pipeline-level bindings for the same slot.
`notarius run` and `notarius config validate --pipeline` resolve the pipeline
against the production module catalog and fail fast for unknown or incompatible
module keys.
Commands that resolve a pipeline fail for unknown or incompatible module keys.
See [CLI Reference](cli.md) for command syntax.
Reference bindings are validated against reference slots declared by eligible
chunk, extract, merge, and normalize targets during pipeline resolution. Required slots
must be bound after config defaults, target-local references, lane-level
compatibility bindings, step-local references, and command-line reference
overrides are applied. Config-relative paths are resolved relative to the
config file; command-line reference paths are resolved relative to the current
working directory. Bound files must be UTF-8 text. Reference media types are
inferred from file extensions and checked when a module restricts accepted
types; unknown extensions use `application/octet-stream`. See [CLI Reference](cli.md#run)
for command-line selectors and [Operations](operations.md) for recorded
provenance and sensitive-data handling.
Pipeline-level `references` are defaults. They are valid when at least one
eligible target in the full configured pipeline declares the slot, including
chunk, extractor, merger, and normalizer targets. During a run, they apply only
to the selected targets that declare the slot. For external bindings, a
step-local binding overrides a pipeline-level default and a target-local
binding retains the existing most-specific precedence. Generated and external
bindings may not resolve to the same target slot, and a step-scoped generated
binding may not be duplicated by a target-local generated binding.
```yaml
pipelines:
dnd-session:
input: seriatim
references:
players: ./campaign/players.txt
party: ./campaign/party-roster.txt
glossary: ./campaign/glossary.txt
artifacts:
spells:
extract: dnd/spells
```
Extractor binding `references` are the canonical lane-local location. The
legacy lane-level `references` field remains supported as an alias; when both
bind the same slot, `extract.references` wins:
```yaml
pipelines:
dnd-session:
input: seriatim
references:
glossary: ./campaign/glossary.txt
artifacts:
spells:
references:
roster: ./campaign/legacy-roster.txt
extract:
module: dnd/spells
references:
party: ./campaign/session-party.txt
```
`chunk.references`, `merge.references`, and `normalize.references` are accepted
in object-form bindings. They override pipeline-level defaults for slots
declared by that target module. Extractor-local references apply only to the
extractor, merger-local references apply only to the merger, and
normalizer-local references apply only to the normalizer.
Target-local reference fields use the same map shape at:
- `pipelines.<id>.chunk.references`
- `pipelines.<id>.artifacts.<lane>.extract.references`
- `pipelines.<id>.artifacts.<lane>.merge.references`
- `pipelines.<id>.artifacts.<lane>.normalize.references`
Each binding is valid only when that target module declares the slot.
Reference source forms are:
- a scalar string, which is an external file path; or
- an object with only `artifact`, containing trimmed `step` and `lane` IDs for
an earlier producer lane.
Pipeline-level references accept only external paths. Generated references are
valid at step scope or on a target-local `references` map. Their producer's
registered codec is authoritative for schema, media type, and canonical
content identity; an external file is not treated as generated merely because
its bytes decode as the same artifact.
## Module Bindings
@@ -146,20 +312,75 @@ or object form:
```yaml
chunk:
module: generic
llm_profile: default
options:
max_units: 50
module: dnd/scenes
llm_profile: local-fast
```
Binding fields:
- `module`: module key.
- `llm_profile`: optional LLM profile ID. Empty means `default`.
- `llm_profile`: optional Scriptorium profile ID. Empty or omitted lets the
Scriptorium prompt default select the profile.
- `retries`: non-negative retry count for extra runtime attempts after the
first attempt. Default: `0`, which permits one initial attempt and no
additional attempts. Supported on `chunk`, `extract`, `merge`, and
`normalize` bindings.
- `options`: optional module-specific settings.
- `references`: optional reference bindings. Supported only for `chunk`,
`extract`, `merge`, and `normalize` bindings. `input` and `output` bindings
reject this field during validation.
- `validators`: optional stage-local validator chain override. Supported only
for `chunk`, `extract`, `merge`, and `normalize` bindings. Omit the field to
use the production default chain; set `validators: []` to force an empty
chain; set a non-empty list to use exactly those validators in configured
order.
The `--llm-profile` run flag overrides every effective module binding to use
one configured profile.
During resolution, each selected module's registered option validator runs.
Production input, chunk, and output bindings reject unknown or invalid options
with the affected binding context.
### NPC Semantic Normalization
The `dnd/npcs` normalizer combines deterministic canonical-name consolidation
with a document-level LLM identity decision for eligible differently named NPC
records. Its `llm_profile` and `retries` use the ordinary `normalize` binding
fields above; `retries: 0` therefore still makes one normalization attempt.
The normalizer declares no references and accepts no options. Context-window
selection is current module policy, not configuration.
### JSON Output Options
The `json` output module accepts only `include_chunk_map`, a boolean that
defaults to `false`. When `true`, it adds the accepted pipeline-wide chunk map
to the logical output bundle when one exists. It does not create a CLI flag or
change output placement.
```yaml
output:
module: json
options:
include_chunk_map: true
```
The payload and index descriptor are defined by the
[Accepted Chunk Map contract](integrations/chunk-map.md). See the
[complete D&D configuration](../examples/dnd-complete.config.yml) for a
copyable pipeline that enables this option.
Validator bindings use the same shorthand or object module-binding form, but
only these fields are supported:
- `module`: validator key.
- `llm_profile`: optional Scriptorium profile ID for LLM-backed validators.
- `options`: optional validator-specific settings.
Validator bindings reject `references`, `retries`, and nested `validators`.
During resolution, deterministic validators reject explicit `llm_profile`
values.
Configured LLM-backed validators with explicit `llm_profile` values are
validated against the configured Scriptorium profile source. Deterministic
production validators do not call the LLM and must not set `llm_profile`.
## Implemented Production Modules
@@ -167,10 +388,173 @@ one configured profile.
| --- | --- | --- |
| input | `seriatim` | Reads Seriatim transcript JSON. |
| chunk | `generic` | Splits source units into ordered chunks. |
| extract | `dnd/spells` | Extracts `dnd.spell_cast` artifacts. |
| merge | `appendorder` | Keeps candidates in append order. |
| normalize | `noop` | Passes merged artifacts through unchanged. |
| output | `json` | Produces JSON output files. |
| chunk | `dnd/scenes` | Uses an LLM to split transcript source units into D&D scenes. |
| extract | `dnd/spells` | Extracts typed D&D spell-list artifacts. |
| extract | `dnd/npcs` | Extracts typed D&D NPC-list artifacts. |
| extract | `dnd/combat-turns` | Extracts typed D&D combat-turn-list artifacts only for exact combat scene matches. |
| extract | `dnd/item-events` | Extracts typed, source-grounded D&D item-event-list artifacts. |
| extract | `dnd/npc-interactions` | Extracts typed D&D NPC-interaction-list artifacts from a supplied NPC registry. |
| extract | `dnd/scene-descriptions` | Extracts one typed D&D scene description for each accepted chunk. |
| merge | `appendorder` | Combines typed artifacts in chunk order. |
| normalize | `noop` | Passes merged typed artifacts through unchanged. |
| normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. |
| normalize | `dnd/npcs` | Deterministically prepares typed D&D NPC-list artifacts, then uses an LLM-assisted document-level identity decision to consolidate safe name-based matches and union exact evidence. |
| normalize | `dnd/combat-turns` | Deterministically canonicalizes, orders, and de-duplicates typed D&D combat-turn artifacts. |
| normalize | `dnd/item-events` | Deterministically trims, source-orders, and removes only exact D&D item-event duplicates. |
| normalize | `dnd/npc-interactions` | Canonicalizes registry NPC names, orders interaction occurrences, and removes only exact duplicates. |
| normalize | `dnd/scene-descriptions` | Validates, orders, trims, and de-duplicates typed D&D scene-description artifacts. |
| output | `json` | Produces JSON output files for normalized `application/json` lanes and can opt in to an accepted chunk map. |
## Implemented Production Validators
| Key | Execution | Notes |
| --- | --- | --- |
| `generic/always_accept` | deterministic | Accepts returned module output. |
| `generic/always_reject` | deterministic | Rejects returned module output with reason `always_reject`. |
| `generic/valid_json` | deterministic | Rejects payloads that are not syntactically valid JSON. |
| `generic/valid_json_schema` | deterministic | Rejects invalid JSON or JSON that does not conform to the module response schema. |
| `extract/dnd/spells/shape` | deterministic | Rejects malformed D&D spell-list artifacts. |
| `extract/dnd/spells/catalog` | deterministic | Rejects spell-list artifacts containing names outside the effective SRD and overlay catalog. |
| `extract/dnd/spells/source_refs` | deterministic | Rejects missing or invalid D&D spell source references. |
| `extract/dnd/spells/source_relatedness` | deterministic | Emits warnings when a spell name is not found near its cited source text. |
| `extract/dnd/npcs/shape` | deterministic | Rejects malformed D&D NPC-list artifacts. |
| `extract/dnd/npcs/source_refs` | deterministic | Rejects missing or invalid D&D NPC source references. |
| `extract/dnd/npcs/source_relatedness` | deterministic | Emits warnings when an NPC name is not found near its cited source text. |
| `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs and duplicate canonical-name or ID ownership. |
| `extract/dnd/combat-turns/shape` | deterministic | Rejects malformed D&D combat-turn artifacts. |
| `extract/dnd/combat-turns/source_refs` | deterministic | Rejects missing or invalid D&D combat-turn source references. |
| `extract/dnd/combat-turns/source_relatedness` | deterministic | Emits warnings when an actor is not found near cited source text. |
| `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, evidence-order, and chronology violations. |
| `extract/dnd/item-events/shape` | deterministic | Rejects malformed D&D item-event-list artifacts and invalid category, holder, or quantity combinations. |
| `extract/dnd/item-events/source_refs` | deterministic | Rejects missing, invalid, or extract-chunk-external D&D item-event source references. |
| `extract/dnd/item-events/source_relatedness` | deterministic | Emits bounded warnings when an item-event name is not found in cited source text. |
| `normalize/dnd/item-events/invariants` | deterministic | Rejects non-normalized D&D item-event order, source references, or exact duplicates. |
| `extract/dnd/npc-interactions/shape` | deterministic | Rejects malformed D&D NPC-interaction-list artifacts. |
| `extract/dnd/npc-interactions/registry` | deterministic | Rejects interaction names absent from the supplied NPC registry. |
| `extract/dnd/npc-interactions/source_refs` | deterministic | Rejects missing, invalid, or extract-chunk-external D&D interaction source references. |
| `extract/dnd/npc-interactions/source_relatedness` | deterministic | Emits bounded warnings when an interaction name is not found in its cited source text. |
| `normalize/dnd/npc-interactions/invariants` | deterministic | Rejects normalized interaction identity, evidence-order, and chronology violations. |
| `extract/dnd/scene-descriptions/shape` | deterministic | Rejects malformed D&D scene-description-list artifacts. |
| `extract/dnd/scene-descriptions/source_refs` | deterministic | Rejects invalid current-source references and extract records not exactly attached to their chunk. |
| `extract/dnd/scene-descriptions/source_relatedness` | deterministic | Emits bounded advisory warnings for title or summary text not grounded in its cited transcript range. |
| `normalize/dnd/scene-descriptions/invariants` | deterministic | Rejects non-normalized scene order, exact duplicates, and ID or range conflicts. |
The production default chain for `dnd/spells` is used for both its extract and
normalize stages:
```yaml
validators:
- generic/valid_json
- generic/valid_json_schema
- extract/dnd/spells/shape
- extract/dnd/spells/catalog
- extract/dnd/spells/source_refs
- extract/dnd/spells/source_relatedness
```
The production default chain for `dnd/npcs` uses the extraction chain for the
extract stage and the identity chain for normalize-stage output:
```yaml
extract:
validators:
- generic/valid_json
- generic/valid_json_schema
- extract/dnd/npcs/shape
- extract/dnd/npcs/source_refs
- extract/dnd/npcs/source_relatedness
normalize:
validators:
- generic/valid_json
- generic/valid_json_schema
- normalize/dnd/npcs/identity
- extract/dnd/npcs/source_refs
- extract/dnd/npcs/source_relatedness
```
The production default chains for `dnd/combat-turns` are:
```yaml
extract:
validators:
- generic/valid_json
- generic/valid_json_schema
- extract/dnd/combat-turns/shape
- extract/dnd/combat-turns/source_refs
- extract/dnd/combat-turns/source_relatedness
normalize:
validators:
- generic/valid_json
- generic/valid_json_schema
- extract/dnd/combat-turns/shape
- normalize/dnd/combat-turns/invariants
- extract/dnd/combat-turns/source_refs
- extract/dnd/combat-turns/source_relatedness
```
The production default chains for `dnd/item-events` are:
```yaml
extract:
validators:
- generic/valid_json
- extract/dnd/item-events/shape
- extract/dnd/item-events/source_refs
- generic/valid_json_schema
- extract/dnd/item-events/source_relatedness
normalize:
validators:
- generic/valid_json
- extract/dnd/item-events/shape
- normalize/dnd/item-events/invariants
- extract/dnd/item-events/source_refs
- generic/valid_json_schema
- extract/dnd/item-events/source_relatedness
```
The production default chains for `dnd/npc-interactions` are:
```yaml
extract:
validators:
- generic/valid_json
- extract/dnd/npc-interactions/shape
- extract/dnd/npc-interactions/registry
- extract/dnd/npc-interactions/source_refs
- generic/valid_json_schema
- extract/dnd/npc-interactions/source_relatedness
normalize:
validators:
- generic/valid_json
- extract/dnd/npc-interactions/shape
- extract/dnd/npc-interactions/registry
- normalize/dnd/npc-interactions/invariants
- extract/dnd/npc-interactions/source_refs
- generic/valid_json_schema
- extract/dnd/npc-interactions/source_relatedness
```
The production default chains for `dnd/scene-descriptions` are:
```yaml
extract:
validators:
- generic/valid_json
- extract/dnd/scene-descriptions/shape
- extract/dnd/scene-descriptions/source_refs
- generic/valid_json_schema
- extract/dnd/scene-descriptions/source_relatedness
normalize:
validators:
- generic/valid_json
- extract/dnd/scene-descriptions/shape
- normalize/dnd/scene-descriptions/invariants
- extract/dnd/scene-descriptions/source_refs
- generic/valid_json_schema
- extract/dnd/scene-descriptions/source_relatedness
```
Empty chains approve output by default.
The `generic` chunker accepts:
@@ -178,36 +562,263 @@ The `generic` chunker accepts:
- `overlap_units`: non-negative integer, default `0`, and must be less than
`max_units`.
## Diagnostics
The `dnd/scenes` chunker requires transcript source capabilities, calls the
configured structured LLM runtime, and does not accept module options. It
declares optional `players`, `party`, and `glossary` references for scene
disambiguation, and accepts `roster` as a deprecated compatibility alias for
`party`.
`diagnostics` fields:
The `dnd/spells` extractor declares optional campaign reference slots:
- `work_dir`: directory for per-run diagnostics. Default: `/tmp/notarius`.
- `retention`: `auto`, `always`, or `never`. Empty uses `auto`.
- `players`
- `party`
- `glossary`
- `roster` as a deprecated compatibility alias for `party`
`auto` retains diagnostics for failed runs and successful runs with warnings.
`always` retains diagnostics for every run. `never` removes diagnostics for
successful runs without regard to warnings; failed runs are retained.
These campaign slots accept UTF-8 plain text, Markdown, YAML, or JSON reference
files. The extractor also declares an optional `spell_catalog` slot that accepts
one UTF-8 `application/json` overlay bundle no larger than 1 MiB. The slot does
not allow multiple files. Its format is defined in the
[spell-catalog overlay contract](integrations/dnd-spell-catalog-overlays.md).
The extractor uses campaign references only as supporting disambiguation
material; spell casts still must be present in the source transcript.
The `--diagnostics-dir` run flag overrides `diagnostics.work_dir` for that
invocation.
It also declares an optional `npcs` slot for a normalized NPC artifact. The
slot accepts exactly one `application/json` artifact no larger than 1 MiB. An
external file is decoded and identity-validated during preparation. A
generated binding is validated at the step handoff and is provided to the
operation through the same reference contract. In both cases, the model
receives a names-only JSON projection for caster-name grounding. Registry source references
may belong to the NPC-producing session and are provenance only; they are not
spell evidence. Generated reference identity and bounded producer provenance
are recorded by the framework; NPC names, content, and paths are not copied
into manifests. Consumer-local checkpoint identity uses the names-only
projection digest. When absent, the prompt receives the exact empty value
`{"npcs":[]}` with its projection digest and no registry provenance.
The `dnd/spells` normalizer declares the same optional `spell_catalog` slot.
When an overlay is used, bind it independently under
`artifacts.<lane>.normalize.references.spell_catalog`; normalize-stage
references are local to that stage and are not inherited from extraction. The
normalizer uses the embedded SRD catalog when no normalize-stage overlay is
bound.
The `dnd/npcs` extractor declares the same optional campaign slots as the spell
extractor, but it does not declare the `npcs` registry slot. Its normalizer
accepts no references. The
[complete D&D example](../examples/dnd-complete.config.yml) binds its accepted
normalized output to later spell, combat-turn, and NPC-interaction targets
through an explicit ordered step.
The `dnd/item-events` extractor accepts optional `glossary`, `party`, `players`,
and deprecated `roster` campaign slots for disambiguation only. It requires no
generated artifact and its normalizer accepts no references, so the complete
example keeps this independent lane in the first step. Current transcript
references remain the only item-event evidence. Its complete durable behavior
is defined in the [D&D item-event artifact contract](integrations/dnd-item-event-artifacts.md).
The `dnd/scene-descriptions` extractor accepts only the optional `players`,
`party`, and `glossary` campaign slots. They can disambiguate terms in the
model-facing title and summary but are never source evidence. Its normalizer
accepts no references. No NPC registry or generated artifact is required. See
the [D&D scene-description artifact contract](integrations/dnd-scene-description-artifacts.md)
and the copyable [complete D&D configuration](../examples/dnd-complete.config.yml).
The `dnd/combat-turns` extractor declares the optional campaign slots, the
optional structured `npcs` slot, and the required `scene_descriptions` slot.
Campaign references guide only the LLM extraction stage. The deterministic
normalizer declares only `npcs`, whose operation-time registry supports the
same actor canonicalization. Each `npcs` slot accepts exactly one UTF-8
`application/json` artifact no larger than 1 MiB. The registry's source ranges
remain provenance for the reference and never become combat evidence.
`scene_descriptions` accepts exactly one approved
`dnd/scene-description-list` artifact with media type `application/json`, no
larger than 1 MiB (1048576 bytes). Pipeline resolution rejects a combat
extractor whose required slot is unbound. The scene artifact is eligibility
control context only; it is neither prompt input nor combat evidence, and the
combat normalizer does not accept it.
An ordered step binds the generated NPC artifact to extraction and normalization
and the generated scene-description artifact to combat extraction:
```yaml
references:
npcs:
artifact:
step: identify-npcs
lane: npcs
scene_descriptions:
artifact:
step: identify-npcs
lane: scene-descriptions
```
When `npcs` is bound, the combat extractor and normalizer receive the generated
NPC registry at operation time. Framework provenance and checkpoint dependencies
contain its kind, schema identity, media type, canonical digest, size, and
bounded producer identity; names, content, and paths are not recorded there.
When absent, the combat prompt receives the exact empty NPC registry value
`{"npcs":[]}` with its projection digest and no registry provenance. The
generated `scene_descriptions` artifact is supplied only to combat extraction.
The complete example uses the same explicit scene binding; an external approved
scene-description artifact may be used instead when the workflow crosses a
process or session boundary.
Both `dnd/npc-interactions` stages require the structured `npcs` slot. Bind one
accepted normalized NPC artifact from an earlier ordered step; the interaction
extractor receives only its names-only projection, while the normalizer uses
the immutable registry for exact canonical-name lookup. A missing, rejected,
or incompatible producer prevents the interaction step from running. Registry
source references remain identity provenance and never supply interaction
evidence: every interaction must cite current transcript units. The complete
durable contract and two-step configuration are defined in the
[D&D NPC interaction artifact contract](integrations/dnd-npc-interaction-artifacts.md).
## State Surfaces
The `output`, `cache`, and `debug` top-level fields select independent physical
roots. Their layout, permissions, lifecycle, and sensitive-data handling are
defined in [Operations](operations.md).
```yaml
output:
directory: ./notarius-output
cache:
chunk_plans:
directory: ""
mode: auto
checkpoints:
enabled: false
directory: ""
debug:
directory: ./notarius-debug
```
`output.directory` is the durable output root. Its precedence is
`--output-dir`, `NOTARIUS_OUTPUT_DIR`, the file value, then the default.
`cache.chunk_plans.mode` accepts `auto`, `bypass`, or `refresh`. Its precedence
is `--chunk_cache`, `NOTARIUS_CACHE_CHUNK_PLANS_MODE`, the file value, then
`auto`. `auto` reuses a valid source-addressed plan and regenerates missing or
invalid records; `bypass` performs no plan-cache I/O; `refresh` regenerates and
publishes a plan after chunk validation.
`cache.chunk_plans.directory` and `cache.checkpoints.directory` each name an
exact cache-family root. Their precedence is the corresponding environment
variable, the file value, then the family-specific per-user default. There is
no CLI cache-root override. The defaults are
`<os.UserCacheDir>/notarius/chunk-plans` and
`<os.UserCacheDir>/notarius/checkpoints`; on Unix, `os.UserCacheDir` ordinarily
uses an absolute `$XDG_CACHE_HOME` or falls back to `$HOME/.cache`. A relative
`XDG_CACHE_HOME` is an error.
`cache.checkpoints.enabled` defaults to `false`. When `true`, every run records
checkpoint transitions and reusable approved results. When `false`, Notarius
does not resolve or create the checkpoint root, and `--resume` is rejected.
The `--resume` flag authorizes loading compatible checkpoints; it does not
control recording.
`debug.directory` chooses a root but never enables debug capture. Its precedence
is `--debug-dir`, `NOTARIUS_DEBUG_DIR`, the file value, then the default.
Only `--debug` requests a bundle; `--debug-dir` is valid only with `--debug`.
Every supplied file, environment, and CLI value is validated even when a
higher-precedence value wins.
## Version 2 To Version 3 Migration
Version 2 files are rejected. Move each setting to the surface it controls and
remove obsolete enablement and retention controls. This complete before/after
example preserves an existing chunk-plan cache and checkpoint directory while
choosing an output and debug root explicitly.
```yaml
# Version 2 (no longer accepted)
version: 2
workspace:
directory: /srv/notarius/state
resume:
enabled: true
debug:
enabled: true
chunk_cache:
directory: /srv/notarius/chunk-plans
mode: auto
diagnostics:
retention: always
diagnostics:
work_dir: /srv/notarius/inspection
```
```yaml
# Version 3
version: 3
output:
directory: /srv/notarius/output
cache:
chunk_plans:
directory: /srv/notarius/chunk-plans
mode: auto
checkpoints:
enabled: true
directory: /srv/notarius/state/checkpoints
debug:
directory: /srv/notarius/debug
```
Run the migrated configuration with `--resume` when checkpoint reuse is wanted,
and with `--debug` when a debug bundle is wanted. Enabled checkpoint recording
occurs with or without `--resume`.
The removed fields are `workspace.directory`, `workspace.resume.enabled`,
`workspace.debug.enabled`, `workspace.chunk_cache.mode`,
`workspace.chunk_cache.directory`, `workspace.diagnostics.enabled`,
`workspace.diagnostics.retention`, `diagnostics.work_dir`, and
`diagnostics.retention`. The removed environment variables are `NOTARIUS_WORKSPACE_DIR`,
`NOTARIUS_WORKSPACE_DIAGNOSTICS_ENABLED`,
`NOTARIUS_WORKSPACE_DIAGNOSTICS_RETENTION`,
`NOTARIUS_WORKSPACE_RESUME_ENABLED`, `NOTARIUS_WORKSPACE_DEBUG_ENABLED`,
`NOTARIUS_WORKSPACE_CHUNK_CACHE_MODE`,
`NOTARIUS_WORKSPACE_CHUNK_CACHE_DIR`, `NOTARIUS_WORK_DIR`, and
`NOTARIUS_DIAGNOSTICS_RETENTION`. The chunk-cache variables are replaced by
`NOTARIUS_CACHE_CHUNK_PLANS_MODE` and
`NOTARIUS_CACHE_CHUNK_PLANS_DIR`; the former shared directory has no direct
replacement.
## Validation
Configuration validation checks:
- supported config version and known YAML fields;
- mutually exclusive `scriptorium.profile_dir` and `scriptorium.profile_file`;
- non-empty, non-duplicated IDs after trimming;
- supported LLM provider and non-negative profile limits;
- positive global LLM concurrency;
- supported diagnostics retention and non-empty work directory;
- module binding LLM profiles refer to configured profiles.
- supported stage-worker keys and an effective extract worker count in the
inclusive range `1..concurrency.total_llm`;
- non-empty output and debug directories;
- a supported chunk-cache mode, Boolean checkpoint enablement, and state-surface
directories without NUL bytes;
- stale removed fields such as `llm_profiles`.
Pipeline resolution additionally checks:
- the pipeline ID exists;
- at least one artifact lane is declared and selected;
- selected lanes exist when `--only` is used;
- at least one artifact lane is declared and selected in each explicit step;
- `artifacts` and `steps` are mutually exclusive, explicit steps are non-empty,
and step IDs are unique after trimming;
- lanes selected through the CLI exist in the resolved pipeline;
- lane IDs are globally unique across ordered steps;
- required module keys are present;
- module keys are registered for the expected slot;
- module capability requirements are satisfied.
- module capability requirements are satisfied;
- non-empty validator overrides reference registered validator keys;
- deterministic validators do not set `llm_profile`;
- LLM-backed validators with explicit `llm_profile` values reference configured
Scriptorium profile IDs;
- bound reference slots are declared by selected chunk, extractor, merger, or
normalizer targets;
- generated references identify one lane in an earlier step, use a declared
compatible artifact kind, and do not conflict with external or target-local
generated bindings;
- required reference slots are bound for selected targets.

41
docs/development.md Normal file
View File

@@ -0,0 +1,41 @@
# Development
This is the first-read landing page for people and LLM coding agents working on
Notarius. It provides a concise repository orientation and routes each kind of
change to its canonical documentation.
Notarius is a Go CLI for configured structured extraction workflows. Start with
the [README](../README.md) for product context, [Architecture](policy/architecture.md)
for system boundaries, and [Internal Overview](internal/overview.md) for the
implemented component map.
## What To Read
| When working on | Read | Why |
| --- | --- | --- |
| Finding the package or component that owns current behavior | [Internal Overview](internal/overview.md) | It is the implemented component inventory and routes to focused internals. |
| Application shape, package boundaries, contracts, dependency direction, runtime guarantees, or safety properties | [Architecture](policy/architecture.md) and relevant [ADRs](adr/) | Architecture defines the intended system and its invariants; ADRs preserve significant decision rationale. |
| Any documentation addition or revision | [Documentation Policy](policy/documentation.md) | It defines canonical homes, audiences, current-behavior rules, and maintenance requirements. |
| Adding, changing, reviewing, or deleting tests | [Testing Policy](policy/testing.md) | It defines risk-based sufficiency, durable test boundaries, test-double guidance, and criteria for retaining tests. |
| Pipeline resolution or execution | [Pipeline Internals](internal/pipeline.md) | It documents profiles, references, validation, retries, checkpoints, and runner behavior. |
| Production modules or validators | [Module Internals](internal/modules.md) | It documents implemented module contracts, capabilities, assets, and registration. |
| LLM clients, prompts, schemas, profiles, or scheduling | [LLM Runtime](internal/llm.md) | It documents the transport boundary and Scriptorium integration. |
| Output, cache, resume, or debug artifacts | [Run State Internals](internal/state.md), [Operations](operations.md), and [Configuration](config.md) | These separate implementation details, operator behavior, and configuration contracts. |
| CLI or user-visible configuration behavior | [CLI Reference](cli.md) and [Configuration](config.md) | These are the canonical user and operator references. |
| External input formats, artifact schemas, or durable output files | [Integration Contracts](integrations/) | Integration documents define external and durable data contracts. |
| Proposed or unimplemented behavior | [Roadmap](roadmap/) | Future work belongs only in roadmap documentation until implemented. |
For an existing subsystem, also inspect its focused tests and the package-local
types and contracts before changing behavior.
## Validation
Use focused package tests while iterating. Run the repository-wide checks when
a change affects shared contracts, application behavior, or maintained
documentation examples:
```sh
go test ./...
go vet ./...
go build ./cmd/notarius
```

View File

@@ -0,0 +1,83 @@
# Accepted Chunk Map
This document defines the durable accepted chunk-map artifact that the JSON
output encoder can write as `chunk-map.json`. It describes the exact accepted,
materialized chunks used by a run; it is not a lane artifact and is never an
input to later pipeline steps. Enable it with the JSON output option described
in [Configuration](../config.md#json-output-options).
## Identity
- Artifact kind: `source/chunk-map`
- Logical file: `chunk-map.json`
- Schema ID: `notarius.source.chunk_map`
- Schema name: `notarius_source_chunk_map_v1`
- Schema version: `v1`
- Media type: `application/json`
The checked-in [JSON Schema](../../internal/framework/chunkmap/assets/schemas/source_chunk_map.v1.json)
defines the strict wire shape. `chunk-map.json` is listed by the optional
`chunk_map` descriptor in [the JSON output index](json-output.md#indexjson),
not by the lane-oriented `output_files` collection.
## Payload
The payload has these required fields:
- `source_id`: accepted source-document identity.
- `source_digest`: canonical lower-case `sha256:` digest of that document.
- `plan_digest`: canonical lower-case `sha256:` digest of the accepted logical
plan.
- `requested_chunker`: chunk module selected by the current resolved pipeline.
- `producer`: the original accepted-plan producer, with required
`input_module` and `chunk_module`; `llm_profile` is present only for an
LLM-backed producer.
- `plan_annotations`: accepted plan-level annotation namespace map. It is
`{}` when no namespaces are present.
- `chunks`: non-empty execution-order collection of accepted chunks.
Each chunk has `id`, zero-based `index`, `source_ref`, positive `unit_count`,
and an explicit `annotations` namespace map. A source reference has the source
ID and inclusive positive `start_unit_id` and `end_unit_id` endpoints.
Annotation values are arbitrary valid JSON under non-empty namespaces. They
are preserved as canonical JSON without interpreting any module-specific
namespace.
## Invariants
The framework constructs this artifact only after materializing the selected
logical plan and accepting it through the configured chunk validator chain.
Construction proves the source and plan digests, source-document range order,
materialized chunk IDs and indexes, source references, unit membership and
counts, and plan/range annotations agree exactly. Chunk IDs are unique and
indexes are contiguous and agree with array order.
The codec rejects unknown fixed-object fields, malformed identities or
digests, invalid annotation JSON, trailing JSON content, and any payload whose
reconstructed logical plan does not reproduce `plan_digest`. It makes
defensive copies at serialization and decoding boundaries.
## Acceptance And Provenance
The artifact is available only when the chunk plan was accepted. It remains
available when a later extraction, merge, or normalization result is rejected;
it is absent when chunk validation rejects the candidate plan.
`requested_chunker` describes the current pipeline selection. `producer`
describes who originally produced the accepted plan. On a cache hit these can
differ: the accepted ranges, annotations, digests, and stable materialized IDs
are reused, while the producer remains the stored producer. Cache paths,
actions, references, metadata, warnings, timestamps, and detailed provenance
remain in the run manifest rather than this payload.
## Data Handling
The map contains structure, not source content. It excludes transcript bytes,
materialized units, source-unit metadata, chunk content, private model
responses, rejected proposals, debug data, external-reference content, and
filesystem paths.
Annotations can nevertheless be source- or model-derived. Treat an enabled
`chunk-map.json` with the same sensitivity and retention expectations as lane
output. Physical placement, confined atomic writing, and permissions follow
the ordinary [output operation](../operations.md#output).

View File

@@ -0,0 +1,180 @@
# D&D Combat-Turn Artifact Contract
This document defines the durable artifact, serialization, extraction,
candidate-validation, normalization, and production lane boundaries for D&D
combat turns.
## Artifact identity
| Property | Value |
| --- | --- |
| Artifact kind | `dnd/combat-turn-list` |
| Schema ID | `notarius.dnd.combat_turns` |
| Schema name | `notarius_dnd_combat_turns_v1` |
| Schema version | `v1` |
| Media type | `application/json` |
The top-level JSON object contains the required `combat_turns` array, which
may be empty. Every object rejects unknown fields.
## JSON shape
Each combat turn contains these required fields:
| Field | Shape |
| --- | --- |
| `actor` | Non-empty string. |
| `turn_kind` | One of `turn`, `reaction`, `legendary_action`, `lair_action`, or `other`. |
| `source_refs` | Required array with at least one source reference. |
Source references use the shared source-reference shape:
```json
{
"source_id": "session-alpha",
"start_unit_id": 1,
"end_unit_id": 2
}
```
`source_id` must be non-empty and both unit IDs must be positive integers. The
codec does not resolve references against a source document or enforce source
range ordering; those checks belong to the later source-reference validation
boundary.
## Codec behavior
The codec exposes two representations of the same typed artifact:
- Candidate encode/decode preserves invalid actor and turn-kind values,
collection presence, and source references so later validators can report
them. Candidate decoding still requires valid JSON, one JSON value, known
fields, and compatible JSON types.
- Approved encode/decode enforces the structural rules in this contract.
The codec owns the durable JSON Schema, whose object layers all set
`additionalProperties` to `false`. Codec metadata contains only
`combat_turn_count`.
The maintained compact fixture is
`internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json`.
## Extraction boundary
The standalone extractor uses these identities:
| Property | Value |
| --- | --- |
| Extractor key | `dnd/combat-turns` |
| Capability | `dnd.combat_turns` |
| Prompt ID | `dnd.combat_turns` |
| Prompt version | `v1` |
| Private response-schema key | `dnd_combat_turns_llm` |
| Private response-schema ID | `notarius.dnd.combat_turns.llm` |
| Default profile | `gemini-2-flash` |
It requires `chunks` and `source.transcript`, accepts no options, and requires
one `scene_descriptions` reference. That reference must be exactly one approved
`dnd/scene-description-list` artifact with media type `application/json` and a
maximum size of 1 MiB (1048576 bytes). It may be an external file validated
during preparation or a canonical generated artifact supplied at an ordered
step handoff. An unbound slot is a configuration error.
The scene artifact controls eligibility, not evidence. The extractor calls the
LLM only when exactly one record has the current chunk's ID, source ID, start
unit ID, and end unit ID, and that record has `kind: combat`. An exact
`narrative`, `recap`, or `meta` record returns an accepted empty
`combat_turns` array without an LLM call, warning, or retry attempt. A missing
or mismatched exact record returns the same accepted empty result without an
LLM call or retry and emits one content-safe
`scene_classification_unavailable` warning. The scene artifact, its title and
summary, and its source references are never copied into combat turns.
For an eligible combat chunk, the prompt receives the chunk-scoped transcript
plus the existing `players`, `party`, and `glossary` inputs, and optionally the
deprecated `roster` reference through the shared party mapping. The optional
`npcs` reference is an approved normalized NPC artifact used only for identity
grounding; it never supplies combat evidence. `scene_descriptions` is never a
combat prompt input.
The private response envelope has the same turn fields and JSON types as the
durable shape except that source references contain only `start_unit_id`
and `end_unit_id`. It enforces required field presence, types, and
unknown-field rejection, while deterministic validators own enum membership,
non-empty values and collections, and positive-number requirements. The
extractor assigns the current source ID, removes exact duplicate ranges, and
stable-sorts turns by the earliest valid source-document position. Numeric unit
IDs are identifiers; source-document slice position determines chronology.
Semantically malformed candidate fields remain in the typed result for the
configured validation and retry boundary.
## Deterministic candidate validation
The standalone validator keys are:
| Validator | Responsibility |
| --- | --- |
| `extract/dnd/combat-turns/shape` | Required list, actor, turn kind, and source references, plus supported turn-kind values. |
| `extract/dnd/combat-turns/source_refs` | Source identity, source-unit existence, and range order through the source document. |
| `extract/dnd/combat-turns/source_relatedness` | At most one advisory warning per turn when the actor is not related to cited transcript text. |
Source-reference and relatedness validators defer malformed shape to the shape
validator. Relatedness also defers when any cited source range is invalid. It
combines overlapping cited ranges once in document order and compares actors
with the shared Unicode-aware NPC identity policy.
The production D&D registrar exposes the extractor and these validators. Its
default extraction chain preserves this order: JSON syntax, combat shape,
source references, private response schema, then source relatedness.
## Normalization boundary
The standalone normalizer uses key `dnd/combat-turns`, requires `merged`,
provides `normalized`, accepts no options, and accepts only the optional
structured `npcs` reference. Campaign references are LLM extraction context and
are not normalizer inputs. For an external file, the NPC registry is resolved
during preparation; for a generated binding, it is resolved at the operation-
time handoff. Runtime normalization uses that immutable prepared or handed-off
view.
Normalization policy is `dnd.combat_turns.normalize.v1`. It display-normalizes
the actor, canonicalizes exact registry actor matches, orders and deduplicates
exact source references, stable-sorts records by earliest valid source-document
position, and collapses only records with the same actor identity, turn kind,
and complete valid evidence set. The first normalized record is retained.
Invalid evidence is never eligible for duplicate collapse. Every mutation and
collapse emits a bounded warning using the merged input index in its scope.
The normalizer reports `normalization_policy` and `identity_policy` metadata
and fingerprints. An external registry may additionally contribute
`npc_registry_digest` and `npc_count`; generated registry identity is retained
in framework handoff provenance and dependency fingerprints. The
normalized-invariants validator is
`normalize/dnd/combat-turns/invariants`; it defers shape and source-reference
failures, then checks actor display normalization, canonical evidence ordering,
chronology, and duplicate identity. It rejects
with `invalid_combat_turn_normalization` under policy
`dnd.combat_turns.validator.normalized.v1`.
The production D&D registrar exposes the normalizer and normalized-invariants
validator. Its default normalization chain is JSON syntax, combat shape,
normalized invariants, source references, durable schema, then source
relatedness. The lane uses the framework's typed append-order merger and has no
merge validator chain.
## Production manifest and references
The selectable lane uses extractor and normalizer key `dnd/combat-turns`,
`appendorder` for the typed merger, and the durable codec above. Bound external
references contribute raw-file provenance to the run manifest. Generated
bindings contribute artifact kind, schema identity, media type, canonical
digest, size, and bounded producer provenance. This includes the generated
scene-description artifact for the combat extractor. Consumer metadata and
checkpoint fingerprints contain no reference names, content, paths, source
ranges, scene titles, or scene summaries. For an external scene artifact,
component metadata records the gate policy plus a semantic eligibility digest
and record count; generated scene identity remains in framework handoff
provenance and dependencies. The eligibility digest changes with scene ID,
exact source range, or kind, but not with title or summary. The normalized lane
is emitted as `lanes/<lane-id>.json` by the JSON output module, and warnings
and rejection summaries remain in their shared companion files.

View File

@@ -0,0 +1,133 @@
# D&D Item-Event Artifact Contract
This document defines the durable D&D item-event artifact and its production
boundaries. It records source-grounded discoveries and possession changes; it
does not maintain an inventory or ledger.
## Artifact Identity
| Property | Value |
| --- | --- |
| Extractor key | `dnd/item-events` |
| Extract capability | `dnd.item_events` |
| Artifact kind | `dnd/item-event-list` |
| Schema ID | `notarius.dnd.item_events` |
| Schema name | `notarius_dnd_item_events_v1` |
| Schema version | `v1` |
| Media type | `application/json` |
| Normalizer key | `dnd/item-events` |
The payload is one strict JSON object containing a required `events` array,
which may be empty. Objects reject unknown fields.
## Event Shape And Categories
Every event has `name`, `kind`, and a non-empty `source_refs` array. `quantity`
is optional and, when present, is a positive integer. `from` and `to` are
optional display values whose presence depends on `kind`.
| Field | Rule |
| --- | --- |
| `name` | Non-empty, transcript-supported display value. |
| `kind` | One of the categories below. |
| `quantity` | Optional positive integer; omitted when the source does not establish a count. |
| `from` | Holder that loses possession when the category permits it. |
| `to` | Holder that gains possession when the category permits it. |
| `source_refs` | One or more current-source references. |
| Kind | Meaning and holder rule |
| --- | --- |
| `discovered` | The party learns of or encounters an item without established possession; neither holder is present. |
| `acquired` | A party member or `party` gains possession; `to` is required and `from` is absent. |
| `lost` | A party member or `party` ceases to possess an item without consuming it; `from` is required and `to` is absent. |
| `consumed` | Use depletes, expends, or destroys an item; `from` is required and `to` is absent. |
| `transferred` | Possession moves between distinct party members; both holders are required and neither may be `party`. |
`party` is the reserved display holder for collective party possession when an
individual holder is not established. Its comparison is case- and
Unicode-insensitive. Transfers require distinct normalized holder values.
Giving an item to an NPC, spending currency, selling an item, or another move
outside party possession is `lost`, not `transferred` or `consumed`. Monetary
spending, purchases, and payments are always `lost`. Currency is `consumed` only
when the source explicitly describes its physical destruction or expenditure as
a non-payment component. Ordinary non-depleting use is not an event.
Currency is represented as an ordinary event name plus an explicit quantity
when the transcript supplies one. Each denomination remains separate. The
artifact never converts denominations, infers a missing count, calculates a
balance, or sums nearby events.
## Source Evidence And Normalization
Each source reference contains `source_id`, `start_unit_id`, and `end_unit_id`.
It must identify an ordered range in the current source document. During
extraction, every cited range must also be wholly contained in the current
accepted chunk. Campaign references may disambiguate names, but never provide
event evidence.
The deterministic normalizer trims only display-edge whitespace in names and
holders, canonicalizes source-reference order and exact duplicate references,
then orders events by earliest valid source position and stable tie-breakers
over name, kind, holders, quantity, and complete evidence. It removes only
events with the same normalized fields and complete valid evidence sequence.
Invalid evidence is never collapsed. It does not rename, singularize, resolve
aliases, infer holders or quantities, or merge nearby events.
The default extraction validation chain is JSON syntax, item-event shape,
source references, durable JSON Schema, then advisory source relatedness. The
normalization chain adds normalized invariants after shape validation and before
source references and schema validation. Relatedness warnings are advisory so
contextual names and currency notation do not reject otherwise valid evidence.
## Representative JSON
```json
{
"events": [
{
"name": "Hidden Cache",
"kind": "discovered",
"source_refs": [{"source_id": "session-7", "start_unit_id": 1, "end_unit_id": 1}]
},
{
"name": "Silver Pieces",
"kind": "acquired",
"quantity": 20,
"to": "party",
"source_refs": [{"source_id": "session-7", "start_unit_id": 2, "end_unit_id": 2}]
},
{
"name": "Torch",
"kind": "lost",
"from": "party",
"source_refs": [{"source_id": "session-7", "start_unit_id": 3, "end_unit_id": 3}]
},
{
"name": "Healing Potion",
"kind": "consumed",
"from": "Aria",
"source_refs": [{"source_id": "session-7", "start_unit_id": 4, "end_unit_id": 4}]
},
{
"name": "Moonblade",
"kind": "transferred",
"from": "Aria",
"to": "Borin",
"source_refs": [{"source_id": "session-7", "start_unit_id": 5, "end_unit_id": 5}]
}
]
}
```
## Production Boundary
The extractor requires `chunks` and `source.transcript`, and accepts optional
`glossary`, `party`, `players`, and deprecated `roster` campaign references for
disambiguation only. It has no generated NPC, scene-description, or item-registry
dependency. The append-order merger preserves chunk order, and the normalizer
has no reference slots.
The normalized lane is emitted as `lanes/<lane-id>.json` by the JSON output
module. See [Configuration](../config.md#implemented-production-modules) for
the selectable keys and default chains, and the
[JSON output contract](json-output.md) for bundle paths.

View File

@@ -0,0 +1,160 @@
# D&D NPC Artifact
This document defines the durable D&D NPC-list artifact, its JSON codec, and
the selectable production NPC pipeline. The normalized JSON payload can be
passed explicitly to the spell extractor as an optional caster-name registry
or to the combat extractor and normalizer as an actor registry. It
remains a reference, not spell or combat evidence.
The NPC interaction extractor and normalizer also consume this registry for
canonical identity; registry source references remain provenance and never
become interaction evidence. Their occurrence contract is defined in the
[D&D NPC interaction artifact](dnd-npc-interaction-artifacts.md).
## Identity
- Artifact kind: `dnd/npc-list`
- Durable schema ID: `notarius.dnd.npcs`
- Durable schema name: `notarius_dnd_npcs_v1`
- Durable schema version: `v1`
- Media type: `application/json`
- Identity policy: `dnd.npcs.identity.v1`
The durable JSON Schema is owned by the D&D NPC codec. NPC IDs are derived from
the Unicode-normalized, case-folded canonical name using the identity policy.
The durable codec enforces the artifact shape and ID syntax; registry identity
validation remains a separate deterministic concern.
The extractor's private LLM response schema is a separate structural transport
contract. It omits framework-assigned NPC and source IDs and admits semantic
candidates for the deterministic shape and source-reference validators; it is
not part of this durable contract.
## Output Shape
The payload is one object with a required top-level `npcs` array:
```json
{"npcs": []}
```
The array may be empty. Every object and nested object rejects unknown fields.
## NPC Fields
Each NPC contains exactly these required fields:
- `id`: `npc:sha256:` followed by 64 lowercase hexadecimal characters;
- `name`: the canonical display name;
- `source_refs`: at least one source reference supporting the NPC record.
Each source reference contains required `source_id`, `start_unit_id`, and
`end_unit_id`; unit IDs are positive integers. Source document identity, unit
existence, and range ordering are validated by the source-reference validator
when the artifact is used by a pipeline.
## Codec Boundary
`EncodeCandidate` and `DecodeCandidate` provide strict single-value JSON
serialization while preserving typed values that still need semantic
validation. `Encode` and `Decode` are the approved-artifact boundary and
require all durable structural fields, non-empty required strings, valid source
reference shapes, and the NPC ID pattern.
Codec metadata contains only `npc_count`. Schema bytes and returned metadata
are independent values so callers cannot mutate codec-owned state.
## Production Pipeline
The production identities are:
- extractor: `dnd/npcs`;
- artifact kind: `dnd/npc-list`;
- normalizer: `dnd/npcs`; and
- durable schema: `notarius.dnd.npcs`, version `v1`, media type
`application/json`.
The extractor maps private model records to the current source identity and
assigns deterministic IDs. Extraction validation checks shape, source
references, and source relatedness. The normalizer first consolidates equal
canonical-name matches, then may make one document-level LLM-assisted identity
decision per configured normalize attempt for eligible distinctly named
records. Consolidation is name-based: it retains a supplied canonical display
name, keeps the earliest affected output position, derives its ID again, and
unions exact evidence in canonical order. Ambiguous, invalid, or conflicting
proposals are not applied; independently safe matches may still be retained.
After the retry budget is exhausted, the safe result is accepted with bounded
normalization warnings and the usual validation. The durable v1 artifact shape
does not add aliases, proposal fields, or any other semantic-normalization
representation.
The extraction prompt asks only for individually identifiable NPC names backed
by source evidence. Groups, generic roles, invented labels, and descriptive or
relationship enrichment are outside the contract.
The default extraction chain is `generic/valid_json`,
`extract/dnd/npcs/shape`, `extract/dnd/npcs/source_refs`,
`generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`. The
default normalize chain is `generic/valid_json`, `extract/dnd/npcs/shape`,
`normalize/dnd/npcs/identity`, `extract/dnd/npcs/source_refs`,
`generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`.
Relatedness emits bounded warnings when an NPC canonical name is not present
near its cited transcript text; opaque campaign
references may explain such a warning but do not become evidence.
## Manifest And Artifact Handoff
The NPC extractor records prompt and response-schema identities. The durable
codec records only `npc_count`; raw names, source references, and payload bytes
stay in the lane file rather than manifest
metadata. The normalized lane can be consumed by a later ordered step through
the registered canonical codec:
```yaml
steps:
- id: identify-npcs
artifacts:
npcs:
extract: dnd/npcs
normalize: dnd/npcs
scene-descriptions:
extract: dnd/scene-descriptions
normalize: dnd/scene-descriptions
- id: grounded-events
references:
npcs:
artifact:
step: identify-npcs
lane: npcs
scene_descriptions:
artifact:
step: identify-npcs
lane: scene-descriptions
artifacts:
spells:
extract: dnd/spells
normalize: dnd/spells
combat:
extract: dnd/combat-turns
normalize: dnd/combat-turns
```
The framework hands only accepted normalized artifacts across the barrier. It
validates the canonical bytes against each consumer slot and clones the NPC
operation-time reference for spell and combat consumers. The accompanying
scene-description reference is required by the combat extractor for eligibility
only; its consumer contract is defined in the
[D&D combat-turn artifact contract](dnd-combat-turn-artifacts.md). Generated
provenance records the artifact kind, schema identity, media type, canonical
digest, size, and producer step/lane/module, but not names, source ranges, or
payload bytes. External normalized files remain supported as explicit references
and retain their file provenance.
NPC source references are registry provenance and are never accepted as spell
or combat evidence. Current transcript units remain the only event evidence.
Consumers receive a separate names-only projection in normalized registry
order, for example `{"npcs":[{"name":"Mira Thorn"}]}`. The projection omits
IDs and evidence. Its digest covers the exact projected bytes and is used for
consumer-local checkpoint identity, while the full durable artifact digest
remains the manifest and generated-reference provenance identity. The unbound
projection is exactly `{"npcs":[]}` and also has a projection digest.

View File

@@ -0,0 +1,148 @@
# D&D NPC Interaction Artifact
This document defines the durable D&D NPC-interaction-list artifact and its
two-step production pipeline. It records discrete, source-grounded occurrences
for NPCs already accepted into a normalized NPC registry; it does not expand
the registry or summarize events.
## Identity And JSON
- Artifact kind: `dnd/npc-interaction-list`
- Durable schema ID: `notarius.dnd.npc_interactions`
- Durable schema name: `notarius_dnd_npc_interactions_v1`
- Durable schema version: `v1`
- Media type: `application/json`
The payload is one strict JSON object with only an `interactions` array. The
array may be empty. Each item has exactly `name`, `kind`, and `source_refs`:
```json
{
"interactions": [
{
"name": "Mira Thorn",
"kind": "dialogue",
"source_refs": [
{"source_id": "session-7", "start_unit_id": 12, "end_unit_id": 13}
]
}
]
}
```
`name` is the canonical display name from the required NPC registry.
`source_refs` contains one or more current-source ranges with required
`source_id`, `start_unit_id`, and `end_unit_id`; unit IDs are positive integers.
During extraction, every range must be wholly contained in the current accepted
chunk. This prevents a candidate from citing valid units that were not presented
to that extraction call.
Unknown fields are rejected.
## Interaction Categories
`kind` is exactly one of:
| Kind | Meaning |
| --- | --- |
| `mentioned` | The NPC is referred to, but is not established as present or communicating in the evidenced passage. |
| `noncombat_presence` | The NPC is present and relevant to the passage but does not meaningfully participate in dialogue or combat. |
| `dialogue` | The NPC speaks, responds, or is directly engaged in a meaningful non-combat exchange. |
| `combat_ally` | The NPC actively participates in combat on the party's side. |
| `combat_opponent` | The NPC actively participates in combat against the party. |
| `other` | The transcript clearly establishes a direct NPC occurrence that fits none of the preceding kinds. |
`other` is a residual category for positively evidenced activity, not a fallback
for uncertain classification. When activities overlap, active combat
participation outranks dialogue, presence, and mention; dialogue outranks
non-combat presence and mention; and non-combat presence outranks mention.
Combat alignment is not resolved by precedence: a meaningful change between
ally and opponent creates separate occurrences.
These categories do not encode summaries, relationships, state, motives, or
unobserved events.
## Occurrence Boundaries And Ordering
One occurrence represents one NPC, one kind, and one locally coherent passage
within one accepted chunk. Repeated evidence belongs to the same occurrence
only while it supports the same uninterrupted activity. A kind change, combat
alignment change, intervening scene or meaningful absence, or transition from
mention to presence starts a new occurrence. Occurrences never span chunks, and
merge or normalization never semantically combines nearby, overlapping, or
cross-chunk records.
Normalization orders records by:
1. earliest valid source-document position;
2. the NPC identity comparison key;
3. the exact canonical NPC display name;
4. interaction kind in lexical order; and
5. the complete canonical source-reference sequence, ordered by source ID and
the source-document positions of each range's start and end.
Only records with identical canonical names, kinds, and complete valid evidence
sequences are duplicates. Different categories, ranges, or separately grounded
occurrences remain separate.
## Evidence, Registry, And Normalization
The registry proves only the canonical NPC identity. Its source references are
registry provenance and are never interaction evidence. Every durable
interaction must cite current transcript units supporting both the name and its
classification.
The extractor receives a names-only registry projection such as
`{"npcs":[{"name":"Mira Thorn"}]}`. The normalizer uses the full immutable
registry for exact canonical-name lookup. It canonicalizes source references
and applies the ordering and exact-duplicate rules above.
## Production Pipeline
The extractor and normalizer key is `dnd/npc-interactions`. Both require the
structured `npcs` slot, so an accepted normalized registry must come from an
earlier step:
```yaml
pipelines:
dnd-npc-interactions:
input: seriatim
steps:
- id: identify-npcs
artifacts:
npcs:
extract: dnd/npcs
normalize: dnd/npcs
- id: extract-interactions
references:
npcs:
artifact:
step: identify-npcs
lane: npcs
artifacts:
interactions:
extract: dnd/npc-interactions
normalize: dnd/npc-interactions
```
The framework passes only the accepted normalized producer. A missing, rejected,
or incompatible NPC artifact prevents the consumer step from executing. It
records generated artifact identity and bounded producer provenance without
copying registry names, source ranges, or payload content into the manifest.
## Validation And Metadata
The default extract chain is `generic/valid_json`, interaction shape, registry,
and source-reference validation, `generic/valid_json_schema`, then warning-only
source relatedness. The normalize chain runs normalized invariants after
registry validation and before source-reference and schema validation, followed
by relatedness. Normalizer and relatedness warnings are bounded and end with an
omission summary when necessary. The codec metadata contains only
`interaction_count`. Extractor metadata identifies its prompt and private
response schema; component-local checkpoint identities include the names-only
registry projection where relevant. Generated registry identity stays in
framework provenance and dependency fingerprints.
See [Configuration](../config.md#implemented-production-modules) for selectable
keys and chains, [the NPC artifact contract](dnd-npc-artifacts.md) for the
registry boundary, and the copyable
[complete D&D example](../../examples/dnd-complete.config.yml).

View File

@@ -0,0 +1,130 @@
# D&D Scene Description Artifact Contract
This document defines the durable `dnd/scene-description-list` artifact
emitted by the D&D scene-description lane.
## Artifact identity
| Property | Value |
| --- | --- |
| Artifact kind | `dnd/scene-description-list` |
| Schema ID | `notarius.dnd.scene_descriptions` |
| Schema name | `notarius_dnd_scene_descriptions_v1` |
| Schema version | `v1` |
| Media type | `application/json` |
The normalized payload is written by the JSON output module to
`lanes/<lane-id>.json`. See [JSON output](json-output.md) for the surrounding
output bundle.
## JSON shape
The payload is a JSON object containing exactly one required field, `scenes`.
Each scene object contains exactly these required fields:
| Field | Shape and ownership |
| --- | --- |
| `id` | Non-empty accepted chunk ID, assigned by Notarius. |
| `source_ref` | Exact inclusive accepted chunk range, assigned by Notarius. |
| `kind` | One of `combat`, `narrative`, `recap`, or `meta`. |
| `title` | Non-empty, trimmed, source-grounded title. |
| `summary` | Non-empty, trimmed, source-grounded summary. |
All object layers reject unknown fields. The `scenes` array must be present and
non-empty. `source_ref` has exactly `source_id`, `start_unit_id`, and
`end_unit_id`; its source ID is non-empty and its unit IDs are positive
integers.
```json
{
"scenes": [
{
"id": "chunk-000001",
"source_ref": {
"source_id": "session-alpha",
"start_unit_id": 1,
"end_unit_id": 3
},
"kind": "narrative",
"title": "Arrival at the watchtower",
"summary": "The party reaches the ruined watchtower and begins to investigate it."
}
]
}
```
`id` and `source_ref` are application-owned identity and evidence. The model
provides only `kind`, `title`, and `summary`; it is not asked for chunk IDs,
source IDs, unit IDs, ranges, participants, or confidence.
## Scene kinds
| Kind | Meaning |
| --- | --- |
| `combat` | Active combat is a substantive central activity. |
| `narrative` | Current-session in-world play that is not principally combat, recap, or meta discussion. |
| `recap` | The table is primarily recounting a previous session. |
| `meta` | Sustained out-of-character discussion is the scene's primary purpose. |
For a mixed accepted chunk, classification prefers `combat`, then `recap`,
then `meta`, then `narrative`. Brief table talk, rules clarification, or a
short recollection does not replace the main current-session activity.
## Extraction and evidence
The extractor runs once for each accepted chunk and maps one successful model
response to one scene record. It copies the current chunk ID and exact chunk
range, preserves the model kind without repair, and trims only surrounding
whitespace from title and summary. A model response cannot represent an empty
result; extraction failure follows the configured retry and rejection policy.
Optional `players`, `party`, and `glossary` campaign references can help
disambiguate names or setting terms. They never supply scene evidence or add
events absent from the accepted chunk. The lane requires no NPC registry or
other generated artifact reference.
## Merge and normalization
Accepted per-chunk lists are appended in chunk order. Normalization then:
1. validates the current-source range, non-empty ID and prose, and closed kind;
2. trims only title and summary whitespace;
3. sorts records by source-document start position, then ID;
4. removes records only when all five durable fields are identical;
5. rejects a reused ID when any remaining durable field differs; and
6. rejects the same exact range when `kind`, `title`, or `summary` differs.
Two different IDs with the same range and identical model-owned content remain
separate records. Normalization does not join adjacent ranges, rewrite prose,
repair kinds, infer missing scenes, or use chunk annotations.
## Validation and warnings
Extraction validation requires exactly one record with an ID and range exactly
equal to its current chunk. Later validation checks source membership without a
current chunk. Shape, source-range, kind, ID, and normalized-invariant failures
reject the artifact.
Relatedness checks are advisory. They separately warn when a scene title or
summary has no significant lexical token in its cited transcript range. The
check ignores short tokens and common function words, uses transcript text only,
and does not treat campaign references as evidence. Warning diagnostics are
bounded and do not copy transcript or campaign-reference content.
## Configuration
Use `dnd/scenes` to form accepted scene chunks, then select
`dnd/scene-descriptions` for extraction and normalization. The maintained
[complete D&D configuration](../../examples/dnd-complete.config.yml) includes
this lane alongside the other D&D artifacts. Selectable keys and default
validator chains are defined in [Configuration](../config.md).
## Downstream combat use
The combat-turn extractor consumes this approved artifact as required
eligibility control context through an explicit ordered reference binding. It
uses only an exact chunk ID and source-range match with `kind: combat` to permit
combat extraction; titles, summaries, and scene references do not become combat
prompt material or evidence. The complete downstream behavior, including empty
results and warnings for unavailable coverage, is defined in the
[D&D combat-turn artifact contract](dnd-combat-turn-artifacts.md).

View File

@@ -1,126 +1,149 @@
# D&D Spell-Cast Artifacts
# D&D Spell Artifact
This document is the durable artifact contract for approved
`dnd.spell_cast` artifacts produced by the implemented `dnd/spells` extractor.
This document is the durable serialized artifact contract for the production
D&D spell extractor. Selectable extractor keys are cataloged in
[Configuration](../config.md#implemented-production-modules).
## Artifact Identity
## Identity
- Extractor key: `dnd/spells`
- Artifact type: `dnd.spell_cast`
- Schema version: `v1`
- Artifact kind: `dnd/spell-list`
- Prompt ID: `dnd.spells`
- Response schema key: `dnd_spells`
- Response schema ID: `notarius.dnd.spells`
- Response schema name: `notarius_dnd_spells_v1`
- Response schema version: `v1`
- Media type: `application/json`
The extractor requires source chunks and transcript source capability. It
returns generic artifact candidates that are serialized by the JSON output
module.
The durable JSON Schema is owned by the D&D spell artifact codec. The
extractor's private LLM response schema is a separate transport contract: its
source-reference objects omit `source_id`, which the extractor assigns while
mapping the response to the canonical artifact. The LLM DTO and transport
schema are not part of this durable contract. The private schema owns required
fields, JSON types, object and array shapes, and unknown-field rejection;
deterministic validators own the durable artifact's semantic constraints.
## Artifact Envelope
The output contains canonical spell casts derived from transcript evidence.
Source IDs are assigned from the input identity; source-unit ranges identify
the evidence location.
Approved artifacts use the generic artifact envelope documented in
[JSON Output](json-output.md#artifact-files):
## Output Shape
```json
{
"extractor_key": "dnd/spells",
"artifact_type": "dnd.spell_cast",
"schema_version": "v1",
"payload": {
"caster": "Aria",
"spell": "Cure Wounds",
"effect": "heals an injured ally",
"narrative_description": "Aria raises her holy symbol and casts Cure Wounds."
},
"source_refs": [
{
"source_id": "session-alpha",
"start_unit_id": "seg-001",
"end_unit_id": "seg-001"
}
]
}
```
The extractor payload is a JSON object with one required top-level array. Its
structure is:
## Payload Fields
The `payload` object contains:
- `caster`: in-world character or creature casting the spell;
- `spell`: spell name;
- `effect`: concise spell effect in the scene;
- `narrative_description`: short description of the spell cast in context.
All payload fields are strings and must be non-empty after trimming.
`caster` is the in-world caster, not the transcript speaker.
## Source References
Source references live on the artifact envelope as `source_refs`; they are not
duplicated inside the `payload`.
Each source reference uses the generic source-reference shape:
- `source_id`
- `start_unit_id`
- `end_unit_id`
Validation requires:
- at least one source reference;
- non-empty source ID and unit IDs;
- source ID matching the source document ID;
- start and end unit IDs existing in the source document;
- start unit appearing before or at the same position as end unit.
## Structured LLM Response Shape
The extractor asks the LLM for this top-level response shape:
```json
{
"spell_casts": [
{
"caster": "Aria",
"spell": "Cure Wounds",
"effect": "heals an injured ally",
"narrative_description": "Aria raises her holy symbol and casts Cure Wounds.",
"source_refs": [
{
"source_id": "session-alpha",
"start_unit_id": "seg-001",
"end_unit_id": "seg-001"
}
]
}
]
}
```text
{"spell_casts": [<spell-cast object>, ...]}
```
`spell_casts` must be present. It may be empty when no spell casts are found.
When multiple chunk results are combined, spell casts remain in chunk order.
When the payload is written as durable output, its logical path is derived from
the configured artifact lane ID as defined by the
[JSON output contract](json-output.md#output-payload-files).
The response schema asset is embedded at
`internal/modules/extract/dnd/spells/assets/schemas/dnd_spells.v1.json`.
## Spell-Cast Fields
## Validators
Each spell cast contains exactly these required fields:
The extractor supplies two deterministic validators by default:
- `caster`: in-world character or creature casting the spell;
- `spell`: spell name;
- `source_refs`: transcript source references with extractor-assigned source
IDs and evidence unit ranges. It must contain at least one entry.
- `dnd/spells/shape`
- `dnd/spells/source_refs`
Both string fields must be non-empty. `caster` is the canonical in-world
caster, not the human player, transcript speaker, or GM when the associated
character or creature can be identified. Player and party references may
disambiguate that identity, but do not independently establish that a cast
occurred. The `spell` value must resolve through the effective SRD-plus-overlay
catalog as either a canonical name or alias. Catalog validation accepts aliases
but does not rewrite them; unknown fields are rejected.
Rejection reason codes:
The artifact includes an actual casting event or an unambiguous declared
casting attempt. Spell mentions, hypothetical plans, rules discussion, and
catalog matches without a casting event are excluded. The spell catalog is a
name-recognition policy and never evidence that a cast occurred.
- `invalid_payload`: payload JSON cannot be decoded as a spell-cast payload.
- `missing_required_field`: `caster`, `spell`, `effect`, or
`narrative_description` is blank.
- `missing_source_ref`: candidate has no source references.
- `invalid_source_ref`: at least one source reference fails generic source
reference validation.
## Source References
Rejected candidates are written to `rejected.json` by the JSON output module.
Each source reference contains exactly three required fields: `source_id`,
`start_unit_id`, and `end_unit_id`. The source ID must match the input identity.
The unit IDs must be positive integers present in the input, and the start unit
must not appear after the end unit. Unknown fields are rejected.
For each cast, the complete `source_refs` collection identifies the transcript
evidence for the caster, spell name, and occurrence of the cast or declared
attempt. The deterministic validators establish that ranges are structurally
valid and that the spell name is related to cited text. Semantic evidence
sufficiency is an extraction policy and remains subject to evaluation rather
than deterministic proof.
Reference slot keys and accepted file types are defined in
[Configuration](../config.md#implemented-production-modules). References are
supporting disambiguation material, not source evidence, and are not
addressable through `source_refs`.
## Optional NPC Grounding
The `dnd/spells` extractor accepts an optional `npcs` reference containing one
normalized NPC artifact as `application/json`, up to 1 MiB. An external file is
validated during preparation; an ordered generated binding is validated at the
step handoff. Both paths use the approved NPC codec and identity policy,
re-encode canonical durable JSON for registry provenance, and supply only the
registry's ordered names as the operation-time spell prompt input. It helps the
model prefer canonical caster names; it does not establish that a spell was
cast.
NPC source references may identify the run that produced the registry or any
other session. They remain registry provenance and are never copied into a
spell cast's `source_refs`; every spell evidence range must still identify the
current transcript. Generated provenance records producer and canonical
artifact identity without payload content or a path. When the slot is absent,
the prompt receives exactly `{"npcs":[]}` with its projection digest, and the
run has no NPC reference provenance.
## Normalization Behavior
When the `dnd/spells` normalizer is selected, each recognized spell name is
rewritten to the effective catalog's canonical display name. Lookup uses the
catalog's case-insensitive, whitespace-normalizing, apostrophe-normalizing, and
alias rules. Unknown names are preserved exactly for the normalize validators;
the normalizer does not guess or apply fuzzy matching.
Each cast's `source_refs` is copied, sorted by exact `source_id`,
`start_unit_id`, and `end_unit_id`, and stripped of exact structural
duplicates. Adjacent or overlapping ranges are not merged, and the normalizer
does not synthesize references or change their boundaries.
After those per-cast changes, duplicate identity requires the same canonical
spell name, the same caster after case folding and whitespace normalization,
and the same complete, non-empty set of source references valid for the source
document. Only the first occurrence is retained, in stable order. Its caster
and canonical references are preserved. Unknown names, empty or invalid
evidence, and casts with different evidence remain separate.
Mutation and duplicate decisions are returned through the normal warnings
surface. Warning scopes use the merged input index, such as `spell_casts[0]`,
so they remain meaningful even when a later duplicate is removed. The
normalizer uses these reason codes:
| Reason code | Meaning |
| --- | --- |
| `spell_name_canonicalized` | A catalog lookup replaced an input name with its canonical display name. |
| `spell_name_unresolved` | A name was not found in the effective catalog and was retained unchanged. |
| `source_references_normalized` | Reference order changed or exact duplicate references were removed. |
| `duplicate_spell_cast_collapsed` | A later cast matched the retained cast's complete duplicate identity. |
Only warnings from an accepted normalize attempt are promoted to
`warnings.json`. If an unresolved name reaches the default normalize validator
chain, the catalog validator rejects the candidate with `unknown_spell`; the
`spell_name_unresolved` warning remains in the attempt's debug artifact. An
explicit validator override that accepts the candidate promotes the unresolved
warning normally.
The default extraction and normalization chains both preserve this registered
order: JSON syntax, spell shape, catalog membership, source references, JSON
Schema, then source relatedness. Extraction validates the private response
schema; normalization validates the durable artifact schema.
## Manifest Metadata
@@ -138,15 +161,44 @@ manifest metadata:
"response_schema_id": "notarius.dnd.spells",
"response_schema_name": "notarius_dnd_spells_v1",
"response_schema_version": "v1",
"response_schema_sha256": "sha256:..."
"response_schema_sha256": "sha256:...",
"catalog_base_id": "dnd-5e-2014-srd-spells",
"catalog_digest": "sha256:...",
"catalog_overlay_ids": ["campaign.example"],
"npc_registry_digest": "sha256:...",
"npc_count": 3
},
"normalizer": {
"catalog_base_id": "dnd-5e-2014-srd-spells",
"catalog_digest": "sha256:...",
"catalog_overlay_ids": ["campaign.example"]
}
}
}
```
Raw prompt and schema content are not included in manifest metadata.
`catalog_digest` identifies the effective semantic catalog, while
`catalog_overlay_ids` is sorted and empty for a base-only configuration. Raw
prompt, schema, catalog, alias, and local overlay-file content are not
included in manifest metadata. The `normalizer` metadata uses the same catalog
identity fields when that module is selected. Overlay origin, media type, byte
size, and raw digest are recorded separately in the manifest's reference
provenance; see the [JSON output contract](json-output.md#manifestjson).
## Compatibility Limit
The `npc_registry_digest` and `npc_count` fields in the example are present for
an external NPC registry when the extractor publishes its prepared module
metadata. They contain no NPC names, source references, paths, or raw
bytes. A generated registry's identity is instead represented by the framework
handoff provenance and dependency fingerprint, so the consumer module metadata
does not duplicate it.
This contract covers only `dnd.spell_cast` artifacts produced by the
implemented spell-cast extractor.
The extractor's prompt hash, private response-schema hash, and effective catalog
digest also contribute independently scoped semantic checkpoint fingerprints.
Changing any of those prepared contracts intentionally produces a cold
checkpoint miss. Fingerprints contain only digests, never prompt, schema,
catalog, or reference content. When an NPC registry is bound, its semantic
digest contributes an additional local `npc_registry` fingerprint for an
external binding; the manifest metadata contains only that digest and
`npc_count`. Raw NPC file provenance remains independently recorded in the
manifest's `references` list. Generated bindings contribute the canonical
artifact dependency fingerprint and bounded producer provenance instead.

View File

@@ -0,0 +1,70 @@
# D&D Spell-Catalog Overlay Contract
This document defines the JSON format accepted by the D&D spell catalog
resolver. An overlay supplies campaign-specific spell names and aliases for
recognition. It does not supply spell rules, levels, classes, effects, or
source evidence.
The `dnd/spells` extractor accepts one optional UTF-8 `application/json` overlay
bundle through its `spell_catalog` reference slot. The framework materializes
that file relative to the configuration or command-line binding, enforces the
1 MiB slot limit, and records its origin and raw digest separately from the
effective catalog digest.
## Shape
An overlay bundle has this shape:
```json
{
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
"catalogs": [
{
"id": "campaign.example",
"ruleset": "dnd-5e-2014",
"source": {
"title": "Example campaign spells",
"version": "1",
"url": "",
"license": ""
},
"spells": [
{
"name": "Aegis of Emberfall",
"aliases": ["Emberfall Aegis"]
}
]
}
]
}
```
The top-level `schema_version` and `catalogs` fields are required. The schema
version must be exactly `notarius.dnd.spell-catalog-overlay.v1`, and at least
one catalog is required. Catalogs require a unique, non-empty, trimmed `id`,
the exact `dnd-5e-2014` `ruleset`, a `source`, and a non-empty `spells` array.
`source.title` is required and must be non-empty and trimmed. `source.version`,
`source.url`, and `source.license` are optional strings and may be empty.
Each spell requires a non-empty, trimmed `name`. `aliases` may be omitted or
may be an array of trimmed, non-empty strings; JSON `null` is not an alias
array. Overlay objects contain no other supported spell fields.
Decoding is strict: unknown fields, malformed JSON, trailing JSON values, and
non-string optional source fields are rejected.
## Composition
The resolver always starts with the embedded D&D 5e 2014 SRD catalog. Overlay
catalogs are sorted by `id` before composition, so the input order does not
affect the result. A new canonical name adds a recognition entry. A canonical
name matching an existing canonical name augments that spell and keeps the
established canonical display spelling. Repeated aliases for the same spell
are idempotent.
Canonical-name display conflicts and canonical/alias or alias/alias collisions
between different spells are errors, including collisions with the embedded
catalog. Canonical names and aliases use the catalog's case, whitespace, and
common-apostrophe normalization rules. The effective catalog returns canonical
names in sorted order and produces a semantic SHA-256 digest that is stable
under JSON formatting, object-key, catalog, spell, and alias reordering.

View File

@@ -1,30 +1,28 @@
# JSON Output
This document is the durable JSON output file-format contract produced by the
implemented `json` output module and written by the CLI.
production JSON encoder and written by the CLI. Selectable output-encoder keys
are cataloged in
[Configuration](../config.md#implemented-production-modules).
## Output Directory
The CLI writes logical output files under:
```text
<output-root>/<run-id>/
```
The default output root is `./notarius-output`. Operational behavior is covered
in [Operations](../operations.md).
The output module produces the logical bundle described here. The CLI's
physical placement and lifecycle for that bundle are defined in
[Operations](../operations.md#output-directory).
## Files
The `json` output module writes:
The encoder writes:
- `index.json`
- `manifest.json`
- `artifacts/<artifact-type>.json`, one file per approved artifact type
- `lanes/<lane-id>.json`, one file per normalized serialized artifact
- `rejected.json`
- `warnings.json`
- `chunk-map.json`, only when the JSON output binding enables
`include_chunk_map` and the run has an accepted chunk map
Files are pretty-printed JSON with a trailing newline.
Files are pretty-printed JSON with a trailing newline when the payload is JSON.
Logical file paths are relative, slash-separated, and may not contain `..`.
## `index.json`
@@ -33,10 +31,15 @@ Shape:
```json
{
"manifest_file": "manifest.json",
"artifact_files": [
"output_files": [
{
"artifact_type": "dnd.spell_cast",
"file": "artifacts/dnd.spell_cast.json"
"lane_id": "spells",
"media_type": "application/json",
"file": "lanes/spells.json",
"module_key": "noop",
"schema_id": "notarius.dnd.spells",
"schema_name": "notarius_dnd_spells_v1",
"schema_version": "v1"
}
],
"rejected_file": "rejected.json",
@@ -44,25 +47,37 @@ Shape:
}
```
`artifact_files` is sorted by artifact type. It is empty when no artifacts are
approved.
`output_files` is sorted by lane ID. Output file names are produced by
sanitizing the lane ID:
- characters outside `A-Z`, `a-z`, `0-9`, `.`, `_`, and `-` become `_`;
- repeated `..` sequences are replaced;
- leading and trailing `.`, `_`, and `-` are trimmed;
- empty sanitized names are rejected;
- two lanes that sanitize to the same output file are rejected.
`manifest_file`, `rejected_file`, and `warnings_file` contain the fixed paths
shown above. Each `output_files` entry requires `lane_id` and `file`. It also
contains the normalized payload `media_type`, normalizer `module_key`, and
response `schema_id`, `schema_name`, and `schema_version` when those values are
available.
When present, the top-level optional `chunk_map` descriptor contains exactly
`artifact_kind`, `file`, `media_type`, `schema_id`, `schema_name`, and
`schema_version`. It identifies the pipeline-wide `chunk-map.json`; it is not
a lane output and never appears in `output_files`. The descriptor and file are
both absent when export is disabled or no chunk plan was accepted. Its payload
contract is defined by [Accepted Chunk Map](chunk-map.md).
## `manifest.json`
`manifest.json` contains a run manifest:
`manifest.json` contains a run manifest. This abridged example shows its core
structure:
```json
{
"run_id": "run-123",
"pipeline_id": "dnd-session",
"pipeline_digest": "sha256:...",
"input_module": "seriatim",
"chunker": "generic",
"source_digests": ["sha256:..."],
"extractors": ["dnd/spells"],
"merger": "appendorder",
"normalizer": "noop",
"output_encoder": "json",
"artifact_lanes": [
{
"id": "spells",
@@ -71,13 +86,6 @@ approved.
"normalizer": "noop"
}
],
"llm_profiles": [
{
"id": "default",
"provider": "openai-compatible",
"model": "configured-model"
}
],
"validation_status": "approved",
"started_at": "2026-01-01T00:00:00Z",
"completed_at": "2026-01-01T00:00:01Z"
@@ -86,52 +94,76 @@ approved.
Fields with empty values may be omitted by JSON encoding.
`validation_status` is `approved` when no candidates were rejected and
`rejected` when one or more candidates were rejected.
The manifest fields are:
## Artifact Files
- `run_id`, `pipeline_id`, and `pipeline_digest`: run and resolved-pipeline
identity;
- `input_module`, `chunker`, `extractors`, `merger`, `normalizer`, and
`output_encoder`: resolved module keys;
- `chunk_plan`: payload-free provenance for the effective chunk plan. `mode`
is the effective cache mode; `action` is `reused`, `generated`,
`refreshed`, or `bypassed` when a plan was materialized. `requested_module`
is the current pipeline chunker, while `producer_input_module`,
`producer_module`, `producer_llm_profile`, `producer_references`,
`producer_metadata`, `source_digest`, `plan_digest`, `plan_schema_version`,
and `created_at` describe the stored or generated producer when available.
A cached plan can therefore identify a producer different from the requested
module. This object never embeds ranges, units, annotations, prompts,
responses, or reference content;
- `module_metadata` and `artifact_lanes`: module and per-lane provenance,
including prompt and response-schema provenance when provided;
- `validator_chains`: resolved validation points and validators;
- `source_digests` and `references`: source and reference provenance;
- `normalized_outputs` and `rejected_outputs`: payload-free result summaries;
- `llm_profiles`: selected profile IDs and provider or model names when
available;
- `metadata`: the effective prompt `session_id`;
- `validation_status`: `approved` or `rejected`;
- `started_at` and `completed_at`: UTC run timestamps.
Each artifact file has this shape:
`source_digests` contains source document digests only. Bound references are
recorded separately under `references`, which contains provenance only: target
stage, lane ID when present, slot name, origin type and URI, digest, media
type, byte size, and binding source. Reference content is not written to
durable output.
```json
{
"artifact_type": "dnd.spell_cast",
"artifacts": [
{
"extractor_key": "dnd/spells",
"artifact_type": "dnd.spell_cast",
"schema_version": "v1",
"payload": {},
"source_refs": [
{
"source_id": "session-alpha",
"start_unit_id": "seg-001",
"end_unit_id": "seg-001"
}
]
}
]
}
```
Reference `stage` is `chunk`, `extract`, `merge`, or `normalize`. `lane_id` is
omitted for chunk references and present for extract, merge, and normalize
references.
Artifact envelope fields:
`validation_status` is `approved` when no outputs were rejected and `rejected`
when one or more outputs were rejected.
- `extractor_key`: extractor module key.
- `artifact_type`: artifact type.
- `schema_version`: artifact schema version.
- `payload`: artifact-type-specific JSON payload.
- `source_refs`: optional generic source references.
- `metadata`: optional artifact metadata.
Producer warnings and the current run's chunk-validation warnings remain in
`warnings.json`. The manifest records only provenance and decision summaries;
empty producer-only values are omitted for compatibility with existing readers.
Artifact file names are produced by sanitizing the artifact type:
`validator_chains` records the resolved validator chain for each validation
point. Entries include stage, lane ID when applicable, module key, and validators
with key and execution class. Empty chains are recorded with an empty
`validators` array, including chains resolved from explicit empty config
overrides.
- characters outside `A-Z`, `a-z`, `0-9`, `.`, `_`, and `-` become `_`;
- repeated `..` sequences are replaced;
- leading and trailing `.`, `_`, and `-` are trimmed;
- empty sanitized names are rejected.
`normalized_outputs` summarizes each normalized lane output without embedding
payload bytes. Entries include lane ID, normalizer module key, source ID, media
type, and response schema provenance where available.
For current D&D spell-cast artifacts, the file is
`artifacts/dnd.spell_cast.json`.
`rejected_outputs` summarizes rejected module outputs without embedding raw
payload bytes. Entries include stage, lane, module, chunk, validator or reason,
message, attempt count, and optional diagnostic artifact path.
## Output Payload Files
Each normalized serialized artifact is written to
`lanes/<sanitized-lane-id>.json`. The JSON output encoder is domain-neutral and
accepts only artifacts whose codec media type is `application/json`. The file
contains the codec-owned JSON bytes pretty-printed.
The schema of each lane payload is owned by that artifact contract. For the
current D&D lanes, see [D&D Spell Artifact](dnd-spell-artifacts.md),
[D&D NPC Artifact](dnd-npc-artifacts.md), and
[D&D Combat-Turn Artifact](dnd-combat-turn-artifacts.md), and
[D&D Scene Description Artifact](dnd-scene-description-artifacts.md).
## `rejected.json`
@@ -139,25 +171,14 @@ Shape:
```json
{
"rejected": [
{
"candidate": {
"index": 0,
"extractor_key": "dnd/spells",
"artifact_type": "dnd.spell_cast",
"schema_version": "v1",
"payload": {},
"source_refs": []
},
"validator_name": "dnd/spells/source_refs",
"reason_code": "missing_source_ref",
"message": "spell cast candidate must include at least one source ref"
}
]
"rejected": []
}
```
`rejected` is an empty array when no candidates are rejected.
When output validation rejects an output, each entry contains `stage` and
`message`. It includes `lane_id`, `module_key`, `chunk_id`, `chunk_index`,
`validator_name`, `reason_code`, `attempt_count`, and
`diagnostic_artifact_path` when applicable.
## `warnings.json`
@@ -167,26 +188,14 @@ Shape:
{
"warnings": [
{
"scope": "output",
"reason_code": "example_warning",
"message": "warning message"
"scope": "extract",
"reason_code": "example",
"message": "human-readable warning"
}
]
}
```
`warnings` is an empty array when no warnings are reported.
## Path Safety
The output module returns slash-separated logical paths. The CLI also validates
logical output names before writing:
- names must be non-empty;
- names must be relative;
- names must be clean;
- names must use `/`, not `\`;
- names must not contain `..`;
- resolved paths must stay under the run output directory.
Durable writes are atomic per file.
Each warning requires `reason_code` and `message`; `scope` is omitted when it is
empty.

View File

@@ -1,128 +0,0 @@
# OpenAI-Compatible Structured Output
This document describes the external LLM provider contract implemented by the
production Notarius LLM client.
## Provider
- Provider key: `openai-compatible`
- HTTP method: `POST`
- Endpoint: `<base_url>/chat/completions`
- Request body: JSON
- Response mode: chat completions with structured JSON schema output
`base_url` is trimmed of trailing slashes before `/chat/completions` is
appended. Configure provider settings in [Configuration](../config.md).
## Request
The client sends a JSON object with:
```json
{
"model": "configured-model",
"messages": [
{
"role": "system",
"content": "..."
},
{
"role": "user",
"content": "..."
}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "schema_name",
"strict": true,
"schema": {}
}
}
}
```
Implemented request behavior:
- `model` comes from the structured completion request when set, otherwise from
the configured LLM profile.
- `messages` must be non-empty; each role and content must be non-empty after
trimming.
- `response_format.type` is always `json_schema`.
- `response_format.json_schema.strict` is always `true`.
- `response_format.json_schema.name` and `schema` come from the extractor or
validator making the call.
If an API key is configured, the client sends:
```text
Authorization: Bearer <api-key>
```
The client always sends `Content-Type: application/json`.
## Response
The client expects a JSON response with at least one choice:
```json
{
"model": "provider-model",
"choices": [
{
"message": {
"content": "{\"field\":\"value\"}"
}
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15
}
}
```
`choices[0].message.content` may be either:
- a JSON string whose contents are valid JSON; or
- raw JSON.
The decoded content is unmarshaled into the caller-provided structured output
target. If `usage` is present, prompt, completion, and total token counts are
copied into the completion response.
## Errors And Retries
The client validates base URL, model, response schema name, response schema
JSON, messages, and output target before or during the call.
Retryable failures:
- HTTP request failure;
- response body read failure;
- HTTP `429`;
- HTTP `5xx`;
- malformed provider response envelope;
- missing choices;
- missing, empty, or invalid assistant JSON content;
- structured-output decode failure.
Non-retryable provider status codes include non-`429` `4xx` responses.
Provider error bodies are parsed for `error.message` or `message` when present.
Configured API key values and bearer-token values are redacted from returned
provider errors.
## Timeouts And Concurrency
The configured profile timeout is applied per provider request when greater
than zero. Context cancellation is respected.
The production CLI wraps the provider client with the LLM scheduler. Effective
concurrency is described in [LLM runtime internals](../internal/llm.md).
## Limits
This contract documents only the fields the implemented client sends and reads.
Provider-specific extensions are ignored unless they affect those fields.

View File

@@ -1,116 +1,65 @@
# Seriatim Transcript JSON
This document is the external input contract for the implemented `seriatim`
input adapter.
This document is the external input contract consumed by the production
Seriatim input adapter. Selectable input-adapter keys are cataloged in
[Configuration](../config.md#implemented-production-modules).
## Adapter
- Module key: `seriatim`
- Document kind: `transcript`
- Unit kind: `transcript_segment`
- Source format: `application/vnd.seriatim+json`
The adapter parses raw Seriatim JSON into a generic source document. It owns
transcript-specific JSON parsing and metadata mapping; core source and pipeline
code stay source-format agnostic.
## Accepted Shape
The input must be one JSON object with top-level `metadata` and `segments`
fields. This covers the maintained minimal fixture and Seriatim intermediate
output that provides the same required segment fields.
```json
{
"metadata": {
"id": "session-alpha",
"title": "Synthetic D&D spell session"
},
"segments": [
{
"id": "seg-001",
"start": 0,
"end": 4,
"speaker": "Aria",
"text": "Aria raises her holy symbol and casts Cure Wounds."
}
]
}
```
The maintained example is
[examples/seriatim-minimal-transcript.json](../../examples/seriatim-minimal-transcript.json).
Top-level metadata entries are preserved. Other segment fields, such as
`categories`, are ignored.
Required top-level fields:
- `metadata`: an object. Its entries are accepted as source metadata.
- `segments`: a non-empty array of segment objects.
Required segment fields:
- `id`: a positive integer JSON number or canonical decimal string without
leading zeros or surrounding whitespace;
- `start`: a finite, non-negative JSON number or numeric string;
- `end`: a finite, non-negative JSON number or numeric string that is not less
than `start`;
- `speaker`: a non-empty string;
- `text`: a non-empty string.
Other top-level and segment fields, such as `categories`, are ignored.
Multiple top-level JSON values are rejected.
## Validation
The adapter rejects:
- empty raw input;
- malformed JSON;
- top-level JSON that is not an object;
- missing, null, or non-object `metadata`;
- missing, null, non-array, or empty `segments`;
- segment values that are not objects;
- segment `id` values that are neither strings nor numbers;
- non-string `speaker` or `text`;
- empty segment IDs;
- segment IDs with leading or trailing whitespace;
- duplicate segment IDs;
- missing or empty `speaker`;
- missing, empty, invalid, non-finite, or negative `start`;
- missing, empty, invalid, non-finite, or negative `end`;
- `end` values before `start`;
- missing or empty `text`.
The adapter rejects empty input, malformed JSON, multiple top-level JSON values,
non-object segment values, duplicate segment IDs, and any violation of the
shape or field constraints above.
Segment text is preserved as provided, but it must not be empty after trimming.
## Source Mapping
## Derived Identity
The adapter maps input to `SourceDocument`:
Notarius identifies the parsed source in this order:
- `metadata` becomes `SourceDocument.Metadata`;
- `SourceDocument.Kind` is `transcript`;
- `SourceDocument.Format` is `application/vnd.seriatim+json`;
- `SourceDocument.Digest` is `sha256:<hex>` of the exact raw input bytes.
1. `metadata.id`, when it is a non-empty string after trimming;
2. `metadata.source_id`, when it is a non-empty string after trimming;
3. `seriatim:<first-16-hex-chars-of-raw-sha256>`.
`SourceDocument.ID` is selected in this order:
1. the parse request source ID, after trimming;
2. `metadata.id`, when it is a non-empty string after trimming;
3. `metadata.source_id`, when it is a non-empty string after trimming;
4. `seriatim:<first-16-hex-chars-of-raw-sha256>`.
Each segment becomes one `SourceUnit`:
- `segment.id` becomes `SourceUnit.ID`; numeric IDs are converted to their JSON
number text, so `1` becomes `"1"`;
- `segment.text` becomes `SourceUnit.Text`;
- `SourceUnit.Kind` is `transcript_segment`;
- `speaker`, `start`, and `end` are stored in source-unit metadata.
## Metadata Keys
Seriatim unit metadata uses these keys:
- `speaker`: string speaker label;
- `start`: `json.Number` start value;
- `end`: `json.Number` end value.
The `internal/modules/input/seriatim` package exposes typed accessors for these
values.
## Capabilities
The module declares these provided capabilities:
- `source.transcript`
- `transcript.speaker`
- `transcript.timestamps`
The exact raw input SHA-256 remains the basis of the fallback source ID. The
source digest recorded in output provenance is instead the SHA-256 of the
canonical generic source document, excluding the digest field itself. It covers
the derived source identity, document kind and format, ordered units and their
self-references, and accepted metadata. Segment IDs become the unit IDs used by
artifact source references; each produced unit carries a self-reference whose
source ID is the derived document ID and whose start and end IDs both equal the
segment ID.
## Compatibility Limit

View File

@@ -1,88 +0,0 @@
# Diagnostics Internals
Diagnostics internals live in `internal/core/diagnostics`. Operator-facing run
behavior is documented in [Operations](../operations.md).
## Purpose
Diagnostics provide local inspection artifacts for a run without becoming the
durable output contract. Durable user output is produced by output modules and
written by the CLI.
Diagnostics must not expose secrets.
## Run Directory
`NewRunDirectory(workDir, retention)` creates:
```text
<workDir>/run-<unix-nanoseconds>/
```
If `workDir` is empty, it defaults to `/tmp/notarius`. Empty retention defaults
to `auto`.
The writer makes the work directory if needed, then attempts to create a unique
run directory. It retries run ID creation a bounded number of times if a
collision occurs.
## Artifact Writers
Implemented artifact names:
- `invocation.json`
- `effective-config.json`
- `resolved-pipeline.json`
- `source-document.json`
- `run-manifest.json`
- `run-report.json`
- `warnings.json`
- `error.log`
JSON artifacts are encoded with indentation and a trailing newline. Writes are
atomic through a temporary file in the target directory followed by rename.
Artifact names must be single relative file names. Absolute paths, path
separators, and names resolving outside the run directory are rejected.
## Redacted Effective Config
Diagnostics writers accept payloads that implement
`RedactedDiagnosticsPayload`. `internal/core/config` uses this to redact API
keys in effective config diagnostics while preserving resolved pipeline context.
The redaction path clones config data before replacing secret values.
## Retention
Retention is decided by `ShouldRetainRunDirectory`.
- Failed runs are always retained.
- `always` retains successful runs.
- `never` removes successful runs.
- `auto` retains successful runs only when warnings exist.
- Unknown retention values are treated as retain by the retention decision, but
config validation rejects unsupported values before normal runs.
`ApplyRetention` removes only the specific run directory.
## CLI Failure Behavior
The CLI creates the diagnostics run directory after config loading and before
pipeline resolution. Failures before that point do not have diagnostics.
After diagnostics creation, run failures call `WriteErrorLog` and apply
retention with `RunSucceeded: false`, so the run directory remains available.
When the pipeline returns a partial manifest on failure, the CLI writes that
manifest before logging the failure.
## Invariants
- Diagnostics paths must be narrow and run-directory scoped.
- Writes should be atomic where practical.
- Secrets must be redacted.
- Diagnostics write failures are command failures because they can hide the
information needed for recovery.
- Durable output file contracts belong to output modules and integration docs,
not to diagnostics.

View File

@@ -1,116 +1,252 @@
# LLM Runtime
# LLM Runtime Internals
The implemented LLM runtime lives in `internal/framework/llm`. It provides
transport-neutral structured completion contracts, an OpenAI-compatible HTTP
adapter, concurrency scheduling, schema registry helpers, retry behavior, and
secret redaction.
`internal/framework/llm` implements Notarius's transport boundary for structured
completion. It contains the Scriptorium adapter, concurrency scheduler,
prompt/schema registries, selected-profile recording, and provider-error
redaction.
## Contract
Provider-neutral ownership rules are defined in
[Architecture](../policy/architecture.md#llm-boundary). Profile sources,
credentials, and concurrency settings are defined in
[Configuration](../config.md).
Modules depend on `contracts.StructuredLLMClient`:
## Structured Contract
```go
CompleteStructured(ctx, request, out) (response, error)
```
Modules and LLM-backed validators depend on
`contracts.StructuredLLMClient.CompleteStructured`. A request identifies a
prompt and optional profile/session, supplies named input materials and
variables, and provides a caller-owned decoding target. A successful response
contains the validated raw structured bytes plus non-secret provider, model,
profile, and token metadata.
The request contains messages, optional model override, response schema name,
and response schema JSON. The caller supplies a pointer target for decoded
structured output.
The caller owns prompt selection, response-schema selection, and interpretation
of the decoded result. `LLMInputMaterial` keeps source and reference bytes with
their origin metadata so the adapter can pass named artifacts to Scriptorium
without exposing Scriptorium types through stage contracts.
Extractors own prompts and schemas. Provider adapters should not contain
domain-specific prompt logic.
## Production Construction
## Production Client Construction
`internal/cli` constructs the production runtime by:
`internal/cli` builds the production LLM client from the effective config:
1. allocating the asset registry populated by the generic, Seriatim, and D&D
package-family registrars;
2. creating a `ScriptoriumClient` from the effective profile source;
3. attaching an `LLMProfileRecorder`;
4. creating a scheduler from the effective concurrency limit;
5. returning a `ScheduledClient` wrapper;
6. decorating that shared client before preparation when debug recording is
enabled; and
7. injecting that one shared client into complete pipeline preparation before
the source file is read or the runner is invoked.
1. find the effective LLM profile;
2. build `OpenAICompatibleClientConfig`;
3. create an OpenAI-compatible client;
4. create a scheduler from profile or global concurrency;
5. wrap the client with `NewScheduledClient`;
6. return non-secret LLM profile manifest metadata.
The D&D scene chunker and spell, NPC, combat-turn, item-event, NPC-interaction, and
scene-description extractors retain this
injected client and use it for every structured completion. Operation requests
do not carry an LLM client.
The current run command requires exactly one distinct effective LLM profile for
the resolved pipeline.
The CLI separately gathers explicit profile IDs from resolved LLM-capable stage
and validator bindings. It prepares a small internal check prompt for each ID so
missing or invalid profiles fail before pipeline execution. The runtime profile
override syntax and scope are defined in the
[CLI reference](../cli.md#run); binding rules are defined in
[Configuration](../config.md#module-bindings).
## OpenAI-Compatible Adapter
## Scriptorium Adapter
`OpenAICompatibleClient` posts JSON to:
`ScriptoriumClient` converts a Notarius request into a Scriptorium `RunRequest`.
It validates the decoding target and prompt identity, maps named input materials
to inline artifacts, forwards explicit profile and session context, delegates
rendering/provider execution/structured validation, and unmarshals successful
JSON into the caller target.
```text
<base_url>/chat/completions
```
Empty optional input material is represented by a single space so Scriptorium
retains the named input. The client returns Scriptorium's validated structured
bytes rather than re-encoding the caller target, allowing modules to preserve
the runtime result exactly.
It sends:
Selected profile, provider, model, and token metadata are mapped into the
Notarius response. The recorder deduplicates profiles by identity and supplies
manifest-safe profile summaries after actual calls; manifest population does
not guess the selected prompt default in advance.
- `model`
- `messages`
- `response_format.type = "json_schema"`
- `response_format.json_schema.name`
- `response_format.json_schema.strict = true`
- `response_format.json_schema.schema`
Generated-output validation failures and provider failures are wrapped with
prompt context. Error strings pass through bearer-token redaction before they
cross the runtime boundary.
If an API key is configured, the adapter sends an `Authorization: Bearer ...`
header.
## Scheduling
The adapter accepts assistant content either as a JSON string containing JSON or
as raw JSON content. It then unmarshals that content into the caller-provided
target.
`Scheduler` uses a bounded permit count and a FIFO waiter queue. Immediate
acquisition increments the in-flight count; queued acquisition waits for a
permit or context cancellation. Cancellation removes a queued waiter, while a
cancelled waiter that has already received a permit releases it.
External wire-contract details belong in the
[OpenAI-compatible integration doc](../integrations/openai-compatible.md).
`ScheduledClient` acquires a permit around each structured completion and
defers release on every result path. The effective limit and default are
configuration facts in [Configuration](../config.md#defaults).
## Retries And Timeouts
This provider-call ceiling is independent of the pipeline's extract worker
limit. Concurrent lanes, retries, and validators all use the same scheduled
client, so increasing framework workers cannot exceed `total_llm`. Pipeline
dispatch and cancellation mechanics are documented in
[Pipeline Internals](pipeline.md#execution-flow).
The adapter retries:
## Prompt And Schema Assets
- provider request failures;
- response read failures;
- HTTP `429`;
- HTTP `5xx`;
- malformed provider envelopes;
- malformed assistant JSON;
- structured-output decode failures.
`AssetRegistry` combines caller-owned prompt filesystems under stable prefixes
and rejects invalid or conflicting registrations. Production module packages
register their own prompt and schema assets; generic framework code contains no
D&D prompt content. `internal/framework/promptfs` provides the domain-neutral
filesystem composition helper used to combine module-owned files with shared
domain prompt fragments.
Non-retryable `4xx` responses are returned without retry. Request timeout comes
from the effective LLM profile. Context cancellation is respected.
The D&D scene chunker and spell, NPC, combat-turn, item-event, NPC-interaction, and
scene-description extractors each declare an
ordered prompt asset manifest. The manifest lists the package-owned YAML and
Markdown files, then the exact shared fragments rendered by that prompt; the
same ordered list drives both filesystem mounting and the prompt fingerprint.
Unused shared assets are neither mounted nor fingerprinted. Universal
extraction-evidence and output policy lives only in the shared extraction
assets; package-owned prompt files retain artifact-specific rules. The scene
prompt keeps its separate output rule because it does not render the
extraction-evidence asset.
## Scheduler
### D&D Extraction Prompt Ordering And Cache Boundaries
`Scheduler` bounds concurrent provider calls. It tracks in-flight calls and a
FIFO queue of waiters. Cancellation removes queued waiters or releases granted
permits.
D&D extraction prompts order messages from the most reusable content to the
most variable content. New extraction lanes use these tiers in order:
`NewScheduledClient` wraps any structured LLM client and runs each completion
inside the scheduler.
1. universal shared content, including the system, extraction-evidence, and
in-world identity messages;
2. stable campaign or run context shared across lanes, including campaign
references;
3. stable subset- and lane-specific context and instructions, including an NPC
registry, catalog, task, or extraction instructions when applicable;
4. the chunk transcript as the final user message.
Effective concurrency is:
This ordering lets requests reuse the longest identical prefix before the
per-chunk transcript changes. Cache reuse requires the preceding message
sequence and content to be exactly identical; semantic similarity is not
sufficient. Cache boundaries belong at the ends of reusable stable tiers,
subject to the provider's cache-boundary limit. The shared identity and
campaign-reference messages form the first two extraction boundaries. Spell,
combat, and interaction prompts add a boundary at the shared NPC registry. Each extraction
prompt places its final boundary on its lane-specific instructions, immediately
before the transcript. The transcript does not carry cache control because no
reusable content follows it.
1. `llm_profiles.<id>.max_concurrency`, when greater than zero;
2. `concurrency.total_llm`, when greater than zero;
3. `1`.
Accordingly, the common prefix of the spell, NPC, combat, item-event, and interaction
extraction prompts is system,
extraction evidence, identity, and campaign references. The NPC prompt then
renders task, instructions, and transcript. Spell renders the NPC registry,
catalog, task, instructions, and transcript. Combat renders the NPC registry,
task, instructions, and transcript. Item-event renders task, instructions, and
transcript without a generated-artifact input. NPC interaction renders the names-only NPC
registry, task, instructions, and transcript. The
scene chunker is not an extraction lane: it retains its separate system,
transcript, campaign-reference, task, and instruction order and marks its
transcript and campaign-reference messages ephemeral.
## Schema Registry
The scene-description extractor deliberately omits the citation-oriented
`common-dnd-extraction-evidence.md` asset because Notarius attaches the whole
accepted chunk range itself. Its manifest is system, shared identity, shared
campaign references, lane task, lane instructions, then the transcript. The
identity, campaign-reference, and instruction messages are ephemeral cache
boundaries; the transcript is last and has no cache control. Compatible shared
messages remain canonical shared assets rather than copied package text.
The framework schema registry embeds generic test schemas. It also exposes
helpers for caller-owned schemas:
### D&D NPC Normalization Prompt Ordering And Cache Boundaries
- `LoadResponseSchema`
- `LookupResponseSchema`
- `MustLookupResponseSchema`
- `ResponseSchema.DiagnosticsMap`
NPC normalization has a distinct prompt and response-schema identity from NPC
extraction. Its stable message tiers are the common D&D system asset, followed
by package-owned task and normalization instructions. Cache boundaries follow
the shared system tier and the package instructions. The variable tail contains
the private candidate-name-and-range input and a windowed transcript input
whose cited units provide local context; neither has a cache boundary because
it changes with the document.
`DiagnosticsMap` omits raw schema content and includes metadata such as key,
ID, version, name, and SHA-256.
This prompt intentionally omits shared identity guidance,
extraction-evidence, and campaign-reference assets: it reconciles existing
records rather than extracting events or adding evidence. Its package-owned
manifest and schema identity are fingerprinted separately, so a normalization
prompt or schema change cannot reuse a prior normalization checkpoint.
The D&D spell extractor owns and loads its own embedded response schema.
Shared wording belongs in the canonical assets under
`internal/modules/dnd/shared`; extraction packages reference those assets in
their manifests instead of copying similar text into package-local files.
Package-local assets contain only lane-specific content. An extraction lane may
depart from the tier order only when prompt-quality evidence or a provider
constraint makes the exception necessary; document the exception and rationale
here when it becomes implemented behavior.
## Secret Redaction
Schema helpers load embedded JSON Schema with identity and digest metadata,
return defensive copies, and expose a diagnostics map that omits schema bytes.
The small framework registry contains only generic test schemas; production
schemas remain package-owned.
Provider errors are passed through `ErrorWithSecretsRedacted` with the API key
and bearer-token value. Config diagnostics use redacted effective config
payloads.
The spell, NPC, combat, item-event, NPC-interaction, and scene-description extractors'
package-owned prompts declare their
structured JSON inputs and private response schemas. Each private response
schema remains separate from its durable artifact codec schema; this work does
not use shared schema fragments or schema generation. Those private schemas own
the transport envelope—required fields, JSON types, nullability, and
unknown-field rejection—while deterministic validators own semantic constraints
such as enum membership, non-empty values and collections, and positive
numbers. The spell extractor's prompt declares a required
`application/json` `spell_catalog` input and an optional `application/json`
`npcs` input. The extractor generates
the catalog input from its prepared
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
The shared D&D prompt assets include a generic NPC grounding fragment directly
after the campaign reference message for spell, combat, and interaction prompts. When an NPC
registry is bound, the
domain registry boundary strictly decodes and identity-validates one durable
artifact, re-encodes canonical JSON for provenance, and separately generates a
names-only prompt projection. The unbound projection is exactly `{"npcs":[]}`.
Prompt input and component-local checkpoint digests cover the projected bytes;
manifests retain the optional full registry digest/count rather than names,
overlay bytes, registry paths, or source metadata. Combat and interaction prompt,
response-schema, mapping, normalization, identity, and registry-projection
fingerprints remain separate semantic inputs to checkpoint identity.
Do not add raw provider request bodies, response bodies, API keys, or prompt
payloads to diagnostics by default.
## Debug And Redaction Boundaries
The pipeline may wrap the client with a debug recorder that captures prepared
prompt/response material for an explicitly requested debug run. Debug summaries
and manifests receive identities, hashes, usage, and selected profile summaries
rather than prompt, source, reference, schema, or response content.
The Scriptorium error wrapper removes bearer credential values from surfaced
provider errors; `RedactSecrets` and `ErrorWithSecretsRedacted` support known
secret values elsewhere in the runtime. Config summaries use a separate
clone-and-redact path in `internal/core/config`. These mechanisms implement the
security invariant in
[Architecture](../policy/architecture.md#state-output-and-safety); operator
handling of debug data is defined in [Operations](../operations.md#debug).
## Failure Behavior
- Invalid targets, missing prompt IDs, malformed structured output, and
Scriptorium failures return contextual errors to the calling module.
- Scheduler construction rejects non-positive limits; acquisition respects
context cancellation.
- Asset registration rejects invalid roots, missing content, and path conflicts.
- Schema loading distinguishes missing assets, invalid JSON, and invalid
metadata.
- Profile validation errors occur during CLI preparation when an explicit
selected ID cannot be prepared.
## Tests To Inspect
- `internal/framework/llm/scriptorium_client_test.go`: adapter mapping and local
HTTP integration.
- `internal/framework/llm/scheduler_test.go` and
`scheduled_client_test.go`: permits, FIFO behavior, cancellation, and wrapper
release.
- `internal/framework/llm/asset_registry_test.go` and
`schema_registry_test.go`: asset composition, validation, and defensive
copies.
- `internal/framework/llm/secrets_test.go`: provider-error redaction.
- `internal/cli/run_contract_test.go`: profile validation, production client
wiring, manifest recording, and debug integration.
- Module-local `scriptorium_assets_test.go` files: prompt inputs and package
asset registration.

View File

@@ -1,165 +1,728 @@
# Modules
# Module And Validator Internals
Production modules live under `internal/modules`. Each module implements one
contract from `internal/framework/contracts`, exposes a `ModuleSpec`, and
registers itself with the matching pipeline registry.
Production module and validator implementations live under their domain-first
trees in `internal/modules`.
The selectable keys, configuration options, reference slots, and default
validator chain are canonical in the
[module](../config.md#implemented-production-modules) and
[validator](../config.md#implemented-production-validators) catalogs in
Configuration.
The CLI production catalog currently registers only the modules listed here.
## Extension Pattern
## Contract Pattern
A stage module package provides a stable key, constructor, contract
implementation, `ModuleSpec`, `Register`, and focused behavior and registration
tests. A validator package follows the same pattern with `ValidatorSpec` and the
validator registry. Package-family registrars compose those leaf registrations
into the production catalog and own family-level policy such as default
validator chains and prompt asset collection.
A production module package should provide:
Production input, chunk, output, and D&D spell-, NPC-, combat-, item-event-, interaction-, and scene-description-extract packages
register strict option decoders and run-local builders. Preparation decodes their options into
implementation-owned values and injects dependencies plus the materialized
reference set for the selected target. Each builder receives an isolated clone
of that set; input and output builders receive no references. The spell, NPC,
combat, item-event, interaction, and scene-description extractors are typed over the canonical D&D model. D&D validators, merge,
and normalize use typed variants; JSON representation validators use serialized
requests; and unconditional validators expose separate chunk and typed
variants. The D&D production registrar registers the canonical typed spell,
NPC, combat, item-event, interaction, and scene-description implementations, including their kind-specific merge and
normalize behavior.
- a stable module key;
- a constructor such as `New`;
- the relevant contract implementation;
- `ModuleSpec`;
- `Register`;
- focused tests for registration, options, contract behavior, and errors.
For D&D artifact defaults, generic JSON syntax validation runs first. Rejecting
domain validators then own semantic diagnostics before generic JSON Schema
validation provides the final rejecting representation backstop; warning-only
relatedness validators run last. This default composition does not reorder an
explicitly configured validator chain.
Module specs should describe capabilities accurately. Resolution uses specs to
reject incompatible pipelines before execution.
Prepared extractors, extract validators, and codecs may be reused concurrently
by the run-wide extract pool. Production implementations are immutable after
construction: they retain only typed options, immutable assets, or the shared
concurrency-safe LLM client. Implementations that introduce mutable state must
synchronize that state without creating a separate provider scheduler.
## `seriatim` Input
Specs expose capability and execution metadata without constructing an
implementation. Registry entries separately expose option validation and
run-local construction. Chunk, extract, merge, and normalize modules that accept
auxiliary material declare identical reference slots from both
`ReferenceSlots()` and `ModuleSpec().ReferenceSlots`; registration tests enforce
that agreement. Runtime delivery uses the corresponding stage request's
`References` field.
Package: `internal/modules/input/seriatim`
LLM-backed extensions own their prompt definitions and response schemas under
package-local embedded assets. Shared filesystem composition belongs in
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
declarations, prompt-input assembly, and source-unit/citation helpers belong in
`internal/modules/dnd/shared`, which owns operation-scoped indexed
source-reference validation, citation traversal, ordering and canonicalization,
plus bounded D&D diagnostics. The
D&D scene chunker and spell, NPC, combat-turn, item-event, NPC-interaction, and scene-description extractors use ordered
package-local prompt manifests for both rendering and prompt fingerprinting, so
only the shared fragments each prompt actually renders participate in either
operation. Extraction prompts place stable shared and lane-specific context
before the variable transcript and use shared assets for wording common across
lanes. The canonical ordering and cache-boundary policy is documented in
[LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries). Stage
contracts expose only Notarius structured-completion types, not Scriptorium
public types.
The `seriatim` adapter parses Seriatim transcript JSON into a generic source
document. It owns transcript JSON details, source ID selection, source digest
creation, transcript segment validation, and segment metadata mapping.
The shared `PrepareChunkExtraction` helper owns common extraction preflight and
transcript material preparation for the spell, NPC, combat-turn, item-event,
NPC-interaction, and scene-description extractors. It validates common request
state, clones supplied source metadata, falls back to the materialized chunk
when content is absent, checks that content remains chunk-identical, and fills
only the common default fields. Extractors retain receiver, dependency, and
lane-specific checks locally and wrap helper errors with their module context.
Provides:
Reference material may inform a module or prompt but must not become source
evidence. The resolver and materializer behavior is described in
[Pipeline Internals](pipeline.md#reference-materialization).
- `source.transcript`
- `transcript.speaker`
- `transcript.timestamps`
## Domain Reference Data
External JSON shape belongs in the Seriatim integration doc.
### `internal/modules/dnd/spells/catalog`
## `generic` Chunker
The spell catalog package owns the embedded, versioned D&D 5e 2014 SRD spell
reference data. Its strict JSON asset contains one canonical record per spell,
including spell level and all applicable class memberships. `LoadSRD5E2014`
validates catalog identity, provenance metadata, ordering, uniqueness, levels,
classes, aliases, and lookup-key collisions before exposing immutable copies.
Package: `internal/modules/chunk/generic`
Lookup is case-insensitive and normalizes whitespace and common apostrophe
variants while preserving source punctuation in canonical display names. The
catalog contains 319 unique spells and 779 class memberships. Source and
license details live beside the asset in `SOURCES.md`. This domain-owned data is
separate from `internal/modules/dnd/shared`, which is reserved for reusable
prompt and source-reference machinery.
The `generic` chunker splits source units into ordered chunks. It validates the
source document, clones source units, assigns chunk IDs such as `chunk-000001`,
and records chunk metadata for start unit, end unit, and unit count.
`ResolveEffectiveCatalog` builds the immutable recognition view used by the
spell extractor and catalog validator. It starts with the embedded SRD catalog
and optionally applies one strict JSON overlay from the `spell_catalog` item in
a materialized reference set. Overlay catalogs are ordered by ID, may add names
and aliases, and may augment an existing canonical spell without replacing its
display name. Cross-spell lookup collisions are errors. The effective view
exposes sorted canonical names, normalized lookup, overlay identities, and a
semantic digest; overlay content remains contextual reference material rather
than source evidence. Its external JSON contract is defined in the
[spell-catalog overlay contract](../integrations/dnd-spell-catalog-overlays.md).
Options:
### `internal/modules/dnd/npcs/identity`, `internal/modules/dnd/npcs/registry`, and `internal/modules/dnd/codec/npcs`
- `max_units`: positive integer, default `50`;
- `overlap_units`: non-negative integer, default `0`, and less than
`max_units`.
The NPC identity package owns Unicode comparison keys, deterministic
`npc:sha256:` IDs, display normalization, and whole-registry collision issues.
The registry package resolves one optional normalized artifact through the
strict codec, validates whole-registry identity, canonicalizes its JSON, and
provides immutable records, a names-only prompt projection, distinct durable
and projection digests, count, and exact canonical-name lookup. External files cross this boundary during
preparation; generated artifacts cross it at the ordered step handoff. It owns
the `npcs` slot and its bounded, content-safe validation failures. NPC source
references are durable provenance and are not treated as evidence for a
consuming pipeline. The NPC codec owns the strict durable `dnd/npc-list` JSON
boundary and exposes candidate versus approved encode/decode operations. The
shared `internal/modules/dnd/codec/candidatejson` package supplies strict typed
candidate JSON mechanics; each artifact codec retains its own durable schema
and approved-value policy.
Provides:
### `internal/modules/dnd/scenedescriptions/registry`
- `chunks`
The scene-description registry owns the required `scene_descriptions` control
reference used by combat extraction. It decodes exactly one approved scene-list
artifact through the scene-description codec and retains only scene ID, exact
source reference, and kind. Titles, summaries, original bytes, paths, and
prompt material do not cross this domain boundary.
## `dnd/spells` Extractor
An external reference is validated during preparation; an unbound seed is
permitted only while a configured generated reference awaits the ordered
handoff. At operation time, a generated artifact overrides the seed and is
resolved into an immutable view safe for concurrent extract jobs. Matching is
strictly exact by chunk ID, source ID, start unit ID, and end unit ID, producing
an exact, missing, or mismatched result. Only an exact result exposes kind.
Package: `internal/modules/extract/dnd/spells`
The registry's semantic eligibility digest is derived from a sorted projection
of ID, exact range, and kind. It ignores titles, summaries, and input order;
the unbound view has a stable empty projection digest. Combat extractor
metadata and checkpoint identity use this semantic boundary for external
references, while generated artifact identity and dependencies remain owned by
the framework handoff.
The `dnd/spells` extractor owns D&D spell-cast artifact semantics. It renders
embedded prompts, loads the embedded structured response schema, calls the
structured LLM client, converts spell-cast responses into artifact candidates,
and supplies deterministic validators.
The `internal/modules/dnd/codec/combatturns` package owns the durable
`dnd/combat-turn-list` schema and candidate versus approved JSON boundary. It
is registered by the production D&D family registrar for the selectable combat
lane.
Requires:
The `internal/modules/dnd/codec/itemevents` package owns the durable
`dnd/item-event-list` schema and candidate versus approved JSON boundary. It is
registered by the production D&D family registrar. Its external contract is
defined in the [D&D item-event artifact contract](../integrations/dnd-item-event-artifacts.md).
- `chunks`
- `source.transcript`
The `internal/modules/dnd/codec/npcinteractions` package owns the durable
`dnd/npc-interaction-list` schema and candidate versus approved JSON boundary.
It is registered by the production D&D family registrar for the selectable
interaction lane. Its external contract is documented in the
[D&D NPC interaction artifact contract](../integrations/dnd-npc-interaction-artifacts.md).
Provides:
The `internal/modules/dnd/codec/scenedescriptions` package owns the durable
`dnd/scene-description-list` schema and candidate versus approved JSON boundary.
It is registered by the production D&D family registrar. Its external contract
is documented in the
[D&D scene-description artifact contract](../integrations/dnd-scene-description-artifacts.md).
- `dnd.spell_casts`
## Input Adapter
Artifact type and schema version:
### `internal/modules/seriatim/input/transcript`
- artifact type: `dnd.spell_cast`
- schema version: `v1`
The adapter decodes the supported transcript JSON, selects the source identity,
computes canonical source provenance, validates segments, and maps each segment
into a generic source unit with a self-reference plus speaker and timestamp
metadata. It accepts no module options. Its spec advertises the transcript
capabilities consumed by D&D modules.
The extractor adds prompt and response-schema provenance to lane manifest
metadata. Durable artifact payload details belong in the
Parsing is strict about required values and duplicate unit IDs but deliberately
ignores unrelated Seriatim fields. The external format and derived-identity
rules are defined in the
[Seriatim contract](../integrations/seriatim.md).
## Chunkers
Chunkers implement `contracts.Chunker.Plan`. A plan identifies ordered source
unit ranges and may carry optional namespaced JSON annotations; it does not
contain materialized chunk content. The framework canonicalizes annotations,
validates ranges against the current source, and materializes chunk IDs,
indexes, references, content, units, and generic metadata. Materialized source
unit metadata is independently owned. Annotation
namespaces remain optional data: generic framework code and downstream modules
must not require D&D scene annotations or import `dnd/scenes`.
### `internal/modules/generic/chunk/units`
The generic chunker validates the source document and returns ranges over units
in configured windows. Overlap changes the next window start but never reorders
units. Framework materialization derives the resulting chunk identity and
generic metadata from those ranges.
The accepted options and defaults are defined in
[Configuration](../config.md#implemented-production-modules). Generic
framework validation canonicalizes the returned unit slices before extraction.
The chunker decodes its options during construction and retains only the typed
window settings used by `Plan`.
### `internal/modules/dnd/chunk/scenes`
The scene chunker prepares a structured Scriptorium request from the full
transcript, session, and optional D&D reference inputs. It validates the model's
inclusive source-unit endpoints against document position and converts them
into deterministic plan ranges. Preparation injects the shared structured LLM
client into the chunker; `Plan` supplies only the run-specific profile, session,
source, references, and metadata.
Scene validation requires sequential, contiguous, non-overlapping coverage from
the first source unit through the last. Its private response contains only the
boundary endpoints; the accepted plan has no D&D-specific annotations and
produces no boundary warnings. Malformed structured output is returned as an
error; there is no fallback chunker.
The package embeds its prompt and response schema and reports their non-secret
identity and hashes through singleton module metadata. Shared D&D assets supply
reference declarations and prompt inputs; their user-facing keys and accepted
file types remain canonical in [Configuration](../config.md).
## Extractor
### `internal/modules/dnd/extract/spells`
The spell extractor prepares a structured request from one chunk, the
chunk-scoped source input, the session, and optional D&D reference inputs. It
decodes the model response, assigns the generic source identity to every source
reference, canonicalizes duplicate references, orders spell casts by their
earliest valid source-document position, and returns `dnd.SpellList`.
Its private response schema admits only the structural transport envelope:
required fields, JSON types, array and object shapes, and unknown-field
rejection. It maps integer source-unit candidates directly without repairing
semantic values, so the deterministic shape, catalog, and source-reference
validators own blank values, empty evidence, and invalid or unresolved ranges.
The extractor owns its private model-response DTO, embedded prompt, LLM response
schema, strict option decoder, injected shared LLM client, and prompt/schema
manifest metadata. During preparation it resolves the optional `spell_catalog`
reference into an immutable effective catalog and adds a generated
canonical-name-only JSON input to every structured completion request. Overlay
failures therefore stop construction before source parsing or an LLM call;
campaign references remain separate disambiguation inputs and never become
source evidence.
The prompt includes only actual casting events and unambiguous declared casting
attempts. Spell mentions, plans, rules discussion, and catalog matches without
a casting event are excluded. Shared extraction-evidence and identity rules
require transcript-supported caster and spell facts, while the catalog,
campaign references, and NPC names only disambiguate source text. Structural
source validation remains deterministic; semantic evidence sufficiency is
enforced through extraction policy and evaluation.
Both the extractor and deterministic catalog validator expose
the effective base-plus-overlay semantic digest as scoped prepared-component
checkpoint identity. Raw overlay provenance independently covers file-byte
changes, while the semantic digest also invalidates reuse when the embedded
catalog or catalog composition changes. The extractor additionally fingerprints
its complete prompt assets and private response schema, so either semantic
contract changing invalidates previously recorded extraction checkpoints. The
separate `internal/modules/dnd/codec/spells` package
owns the durable schema and stable JSON representation for artifact kind
`dnd/spell-list`. The runner keeps the result typed through validators and later
stages, using the codec only for checkpoint, debug, and output boundaries.
Shared D&D helpers keep prompt input names and source-unit reference conversion
consistent with the scene chunker.
The extractor also declares the optional `npcs` registry slot and consumes the
immutable registry boundary from `internal/modules/dnd/npcs/registry`. An
external registry is prepared before execution; a generated registry is
validated and supplied at operation time. Bound external registries add only
the full `npc_registry_digest` and `npc_count` to module metadata. The local
`npc_registry` checkpoint fingerprint always covers the names-only projection,
including its exact unbound value. Generated bindings are represented by
framework handoff provenance and dependency fingerprints. The unbound prompt
input is exactly `{"npcs":[]}` and has no registry provenance.
The shared NPC grounding fragment is placed immediately after the common
campaign reference message and is included in the spell prompt fingerprint.
The durable payload and manifest metadata shapes are defined in the
[D&D spell artifact contract](../integrations/dnd-spell-artifacts.md).
### `internal/modules/dnd/extract/npcs`
The NPC extractor maps private model output to the canonical `dnd.NPCList`,
assigns source identity and deterministic NPC IDs, and preserves source
references for deterministic validation. It uses the shared campaign
references only for disambiguation and does not consume the optional NPC
registry slot. Its prompt and private response schema are package-owned. The
private response contains only a name and model-facing evidence ranges for each
record; anonymous groups, generic roles, invented labels, descriptions,
aliases, and relationships are outside its contract. The
prompt follows the shared D&D extraction ordering and cache policy documented
in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries).
The private response schema owns only structural transport validation and maps
integer source-unit candidates unchanged. Required semantic content, non-empty
evidence, and valid source ranges are rejected by the deterministic shape and
source-reference validators.
### `internal/modules/dnd/extract/scenedescriptions`
The scene-description extractor makes one structured completion for each
accepted chunk and maps its private `kind`, `title`, and `summary` response to
one `dnd.SceneDescription`. It assigns the current chunk ID and exact range,
preserves kind without repair, and trims only title and summary whitespace.
Optional players, party, and glossary references can disambiguate prompt terms
but do not supply evidence. The package owns its private schema, prompt assets,
and mapping fingerprint; deterministic validators own the durable semantic
checks. The durable contract is defined in the
[D&D scene-description artifact contract](../integrations/dnd-scene-description-artifacts.md).
### `internal/modules/dnd/extract/combatturns`
The combat extractor requires the `scene_descriptions` reference and resolves
it through the immutable scene-description registry before it resolves NPC
grounding or constructs prompt inputs. It calls the LLM only for an exact
current-chunk match whose kind is `combat`. Exact `narrative`, `recap`, and
`meta` matches return an accepted empty `dnd.CombatTurnList`; missing or
mismatched coverage returns the same result with one bounded unavailable-
classification warning. These deterministic results do not consume retry
attempts. Scene descriptions are control context only and are not passed to the
combat prompt or copied into combat evidence.
For eligible chunks, the extractor prepares one structured request using the
shared extraction-evidence, identity, campaign-reference, NPC-grounding, and
transcript prompt inputs. It maps the private response to
`dnd.CombatTurnList`, assigns the current source identity, removes exact
duplicate source ranges, and orders turns by valid source-document position
while preserving malformed candidate fields for deterministic validators. Its
package-owned private response schema enforces only the structural JSON
envelope; semantic artifact constraints remain with the validator chain.
Prepared metadata and checkpoint fingerprints include prompt, response-schema,
mapping, and scene-gate identities. An external scene reference additionally
reports its semantic eligibility digest and count; generated identity remains
framework handoff provenance and dependency state. Neither surface retains
scene prose or payload bytes. The prompt follows the shared D&D extraction
ordering and cache policy documented in
[LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries). The
package exposes typed registration and is included in the production D&D
registrar with the default combat extraction chain.
The combat normalizer accepts only the optional structured NPC registry.
Campaign references remain extractor-only LLM context and are not materialized
for deterministic normalization.
### `internal/modules/dnd/extract/itemevents`
The item-event extractor prepares one structured request from the accepted
chunk and optional campaign references, then maps private records to
`dnd.ItemEventList` with the current source identity. It declares only optional
`glossary`, `party`, `players`, and deprecated `roster` reference slots; these
can disambiguate names but never supply evidence. It has no NPC,
scene-description, or item-registry dependency.
The private response schema owns structural transport validation. The extractor
preserves candidate category, holder, quantity, and source-range values for the
deterministic validators, removes exact duplicate ranges, and source-orders
events. The source-reference validator requires citations to fit the current
accepted chunk. Prompt, response-schema, and mapping identities participate in
checkpoint identity. The durable schema is owned separately by
`internal/modules/dnd/codec/itemevents`.
### `internal/modules/dnd/extract/npcinteractions`
The NPC interaction extractor requires the structured `npcs` registry slot. It
uses the registry's names-only prompt projection with shared extraction
evidence, identity, and transcript material, then maps private model records to
`dnd.NPCInteractionList` with the current source identity. Registry source
references are never reused as interaction evidence. The private response
schema carries only name, bounded interaction kind, and source-unit ranges;
deterministic validators own registry membership, source validity, and
relatedness. Extract-stage source validation additionally requires every cited
range to be wholly contained in the current materialized chunk. Prompt, schema,
mapping, and the names-only registry projection
participate in checkpoint identity, while generated producer identity remains
framework provenance.
The shared D&D source-reference order defines canonical evidence ordering. The
domain-owned `internal/modules/dnd/npcinteractions` package defines occurrence
ordering, valid-evidence eligibility, and collision-safe exact identity. The
interaction normalizer and normalized invariants validator consume those
rules, so their production and checking paths cannot drift. Normalizer and
relatedness warning lists use the shared D&D diagnostic cap and emit a final
omission-summary warning when truncated.
### `internal/modules/dnd/normalize/npcs`
The NPC normalizer deterministically trims display names, recomputes IDs,
canonicalizes evidence, and consolidates equal comparison keys before semantic
work. Records are eligible for the document-level identity call only when they
have a non-empty comparison key and wholly valid current-document references.
It sends private candidate names and source ranges plus coalesced, cited
transcript windows to its own prompt; stable NPC IDs and the durable artifact
shape are not prompt inputs.
The private structured response proposes groups of supplied names and a
canonical supplied name. Deterministic comparison-key resolution validates each
group, discards unsafe or overlapping groups, and independently applies safe
ones. Application preserves earliest record order, unions canonical evidence,
and derives the final canonical ID. Invalid structured output and discarded
groups request framework retry with a safe fallback; bounded diagnostics become
durable only on final fallback exhaustion.
The normalizer records prompt and response-schema identities and digests,
identity and normalization policies, and semantic-context policy and radius as
manifest metadata. Its local checkpoint fingerprints cover the prompt, response
schema, identity policy, normalization policy, and semantic-context policy so a
meaningful behavior change invalidates prior normalize reuse.
## Merger And Normalizer
### `internal/modules/generic/merge/appendorder`
The merger passes typed values to an injected combine function in framework
source-chunk order. The D&D registrar specializes it for all six artifact
lists; each append merger preserves collection presence and order while giving
the result independently owned nested source-reference slices.
### `internal/modules/generic/normalize/noop`
The normalizer returns the merged domain value unchanged and is reusable for
any registered artifact type.
### `internal/modules/dnd/normalize/spells`
The typed spell normalizer resolves the optional `spell_catalog` reference into
the same immutable SRD-plus-overlay effective catalog used by spell extraction
and catalog validation. It performs no LLM calls. For each spell cast it
canonicalizes recognized names using the catalog's case, whitespace,
apostrophe, and alias rules; canonicalizes source references with the shared
document-aware order; removes only exact reference duplicates; and emits
bounded, scoped warnings for each mutation or unresolved name.
After those per-cast changes, it collapses only casts with the same canonical
spell, case-folded and whitespace-normalized caster, and complete non-empty
valid source-reference set. It retains the first occurrence and its caster,
source references, and stable order. Unknown names, empty or invalid evidence,
and adjacent or overlapping but different ranges remain unchanged for
validation.
The normalizer exposes the effective catalog digest as its independently scoped
`effective_catalog` checkpoint fingerprint and reports catalog base ID, digest,
and overlay IDs as manifest metadata. Catalog contents, reference paths, and
raw overlay bytes are not included in either surface. The normalize-stage
reference is stage-local, so an overlay-capable pipeline binds the catalog
independently for extraction and normalization.
### `internal/modules/dnd/normalize/combatturns`
The combat normalizer prepares an external NPC registry before execution or
receives a generated registry at the ordered step handoff, then uses the
immutable view during runtime. It display-normalizes actors,
rewrites canonical-name matches for actors, orders and deduplicates source
references, stable-sorts records by source-document position, and collapses
only exact duplicate identities with fully valid evidence. It deep-clones
output storage and emits bounded warnings scoped to merged input indexes. Its
metadata and fingerprints identify the normalization and NPC identity policies.
External bindings may contribute registry
digest/count metadata; generated identity is retained in framework provenance
and dependency fingerprints. The normalizer is included in the production D&D
registrar with the default combat normalization chain.
### `internal/modules/dnd/normalize/itemevents`
The item-event normalizer accepts no options or references and makes no LLM
calls. It trims display-edge whitespace in names and holders, canonicalizes
source references, source-orders events, and collapses only exact duplicates
with complete valid evidence. It does not create a ledger, calculate balances,
resolve aliases, infer quantities or holders, or reconcile nearby events. Its
policy fingerprint and bounded warnings identify deterministic normalization;
the matching invariant validator checks the resulting order and duplicate rule.
### `internal/modules/dnd/normalize/npcinteractions`
The interaction normalizer requires the same immutable NPC registry. It
canonicalizes exact registry-name matches, orders and de-duplicates source
references, stable-sorts occurrences by source-document position, and collapses
only exact interaction identities with valid evidence. It does not infer,
merge, or summarize distinct occurrences. Its metadata and fingerprints expose
the normalization and NPC identity policies; generated registry identity stays
in framework provenance and checkpoint dependencies.
### `internal/modules/dnd/normalize/scenedescriptions`
The scene-description normalizer has no options or references. It validates
each source range against the source document, trims title and summary
whitespace, orders records by source position then ID, removes only exactly
identical records, and rejects conflicting reused IDs or ranges. Its policy
fingerprint identifies this deterministic behavior; the matching invariant
validator checks the normalized result in the production chain.
## Output Encoder
### `internal/modules/generic/output/json`
The JSON encoder sorts normalized results by lane, derives collision-checked
safe logical names, pretty-prints JSON payloads, and assembles the logical index,
manifest, rejected-result, warning, and lane files. Invalid JSON, unsupported
media types, unsafe names, and sanitized-name collisions are errors.
Its strict `include_chunk_map` option is disabled by default. When enabled, it
validates the framework-supplied accepted chunk map through its codec and adds
the pipeline-wide `chunk-map.json` plus its index descriptor; it does not treat
the map as a lane payload. The external shape is owned by the
[Accepted Chunk Map contract](../integrations/chunk-map.md).
The encoder returns logical files only. The CLI places them on disk, and the
[JSON output contract](../integrations/json-output.md) defines their external
paths and schemas.
## Generic Validators
The generic validator implementations live under
`internal/modules/generic/validate`.
The unconditional accept and reject validators provide explicit chunk and
typed-artifact variants used primarily for controlled composition and tests.
The serialized JSON syntax validator uses `encoding/json` to reject malformed
representation bytes. The serialized JSON Schema validator requires schema
bytes, parses the instance and schema with `jsonschema`, and distinguishes
payload rejection from schema loading or compilation errors. The framework
serialized-validation request carries either canonical chunk bytes or artifact
codec bytes according to its target context. Neither validator calls the LLM.
## D&D Spell Validators
The spell extractor returns two built-in validators:
All four validators receive `dnd.SpellList` directly. The shape validator
rejects a missing list, blank caster or spell names, and empty reference lists.
The catalog validator defers when shape is invalid, then checks every non-empty
spell name against the immutable effective SRD and overlay catalog. It accepts
normalized canonical names and aliases without rewriting the artifact; unknown
names reject the complete result with bounded, stable index/name diagnostics. The
source-reference validator defers malformed shapes, validates every cited
range, and reports all range defects through a bounded aggregate while
preserving `invalid_source_refs`. The relatedness validator resolves all cited
ranges through the shared document-order traversal, then warns when a normalized
consecutive spell-name token sequence is absent from the cited source text.
Invalid shape
or cited ranges produce no relatedness warnings; the shape and source-reference
validators own those defects.
- `dnd/spells/shape`: rejects malformed payloads and missing required fields.
- `dnd/spells/source_refs`: rejects candidates without valid source references.
These validators are deterministic. Shape, source-reference, and relatedness
each expose a local semantic `policy` checkpoint fingerprint. The catalog
validator instead exposes its effective catalog digest as its semantic
checkpoint identity and does not add a separate policy fingerprint. Their
selectable keys and production order are defined in
[Configuration](../config.md#implemented-production-validators); their durable
payload rules are defined in the
[artifact contract](../integrations/dnd-spell-artifacts.md).
Reason codes include:
## D&D NPC Validators
- `invalid_payload`
- `missing_required_field`
- `missing_source_ref`
- `invalid_source_ref`
NPC shape validation checks the required ID and name strings, list presence, and source-reference
shape. The source-reference validator defers malformed shapes, checks
current-document identity, unit existence, and range ordering, and reports all
defects through bounded aggregates. Source relatedness uses the shared
document-order traversal and normalized consecutive-token matching, emitting at
most one bounded warning per record when the canonical name does not occur near
its cited text. Invalid shape or cited ranges produce no relatedness warnings.
Normalize identity validation checks deterministic IDs, canonical names, and
duplicate canonical-name or ID ownership.
All are deterministic and expose the policy fingerprints used by the
production chains.
These validators are supplied by the extractor when no validators are configured
for the lane.
## D&D Combat Validators
## `appendorder` Merger
Combat shape validation owns the required list, actor, supported turn kind, and
non-empty source-reference collection. Combat source-reference validation defers invalid
shape, checks source identity, unit existence, and range order, and reports all
defects through bounded aggregates. Combat source-relatedness defers invalid
shape or ranges, uses the shared traversal to combine overlapping cited units
in document order, and emits at most one bounded advisory warning per turn for
an unrelated actor. Actors use normalized consecutive-token matching. The
normalized-invariants validator owns actor display normalization, canonical
source-reference order, chronology, and exact duplicate identity; it defers
shape and source-reference failures. All four validators are deterministic and
expose local policy fingerprints. In the registered defaults, JSON syntax runs
first; combat shape, normalized invariants when applicable, and source-reference
validation precede JSON Schema validation; warning-only relatedness runs last.
Package: `internal/modules/merge/appendorder`
## D&D Item-Event Validators
The `appendorder` merger clones and appends candidates in chunk order. It does
not deduplicate or reconcile candidates.
Item-event shape validation owns the required list, non-empty name, supported
category, category-and-holder combination, positive optional quantity, and
non-empty source-reference collection. Source-reference validation defers
malformed shapes, checks current-source identity and ordered ranges, and during
extraction requires every citation to fit the accepted chunk. Relatedness is
advisory and warning-only: it checks the event name against cited transcript
text while deferring malformed candidates and invalid ranges to their blocking
owners. The normalized-invariants validator owns display normalization,
canonical source-reference order, chronology, and exact duplicate identity.
All four validators are deterministic and expose policy fingerprints. The
registered chains run syntax and blocking checks before durable JSON Schema;
relatedness remains last.
Provides:
## D&D NPC Interaction Validators
- `merged`
Interaction shape validation owns the required list, registry name, supported
kind, and non-empty source-reference collection. Registry validation checks
exact membership in the required immutable NPC registry. Source-reference and
relatedness validation use the current transcript only; malformed candidates
are deferred by later validators and produce no relatedness warning. The
normalized-invariants validator owns canonical registry names, source-reference
order, chronology, and exact duplicate identity. The production chains run
shape, registry, and source-reference checks before JSON Schema validation;
relatedness remains warning-only and last.
## `noop` Normalizer
## D&D Scene Description Validators
Package: `internal/modules/normalize/noop`
The `noop` normalizer clones merged candidates and returns them unchanged.
Requires:
- `merged`
Provides:
- `normalized`
## `json` Output
Package: `internal/modules/output/json`
The `json` output encoder converts approved artifacts, rejected artifacts,
warnings, and the run manifest into logical JSON output files. It groups
approved artifacts by artifact type and sanitizes artifact-type file names.
Requires:
- `normalized`
Provides:
- `encoded`
Durable output file shapes belong in the
[JSON output contract](../integrations/json-output.md). Operator behavior
belongs in [Operations](../operations.md).
Scene-description shape validation owns the non-empty list, trimmed ID and
prose, closed kind, and basic source-reference shape. Extract-stage source
validation additionally requires the one record to attach exactly to the
current accepted chunk; later source validation checks source membership.
Relatedness checks the title and summary independently against only their cited
transcript range and emits bounded advisory warnings. The normalized-invariants
validator owns ordering, exact duplicate elimination, and conflicting ID or
range detection. The production chains run shape and source-reference checks
before JSON Schema validation; the warning-only relatedness check is last.
## Production Registration
Production registration is centralized in `internal/cli/catalog.go`.
Production composition occurs through family registrars. The CLI allocates one
complete framework registry set and one LLM asset registry. It invokes
`internal/modules/generic/register`,
`internal/modules/seriatim/register`, and `internal/modules/dnd/register` in
that order, then exposes the matching catalog for resolution. The generic and
Seriatim registrars own their production leaf registrations. The D&D registrar
owns D&D leaf registrations, typed spell, NPC, combat, item-event, interaction, and scene-description default-validator
chains, typed append-order specializations, and D&D prompt/schema asset
collection. Its registration helpers group module, validator, prompt-asset, and
chain composition while retaining artifact-specific merge and clone behavior in
the registrar.
Do not make framework code import production modules. The CLI wires production
modules at the application boundary; tests may provide fake registries or fake
catalogs directly.
Concrete implementation packages do not import generic implementation
packages directly. A concrete family's `register` package is its composition
point for specializing reusable generic implementations, while the generic
registrar composes only generic children.
## Adding A Module
Core and framework production packages do not import production extensions.
CLI production code is the sole application composition root for extensions
and imports only exact family registrar packages. Other production packages,
including commands and newly introduced package trees, do not import module
packages directly. Compatibility tests in the CLI, core, and framework trees
may import roots and implementation leaves directly. Other non-module tests do
not receive that exemption. White-box tests within module families retain the
production family boundaries. `internal/modules/integration` is test
infrastructure: its black-box tests may compose multiple families, but it is
not a production module family or production dependency target.
When adding a module, keep source-format and extraction-domain boundaries clear:
## Adding An Extension
- input modules may know external source formats;
- extract modules may know artifact semantics and prompt/schema assets;
- merge and normalize modules own candidate combination and reconciliation;
- output modules own serialization, not diagnostics or CLI reporting.
When adding a production module or validator:
Update [Development](../policy/development.md), [Configuration](../config.md),
internal docs, integration docs, and examples when the new module becomes
implemented production behavior.
1. implement the stage or validator contract and package-local key;
2. expose and test its spec, constructor, and registration function;
3. keep format or domain parsing inside the concrete package;
4. add package-owned prompt/schema assets when the extension is LLM-backed;
new LLM-backed D&D extraction modules must follow the stable-to-variable
prompt ordering, shared-asset ownership, and cache-boundary policy in
[LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries), or
document the implemented exception and its evidence there;
5. register it through its package-family registrar and add a default chain
there only when production policy requires one;
6. add resolution and composition coverage for capabilities, options,
references, and validation behavior;
7. update the selectable-key catalog in [Configuration](../config.md), the
relevant external contract, this inventory, and maintained examples when
user-visible behavior changes.
Do not add the extension to `docs/development.md`; that file routes by task and
does not inventory implementations.
### D&D Extractor Contract
New D&D extractors preserve these package-owned responsibilities:
- Reject unknown options unless an option namespace is intentionally
extensible, and use shared common preflight while retaining receiver,
dependency, and lane-specific checks locally.
- Return independently owned results and exposed metadata that callers may
safely mutate.
- Keep the private response DTO, structural response schema and its identity,
provider-response mapping, durable artifact conversion, and lane diagnostics
in the owning package.
- Include every stable semantic input that can change durable output in
checkpoint identity. Consider prompt, schema, mapping, canonicalization,
prepared reference projections, identity, normalization, and trimming where
applicable.
- Add focused behavioral coverage where the lane's risks warrant it, including
construction and registration, option rejection, preflight, provider
failures, structured decoding, mapping and ownership, prompt
role/input/cache order, and checkpoint invalidation.
Prompt ordering, shared-asset ownership, cache boundaries, and private-schema
rules are defined in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries).
[Pipeline Internals](pipeline.md#reference-materialization) owns reference
materialization, and its [checkpoint hooks](pipeline.md#checkpoint-and-debug-hooks)
define checkpoint behavior. Follow [Architecture](../policy/architecture.md#source-and-domain-boundaries)
for ownership boundaries and the [Testing Policy](../policy/testing.md) when
selecting durable coverage. This contract intentionally does not prescribe
prompt prose or length, hashes, test counts, filenames, fixture layouts, or
generic implementation builders.
## Tests To Inspect
- Package-local `*_test.go` files under the module or validator being changed.
- `internal/framework/pipeline/typed_resolution_test.go`: typed registry, spec,
and heterogeneous artifact composition.
- `internal/framework/pipeline/profile_test.go`: framework binding defaults and
profile resolution.
- `internal/cli/production_contract_test.go`: production catalog, config
resolution, and composition smoke coverage.
- `internal/cli/example_contract_test.go`: maintained example ownership.
- `internal/framework/promptfs/*_test.go` and
`internal/modules/dnd/shared/*_test.go`: shared prompt and reference assembly.
- `internal/modules/integration/*_test.go`: black-box composition across
production extension domains.

View File

@@ -1,86 +1,191 @@
# Internal Overview
This directory documents implemented Notarius internals for developers and LLM
coding agents. It complements [Architecture](../policy/architecture.md), which
is the durable policy for boundaries and invariants.
This document inventories the implemented Notarius components. Normative
boundaries and dependency direction belong in
[Architecture](../policy/architecture.md); external behavior belongs in the
[CLI](../cli.md), [Configuration](../config.md),
[Operations](../operations.md), and [integration contracts](../integrations/).
## Executable And CLI
## Execution Path
`cmd/notarius` calls the CLI package. `internal/cli` owns:
`cmd/notarius` delegates to `internal/cli`, the production composition root.
The CLI loads configuration, builds the production catalogs and runtime
collaborators, invokes `internal/framework/pipeline`, and places the logical
output files returned by the runner. Cache and debug collaborators are supplied
at this boundary.
- command parsing and usage;
- config discovery and loading;
- production module catalog and registry wiring;
- production LLM client construction;
- run directory creation;
- durable output writes;
- user-facing stdout, stderr, and exit codes.
Resolution produces a fixed ordered workflow of steps and globally unique,
sorted artifact lanes. Preparation constructs the complete module and validator
set before the runner receives source bytes. Source parsing and chunking are
serial. Each step then uses a bounded run-wide extraction pool followed by
serial per-lane merge and normalize continuations. A step barrier prevents
later consumers from starting until all earlier lanes are terminal and their
required normalized artifacts have crossed the typed handoff.
The CLI should stay thin around framework contracts. Domain extraction behavior
belongs in modules, not in command handlers.
## Application Boundary
| Package | Implemented responsibility |
| --- | --- |
| `cmd/notarius` | Executable entry point and process exit delegation. |
| `internal/cli` | Command parsing, config discovery, package-family registrar invocation, LLM client construction, reference materialization, state collaborator setup, durable writes, and user-facing results. |
## Core Packages
- `internal/core/artifacts`: artifact candidates, approved artifacts, rejected
artifacts, validation decisions, and run manifests.
- `internal/core/config`: defaults, YAML config parsing, environment overrides,
validation, redaction, and resolved pipeline config.
- `internal/core/diagnostics`: per-run diagnostics directory creation,
diagnostics artifact writers, atomic writes, and retention decisions.
- `internal/core/source`: source documents, source units, source references, and
validation.
Core packages should remain deterministic and concrete. They should not import
production modules.
| Package | Implemented responsibility |
| --- | --- |
| `internal/core/artifacts` | Run-manifest and provenance models. |
| `internal/core/config` | Defaults, YAML parsing, environment overrides, validation, redaction, and effective pipeline resolution. |
| `internal/core/debugbundle` | Explicit per-run debug-bundle allocation and redacted summary writing. |
| `internal/core/fileio` | Generic confined atomic file and JSON writes with caller-selected permissions. |
| `internal/core/source` | Generic source documents, units, chunks, canonical references, validation, deterministic source digests, and independent metadata materialization. |
## Framework Packages
- `internal/framework/contracts`: interfaces and request/result structs for
input adapters, chunkers, extractors, mergers, normalizers, validators, output
encoders, and structured LLM clients.
- `internal/framework/pipeline`: module registries, module specs, profile
resolution, capability checks, run orchestration, warnings, validation, and
manifest population.
- `internal/framework/llm`: OpenAI-compatible structured-output client,
scheduler, schema registry, retries, and secret redaction.
- `internal/framework/prompt`: embedded prompt registry and template rendering.
- `internal/framework/validate`: validator decision helpers and cardinality
enforcement.
| Package | Implemented responsibility |
| --- | --- |
| `internal/framework/contracts` | Source-stage contracts plus artifact identity, schema, serialized representation, codec, validator, reference, output, and structured-completion interfaces and data types. |
| `internal/framework/pipeline` | Module and artifact-codec registries, ordered-step and generated-reference resolution, option validation, profile resolution, capability checks, external reference materialization, complete pipeline preparation, typed handoff, retries, orchestration, warnings, checkpoint decisions, and manifest population. |
| `internal/framework/validate` | Shared validator decision and cardinality helpers. |
| `internal/framework/llm` | Scriptorium-backed structured completions, prompt/schema registration, scheduling, profile recording, and secret redaction. |
| `internal/framework/promptfs` | Builds module prompt filesystems from module-owned and caller-provided shared prompt assets. |
| `internal/framework/checkpoint` | Root-based checkpoint loading, recording, identity, and payload serialization. |
| `internal/framework/chunkplan` | Source-addressed chunk-plan filesystem storage, envelope validation, and atomic publication. |
| `internal/framework/chunkmap` | Strict durable accepted chunk-map construction, schema, validation, cloning, and serialization. |
| `internal/framework/debug` | Root-based framework and LLM debug recording. |
Framework code should stay source-agnostic and domain-agnostic.
Framework contracts provide typed artifact, provenance-wrapper, chunk-validator,
serialized-validator, and
typed-validator interfaces. The runner owns handoff provenance, validation
sequencing, rejection handling, checkpoint and debug boundaries, and final
manifest assembly.
## Module Packages
Artifact registries support heterogeneous typed extraction entries and
kind-specific merger, normalizer, and validator variants. Resolution derives a
lane's kind from its extractor, requires the matching codec, verifies exact Go
type equality across the lane, and records schema identity in the resolved lane
and pipeline digest. Registry entries carry separate option-validation and
run-local construction closures. Preparation injects shared dependencies and
constructs input, chunk, validators, ordered lanes, and output before source
parsing. Production modules use strict construction-time option decoding, and
LLM-backed modules retain the injected shared client. The D&D family registers
the canonical `dnd/spell-list`, `dnd/npc-list`, `dnd/combat-turn-list`,
`dnd/item-event-list`, `dnd/npc-interaction-list`, and
`dnd/scene-description-list` codecs, typed spell, NPC, combat, item-event,
interaction, and scene-description extractors and normalizers, validators,
plus kind-specific generic merge strategies; generic JSON validators use the
serialized-validation contract. The runner executes lanes through
private exact-type-checked closures, coordinates extract results independently
of completion timing, and serializes artifacts only through their codec at
checkpoint, debug, and output boundaries.
Production module packages live under `internal/modules`:
## Production Extensions
- `input/seriatim`
- `chunk/generic`
- `extract/dnd/spells`
- `merge/appendorder`
- `normalize/noop`
- `output/json`
The canonical catalogs of user-selectable
[module](../config.md#implemented-production-modules) and
[validator](../config.md#implemented-production-validators) keys are in
Configuration. The implemented module packages are:
Each module package owns its contract implementation, module spec,
registration, options, focused tests, and module-specific errors.
| Package | Implemented responsibility |
| --- | --- |
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
| `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. |
| `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. |
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, combat-turn-list, combat-turn, item-event-list, item-event, NPC-interaction-list, and scene-description-list artifact types. |
| `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. |
| `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. |
| `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. |
| `internal/modules/dnd/codec/itemevents` | Strictly decodes and stably encodes the durable D&D item-event-list representation. |
| `internal/modules/dnd/codec/npcinteractions` | Strictly decodes and stably encodes the durable D&D NPC-interaction-list representation. |
| `internal/modules/dnd/codec/scenedescriptions` | Strictly decodes and stably encodes the durable D&D scene-description-list representation. |
| `internal/modules/dnd/extract/spells` | Maps private structured model output to canonical source-grounded D&D spell lists. |
| `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. |
| `internal/modules/dnd/extract/combatturns` | Uses exact scene eligibility to select combat chunks, then maps private structured model output to source-grounded D&D combat-turn candidates. |
| `internal/modules/dnd/extract/itemevents` | Maps private structured model output to source-grounded D&D item-event candidates. |
| `internal/modules/dnd/extract/npcinteractions` | Maps private structured model output to current-source NPC interaction candidates grounded by a required registry. |
| `internal/modules/dnd/extract/scenedescriptions` | Maps one private scene description to the current accepted chunk's ID and exact range. |
| `internal/modules/dnd/npcinteractions` | Owns interaction occurrence ordering, valid-evidence checks, and exact interaction identity shared by normalization and invariant validation. |
| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates. |
| `internal/modules/dnd/normalize/itemevents` | Trims, source-orders, and removes only exact valid-evidence item-event duplicates. |
| `internal/modules/dnd/normalize/npcinteractions` | Canonicalizes required-registry names, orders interaction occurrences, and collapses only exact valid-evidence duplicates. |
| `internal/modules/dnd/normalize/scenedescriptions` | Trims, source-orders, and removes only exactly identical scene descriptions while rejecting ID and range conflicts. |
| `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, source-relatedness, and normalized-invariant validation for the production combat chains. |
| `internal/modules/dnd/validate/itemevents` | Provides deterministic shape, source-reference, source-relatedness, and normalized-invariant validation for item-event chains. |
| `internal/modules/dnd/validate/npcinteractions` | Provides deterministic shape, registry, source-reference, source-relatedness, and normalized-invariant validation for interaction chains. |
| `internal/modules/dnd/validate/scenedescriptions` | Provides deterministic shape, exact extraction attachment, source-relatedness, and normalized-invariant validation for scene-description chains. |
| `internal/modules/dnd/npcs/registry` | Resolves validated normalized NPC references into immutable grounding data and exact identity lookup. |
| `internal/modules/dnd/scenedescriptions/registry` | Resolves approved scene descriptions into immutable exact-match combat eligibility data without retaining scene prose. |
| `internal/modules/dnd/npcs/identity` | Owns Unicode-aware NPC identity, ID derivation, and registry collision validation. |
| `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD catalog, composes optional overlays, and provides immutable effective lookup. |
| `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. |
| `internal/modules/generic/normalize/noop` | Preserves accepted merged output. |
| `internal/modules/dnd/normalize/spells` | Canonicalizes catalog-backed spell names and exact source references, conservatively collapses duplicate casts, and reports deterministic warnings and independently scoped catalog checkpoint identity. |
| `internal/modules/dnd/normalize/npcs` | Deterministically prepares and safely applies document-level LLM-assisted NPC identity consolidation, preserving canonical evidence, order, and diagnostics. |
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, rejections, and an explicitly enabled accepted chunk map as logical JSON files. |
## Fixtures And Tests
`internal/modules/dnd/shared` owns reusable D&D prompt fragments,
reference declarations, prompt input assembly, document-aware source-reference
ordering and canonicalization, and bounded diagnostics under
`internal/modules/dnd/shared/diagnostics`.
The shared NPC grounding fragment is mounted for D&D prompts and is owned by
this package. Domain-neutral prompt filesystem composition lives in
`internal/framework/promptfs`.
The repository uses focused package tests plus a fixture-driven CLI workflow.
The `dnd/npcs/registry` package owns the optional `npcs` registry boundary.
External references are strictly decoded and identity-validated during
preparation; generated references are decoded and identity-validated at the
ordered step handoff. Both paths retain canonical registry JSON for provenance
and emit a names-only projection to operation-time spell, combat, and
interaction prompts. Combat and interaction normalization use the canonical
registry for exact name lookup. The
framework records generated identity and bounded producer provenance, while
the raw external reference remains independently tracked by pipeline
provenance. An absent registry is represented only by the empty prompt value
`{"npcs":[]}`. Spell
and combat consumers use this shared boundary without changing their public
module contracts. Interaction consumers require it and retain only current
transcript references as durable evidence.
- CLI acceptance tests cover maintained examples under `examples/`.
- Pipeline tests cover registry composition and end-to-end framework behavior
with fakes.
- Module tests cover implemented module contracts without requiring real
provider calls.
- LLM tests use local test servers and fakes.
Generic validators under `internal/modules/generic/validate` provide
unconditional test decisions, JSON syntax validation, and JSON Schema
validation. D&D spell validators under `internal/modules/dnd/validate/spells`
consume the canonical spell-list type directly to provide shape,
effective-catalog, source-reference, and source-relatedness decisions.
Do not use real external services in tests. Use fakes, fixtures, or local test
servers.
Production composition is grouped behind package-family registrars, and every
implemented production extension uses its domain-first tree:
## Boundary Reminders
| Package | Implemented responsibility |
| --- | --- |
| `internal/modules/generic/register` | Registers domain-neutral chunk, merge, normalize, output, and validator implementations. |
| `internal/modules/seriatim/register` | Registers the Seriatim input adapter. |
| `internal/modules/dnd/register` | Registers D&D modules, validators, default validator policy, and prompt/schema assets. |
- Source-format details stay in input modules and integration docs.
- Extraction-domain details stay in extract modules and artifact docs.
- Provider wire details stay in the LLM runtime and provider integration docs.
- Durable output contracts belong in integration docs.
- Operator procedures belong in `docs/operations.md`, not internal docs.
The CLI allocates the framework registries and asset registry, then invokes
these registrars in generic, Seriatim, and D&D order.
Implementation details for all production extensions are in
[Module Internals](modules.md).
## Run-State Components
| Surface | Implemented owners | Internal purpose |
| --- | --- | --- |
| Durable output | Output module, pipeline runner, and CLI writer | Return logical consumer files and place them for a run. |
| Cache checkpoints | `internal/framework/checkpoint` and `internal/cli` | Validate and serialize reusable extract, merge, and normalize outcomes, including ordered-step scope and generated-artifact dependency decisions. |
| Chunk-plan cache | `internal/framework/chunkplan` and `internal/cli` | Persist and select source-addressed plans before framework materialization. |
| Debug bundles | `internal/core/debugbundle`, `internal/framework/debug`, and pipeline instrumentation | Persist redacted summaries and application-owned traces. |
Physical layout, cleanup, recovery, and sensitive-data handling are defined
in [Operations](../operations.md). Concrete modules receive recorder
interfaces and request data, not physical state roots.
## Focused Documentation
- [Pipeline Internals](pipeline.md): resolution, execution, validation, retries,
checkpoint/debug hooks, and result assembly.
- [Module Internals](modules.md): production modules, validators, assets,
registration, and the contributor recipe for adding an extension.
- [LLM Runtime](llm.md): structured completion contracts, Scriptorium adapter,
assets, scheduling, profile recording, and redaction.
- [Run State Internals](state.md): output, cache, debug collaborator
composition, and path safety.

View File

@@ -1,127 +1,491 @@
# Pipeline Internals
The implemented pipeline runner lives in `internal/framework/pipeline`. It
executes the fixed workflow defined by the architecture policy:
The implemented resolver and runner live in `internal/framework/pipeline`.
Their fixed workflow and ownership boundaries are defined by
[Architecture](../policy/architecture.md#system-shape). Configuration fields,
defaults, and selectable keys are defined in
[Configuration](../config.md#pipelines).
```text
input -> chunk -> extract -> merge -> normalize -> output
```
Resolution fixes the ordered steps, selected lanes, and all stage bindings;
preparation constructs every selected implementation before the runner begins
source work. After serial input parsing and plan selection or generation, the
runner materializes chunks and executes one step at a time. Within a step,
extract work uses one bounded run-wide worker pool in chunk-first, lane-second
order. Each lane's merge and normalize operations remain serial, and lanes in
the same step may overlap once their extracts are terminal. A later step cannot
start across its barrier until every earlier lane is terminal and each required
generated artifact has been accepted and handed off.
Pipeline execution is serial. The runner executes the resolved lanes one after
another in the fixed workflow order.
## Resolution
## Profile Resolution
`internal/core/config.Config.Resolve` validates the loaded configuration,
selects the named profile, applies the runtime inputs supplied by the CLI, and
calls `pipeline.ResolvePipeline`.
Config loading produces `pipeline.PipelineProfile` values. Resolution happens
before execution:
`ResolvePipeline`:
1. `internal/core/config.Config.Resolve` validates config and finds the named
pipeline.
2. The optional lane selection is passed to `pipeline.ResolvePipeline`.
3. Module bindings are defaulted:
- chunk: `generic`
- merge: `appendorder`
- normalize: `noop`
- output: `json`
- LLM profile: `default`
4. The module catalog is checked for each bound module key.
5. Module capabilities are checked in workflow order.
6. A digest is calculated from the resolved pipeline without the digest field.
1. selects the explicit ordered steps, or creates the implicit `default` step
from the legacy top-level `artifacts` map;
2. selects and sorts artifact lanes within each step while enforcing global lane
identity;
3. completes omitted bindings using the documented configuration defaults;
4. looks up each module and validator spec without constructing it;
5. for a typed extractor, derives its artifact kind, requires the codec, and
selects exact-type merger, normalizer, and validator variants;
6. checks required and provided capabilities in workflow order;
7. resolves external and generated target-aware reference bindings and
validates producer order, consumer slot declarations, and artifact-kind
compatibility;
8. validates each selected module and validator option set through its registry
entry; and
9. calculates a digest over the resolved structure, including step order, step
IDs, lane membership, generated topology, producer and consumer identities,
typed artifact kind and schema identity, and the effective validator policy
in its resolved execution order.
The CLI writes the resolved pipeline and digest to diagnostics.
Resolution returns a `ResolvedPipeline` containing ordered steps, lanes,
concrete bindings, validator chains, reference targets, and the digest. It does
not read external reference bytes or construct runtime modules. CLI lane and
reference selector syntax is defined in the [CLI reference](../cli.md#run).
## Registries And Module Specs
The digest includes each resolved step's ID and lane membership, generated
producer/consumer topology, and each validator chain's stage, lane, owning
module, ordered validator bindings, execution classes, targets, and artifact
kinds. Changing step order, a dependency, a default chain, or an explicit
override therefore changes pipeline identity whenever it changes effective
execution policy.
`pipeline.Registries` holds concrete constructors for execution. A
`pipeline.ModuleCatalog` exposes module specs for config validation and
resolution.
## Reference Materialization
Every production module registers a `ModuleSpec` with:
The CLI calls `MaterializeReferences` after resolution and before constructing
the LLM client or running the pipeline. For external bindings, the materializer
checks each binding against its resolved target declaration, reads and validates
the file, and builds both a `contracts.ReferenceSet` and provenance-only
metadata on the corresponding `ResolvedReferenceTarget`. A structured
generated binding is declaration-only at this point: its producer bytes do not
exist until the producer lane reaches an accepted normalized result.
- `Key`: module key used in config;
- `Stage`: module kind such as input, chunk, extract, merge, normalize,
validate, or output;
- `Provides`: capabilities added after that module runs;
- `Requires`: capabilities that must already be available.
Preparation delivers the materialized external set for each target through
`pipeline.BuildRequest`: chunkers and chunk validators receive the chunk target;
extractors and extract validators receive the lane extract target; mergers and
merge validators receive the lane merge target; and normalizers and normalize
validators receive the lane normalize target. Input and output builders receive
an empty set because those stages cannot declare references. Every builder gets
an isolated deep clone of its target set, so construction-time mutation cannot
change another builder, the resolved pipeline, or later runtime requests.
Capability checks prevent incompatible pipeline composition before a run starts.
Prepared consumers do not need to be reconstructed when generated content is
available. At the step boundary, the runner encodes the accepted producer value
through its registered canonical codec, validates the generated bytes against
each target slot's kind, schema, media type, and size, and clones one immutable
reference item into the operation request. The item includes canonical digest,
size, and bounded producer provenance but no filesystem URI. A handoff failure
is a framework dependency error and prevents every consumer in that step from
starting.
## Runner Input And Output
The runner continues to clone the resulting set into the chunk, extract, merge,
or normalize request that owns the target. LLM-backed extensions may convert
those items into named prompt inputs. Reference content remains separate from
source evidence and source digests, whether the item came from a file or a
generated handoff.
`pipeline.RunInput` carries:
Binding precedence, path resolution, accepted content, and media-type behavior
are configuration contracts; see [Configuration](../config.md#pipelines).
Durable provenance is defined in the
[JSON output contract](../integrations/json-output.md#manifestjson), while
runtime sensitive-data handling belongs in [Operations](../operations.md).
- a `ResolvedPipeline`;
- optional source ID, input path, and raw input bytes;
- a structured LLM client;
- run ID, start time, LLM profile manifest metadata, and CLI metadata.
## Registries And Specs
`pipeline.RunOutput` carries:
`pipeline.Registries` holds option validators and run-local builders used during
resolution and preparation.
`pipeline.ModuleCatalog` exposes their specs during configuration validation and
resolution. Separate registries exist for every stage and for validators;
`ValidatorChainRegistry` stores production default-chain mappings. Both
containers also carry an `ArtifactCodecRegistry`. Generic registration records
one codec per stable artifact kind, validates its schema metadata and JSON
Schema, retains the exact schema digest and Go type, and safely encodes or
decodes framework-erased values with typed errors on incompatibility.
- run manifest;
- approved artifacts;
- rejected artifacts;
- warnings;
- logical output files returned by the output encoder.
Typed extractor entries are keyed by module key and declare one artifact kind.
Merger, normalizer, and typed-validator variants are keyed by module or
validator key plus artifact kind. Chunk and serialized validators occupy
separate target namespaces; serialized registrations declare whether they
support chunks, artifacts, or both. Duplicate variants and exact Go-type
mismatches are rejected deterministically.
The CLI owns durable file writes and diagnostics writes after the runner returns.
Lane-sensitive merger and normalizer spec discovery always supplies the
extractor's artifact kind, so variants under one reusable key may declare
different capabilities and reference slots. Kind-neutral registry inspection
selects the first registered artifact kind in sorted order.
## Execution
Production composition registers the D&D spell-list, NPC-list, combat-turn-list,
NPC-interaction-list, and scene-description-list codecs and typed lane
variants, plus serialized JSON validators. Every artifact lane resolves through
the typed registries and a matching codec.
A `ModuleSpec` declares its stage plus required and provided capabilities.
Chunk, extract, merge, and normalize specs may also declare reference slots.
Registry implementations defensively copy spec metadata, reject duplicate keys,
and verify that a constructed implementation reports the registered key.
Builder registrations accept `ModuleDependencies` and cloned configuration
options through one `BuildRequest`. Builders decode those options and retain
typed values or injected dependencies in the constructed implementation.
Extractors declare their artifact kind, and merger, normalizer, and validator
resolution selects the matching typed variant.
A `ValidatorSpec` declares a validator key and execution class. Resolution uses
the execution class to reject incompatible profile bindings before execution.
The current production catalog and default chain are listed only in
[Configuration](../config.md#implemented-production-validators).
## Preparation And Runner Boundary
`pipeline.Prepare` receives a resolved pipeline, the registries, and shared
module dependencies. It constructs input; chunk and its validators; every
step's lane extract, merge, and normalize modules and validator chains in
resolved order; then output. It stops at the first error with pipeline, step,
stage, lane, module, and validator context as applicable. It never invokes an
operation method. Generated references are not available during preparation;
the operation request is the handoff boundary.
`PreparedPipeline` keeps private constructed executors and exposes cloned
resolved input, chunk, lane, and output identities. Prepared components may
implement `pipeline.CheckpointFingerprintProvider` to contribute explicit
semantic identities to checkpoint reuse. Preparation trims and validates each
non-secret name and value, prefixes it with the component's stage, lane,
module, and validator scope, rejects duplicates, and retains the resulting
sorted collection behind a defensive-copy accessor. Fingerprints must be
stable and must not contain source content, credentials, local paths,
timestamps, or other invocation-specific values.
`pipeline.RunInput` carries that prepared pipeline, raw source input, run identity and timing, optional
session and profile metadata, a chunk-plan store and mode, a checkpoint
execution policy, and checkpoint/debug collaborators. The runner
parses source bytes through the already constructed input adapter. Later stage
requests receive the generic source model; extract requests receive
chunk-scoped input material, while chunk, merge, and normalize requests retain
access to the original source material. Input, chunk, and output operation
requests do not carry raw module options. The chunk request also does not carry
an LLM client; an LLM-backed chunker receives the shared client during
preparation. Their operation requests retain run-specific source, reference,
profile, session, metadata, and step-handoff context as applicable. A generated
reference is cloned into each compatible consumer request and is never exposed
as a path.
Prepared lanes retain exact-type-checked erased operation closures. The runner
uses those closures to keep each value typed through extraction, validation,
merge, and normalization.
Source validation requires every unit to carry a canonical self-reference to
its containing document and its own unit ID. Explicit clone, checkpoint, and
debug boundaries retain that reference, and the canonical source digest covers
it deterministically. Chunks use the same source model and carry one canonical
reference spanning the first selected unit through the last.
`pipeline.RunOutput` carries the run manifest, accepted normalized serialized
artifacts with lane and normalizer provenance,
rejected results, warnings, checkpoint events, and logical files returned by the
output encoder. The CLI owns debug-summary and durable filesystem writes after
the runner returns.
## Execution Flow
The pipeline-wide coordinator owns the ordered step loop, generated-reference
sets at each barrier, and deterministic merging of step outcomes. For one step,
one run-local lane engine owns worker lifecycle, cancellation, dispatch,
continuation queues, and result collection. It initializes checkpoint state in
lane order, dispatches bounded extract work, advances terminal lanes through
serial merge and normalize work, selects failures by stable pipeline scope, and
merges lane-local outcomes back in resolved order. Completion timing never
becomes public ordering.
The runner:
1. validates run input and registries;
2. builds the input adapter and parses the raw input into a source document;
3. validates the source document;
4. builds the chunker and produces source chunks;
5. runs each selected artifact lane in sorted resolved order;
6. builds the output encoder and validates logical output file names.
1. validates its prepared input;
2. parses the raw input with the prepared adapter and validates the generic
source document;
3. selects a stored plan or executes the configured chunker's `Plan` operation;
4. canonicalizes and materializes the plan, then validates the resulting
chunks;
5. builds the framework-owned accepted chunk map from the accepted source,
logical plan, and exact materialized chunks, then supplies it to the output
request independently of output-module options;
6. executes each resolved step in configuration order. For one step, it
dispatches extract jobs in source-chunk then resolved-lane order, starts a
bounded lane continuation when all extracts for that lane are terminal, and
waits for every lane to become terminal;
7. encodes and validates each accepted normalized producer artifact, then
builds the immutable generated reference sets for the next step;
8. invokes the prepared output encoder only after every step succeeds and
validates its logical file results;
9. returns the assembled manifest, outcomes, warnings, and files.
Within an artifact lane, the runner:
Within each artifact lane, it reuses the prepared extractor, merger, normalizer,
and validators while performing these transitions:
1. builds the extractor, merger, and normalizer;
2. records module manifest metadata when modules provide it;
3. extracts candidates from each chunk;
4. normalizes candidate envelope fields such as index, extractor key, artifact
type, and schema version;
5. merges candidates;
6. normalizes merged candidates;
7. validates candidate envelope consistency;
8. runs validators;
9. converts approved candidates to artifacts.
1. extract once per accepted chunk and add runner-owned lane, source, and chunk
provenance;
2. validate each extract result and omit rejected results from merge input;
3. skip the rest of the lane when no extract result is accepted;
4. merge accepted extract results in their existing order;
5. validate the merge result and skip normalization on rejection;
6. normalize the accepted merge result;
7. validate and append the accepted normalized result.
## Validators
At a step barrier, a lane with no accepted normalized output is still a regular
rejection unless a later generated binding names that lane as a required
producer. In that case the runner raises a deterministic dependency error and
does not start the consumer step. One accepted typed artifact may fan out to
multiple compatible target slots. Consumers in the same step may run
concurrently after the handoff; no work crosses the barrier early.
If a lane declares validators in config, the runner builds those validators from
the validator registry. Otherwise it uses validators returned by the extractor.
Module-provided warnings and payload warnings are promoted only from attempts
whose results are accepted and used.
Each validator must return exactly one decision for each eligible candidate. The
runner enforces decision cardinality with `internal/framework/validate`.
Rejected candidates are removed before the next validator runs. Approved
candidates continue through the chain.
## Chunk Plans And Reuse
The production CLI currently registers no standalone validator modules. The
current D&D spell extractor supplies deterministic shape and source-reference
validators.
`Chunker.Plan` returns a `source.ChunkPlan`: the canonical source digest,
ordered unit-ID ranges, and optional plan or range annotations. The framework
owns plan canonicalization and materialization. It creates the generic chunks
and therefore owns their IDs, indexes, source references, JSON content, units,
media type, and generic metadata. Plan and range annotations are independently
owned raw JSON and become `Chunk.PlanAnnotations` and `Chunk.Annotations`.
## Warnings And Failures
In `auto`, the runner looks up the source digest before invoking the chunker. A
valid hit is materialized and sent through the current run's configured chunk
validators; it does not invoke the chunk module, consume its retry budget, or
make a chunk-stage LLM call. A missing, invalid, or unmaterializable record
generates a candidate. `refresh` generates without lookup; `bypass` generates
without cache access. Generated plans are published only after the full chunk
validator chain approves them. A validator rejection is a regular rejected
pipeline outcome and never replaces a cached plan.
Warnings from chunking, extraction, merging, normalization, validation, and
output encoding are accumulated in `RunOutput.Warnings`.
The store is source-addressed, not pipeline-addressed. Changes to pipeline
configuration, requested chunker, options, references, lanes, validators, or
LLM profile do not prevent a source-digest hit. The manifest records both the
currently requested chunker and the effective plan producer. Cache state and
paths are configured and operated outside the runner; see
[Configuration](../config.md#state-surfaces) and [Operations](../operations.md).
Errors wrap the operation and module key or lane context. If execution fails
after a manifest exists, the returned manifest is marked `failed` and receives a
completion timestamp.
For an accepted plan, the runner also constructs the strict framework-owned
[Accepted Chunk Map](../integrations/chunk-map.md) before lane execution. It
uses the current resolved chunker as `requested_chunker` and the stored or
generated record as `producer`, preserving that distinction on reuse. Chunk
rejection supplies no map; later lane rejection does not discard it. Output
encoders receive a defensively owned serialized value and may explicitly
ignore it.
On successful execution, the manifest validation status is:
The extract job channel has the same capacity as the effective extract worker
count, so dispatch applies backpressure. A fixed continuation executor prevents
ready or checkpoint-reused lanes from creating one goroutine each. Workers and
continuations publish lane-local results; the coordinator is the only writer of
aggregate output and merges those results in resolved lane and source-chunk
order.
- `approved` when no candidates were rejected;
- `rejected` when at least one candidate was rejected.
## Plan Canonicalization And Chunk Materialization
## Manifest Population
Plan canonicalization requires canonical JSON annotations, a matching source
digest, at least one range, existing ordered boundaries, and increasing range
starts. Ranges may overlap or leave gaps; a chunker may impose stricter policy.
Materialization deterministically reconstructs each range from the current
source document, deep-clones JSON-shaped source-unit metadata, and copies
annotations without interpreting their namespaces. Materialized chunks and
separate materializations do not share mutable unit metadata; unsupported or
cyclic metadata fails materialization with context.
The manifest records run ID, pipeline ID, pipeline digest, module keys, artifact
lanes, LLM profile metadata, source digest, validation status, and timing.
Before lane execution, generic chunk validation checks the materialized chunks'
identities, order, source references, content, media type, units, and metadata.
No chunk checkpoint participates in plan selection: plan storage is the only
chunk-reuse mechanism. Extract, merge, and normalize checkpoints continue to
use materialized chunk digests as their dependencies.
Modules can add non-secret manifest metadata by implementing
`contracts.ManifestMetadataProvider`. The D&D spell extractor uses this for
prompt and response-schema provenance.
## Validation And Retries
Chunk, extract, merge, and normalize results pass through the resolved validator
chain for their stage and module. Chunk validators receive canonical chunks;
typed validators receive the domain value; and serialized validators receive
canonical chunk JSON or artifact codec bytes. Validators execute in resolved
order and stop at the first error or rejection. An empty chain approves the
result.
Production D&D artifact chains keep generic JSON syntax validation first, then
run every rejecting domain validator before generic JSON Schema validation. The
domain validator therefore owns expected semantic diagnostics; the generic
schema validator remains the final rejecting representation backstop, before
warning-only relatedness validation. Explicitly configured validator chains
retain their configured order.
`runWithRetry` applies the effective retry policy around module execution and
its complete validation chain. A module or validator error becomes a framework
error when attempts are exhausted. A rejection becomes a recorded
`RejectedOutput` when attempts are exhausted. Cancellation stops retry
processing immediately.
Structured-completion adapters classify malformed or undecodable provider
output with the provider-neutral `contracts.ErrInvalidStructuredOutput` error.
A typed normalizer may turn that condition, or another unsafe proposal, into a
normalize retry directive with a module-supplied safe candidate, stable
diagnostic, and fallback warnings. The directive consumes the same configured
normalize retry budget: `retries` permits that many additional attempts after
the initial attempt. It neither creates a normalizer-local retry loop nor
records an accepted checkpoint for the discarded attempt.
Before adding a normalize retry directive to attempt debug data, the runner
requires a nonblank, valid UTF-8 reason code of at most 128 bytes and a
nonblank, valid UTF-8 message of at most 4,096 bytes. These are encoded-byte
limits. The framework rejects an invalid directive without truncating or
rewriting either field. It validates only this mechanical contract; normalizers
remain responsible for ensuring their otherwise valid diagnostics do not expose
source material, credentials, paths, names, or other sensitive content.
The framework treats a module-supplied candidate as opaque. The normalizer owns
its safety determination, and the configured normalizer validator chain remains
the acceptance boundary for the final fallback.
When a later normalize attempt succeeds, its candidate alone proceeds through
the usual validation and checkpoint path. When the final attempt still returns
a directive, the runner validates its supplied safe fallback through that same
normalizer validator chain before accepting or rejecting it. Ordinary
attempt-local warnings and fallback warnings remain unpromoted while another
attempt is available; only final exhaustion promotes the supplied fallback
warnings. Rejected output is a non-fatal pipeline outcome and does not advance.
Configuration owns retry counts and validator overrides; see
[Module Bindings](../config.md#module-bindings).
## Checkpoint And Debug Hooks
The runner depends on recorder and loader interfaces, using no-op
implementations when collaborators are absent. Each checkpointed workflow
boundary records a running, succeeded, or failed transition. Reuse decisions
are consulted in workflow order and accepted payloads are cloned before
entering the normal handoff path. Typed extract, merge, and normalize
checkpoints store codec bytes with artifact kind, schema ID, name, version and
exact digest, and media type. Reuse compares that identity with the prepared
codec and decodes through the codec; missing identity, mismatches, corrupt
bytes, and decode failures become explicit reuse misses and execute the lane
normally. Dependency fingerprints and debug content digests use the same stable
codec bytes that cross those boundaries.
That progressive extract, merge, and normalize reuse is the ordinary resume
path. A lane marked as a required predecessor for selective recomputation takes
a separate accepted-output path before extract scheduling. The loader reads the
existing successful normalize manifest and payload by step, lane, and
normalizer, without consulting extract or merge dependencies. It requires the
current non-empty checkpoint identity to match, so the invocation identity
still binds the input, resolved topology and configuration, references, runtime
overrides, profiles, and component fingerprints.
The runner decodes and canonically re-encodes each reusable artifact once with
the prepared codec, requiring exact kind, schema identity and digest, media
type, canonical bytes, content digest, and producer provenance. A valid accepted
producer becomes a runner-owned cloned normalized output, restores only
normalize-checkpoint warnings, and records one `accepted_artifact_reused`
normalize decision. It does not invoke or record extract, merge, normalize, or
their validators. Invalid or unavailable accepted state records its decision
and fails the producer step; the dependent step never starts and the producer
is not implicitly rerun. If a later required lane fails during initialization,
already hydrated terminal lanes remain in the failed output in resolved order.
Generated references add downstream dependencies containing the producer's
artifact kind, complete schema identity, media type, canonical content digest,
and size. Compatible accepted producer outputs may therefore feed a later step
without re-executing the producer. Forced lanes bypass accepted-output
hydration and execute normally. A missing, rejected, corrupt, incompatible, or
changed producer blocks its dependent while leaving independent work eligible
for reuse. The runner records bounded decision
categories: `reused`, `executed`, `forced_recompute`, and
`dependency_invalidated`. Operator meanings for the stable reason codes belong
to [Operations](../operations.md#resume-and-selective-recompute).
The CLI includes prepared-component fingerprints in the run-wide checkpoint
identity alongside resolved configuration, raw input, reference provenance,
runtime overrides, and LLM-profile fingerprints. Module metadata is not used
implicitly for cache identity: components opt in only with stable semantic
values that can change accepted output. Adding or changing a component
fingerprint intentionally produces a cold cache miss. Existing checkpoint
schemas and paths remain unchanged.
The CLI's `--recompute-step` policy forces the selected step and all transitive
dependents, but requires accepted normalized artifacts for every unselected
producer on which that closure depends. It changes execution policy only; it
does not alter persistent checkpoint identity.
Debug instrumentation wraps run, stage, attempt, validator, and structured LLM
boundaries. Every executed chunk, extract, merge, and normalize attempt writes
one terminal envelope for acceptance, validator rejection, module or validator
error, or applicable candidate or final serialization error. The envelope
contains its attempt-local warnings, any available candidate and rejection,
and terminal error text; normalize retry directives retain their attempt-local
candidate and diagnostic, while only the final safe fallback reaches validation.
Failures before a candidate exists omit that payload.
Only LLM calls made by the module operation belong to the module attempt.
Validator calls retain independent scopes under `validate/` and are not
duplicated into the module envelope. A failed terminal-envelope write is a
non-retryable framework error and is joined with any primary attempt error.
Debug data is never used as a checkpoint source. Typed artifact debug envelopes
are domain-neutral, redact sensitive metadata and bytes through the common
debug policy, and record codec identity plus schema and content digests.
Merge and normalize attempts serialize their in-memory candidate with the
codec's required candidate encoder before typed validation. Serialized
validators and attempt debug use that candidate representation, which carries
the codec media type and schema identity but is never checkpointed or passed
downstream. Only a validator-approved value is encoded through the strict final
codec and made eligible for a checkpoint or stage output.
Checkpoint identity, physical layout, reuse behavior, and debug artifact
handling are operator contracts in [Operations](../operations.md). Serialization
and recorder implementation are inventoried in
[Internal Overview](overview.md#run-state-components).
## Results And Failures
The runner owns manifest assembly and handoff summaries but not the durable JSON
schema. It records resolved module and lane provenance, validator chains,
source/reference identities, selected LLM profiles, normalized and rejected
summaries, status, and timing. Serialized artifact content remains outside the manifest.
Module metadata providers may add non-secret singleton or lane-scoped metadata.
Execution errors include stage, module, lane, or validator context. Once a
manifest exists, a failing run returns it with failed status and completion
time. Successful status reflects whether any result was rejected. The
durable manifest and logical file schemas are defined in the
[JSON output contract](../integrations/json-output.md).
On a framework failure, the runner cancels its derived context, stops submitting
new extract work, drains started tasks, and skips the output encoder. Parent
cancellation takes precedence. Otherwise context-cancellation fallout is
discarded when a substantive error exists, and the primary error is selected by
stage, resolved lane, and source chunk rather than completion time.
## Tests To Inspect
- `internal/core/config/effective_config_test.go`: config-to-resolution boundary.
- `internal/framework/pipeline/profile_test.go`: selection, defaults,
capabilities, validator chains, and digest behavior.
- `internal/framework/pipeline/artifact_codec_registry_test.go`: typed codec
metadata, registration, erasure safety, strict decoding, and cloning.
- `internal/framework/pipeline/typed_resolution_test.go`: heterogeneous typed
lane resolution and preparation, target-specific validators,
incompatibilities, ordering, and schema-sensitive pipeline identity.
- `internal/framework/pipeline/runner_concurrency_test.go`: bounded dispatch and
continuations, reverse completion, stable errors, rejection, cancellation,
retries, and independent provider-call limits.
- `internal/framework/pipeline/preparation_test.go`: option validation,
construction order, dependency failures, and the before-source-work boundary.
- `internal/framework/pipeline/references_test.go`: target resolution and
materialization.
- `internal/cli/run_contract_test.go`: production run transitions, retries,
rejections, warnings, CLI recomputation controls, debug hooks, and manifests.
- `internal/cli/recompute_execution_contract_test.go`: filesystem-backed
selective recomputation and accepted-producer recovery.
- `internal/cli/production_contract_test.go`: production composition and
configuration-resolution smoke coverage.
- `internal/cli/example_contract_test.go`: maintained example resolution and
execution ownership.
- `internal/modules/integration/*_test.go` and
`internal/modules/seriatim/input/transcript/runner_test.go`: typed runner
composition across concrete module families.
- `internal/framework/checkpoint/*_test.go`: checkpoint serialization and reuse
collaborators.

116
docs/internal/state.md Normal file
View File

@@ -0,0 +1,116 @@
# Run State Internals
This document describes the implementation collaborators behind output, cache,
and debug state. User-visible fields belong in [Configuration](../config.md),
and layouts and lifecycle belong in [Operations](../operations.md).
## Composition
`internal/cli` is the only physical-path composition root. It resolves the
effective configuration, selects exact roots, allocates requested debug bundles,
constructs cache collaborators, writes logical output files, and reports paths.
Pipeline modules receive interfaces and request data, never output, cache, or
debug roots.
## Output And Cache
The pipeline runner returns logical output files. After validating every
logical name, the CLI exclusively creates the run directory beneath the
selected output root and performs confined, atomic file writes within it.
The runner supplies an accepted chunk map as an optional, defensively owned
output-request artifact. The JSON encoder alone decides whether its explicit
option writes the map and optional index descriptor; neither the map payload
nor its annotations are copied into the run manifest. The durable fields are
owned by the [Accepted Chunk Map contract](../integrations/chunk-map.md).
`internal/framework/chunkplan` owns source-addressed plan storage, validation,
and atomic publication. Its store is constructed only when the selected mode is
not `bypass`.
`internal/framework/checkpoint` owns checkpoint identity, manifests, payload
codecs, loader, and recorder. The CLI constructs a recorder whenever checkpoint
recording is enabled and constructs a loader only for a `--resume` invocation.
Identity incorporates explicit stable semantic fingerprints collected from
prepared modules and validators in addition to configuration, input,
references, runtime overrides, and LLM profiles.
The serialized
`workspace_schema_version` identifiers are frozen wire-compatibility fields;
they do not describe a current public state surface.
Ordered-step lane checkpoints include the step identity in their storage scope.
When a later lane consumes a generated artifact, its dependency fingerprints
include the producer's artifact kind, complete schema identity, media type,
canonical content digest, and size. Ordinary resume compares those fingerprints
when progressively loading consumer stage checkpoints, so changed producer
content produces `dependency_invalidated` rather than stale downstream reuse.
Selective recomputation instead requires each unselected producer's accepted
normalized artifact; invalid accepted state records its specific bounded reason
and stops before the dependent. The selected step and its transitive dependents
record `forced_recompute`.
Ordinary resume loads extract, merge, and normalize checkpoints progressively
and may execute later lane stages after an earlier cache miss. Selective
recomputation instead asks the loader for the required producer's accepted
normalize artifact. That lookup reuses the existing normalize files, requires
workspace schema v3 plus an exact non-empty invocation identity, and deliberately
does not require extract or merge checkpoint files or dependency fingerprints.
The runner performs canonical codec and producer-provenance validation before
cloning the artifact into normal step output. Success restores only stored
normalize warnings and emits one normalize decision; failure retains the files,
records the decision, and stops without executing the producer or consumer.
The loader assigns a typed category and reason code at each validation site;
diagnostic prose is not classified after the fact. The runner then applies
forced-execution policy, validates reusable artifact bytes through the prepared
codec once, returns the canonical hydrated value to the stage, and records the
final decision before enforcing a required-predecessor failure. That failure
names only the step, lane, and stable reason code. Decision detail is selected
from code-owned descriptions by reason code and then UTF-8 normalized and
bounded; callers cannot supply arbitrary diagnostic prose. Typed categories and
codes remain intact through pipeline events and become strings only in manifest
and debug-summary JSON. [Operations](../operations.md#resume-and-selective-recompute)
is the canonical operator-facing reason-code reference.
`internal/core/fileio` provides confined atomic file writes used by state
collaborators. The chunk-plan store retains its stronger entry validation.
## Debug Bundles
`internal/core/debugbundle` allocates an explicitly requested per-run bundle
with `summary/` and `trace/` roots. `SummaryWriter` persists redacted command,
resolution, run, warning, and failure artifacts. `internal/framework/debug`
implements the pipeline-facing trace recorder under the trace root.
The CLI allocates a bundle before pipeline resolution and treats requested
summary or trace persistence failures as command failures. The pipeline's debug
boundaries redact sensitive metadata and credential-shaped bytes while allowing
application-owned trace material. Debug data is never a checkpoint source or
cache input.
Generated reference bytes exist only in cloned operation requests and are not
written as paths into checkpoints, manifests, or debug summaries. Those state
surfaces retain canonical identities and bounded producer provenance so that a
resume decision can be explained without copying generated campaign content.
After allocation, one CLI-owned state value accumulates the known report paths,
pipeline outcome counts, and validation status. A single guarded terminalization
operation writes the success report, or makes one attempt each to write the
failure report and error log. Terminal persistence failures are reported
separately and never replace the command's primary error.
## Tests To Inspect
- `internal/cli/run_contract_test.go`: command-owned state allocation,
terminalization, and output/report boundaries.
- `internal/cli/state_hardening_test.go`: independent roots, reuse, failures,
permissions, cleanup, and redaction.
- `internal/cli/recompute_execution_contract_test.go`: selective recomputation,
filesystem recovery, deterministic decisions, and failed predecessor state.
- `internal/cli/production_contract_test.go`: production composition and
configuration validation at the CLI boundary.
- `internal/cli/example_contract_test.go`: maintained example ownership.
- `internal/core/debugbundle/*_test.go`: bundle allocation and summary writes.
- `internal/framework/checkpoint/*_test.go`: checkpoint serialization and
reuse.
- `internal/framework/chunkplan/store_test.go`: plan envelope, confinement,
publication, and permissions.

View File

@@ -1,132 +1,322 @@
# Operations
This is the canonical reference for operating implemented Notarius runs.
This is the canonical guide to operating Notarius filesystem state. Command
syntax is in the [CLI reference](cli.md); field definitions and precedence are
in [Configuration](config.md).
## Normal Run
## State Model
A run reads one source file, resolves one configured pipeline, calls the
configured OpenAI-compatible LLM profile, writes durable JSON output, and writes
diagnostics for inspection.
Notarius uses three independent filesystem surfaces:
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-spells.config.yml \
--input examples/seriatim-minimal-transcript.json \
--output-dir ./notarius-output \
--diagnostics-dir /tmp/notarius
```
- output is durable user data;
- cache is reconstructible chunk-plan and checkpoint state; and
- debug is explicitly requested inspection data.
The command prints a success line with the pipeline ID, approved and rejected
artifact counts, and the output path.
Choose separate roots and access controls for each surface. A normal run writes
durable output, may use the chunk-plan cache, and records checkpoints when
`cache.checkpoints.enabled` is true. It does not create debug state unless its
invocation includes `--debug`.
## Output Directory
## Output
Durable output is written to:
Durable logical files are written under:
```text
<output-root>/<run-id>/
```
The default output root is `./notarius-output`. Use `--output-dir` to choose a
different root.
The CLI generates one run ID in the form
`run-<started-at-unix-nanoseconds>-<32-lowercase-hex-characters>` and uses it
for output, manifests, and any requested debug bundle. It validates every
logical output name before exclusively creating the run directory. If that
directory already exists, the invocation fails without changing it.
The `json` output module writes these files:
Each output file is written atomically. A later file-write failure leaves the
newly allocated partial run directory in place for inspection; Notarius never
automatically removes output. The
[JSON output contract](integrations/json-output.md) owns the logical file
names, schemas, and media types inside a run directory.
- `index.json`: file index with paths to the manifest, artifact files,
rejected artifacts, and warnings.
- `manifest.json`: run manifest with resolved pipeline provenance, module keys,
validation status, and timing.
- `artifacts/<artifact-type>.json`: approved artifacts grouped by artifact
type. For the current D&D spell extractor, this includes
`artifacts/dnd.spell_cast.json` when spell-cast artifacts are approved.
- `rejected.json`: rejected candidates and validator decisions.
- `warnings.json`: warnings reported by pipeline modules or the output encoder.
An enabled JSON `include_chunk_map` option adds an accepted chunk map to durable
output. Its annotations may contain source- or model-derived data, so retain
and protect it like lane output. The map is opt-in and does not alter existing
bundles; its payload exclusions are defined in the
[Accepted Chunk Map contract](integrations/chunk-map.md).
Output writes are atomic per file. Logical output file names must be clean,
relative, slash-separated paths and must not contain `..`.
Remove an output run directory only after its consumer data is no longer
needed. This is data deletion, not cache cleanup.
## Diagnostics Directory
## Ordered D&D Workflow
Diagnostics are written under:
The maintained [complete D&D configuration](../examples/dnd-complete.config.yml)
contains one pipeline with two ordered steps. The first step extracts and
normalizes independent item events, NPCs, and scene descriptions. Only after
the NPC and scene-description lanes reach accepted terminal results does the
second step begin; its generated NPC reference is
supplied in memory to spell, combat-turn, and NPC-interaction extraction and
the applicable normalizers, while its generated scene-description reference is
supplied to combat-turn extraction. The item-event lane has no generated
reference dependency and retains only current-transcript evidence.
```text
<diagnostics-work-dir>/<run-id>/
```sh
go run ./cmd/notarius run dnd-session \
--config examples/dnd-complete.config.yml \
--input examples/seriatim-minimal-transcript.json \
--output-dir ./npc-grounded-output
```
The default diagnostics work directory is `/tmp/notarius`. It can be set with
`diagnostics.work_dir`, `NOTARIUS_WORK_DIR`, or `--diagnostics-dir`.
The NPC artifact grounds canonical names through a names-only prompt projection, not spell or combat
evidence. Current-transcript source ranges remain the only event evidence. The
manifest records generated-reference identity and bounded producer provenance;
it does not record generated payload content, and no generated content is
exposed through a filesystem path. The same producer artifact may fan out to
compatible consumers, while a missing or rejected producer prevents the later
step from starting.
Implemented diagnostics artifacts:
Combat-turn extraction calls its LLM only when a scene record exactly matches
the accepted chunk and has kind `combat`. Exact `narrative`, `recap`, and
`meta` records produce an accepted empty combat-turn result without an LLM call.
Missing or mismatched scene coverage also produces an accepted empty result and
a bounded warning. Scene descriptions remain control context rather than combat
evidence or prompt material; the complete behavior is defined by the
[D&D combat-turn artifact contract](integrations/dnd-combat-turn-artifacts.md).
- `invocation.json`: command metadata such as operation, config path, input
path, selected lanes, run ID, and pipeline digest when available.
- `effective-config.json`: resolved config with API keys redacted.
- `resolved-pipeline.json`: resolved module bindings and pipeline digest.
- `run-manifest.json`: the same run manifest written to durable output when it
is available.
- `warnings.json`: warning list.
- `run-report.json`: counts, status, output path, diagnostics path, and run ID.
- `error.log`: failure message, written after diagnostics directory creation
when a run fails.
Standalone module configurations continue to support external NPC files when a
workflow intentionally crosses a process or session boundary. Those files are
validated against the consumer slot and must be protected as sensitive
campaign data. They are not part of the maintained ordered handoff workflow.
`source-document.json` is supported by the diagnostics writer but is not written
by the current CLI run workflow.
### NPC Semantic Normalization
## Retention
Before the first step can release its accepted NPC artifact across the ordered
generated-reference barrier, `dnd/npcs` performs one document-level semantic
normalization call for each configured normalize attempt when eligible distinct
names remain. The normalize binding's `retries` setting controls additional
attempts. If an invalid or unsafe identity proposal exhausts that budget, the
run safely accepts the deterministic base result and any independently
validated, model-proposed partial consolidation, with a bounded warning;
ordinary validation still applies before the artifact can cross the barrier.
Diagnostics retention is configured with `diagnostics.retention`,
`NOTARIUS_DIAGNOSTICS_RETENTION`, or the default `auto`.
An accepted normalized NPC checkpoint can be reused on `--resume` just like
other accepted normalize work. A changed normalization prompt, response schema,
or policy identity produces a cold cache miss, so the current reconciliation is
recomputed rather than silently reusing incompatible state.
- `auto`: keep failed runs and successful runs with warnings; remove successful
warning-free runs.
- `always`: keep every diagnostics run directory.
- `never`: remove successful run directories; failed runs are still retained.
## Chunk-Plan Cache
Unknown retention values are rejected during config validation.
Chunk plans are stored at:
## Failures
```text
<chunk-plan-root>/<source-sha256-hex>/plan.json
```
Failures before diagnostics directory creation, such as a missing config file or
an unusable diagnostics work directory, are printed to stderr and may not have a
diagnostics run directory.
`auto` reuses a complete valid plan or regenerates missing or invalid state.
`refresh` regenerates and atomically replaces a plan after chunk validation.
`bypass` performs no plan-cache I/O and does not resolve or create the root.
Plan selection is source-addressed and independent of checkpoint and debug
roots.
Failures after diagnostics directory creation are printed to stderr and written
to `error.log`. Depending on where the failure occurred, diagnostics may also
include invocation metadata, redacted effective config, resolved pipeline data,
the run manifest, warnings, and a run report.
When its directory is empty in configuration, the root is
`<os.UserCacheDir>/notarius/chunk-plans`. A configured directory is the exact
root; no suffix is appended. Directories and files created by the store use
`0700` and `0600` permissions on supported Unix systems. The configured root
is a trust boundary: do not share it among mutually untrusted users.
If durable output writing fails after the pipeline completes, diagnostics are
retained for inspection and may include `run-manifest.json`, `warnings.json`,
`run-report.json`, and `error.log`.
Remove an exact digest directory or the configured root only when accepting the
cost of recomputing plans and any chunk-stage work. Cache publication is atomic;
there is no history, locking, garbage collection, or rollback facility.
## Warnings
For a Linux service account, provision a dedicated restrictive root such as:
A successful run with warnings exits with code `0`, prints a warning count to
stderr, and writes warnings to durable output and diagnostics when retained.
```yaml
cache:
chunk_plans:
directory: /var/cache/notarius/chunk-plans
```
The run manifest `validation_status` indicates whether final artifacts were
approved or rejected after validation.
## Checkpoint Cache
Checkpoint recording is controlled by `cache.checkpoints.enabled`, which
defaults to `false`. When enabled, every run records running, succeeded, and
failed transitions and reusable validator-approved results. Successful,
rejected, and failed runs may therefore all leave checkpoint state. The
`--resume` flag additionally loads compatible completed work before executing
missing or incompatible stages. Without `--resume`, a recording-enabled run
never loads checkpoints. Using `--resume` while recording is disabled is an
error.
Checkpoints use the selected root and the existing identity hierarchy:
```text
<checkpoint-root>/<pipeline-id>/<input-key>-<source-or-input-digest>/<pipeline-digest>/<identity-digest>/...
```
The final identity digest includes stable semantic fingerprints explicitly
contributed by prepared modules and validators. Adding or changing one of
these fingerprints intentionally causes a cold cache miss; old checkpoint
directories are left in place and are never migrated or deleted automatically.
An empty configured directory selects
`<os.UserCacheDir>/notarius/checkpoints`. The root is exact when configured.
Created directories and files use `0700` and `0600` permissions on supported
Unix systems.
Checkpoint payloads can contain source text, intermediate artifacts, metadata,
warnings, and content digests. Treat them as sensitive derived application
data. Compatible files from a former checkpoint root remain reusable when
`cache.checkpoints.directory` names that exact existing root. They are not
moved, migrated, or deleted automatically. The frozen serialized identifier
`workspace_schema_version` remains part of checkpoint compatibility; it is not
a configuration setting.
For a Linux service account, independently provision:
```yaml
cache:
checkpoints:
enabled: true
directory: /var/cache/notarius/checkpoints
```
Remove an exact checkpoint identity directory or the configured root only when
recomputation is acceptable.
### Resume And Selective Recompute
`--resume` loads compatible accepted work only when checkpoint recording is
enabled. A normal resumed run may reuse source, extract, merge, and normalize
checkpoints independently and may recompute a stage after a cache miss.
Generated references add a dependency fingerprint
for the producer's artifact kind, schema identity, media type, canonical
content digest, and size. If that fingerprint changes or the producer is
missing, dependent checkpoints are invalidated; unrelated work remains eligible
for reuse.
`--recompute-step <step-id>` requires both `--resume` and
`cache.checkpoints.enabled: true`. It forces the named step and all transitive
dependents to execute, while compatible predecessors and unrelated lanes remain
reusable. The ID may be an explicit configured step or `default` for an
implicit single-step pipeline. It cannot be combined with `--only`, and it does
not change the persistent identity of otherwise identical checkpoints.
Decisions are bounded and categorized as `reused`, `executed`,
`forced_recompute`, or `dependency_invalidated`.
For an unselected producer required by a recomputed step, Notarius loads the
accepted normalized artifact directly. Valid normalize state is sufficient even
when that producer's extract or merge checkpoint is missing or corrupt. The
normalize manifest must be successful and match workspace schema v3, the exact
current invocation identity, step, lane, and normalizer; its payload digest and
canonical codec representation must also validate. A forced producer bypasses
this lookup and executes.
If a required predecessor's accepted normalized artifact is missing, rejected,
corrupt, non-canonical, or incompatible, the run fails before the dependent
step starts. It does not fall back to rerunning that predecessor. The failure
manifest retains completed upstream outcomes and dependency context but not
generated reference content. For diagnosis, first check the producer step and
lane in the manifest, then inspect checkpoint decision categories and reason
codes. Rerun the producer explicitly rather than copying an artifact into the
checkpoint root.
The decision that caused a required-predecessor failure is retained before the
run returns, and the CLI error identifies its step, lane, and reason code.
Checkpoint reason codes are stable diagnostic identifiers:
| Reason code | Operator meaning |
| --- | --- |
| `loading_disabled` | This invocation did not enable checkpoint loading. |
| `checkpoint_missing` | The requested checkpoint file does not exist. |
| `checkpoint_path_invalid` | The requested checkpoint location failed confinement validation. |
| `checkpoint_read_failed` | An existing checkpoint could not be read. |
| `checkpoint_decode_failed` | Checkpoint JSON could not be decoded. |
| `workspace_schema_incompatible` | The stored workspace schema is not supported by this build. |
| `identity_mismatch` | The stored invocation identity differs from the current invocation. |
| `stage_mismatch`, `step_mismatch`, `lane_mismatch`, `module_mismatch` | Stored scope does not match the requested pipeline scope. |
| `status_not_reusable` | The stored operation did not finish in a reusable status. |
| `dependency_mismatch` | Stored dependencies differ; the category is `dependency_invalidated`. |
| `artifact_payload_invalid` | Stored artifact payload structure or encoding is invalid. |
| `artifact_digest_mismatch` | Stored artifact bytes do not match their recorded digest. |
| `artifact_codec_incompatible` | Stored artifact identity is incomplete or incompatible with the codec contract. |
| `artifact_not_canonical` | The codec can decode the artifact, but its bytes are not canonical. |
| `checkpoint_reused` | The stored checkpoint passed validation and was reused. |
| `accepted_artifact_reused` | A required producer's accepted normalized artifact was canonically validated and hydrated. |
| `recompute_step` | Selective recomputation forced execution of this lane. |
Decision detail is bounded explanatory text derived from the stable reason code,
not caller-supplied prose or a data-recovery channel. It never contains
checkpoint paths, artifact or reference content, source content, credentials,
or environment values.
## Debug Bundles
Only `notarius run --debug` enables debug collection. The selected root contains
one retained bundle per invocation:
```text
<debug-root>/<run-id>/
summary/
trace/
```
`summary/` contains redacted invocation, effective-configuration, resolved
pipeline and reference provenance, checkpoint and chunk-plan decisions, run
manifest, warnings, report, and any available error text. It excludes raw
source, references, annotations, prompts, model responses, credentials, and
malformed cache bytes.
`trace/` contains application-owned execution detail, including source and
stage material, plans, chunks, validator attempts, prompts, model responses,
timing, and serialized artifacts. It may retain application data omitted from
output. Credentials, credential-shaped values, sensitive metadata, unrelated
environment values, and unrelated filesystem content are not captured.
Bundles inherit the sensitivity of the application data they capture. Their
additional risk comes from copying and aggregating that data, so restrict
access, avoid shared roots between untrusted users, and define retention outside
Notarius. Created bundle directories use `0700` and files use `0600` on
supported Unix systems.
Notarius never automatically deletes a requested bundle. If allocation
succeeds, its path is reported on success and failure. A requested summary or
trace write failure makes the command fail, preserving whatever bundle data was
already written for inspection. Every allocated bundle makes one best-effort
attempt to record a terminal `run-report.json`.
## Failures And Warnings
Failures before debug allocation are reported on stderr without a bundle.
Failures after allocation report the bundle path on stderr and make independent
attempts to write a failure `run-report.json` and `error.log`. The report retains
the paths and pipeline outcome fields known at the failure point. If either
terminal write fails, the original command error remains first on stderr,
followed by the persistence error and bundle path. An output-write failure
leaves the allocated bundle in place. A successful run with warnings exits `0`,
reports a warning count on stderr, and records warnings in durable output and
any requested debug summary.
## Cleanup
It is safe to remove specific old run directories after their output and
diagnostics are no longer needed:
Use exact paths for manual cleanup. Examples:
```sh
rm -rf /tmp/notarius/run-1234567890
rm -rf ./notarius-output/run-1234567890
rm -rf ./notarius-output/run-1721300000000000000-0123456789abcdef0123456789abcdef
rm -rf /var/cache/notarius/chunk-plans/0123abcd
rm -rf /var/cache/notarius/checkpoints/pipeline/input-0123/pipeline-4567/identity-89ab
rm -rf ./notarius-debug/run-1721300000000000000-0123456789abcdef0123456789abcdef
```
Use exact run-directory paths. Avoid broad cleanup commands against parent
directories unless they are part of your own operational policy.
Avoid broad recursive cleanup against a parent root unless it is an explicit
operator policy. Output deletion is permanent user-data loss. Cache deletion is
recoverable but can repeat expensive work. Debug deletion removes troubleshooting
evidence and any retained application-data copy.
## Operational Limits
There is no command to resume a failed run. Re-run `notarius run` after fixing
the cause.
Provider retries are limited to the OpenAI-compatible client retry behavior
configured by the effective LLM profile. There is no separate CLI retry command.
Notarius writes local files only. Remote storage and archive management are not
part of the implemented CLI.
Provider retries and timeouts are handled by Scriptorium according to the
selected execution profile. Pipeline module retry settings are defined in
[Configuration](config.md#module-bindings). Extract worker concurrency and
actual provider-call concurrency are separate limits; their fields and
validation are defined in [Configuration](config.md#concurrency). Notarius
writes local files only; remote storage and archive management are outside the
implemented CLI.

View File

@@ -1,210 +1,228 @@
# Architecture
This document defines Notarius development policy. It is inward-facing:
developers and LLM coding agents should use it to preserve the project's shape,
boundaries, and invariants as the code evolves.
This document defines the intended high-level architecture of Notarius and the
invariants that changes must preserve. Implemented component details belong in
[Internal Overview](../internal/overview.md) and its linked documents. The
reasoning behind significant architectural choices belongs in
[ADRs](../adr/).
Keep this document concise. It should describe durable architectural rules, not
CLI syntax, configuration reference material, module catalogs, or roadmap items.
## System Shape
## Project Shape
Notarius is a small, dependency-light Go application for extracting structured
artifacts from source material. It is a general extraction platform whose
source formats, extraction domains, validation policies, LLM providers, and
output formats are isolated behind explicit boundaries.
Notarius is a small, explicit, dependency-light Go application for extracting
structured artifacts from source material using modular pipeline stages.
The application is contract-first but not abstraction-heavy. Add interfaces and
extension points when they protect a real boundary:
- external source formats;
- pipeline stage modules;
- validators;
- LLM providers and runtime plumbing;
- output schemas and embedded assets.
Avoid abstractions that only anticipate hypothetical complexity. Prefer narrow
contracts that can be exercised by tests and real modules.
## Core Invariants
The framework must remain source-agnostic and domain-agnostic.
Source-format details belong in input modules. Transcript-specific concepts such
as segments, speakers, timestamps, and transcript schemas must not spread into
runner, extractor, validator, or LLM framework code.
Extraction-domain details belong in domain modules. D&D-specific concepts such
as spells, NPCs, items, combat turns, and encounters must not spread into core
source, runner, or LLM framework packages.
Extracted facts should be grounded with source references. Source references
should point to generic source units, not transcript-only structures. Framework
code should preserve source-reference ranges exactly and should not merge or
rewrite overlapping ranges unless a module explicitly owns that behavior.
The application workflow is fixed:
The application has one fixed pipeline shape:
```text
input -> chunk -> extract -> merge -> normalize -> output
```
These stages should remain explicit in the architecture. Chunking, merging, and
normalization must not be hidden inside domain extractors when they represent
general pipeline behavior.
Pipelines are configured compositions of this shape. They are not arbitrary
DAGs or a general workflow language. Every stage remains explicit; general
chunking, merging, or normalization behavior must not be hidden inside an
extractor.
Pipelines are fixed-shape templates for this workflow, not arbitrary DAGs or a
general workflow language. Module selection should be configuration- and
registry-driven, not scattered through conditionals.
Input and chunking are pipeline-wide. Each selected artifact lane owns its
extract, merge, and normalize stages, and the output stage aggregates the run's
lane outcomes.
## Package Boundaries
Notarius is contract-first without being abstraction-heavy. Interfaces and
extension points should protect demonstrated boundaries. New abstraction is not
itself an architectural goal.
Prefer fewer, larger framework packages until a boundary proves itself through
import direction, ownership, test seams, or substantial file size.
## Layers And Dependency Direction
Core packages should contain deterministic models and policy. Framework
packages should contain reusable orchestration and provider plumbing. Concrete
business logic should live under stage-oriented module packages:
The application boundary is the composition root and may depend on concrete
implementations. Domain-neutral model and framework layers provide reusable
policy, contracts, and orchestration. Concrete input, pipeline, output, and
validation extensions depend inward on those generic layers.
```text
internal/modules/input/...
internal/modules/chunk/...
internal/modules/extract/...
internal/modules/merge/...
internal/modules/normalize/...
internal/modules/output/...
```
Generic layers must not depend on production extensions. Concrete extensions
must not compose the application or take ownership of process behavior. The
current packages implementing these layers are inventoried in
[Internal Overview](../internal/overview.md).
Use short, lowercase, idiomatic Go package names. Avoid package names that repeat
parent-stage context.
The following dependency boundaries are mandatory:
Input modules translate external source formats into the core source model.
They may know about external schema details, source-specific metadata, and
format-specific validation rules. They should not own extraction-domain
decisions.
- extractors and validators do not depend on concrete input adapters;
- provider-specific types do not cross the LLM runtime boundary;
- external dependency types do not leak across internal package boundaries
unless that dependency is the package's explicit contract.
Extract modules own artifact semantics, prompt usage, structured response schema
selection, validator defaults, and domain-specific interpretation. They should
depend on framework contracts and core source/artifact types, not concrete input
module packages.
Shared helpers may support demonstrated common needs, but must not move
source-format or extraction-domain knowledge into generic framework packages.
External dependencies require a clear correctness, security, interoperability,
or complexity benefit.
Merge modules combine extracted candidates. Normalize modules reconcile merged
candidates for semantic consistency. Generic behavior may exist for simple
artifact types, but domain-specific behavior belongs in modules for the relevant
stage.
## Source And Domain Boundaries
Output modules serialize final artifacts and may report warnings out of band.
CLI, diagnostics, and reporting layers are responsible for surfacing those
warnings.
Input modules translate external source formats into the generic source model.
Format-specific schemas, fields, and validation remain with the input module
and its integration contract.
Framework stages operate on source documents, source units, and source
references rather than format-specific structures. A source reference identifies
an ordered range of generic source units. Framework code preserves those ranges
and does not merge or rewrite them unless a stage module explicitly owns that
behavior. Every source unit carries a validated self-reference to its containing
document and its own unit ID.
Extract modules own artifact semantics, prompt use, response schemas, and
domain interpretation. Domain-specific concepts remain in the relevant module,
validator, shared domain helper, and artifact contract.
Typed artifact registrations declare one stable artifact kind and exact Go
type from extraction through merge, normalization, and semantic validation.
Pipeline resolution requires a compatible codec and matching kind-specific
variants before a typed lane can be accepted. Framework-owned erasure remains
private and must report type incompatibility as an error rather than a panic.
Auxiliary references provide context or disambiguation. They are not source
evidence and must not be converted into source references.
## Pipeline Composition And Ownership
Module selection is configuration- and registry-driven. The framework resolves
named pipeline definitions, applies explicit defaults and runtime overrides,
and verifies module availability and capabilities before execution. Structural
pipeline choices must not be scattered through conditionals or hidden behind
ad hoc command flags.
Resolution validates every selected module and validator option set. A separate
preparation boundary then constructs the complete input, chunk, lane,
validation, and output implementation set in pipeline order. The runner accepts
only that prepared set, so construction and dependency failures occur before
source parsing or any other module operation.
Stage ownership is explicit:
- input modules convert external material into the generic source model;
- chunk modules partition source material for extraction;
- extract modules produce domain artifacts from chunks;
- merge modules combine accepted extraction outputs;
- normalize modules reconcile merged output;
- output modules encode accepted results and run outcomes into logical files.
Chunk modules produce source-addressed chunk plans rather than materialized
chunks. The framework validates and materializes those plans into the generic
chunk representation before chunk validation and lane execution. Plan reuse is
therefore independent of the configured pipeline, module options, references,
lanes, validators, and LLM profile: the canonical source digest selects the
plan, while the current run still applies its configured chunk validators to
the materialized chunks.
The framework owns orchestration and handoff provenance. Modules return logical
results and warnings; they do not own CLI reporting, physical output, cache, or
debug roots, durable file placement, or checkpoint and debug lifecycle.
After pipeline-wide chunking, extraction uses bounded framework concurrency.
One run-wide worker pool receives chunk-scoped lane jobs in deterministic
chunk-first, lane-second order. A lane may begin its merge and normalize
continuation only after all of its extract jobs are terminal; that continuation
remains serial within the lane, while bounded continuations for different lanes
may overlap. The framework must not create unbounded goroutines per lane or
chunk.
Completion timing does not choose public ordering or errors. The coordinator
orders accepted artifacts, warnings, rejections, checkpoint events, and
framework errors by stable pipeline scope. Rejections do not cancel unrelated
work. A framework error cancels derived work, prevents undispatched work from
starting, waits for started work, and prevents output encoding.
## Validation
Validators should be independently testable and composable.
Validation is a framework-managed boundary around outputs from chunk, extract,
merge, and normalize stages. Validators receive immutable stage output
and make an explicit whole-output decision: approve, approve with warnings, or
reject.
Deterministic validators should run before LLM-backed validators when both are
present. Validator decision semantics should be explicit: each candidate
artifact evaluated by a validator should receive exactly one decision from that
validator.
Typed artifact validators receive the domain value directly. Chunk validators
receive source-zone chunks, while serialized validators receive immutable
representation bytes and declared schema metadata. A validator registered for
one target or artifact kind cannot satisfy an incompatible selection.
LLM-backed review belongs in module-owned validator chains, not in an implicit
global review phase. Extract and normalize modules may both use deterministic
and LLM-backed validators.
Rejection is a recorded pipeline outcome, not a framework execution error.
Validator execution failures are framework errors. Rejected output does not
advance to the next stage.
Shared validator runtime mechanics belong in framework code. Concrete validator
behavior belongs in module or validator implementation packages.
Default validator chains are production composition policy and are registered
centrally by stage and module. Configuration may replace a stage-local default,
including with an explicitly empty chain. Configured validator order is
authoritative; the framework must not silently reorder it.
## LLM Runtime
## LLM Boundary
LLM provider details belong behind transport-neutral framework contracts.
Modules and validators use transport-neutral structured completion contracts.
Provider request and response types, authentication, transport behavior, and
provider error adaptation remain inside the LLM runtime.
Provider-specific HTTP request and response types should stay inside the LLM
runtime package. Prompt construction should stay in extractors, validators, or
shared prompt helpers; provider adapters should not own domain prompt logic.
The caller of the LLM owns prompt selection, prompt inputs, response schema,
and interpretation of structured output. Provider adapters do not own source-
or domain-specific prompt logic.
Errors, diagnostics, reports, manifests, and redacted configuration must not
expose secrets.
LLM calls and other external operations accept cancellation and respect
timeouts. Concurrency control belongs in shared runtime plumbing rather than in
individual modules.
## Configuration
The application-wide LLM scheduler bounds actual provider calls independently
of framework worker limits. Every LLM-backed module, retry, and validator uses
the single injected scheduled client, including work performed by overlapping
lanes.
Configuration should make pipeline composition explicit and discoverable.
## Configuration And Provenance
Centralize configuration loading, precedence, defaults, and validation. Structural
pipeline choices should come from named pipeline definitions, not ad hoc command
flags. Operational overrides may be handled separately when they do not obscure
the configured pipeline structure.
Configuration loading, precedence, defaults, environment overrides, redaction,
and validation are centralized. Named pipeline definitions make structural
composition explicit and discoverable. Operational overrides are permitted
when they do not obscure the configured pipeline structure.
Module registries should expose module metadata and capabilities without
requiring module construction. Configuration validation should fail fast when a
pipeline binds incompatible or unknown modules.
Run preparation fails before stage execution when statically discoverable
modules, capabilities, reference bindings, or explicitly selected profiles are
invalid or incompatible.
Run manifests should record enough resolved pipeline provenance to make a run
auditable after named configuration changes over time.
Run manifests record enough resolved pipeline, module, source, reference, and
LLM provenance to make a run auditable after configuration changes. Manifests
record identities and summaries rather than secret or large payload content.
## Dependencies
## State, Output, And Safety
Prefer the Go standard library where practical.
Notarius exposes three filesystem surfaces with independent roots and
lifecycle:
Use external dependencies only when justified by correctness, security,
interoperability, or substantial complexity reduction. Good reasons include
widely used file formats, complex validation behavior, or secure transport
handling.
- output is durable user data; output modules define logical files and the CLI
owns their placement;
- cache is reconstructible state, with separate chunk-plan and checkpoint
families; and
- debug is explicitly requested inspection data, combining a redacted summary
with a detailed trace.
Avoid dependencies for small conveniences. Do not let external dependency types
leak across internal package boundaries unless the dependency is itself the
explicit contract of that package.
Chunk plans are keyed only by canonical source digest. Configured checkpoint
recording is independent of checkpoint reuse; checkpoints are loaded only for
an invocation that explicitly requests resume. Debug is never a cache input and
is never created without an explicit request. Pipeline modules receive
collaborator interfaces and never physical roots.
## State, Files, and Safety
Writes are atomic where practical. Paths for writes, moves, overwrites, and
deletion must be narrow and explicit. Notarius never automatically deletes
output or requested debug bundles; cache cleanup is explicit and recoverable.
If the application writes durable state, writes should be atomic where
practical. Multi-step workflows should preserve enough diagnostics to support
inspection after failure.
Secrets must not appear in errors, logs, output, cache, debug summaries,
traces, manifests, documentation, examples, or redacted configuration. Debug
collection is allowlisted to application-owned payloads and must not capture
unrelated process environment values or filesystem content. Trace data may
contain application data and therefore inherits its sensitivity; operators own
access controls and retention. Physical layout and operation are defined in
[Operations](../operations.md).
Code that deletes, moves, or overwrites files must use narrow, explicit paths.
Avoid broad parent-directory operations. Cleanup that can cause data loss must
be opt-in.
## Architectural Non-Goals
## Errors and Logging
Notarius does not aim to provide:
Errors should be actionable and preserve context. Wrap errors with operation and
path or resource context. CLI code should convert internal errors into concise
user-facing messages.
Errors and logs must not expose secrets. Logs should describe operations,
external calls, retries, and failure causes, but should not include large source
or artifact payloads by default.
Long-running operations should accept `context.Context`. External calls,
subprocesses, HTTP requests, storage operations, LLM calls, and multi-stage
workflows should respect cancellation and timeouts.
## Testing
Core logic should be testable without real external services. Use fakes,
fixtures, or local test doubles for input modules, extract modules, validators,
and LLM clients where practical.
Contract-first work should include fake implementations that prove interfaces
compose before real modules depend on them.
Maintain a fixture-driven walking skeleton that exercises the full pipeline with
fake modules and fake external clients. This protects stage composition as real
modules evolve.
Important CLI and configuration workflows should have tests. Adapter, extractor,
validator, and stage contracts should have focused tests that do not require
running the full application unless end-to-end coverage is intentional.
## Documentation
Documentation should follow the project documentation policy. Keep user docs
focused on implemented behavior. Put future, planned, or aspirational work only
under `docs/roadmap/`.
Core documentation should use generic terms such as source document, source
unit, source reference, input adapter, extractor, chunker, merger, normalizer,
artifact, validator, and run manifest.
Source-format details belong in input module or integration docs.
Domain-specific extraction details belong in extract module or artifact docs.
When changing architecture, config, CLI behavior, stage modules, extractor
contracts, validator contracts, LLM runtime behavior, or artifact schemas, update
the relevant docs and examples in the same change.
- an arbitrary workflow graph or general workflow language;
- source-format or extraction-domain behavior in generic framework packages;
- provider-specific contracts exposed to modules;
- structural pipeline composition through ad hoc CLI flags;
- implicit cross-stage behavior that bypasses the fixed pipeline;
- abstractions introduced solely for hypothetical future complexity.

View File

@@ -1,139 +0,0 @@
# Development
This document defines contributor workflow for Notarius. For architectural
invariants and package boundaries, read [Architecture](architecture.md) first.
## Required Reading
Before changing the repository, review:
- [Architecture](architecture.md)
- [Documentation Policy](documentation.md)
Keep current-behavior documentation limited to implemented behavior. Put planned
or deferred behavior under `docs/roadmap/`.
## Repository Layout
- `cmd/notarius`: executable entry point.
- `internal/cli`: CLI parsing, production catalog wiring, config loading, run
command orchestration, output writes, and user-facing errors.
- `internal/core`: deterministic models and policy for artifacts, source
documents, config, and diagnostics.
- `internal/framework`: reusable contracts, pipeline orchestration, prompt
helpers, validation helpers, and LLM runtime plumbing.
- `internal/modules`: concrete input, chunk, extract, merge, normalize, and
output modules.
- `docs`: policy, user/operator docs, internal docs, integration docs, and
roadmap files.
- `examples`: maintained, secret-free examples covered by tests where practical.
## Validation Commands
Run focused tests for the area changed, then run the broader checks when the
change affects shared contracts, CLI behavior, or documentation examples.
```sh
go test ./...
go vet ./...
go build ./cmd/notarius
```
Useful focused checks:
```sh
go test ./internal/cli
go test ./internal/core/config
go test ./internal/framework/pipeline
go test ./internal/framework/llm
go test ./internal/modules/input/seriatim
go test ./internal/modules/extract/dnd/spells
go test ./internal/modules/output/json
```
## Go Conventions
- Prefer the standard library unless a dependency is justified by correctness,
security, interoperability, or substantial complexity reduction.
- Keep package names short, lowercase, and idiomatic.
- Preserve import direction: framework and core code must not depend on concrete
production modules.
- Use `context.Context` for long-running operations and external calls.
- Return contextual errors that name the operation and relevant module, path, or
resource.
- Do not include secrets in errors, logs, diagnostics, manifests, or docs.
## Adding Config Fields
Config behavior is centralized under `internal/core/config`.
When adding a file config field:
1. Update file config structs and YAML parsing in `file_config.go`.
2. Apply the field over defaults in config application code.
3. Add validation in `validation.go` when the field has constraints.
4. Add environment override support in `env.go` only for operational overrides.
5. Update redaction if the field can contain secrets.
6. Add focused config tests.
7. Update [Configuration](../config.md) and maintained examples when behavior
changes.
Pipeline composition should remain config-driven. Do not add command flags that
silently replace structural pipeline definitions.
## Adding CLI Flags Or Commands
CLI behavior lives in `internal/cli`.
When adding CLI surface:
1. Keep syntax explicit and update usage text.
2. Validate arguments before running expensive work.
3. Convert internal errors into concise user-facing messages.
4. Add CLI tests for success, syntax errors, and failure modes.
5. Update [CLI Reference](../cli.md), and update
[Operations](../operations.md) or [Troubleshooting](../troubleshooting.md)
if run behavior changes.
## Adding Modules Or Adapters
Concrete modules live under `internal/modules/<kind>/...` and implement the
interfaces in `internal/framework/contracts`.
For a new production module:
1. Implement the relevant contract.
2. Expose a `ModuleSpec` with the correct module key, module kind, provided
capabilities, and required capabilities.
3. Expose a `Register` function that registers the module with its registry.
4. Add focused module tests for contract behavior, registration, options,
validation, and errors.
5. Register the module in `internal/cli/catalog.go` only when it is production
ready.
6. Update internal docs and user-facing docs only for implemented behavior.
Source-format behavior belongs in input modules and integration docs.
Extraction-domain behavior belongs in extract modules and artifact docs.
## Updating Examples
Examples must be valid, secret-free, and small.
- Prefer environment-based secret configuration.
- Keep `examples/dnd-spells.config.yml` loadable by CLI tests.
- Keep `examples/seriatim-minimal-transcript.json` compatible with the Seriatim
adapter.
- Do not add expected-output fixtures unless they are validated or have a clear
regeneration procedure.
## Documentation Updates
Update docs in the same change when behavior changes.
- CLI syntax: `docs/cli.md`
- Config fields and defaults: `docs/config.md`
- Output, diagnostics, retention, or recovery: `docs/operations.md`
- Common user-facing failures: `docs/troubleshooting.md`
- Internal architecture and contracts: `docs/internal/`
- External file formats and durable integration contracts: `docs/integrations/`
- Future or planned work only: `docs/roadmap/`

View File

@@ -1,446 +1,144 @@
# Go Project Documentation Policy
# Documentation Policy
## Purpose
Project documentation must help five audiences:
1. users who need to run the application;
2. administrators/operators who need to configure and operate it;
3. developers who need to understand and change it safely;
4. LLM coding agents that need clear scope, boundaries, and invariants;
5. developers and LLM coding agents integrating this project from another codebase.
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
This policy assigns each documentation topic to one canonical owner. Its goal is
to keep Notarius documentation accurate, concise, discoverable, and resistant
to drift for users, operators, developers, integrators, and LLM coding agents.
## Core Rules
### 1. Keep docs concise
### One Canonical Owner
Each document should cover a defined scope and only the essentials for that scope.
Each authoritative fact belongs in one document. A non-owning document may give
a short, stable summary for orientation, but it must link to the canonical owner
instead of repeating volatile details.
Avoid:
- long background explanations;
- repeated reference material;
- implementation detail in user-facing docs;
- aspirational language outside roadmap docs;
- verbose examples where one minimal example is clearer.
Volatile details include commands, flags, configuration fields and defaults,
module keys, schemas, file names, paths, status codes, retry behavior, and
runtime guarantees. If readers could reasonably treat a statement as a
contract, maintain it only in the owning document.
### 2. Document only implemented behavior outside roadmap files
### Current And Future Behavior
Outside `docs/roadmap/`, documentation describes implemented behavior only.
Partial features may be described only to their implemented boundary.
ADRs are the narrow exception: an ADR may record an accepted architectural
decision before implementation, but acceptance must not be presented as proof
that the behavior exists. The roadmap owns implementation status and sequencing
until the decision is implemented. Current architecture, user, operator,
integration, and internal documentation are updated when the behavior lands.
Unimplemented, planned, aspirational, experimental, or future work may be described only under:
### Audience And Detail
Write for the document's stated audience and include only the detail needed for
its owned topic. User and operator docs should not expose implementation detail.
Developer docs should link to user-facing and external contracts rather than
restate them.
- `docs/roadmap/`
### Examples
No other documentation file, including `README.md`, should describe code, features, modules, stages, commands, config fields, or behaviors that do not currently exist.
If a feature is partial, non-roadmap docs may describe only the implemented portion and its current boundary.
### 3. Use canonical homes
Each type of information should have one canonical location.
Canonical homes:
- project purpose and quickstart: `README.md`
- development principles: `docs/policy/architecture.md`
- public HTTP API reference: `docs/api.md`
- configuration reference: `docs/config.md`
- CLI reference: `docs/cli.md`
- operations and recovery: `docs/operations.md`
- troubleshooting: `docs/troubleshooting.md`
- public API/package consumer guidance: `docs/consumers/`
- implemented internals: `docs/internal/`
- external protocol, service, and file-format contracts: `docs/integrations/`
- future work: `docs/roadmap/`
- contributor workflow: `docs/policy/development.md`
- copyable examples: `examples/`
Other files should summarize briefly and link to the canonical source.
### 4. Keep examples real
Examples should be valid, maintained, and free of secrets.
Where practical:
- example configs should load successfully;
- example commands should match real CLI syntax;
- important examples should be covered by tests.
## Documentation Profiles
All projects require:
- `README.md`
- `docs/policy/architecture.md`
Additional docs depend on the project.
### Small library
Recommended:
- `docs/policy/development.md`, if contributor conventions are non-obvious
### Simple CLI
Required:
- `docs/cli.md`
Recommended:
- `docs/policy/development.md`
### Config-driven CLI
Required:
- `docs/cli.md`
- `docs/config.md`
Recommended:
- `examples/`
- `docs/policy/development.md`
### Stateful or operator-facing application
Required:
- `docs/cli.md`, if CLI-based
- `docs/config.md`, if config-driven
- `docs/operations.md`
Recommended:
- `docs/troubleshooting.md`
- `examples/`
- `docs/policy/development.md`
### Modular, service-oriented, or orchestration application
Required:
- `docs/cli.md`, if CLI-based
- `docs/config.md`, if config-driven
- `docs/operations.md`
- `docs/internal/`
- `docs/policy/development.md`
Recommended:
- `docs/troubleshooting.md`
- validated examples under `examples/`
### Public HTTP API service
Required:
- `docs/api.md`
- `docs/cli.md`, if CLI-based
- `docs/config.md`, if config-driven
- `docs/operations.md`
- `docs/internal/`
- `docs/policy/development.md`
Recommended:
- `docs/troubleshooting.md`
- `docs/consumers/`, for task-oriented client integration guides
- `docs/integrations/`, for upstream/downstream service contracts
- validated examples under `examples/`
### Project with public packages or consumer APIs
Required:
- `docs/consumers/api.md`
- one `docs/consumers/pkg-<name>.md` file per public package, if public packages exist
Recommended:
- copyable consumer examples under `examples/`, if practical
## Required Documents
### README.md
**Audience:** users, administrators, operators
The README is the outward-facing project orientation page.
It should include, in order:
1. concise description;
2. elevator pitch;
3. shortest useful command or usage example;
4. links to targeted docs.
The README should be short. It is not a manual.
The “shortest useful command” means the simplest command that performs the projects core use case. (It does not mean `app --help`.)
### docs/policy/architecture.md
**Audience:** developers, LLM coding agents
`docs/policy/architecture.md` is required for every project.
It is an inward-facing development policy document. It should describe how the project is intended to be built and changed.
It should include:
- project shape;
- core design principles;
- package and boundary philosophy;
- state/persistence philosophy, if applicable;
- external integration philosophy, if applicable;
- error-handling and logging principles;
- testing expectations;
- documentation expectations;
- architectural invariants;
- explicit non-goals, if useful.
Notably, this file should prescribe a core development *policy* that should remain unchanged as the application evolves. It is not a place for details (e.g., CLI flags) that could change over time.
The contents of `architecture.md` should be trim and concise. LLMs may be directed to review it routinely via AGENTS.md, CLAUDE.md, or similar.
### docs/api.md
**Audience:** external HTTP API consumers, developers, LLM coding agents integrating by HTTP
Required for projects whose primary public interface is HTTP.
`docs/api.md` is the canonical public HTTP API contract. It should be normative for external consumers and should not be duplicated by README, operations docs, consumer guides, or integration docs.
It should include:
1. base URL conventions;
2. authentication and authorization behavior, if implemented;
3. response envelope;
4. supported media types and content negotiation behavior;
5. shared query parameters;
6. endpoint reference grouped by route family;
7. request parameters and validation rules;
8. response fields, units, nullability, and optionality;
9. error response shape and status codes;
10. pagination, caching, rate-limit, idempotency, and retry behavior, if implemented;
11. compact request and response examples.
It must document only implemented endpoints and behavior. Planned endpoints, proposed fields, future filters, and experimental response shapes belong only under `docs/roadmap/`.
For HTTP API projects, `docs/consumers/` may provide task-oriented client integration guides, but those guides should link to `docs/api.md` for the authoritative endpoint contract.
### docs/policy/development.md
**Audience:** developers, LLM coding agents
Required for projects maintained by humans and LLM coding agents.
It should include:
- repository layout;
- build/test commands;
- coding conventions;
- dependency policy;
- how to add config fields;
- how to add CLI flags;
- how to add modules or adapters, if applicable;
- how to update examples;
- documentation update expectations.
### docs/config.md
**Audience:** administrators, operators, advanced users
Required for applications with configuration files.
It should include, in order:
1. config file locations and discovery precedence;
2. minimal working config;
3. production-oriented config;
4. full configuration reference;
5. secrets handling, if applicable;
6. links to maintained examples.
The full configuration reference should be canonical.
### docs/cli.md
**Audience:** users, administrators, operators
Required for CLI applications.
It should include, in order:
1. shortest useful command;
2. command overview;
3. complete flag reference;
4. common workflows;
5. diagnostic or recovery commands, if applicable.
Explain when commands are useful, not just their syntax.
### docs/operations.md
**Audience:** administrators, operators
Required for applications that maintain state, support resume behavior, run multi-step workflows, write durable artifacts, use remote storage, or require recovery procedures.
It should cover:
- normal workflow;
- filesystem layout;
- remote storage layout, if applicable;
- logs and manifests;
- resume/retry behavior;
- cleanup behavior;
- archive/backup behavior;
- safe recovery procedures;
- operational caveats.
### docs/troubleshooting.md
**Audience:** administrators, operators
Recommended once recurring failure modes exist.
Each entry should include:
- symptom;
- likely cause;
- diagnostic command or inspection step;
- safe fix;
- relevant links.
### docs/consumers/
**Audience:** developers and LLM coding agents integrating this project from another codebase
Required for projects with public packages, SDKs, client APIs, plugin APIs, or other application-facing integration surfaces.
This directory describes how an external codebase should consume the project's public API. It should be task-oriented and copyable where useful. It is not the place for internal implementation details or operator procedures.
For projects whose public API is HTTP, `docs/consumers/` is not required, and it should not duplicate the endpoint reference in `docs/api.md`. If present, it may provide practical integration workflows, client-specific examples, or migration notes that link back to `docs/api.md`.
`docs/consumers/api.md` should provide the consumer-facing overview and primary implementation workflow. It should include:
1. intended consumer audience and use cases;
2. required inputs supplied by operators or deployment configuration;
3. recommended public package or API workflow;
4. minimal copyable example;
5. consumer responsibilities and boundaries;
6. retry, idempotency, or status behavior, if applicable;
7. links to package-specific docs and canonical integration contracts.
Package-specific docs should be named `pkg-<name>.md` and should include:
1. import path;
2. intended use cases;
3. primary types and functions needed by consumers;
4. minimal examples;
5. validation, error, retry, and boundary behavior;
6. links to canonical file-format or wire-protocol contracts.
### docs/internal/
**Audience:** developers, LLM coding agents
Required for modular, service-oriented, or orchestration projects.
This directory describes implemented internal components. It is not the roadmap.
Use one file per major component where useful.
Each component doc should include:
1. purpose;
2. inputs and outputs;
3. boundaries;
4. config fields used;
5. external adapters used;
6. state or manifest behavior, if applicable;
7. skip/resume behavior, if applicable;
8. failure behavior;
9. tests to inspect before changing;
10. architectural invariants.
### docs/roadmap/
**Audience:** maintainers, developers, LLM coding agents
This is the only place for planned, future, aspirational, experimental, or unimplemented work.
Roadmap docs should clearly distinguish:
- proposed work;
- accepted plans;
- deferred ideas;
- rejected ideas;
- implementation prompts or task breakdowns, if useful.
Roadmap docs should not be confused with current behavior.
### docs/integrations/
**Audience:** developers, LLM coding agents
Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain.
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses or exposes.
For public HTTP API services, `docs/integrations/` should document upstream, downstream, storage, protocol, or runtime contracts that the service depends on or bridges. It should not become a second copy of the public HTTP endpoint reference; that belongs in `docs/api.md`.
Use one file per integration where useful.
## Examples Directory
Projects with non-trivial configuration or workflows should include `examples/`.
Useful examples include:
- minimal working config;
- production-oriented config;
- full annotated config;
- local development config;
- remote/object-storage config;
- minimal session/input file.
Examples should be valid, maintained, tested when practical, and linked from relevant docs.
## Security and Privacy
Docs and examples must not include:
- real API keys;
- tokens;
- passwords;
- private keys;
- private environment dumps;
- sensitive user data;
- raw private transcripts;
- private infrastructure details unless intentionally public.
Document secret-handling mechanisms, not actual secret values.
## Maintenance Rules
When docs change, verify the affected behavior.
Where practical:
- load example config files in tests;
- test CLI examples or command parser behavior;
- validate documented flags against real flags;
- remove stale references;
- update links after renames;
- keep roadmap content out of non-roadmap docs.
If documentation and code disagree, fix the documentation and/or open a roadmap item; do not leave aspirational behavior in current-behavior docs.
Documentation is complete only when it matches the current code.
## Documentation Change Checklist
Before merging documentation changes, verify:
- README is concise and orientation-focused.
- `docs/policy/architecture.md` describes development principles.
- `docs/api.md` is the canonical HTTP contract for HTTP API services.
- Future work appears only under `docs/roadmap/`.
- User-facing docs avoid unnecessary internals.
- Consumer-facing docs explain public APIs without duplicating HTTP endpoint or integration contracts.
- Developer-facing docs preserve boundaries and invariants.
- Config examples match the schema.
- CLI examples match real commands and flags.
- Defaults appear in the canonical config reference.
- No secrets or private data are included.
- Links are accurate.
Complete copyable files belong in `examples/`. Documentation may use the
smallest illustrative snippet needed to explain its owned topic, but should link
to maintained examples instead of embedding a second complete copy.
Examples must be valid, secret-free, and tested where practical. Commands and
configuration used in documentation should match the application.
### Security And Privacy
Documentation and examples must not contain real credentials, private keys,
private environment dumps, sensitive source material, or private infrastructure
details unless intentionally public. Document secret-handling mechanisms, not
secret values.
## Canonical Ownership
| Topic | Canonical owner | Owned content | Content owned elsewhere |
| --- | --- | --- | --- |
| Product orientation and minimal end-to-end quickstart | `README.md` | What Notarius is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, implementation detail. |
| Contributor entry point | `docs/development.md` | Task-oriented reading guide, minimal contributor orientation, baseline validation commands, and links to canonical docs. | Package inventory, architecture rules, subsystem behavior, detailed change recipes. |
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, architectural boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. |
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression-test policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
| CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, and exit codes. | End-to-end operating procedures, configuration field definitions, runtime filesystem layout, module implementation details. |
| Configuration contract | `docs/config.md` | Discovery and precedence, file schema, fields, defaults, environment overrides, validation rules, and user-selectable module or validator keys. | Complete example files, CLI syntax, runtime state lifecycle, module implementation details. |
| Operations | `docs/operations.md` | Runtime workflows, physical filesystem and state layout, output, cache, and debug handling, resume, cleanup, permissions, recovery, and operational limits. | CLI flag syntax, configuration field definitions, logical output schemas, implementation mechanics. |
| Public HTTP contract, if introduced | `docs/api.md` | Routes, authentication, media types, request and response schemas, status codes, pagination, caching, idempotency, rate limits, and HTTP retry semantics. | Client walkthroughs, upstream or downstream integration internals, implementation detail. |
| Consumer guidance, if a public package or API is introduced | `docs/consumers/` | Task-oriented use of the public interface, minimal client examples, and consumer responsibilities. | HTTP wire semantics, external protocol contracts, internal implementation detail. |
| External and durable integration contracts | `docs/integrations/` | External file formats and protocols, upstream and downstream contracts, logical output bundle paths and schemas, media types, and compatibility behavior. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, configuration defaults. |
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
| Internal component behavior | Other files under `docs/internal/` | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
| Architectural decision history | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
| Future work and implementation status | `docs/roadmap/` | Proposed, accepted, deferred, or rejected work; implementation status; sequencing; and task breakdowns. | Implemented behavior reference and architectural decision rationale. |
| Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other files intended to be copied or run. | Field-by-field reference, command reference, prose explanation. |
Documents that do not exist are required only when the corresponding interface
or responsibility exists. Do not create placeholder API, consumer, integration,
or operations documents for behavior the application does not have.
## Boundary Rules
### Orientation
The README owns product orientation. The developer guide routes contributors.
Architecture owns normative structure. Internal overview owns the current
concrete component map. These documents may link to one another but should not
maintain parallel package or behavior descriptions.
### Commands, Configuration, And Operations
CLI documentation answers how to invoke the application. Configuration
documentation answers what settings mean. Operations answers what happens to
runtime state and how to operate or recover the application. When a workflow
crosses these topics, choose the document that owns the task and link to the
other contracts.
### Contracts And Implementation
Integration and API documents define externally observable shapes and
semantics. Internal documents explain how Notarius implements or consumes those
contracts. Internal docs may name a field, file, or protocol to identify a
dependency, but must link to its canonical contract for the definition.
### Security Topics
This policy owns what documentation and examples may contain. Architecture owns
application security invariants. Configuration owns credential-supply
mechanisms. Operations owns permissions and handling of sensitive runtime
artifacts. Internal docs own implementation mechanisms only.
## Architecture Decision Records
Use sequentially numbered ADR filenames such as
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
1. title;
2. status;
3. date;
4. context;
5. decision;
6. alternatives considered;
7. consequences.
Treat the decision content of an accepted ADR as immutable. When a decision
changes, create a new ADR and update the earlier ADR's status to superseded.
Rejected architectural alternatives belong in the ADR; rejected product ideas
belong in the roadmap.
## Maintenance
When behavior changes, update its canonical owner in the same change. If
ownership moves, remove the old definition and replace it with a link where
navigation remains useful.
Before completing documentation work:
- verify affected behavior and examples;
- check commands, flags, fields, defaults, schemas, and paths against their
implementation;
- keep unimplemented behavior in the roadmap, subject to the ADR exception;
- remove stale references and validate links;
- confirm that non-owning documents summarize and link rather than redefine;
- confirm that no secrets or sensitive private data were added.

296
docs/policy/testing.md Normal file
View File

@@ -0,0 +1,296 @@
# Testing Policy
## Purpose
Our tests exist to make **incorrect changes expensive and correct changes cheap**.
We do not optimize for test count, line coverage, exhaustive isolation, or the fewest possible tests. We optimize for sufficient confidence in important behavior while imposing as little unnecessary friction as possible on future development.
## Every test has a cost
Testing is not an unqualified good. Every test imposes both an immediate cost and a continuing lifetime cost.
A test must be:
- written and reviewed;
- understood by future maintainers and coding agents;
- executed in local and CI workflows;
- diagnosed when it fails;
- updated when legitimate behavior changes;
- maintained as fixtures, APIs, and dependencies evolve; and
- removed or rewritten when it becomes redundant, brittle, misleading, or obsolete.
Tests also create cognitive and architectural friction. They can constrain refactoring, duplicate policy, slow feedback loops, add noise to failures, and cause harmless implementation changes to require unrelated edits across the suite.
A test is warranted only when the confidence it provides justifies these costs.
Apply this cost-benefit analysis at two levels:
1. **Per test:** What realistic defect does this test detect, how consequential would that defect be, and is that protection worth the test's lifetime cost?
2. **Across the suite:** Does this collection provide materially more confidence than a smaller, simpler suite would?
The preferred test suite is a **lean suite that provides sufficient confidence in the risks that matter, without redundant or low-value tests**. We seek sufficient confidence with the least unnecessary testing friction, not the fewest possible tests.
Some friction is intentional. Tests should make dangerous changes—such as breaking compatibility, corrupting data, violating security boundaries, or reintroducing subtle bugs—require deliberate review. They should not make ordinary internal changes needlessly expensive.
The cost of a test is not a reason to omit testing by default. Do not cite maintenance cost abstractly. When omitting a plausible test, be able to state why the protected failure is low-risk, already covered, obvious, reversible, or cheaper to detect elsewhere. For consequential, subtle, or difficult-to-observe behavior, the presumption should favor testing.
## Default testing style
Use a **classical/Detroit-style** approach:
- Test observable behavior, resulting state, contracts, and invariants.
- Use real internal collaborators when they are fast and deterministic.
- Use fakes, stubs, or mocks primarily at expensive, nondeterministic, destructive, or external boundaries.
- Prefer package-level behavioral tests over tests coupled to private helpers or internal call sequences.
- Treat exact collaborator interactions as testable behavior only when the interaction itself is a requirement.
Examples of appropriate seams include clocks, randomness, subprocesses, remote APIs, object storage, email, and paid LLM calls.
## Test execution requirements
Tests in the default suite must be deterministic, offline, and independent of real credentials. They must not invoke paid APIs or depend on mutable external services. Tests that require live infrastructure must be explicitly opt-in and clearly separated from the default suite.
Control clocks, randomness, environment variables, and other process-global or machine-specific state when they affect behavior. Tests should be safe to run repeatedly and alongside other tests without depending on execution order or state left by an earlier test.
## What deserves tests
Prioritize tests for:
1. Public and package-level contracts.
2. Domain rules and important invariants.
3. Boundary conditions and malformed input.
4. Failure handling, cancellation, retries, recovery, and partial success.
5. Serialization, schemas, compatibility, and round trips.
6. Previously observed or plausible regressions.
7. Representative integration and end-to-end workflows.
A package-level contract is behavior relied upon by another package or major collaborator, not every observable detail of a package implementation.
For behavior involving **data integrity, destructive operations, compatibility, security, concurrency, idempotency, or recovery**, presume that durable tests are required unless the behavior is already credibly protected at another layer.
Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere.
## Choose the right test boundary
Test through the narrowest stable boundary that expresses the behavior clearly.
This is often the package API, but it may instead be:
- a smaller pure function when dense domain logic is most clearly isolated there;
- a package-level operation when several internal collaborators jointly produce the behavior; or
- a larger integration boundary when correctness emerges from interaction with a real dependency.
Do not force all behavior through oversized end-to-end tests. Do not test every private helper merely because it exists. Choose the boundary that gives durable confidence with the least incidental coupling.
## Test behavior, not implementation
A test should protect a decision, contract, or invariant—not memorialize the current implementation.
Before adding or retaining a test, ask:
> What realistic defect would this test catch?
A test is suspect when its main purpose is to detect that someone:
- changed an internal constant;
- renamed or split a private helper;
- reordered equivalent internal operations;
- changed incidental formatting;
- replaced one correct algorithm with another; or
- refactored internal object structure without changing behavior.
Refactoring should normally require no test edits unless the refactored structure is itself part of the contract.
A test can be factually correct and still have negative value. Accurately describing current behavior is not enough; the protected behavior must be important enough to justify the future friction.
## Expected effects of different changes
Use the following expectations when evaluating test failures and test maintenance:
| Change | Expected effect on tests |
|---|---|
| Internal refactor that preserves behavior | Existing tests should normally remain unchanged and continue to pass. |
| Change to an internal default with no contractual significance | Behavioral tests should normally remain unchanged; tests should derive expectations from configuration or relationships rather than duplicate the old value. |
| Intentional change to public behavior, policy, schema, or compatibility guarantees | The relevant tests should be reviewed and changed deliberately. |
| Accidental violation of a contract or invariant | Tests should fail; fix the production code rather than rewriting the tests to accept the defect. |
A test failing is not the same as a test needing to be edited. Many tests may correctly fail because of one production defect. The maintenance smell is a correct internal change that requires unrelated expectation updates throughout the suite.
## Separate mechanism from policy
Configurable thresholds and defaults must not be duplicated throughout the test suite.
For example, do not encode an internal concurrency limit indirectly:
```go
// Production policy:
const maxConcurrency = 4
// Brittle test:
err := startProcesses(5)
require.Error(t, err)
```
Instead, test the mechanism relationally:
```go
const limit = 2
runner := NewRunner(limit)
require.NoError(t, runner.Start(limit))
require.ErrorIs(t, runner.Start(limit+1), ErrTooMuchConcurrency)
```
The test should prove:
- the configured limit is accepted; and
- one beyond the configured limit is rejected.
The production default should be tested exactly only when its literal value is itself a public, operational, safety, protocol, or compatibility requirement.
Apply the same rule to limits, timeouts, capacities, retry counts, and ranges: test relationships and behavior, not duplicated literals.
For concurrency limits, test both kinds of behavior when relevant:
1. **Configuration enforcement:** invalid or excessive requested values are handled correctly.
2. **Runtime enforcement:** observed peak concurrency never exceeds the configured limit.
Use a test-controlled limit and measure the behavior relative to that limit. Do not merely assert today's default value.
## Avoid semantic duplication across layers
Each behavior should have a clear test owner.
- Parser tests own parsing cases.
- Validator tests own validation rules.
- Domain tests own transformations and invariants.
- Adapter tests own external integration behavior.
- Orchestrator tests own coordination and failure propagation.
- CLI tests own argument and configuration mapping.
- End-to-end tests prove that representative assembled workflows work.
Higher-level tests should not repeat every lower-level case. A single intentional policy change should not require unrelated edits across many test files.
Tests that are individually reasonable may still be collectively redundant. Evaluate the marginal value of each additional test in light of the protection already provided by the rest of the suite.
## Use test doubles deliberately
Choose the least elaborate test double that provides the required control or observation.
As a default:
1. Prefer real collaborators when they are fast and deterministic.
2. Use small in-memory fakes when realistic stateful behavior is helpful.
3. Use stubs when a dependency only needs to provide controlled responses.
4. Use mocks when the interaction itself is contractual.
Mocks are appropriate when the contract includes facts such as:
- a notification is sent exactly once;
- a transaction is committed only after successful writes;
- cancellation reaches a subprocess;
- an expensive API is called no more than once; or
- a security audit event is emitted.
Do not use mocks merely to isolate every object or reproduce the implementation's call graph.
## Go-specific guidance
Use:
- table-driven tests for meaningful behavioral categories and boundaries;
- `t.TempDir()` for real filesystem behavior;
- `httptest.Server` for realistic HTTP interactions;
- fuzz tests for parsers, normalization, path handling, and broad input spaces;
- golden files only when the complete output is intentionally stable;
- integration tests where correctness depends on component interaction; and
- a small number of representative end-to-end tests.
Avoid exact error-string assertions unless the wording is itself contractual. Prefer `errors.Is`, `errors.As`, typed errors, or structured error fields.
At CLI boundaries, prefer exit classifications, structured output, and the smallest stable semantic fragment needed to identify the error. Do not snapshot complete diagnostic wording unless it is contractual.
Golden-file updates must require an explicit local flag. CI must not update golden files automatically, and reviewers must inspect the semantic diff before accepting an update.
Keep tests readable and direct. Test helpers and fixture frameworks must earn their own maintenance cost; do not build elaborate test infrastructure for small or isolated needs.
## Coverage
Coverage is a diagnostic, not a target.
Use it to find untested critical branches and unexpectedly weak packages. Do not write low-value tests solely to increase a percentage, and do not infer test quality from coverage alone.
Pure domain logic will often warrant higher coverage than CLI wiring or external adapters. Uneven coverage is acceptable when it reflects risk.
Increasing coverage is valuable only when the newly covered behavior protects a meaningful risk at an acceptable cost.
## Regression tests
A bug fix should normally include a regression test that fails before the fix and passes afterward.
Retain the test when the defect could realistically recur and its consequences justify the ongoing cost. Prefer the narrowest durable test of the violated contract or invariant; do not preserve accidental implementation details from the original bug.
Not every historical bug requires a permanent test. If the underlying design has made recurrence impossible, the test has become redundant, or a stronger invariant test now subsumes it, remove or consolidate it.
## Deleting or rewriting tests
Tests are maintained code, not permanent historical artifacts.
Delete or rewrite a test when its maintenance cost exceeds the confidence it provides.
Strong candidates include tests that:
- require updates after harmless internal changes;
- directly assert private constants without protecting a real contract;
- duplicate the same policy across several layers;
- verify mock choreography rather than outcomes;
- snapshot large amounts of incidental output;
- test trivial private helpers already exercised through stable package behavior;
- protect risks already covered more effectively elsewhere;
- are flaky, misleading, obsolete, or disproportionately expensive to diagnose; or
- no longer correspond to a plausible failure mode.
Several brittle tests may encode one genuine requirement. Replace them with one durable behavior-level or invariant test rather than preserving all of them.
Deleting a low-value test can improve the quality of the suite by reducing noise, maintenance burden, and friction around legitimate change.
## Reviewing a proposed test
Use the following questions when the value, boundary, or durability of a proposed test is not self-evident. Significant test additions should be reviewable against them, but written answers are not required for every routine test.
1. What realistic defect would it catch?
2. How likely is that defect?
3. How consequential would it be?
4. Is the behavior already protected elsewhere?
5. At which layer should this behavior be owned?
6. Does the test assert a durable contract or an incidental implementation detail?
7. Could the implementation be refactored without changing the behavior and without editing this test?
8. What should cause this test to fail?
9. What legitimate changes should not cause this test to fail?
10. What ongoing maintenance, execution, and diagnostic cost will the test impose?
11. Is there a smaller or more direct test that protects the same risk?
Do not add the test when its expected lifetime cost exceeds its expected protective value.
When deciding not to test plausible behavior, record or be able to explain why the risk is low, already protected, obvious, reversible, or cheaper to detect elsewhere.
## Definition of sufficient
A test suite is sufficient when:
- important contracts and invariants are protected;
- meaningful boundaries and failure modes are exercised;
- realistic and consequential regressions are credibly protected against silent recurrence;
- behavior involving data integrity, destructive operations, compatibility, security, concurrency, idempotency, and recovery is credibly protected;
- important external boundaries have realistic integration coverage;
- representative complete workflows are tested;
- failures provide useful signal rather than redundant noise;
- legitimate internal changes usually do not require test edits; and
- additional tests would mostly repeat existing protection or preserve inconsequential implementation details.
Sufficiency is a risk judgment, not a coverage percentage or test count. Reassess it as the application, its users, and the consequences of failure evolve.
The governing rule is:
> Test heavily where failure is consequential, subtle, or difficult to detect after the fact. Test lightly where failure is obvious, reversible, and inexpensive—and retain no test whose lifetime cost exceeds the confidence it provides.

View File

@@ -0,0 +1,328 @@
# D&D NPC Semantic Normalization
## Status
Implemented.
## Purpose
Reconcile NPC records that extraction produced under different display names
when the complete transcript establishes that they represent the same
individual. This work addresses identities split across scene or chunk
boundaries while preserving the existing rule that extraction records narrow
source evidence and normalization owns document-wide reconciliation.
The model should make only the semantic identity judgment. Deterministic code
must continue to own identity derivation, proposal validation, artifact
mutation, evidence preservation, ordering, diagnostics, and final validation.
## Target Behavior
The target `dnd/npcs` normalization contract combines:
- the current deterministic display-name normalization, stable-ID derivation,
source-reference canonicalization, and equal-comparison-key consolidation;
- one LLM determination of whether remaining, distinctly named NPC records
represent the same individual and which existing display name is canonical;
and
- deterministic proposal validation and application followed by the configured
normalize validator chain.
Each configured normalization attempt should make at most one semantic pass
over the merged document-level NPC list, not one pass per extraction chunk or
candidate pair. The pass should be skipped when fewer than two distinct
candidate identities remain after deterministic preprocessing.
False consolidation is more damaging than a missed consolidation. Prompt
policy should therefore require affirmative contextual evidence that names
identify the same individual and should prefer no group when identity remains
ambiguous.
Canonical selection should favor the most complete stable proper name supported
by the transcript. A complete proper name is preferable to an abbreviation,
while an unadorned proper name is preferable to the same name plus a contextual
class, role, title, or relationship descriptor unless the transcript establishes
that descriptor as part of the character's name. The model must still select
one supplied display name rather than synthesize a better one.
## Model Proposal Contract
The private structured response should contain only proposed duplicate groups:
```json
{
"duplicate_groups": [
{
"members": [
"Billy",
"Billy the druid"
],
"canonical_name": "Billy"
}
]
}
```
`members` identifies candidates by their supplied display names.
`canonical_name` selects one existing member as the retained display name. An
empty `duplicate_groups` array is a valid determination that no sufficiently
supported duplicates exist.
The model must not receive or return deterministic `npc:sha256:` IDs. Those IDs
are long, non-semantic implementation identities and remain exclusively owned
by deterministic code. The model must not invent a replacement name, rewrite
an NPC record, propose new source references, or return a complete replacement
artifact.
Before the call, deterministic preprocessing ensures that each supplied display
name has a unique NPC identity comparison key. Returned names may resolve using
the existing comparison-key equivalences for case, whitespace, Unicode
compatibility, and supported apostrophes. Resolution must not use fuzzy,
substring, edit-distance, embedding, or other approximate matching.
An applicable group must:
- contain at least two distinct, known members;
- resolve every member uniquely against the supplied candidate set;
- select a known `canonical_name` that belongs to the group;
- contain no repeated member; and
- share no resolved member with any other proposed group.
Unknown or ambiguous names, singleton groups, invalid canonical selections, and
other unsafe groups must not mutate the artifact. Groups are otherwise
independent: a locally valid group may be applied when none of its resolved
members appears in any other proposed group.
Overlap is a proposal conflict even when one participating group is already
locally invalid. Deterministic code must discard every group in the connected
conflict set rather than selecting a winner by response order. Locally valid,
non-conflicting groups remain safe to apply. This permits useful partial
reconciliation without allowing an unsafe group to influence an NPC identity
that another group would mutate.
## Retry And Safe Fallback
An invalid or partially unsafe private proposal is a retryable normalization
attempt. This includes:
- structurally invalid model output classified by the LLM boundary as an
invalid structured completion; and
- a structurally valid response containing any locally invalid or conflicting
group.
The framework owns retry counting and attempt diagnostics. The normalizer
returns a safe candidate together with a bounded, content-safe retry
diagnostic. For structurally invalid output, that candidate is the
deterministic pre-LLM result. For a decoded proposal with unsafe groups, it
also includes every independently valid, non-conflicting group from that
attempt. When normalize retries remain, the framework invokes the normalizer
again from the same merged input; safe groups are not accumulated across
attempts. A later completely safe proposal is applied normally.
When the configured attempt budget is exhausted, the framework validates and
accepts the final attempt's safe fallback instead of failing or rejecting the
NPC lane, provided that fallback passes the configured normalize validators.
It promotes one durable warning stating that one or more proposed groups were
omitted. The fallback may therefore contain a safe partial reconciliation, or
only the deterministic pre-LLM result when no group could be applied. Earlier
retry diagnostics, candidate values, and warnings remain attempt-local in
debug artifacts.
The normal module-binding default remains `retries: 0`, meaning one total
normalization attempt and immediate fallback after its invalid proposal.
Retries occur only when the user configures a positive normalize retry count.
For example, `retries: 2` permits the initial proposal plus two additional
attempts before fallback.
Transport, authentication, prompt-preparation, cancellation, input-encoding,
and other operational errors are not safe-proposal failures. They retain the
existing module/framework error behavior rather than being converted into an
accepted fallback.
## Transcript Context Policy
The model should receive every candidate's display name and source references,
together with transcript windows derived from those references. It should not
receive the complete transcript by default.
Each window must contain:
- the complete inclusive source range cited by the NPC record;
- up to two source units immediately before the cited range; and
- up to two source units immediately after the cited range.
The surrounding-unit count is defined once as a named module policy constant
with value `2`; it is not a user-accessible configuration field. Window
construction consumes that value through one clear boundary so a later
configuration option can replace the fixed value without changing prompt or
reconciliation contracts. The context-radius policy participates in module
metadata and checkpoint identity so changing it invalidates incompatible
normalize checkpoints.
The value was selected by reviewing the July 19 evaluation transcript. Its NPC
citations are generally self-contained; where additional context is useful,
two surrounding units capture the relevant question-and-answer exchange,
speaker transition, or short anaphora chain. A third unit frequently begins a
separate joke or table exchange and adds distraction without improving the
identity evidence.
Window expansion must use source-document positions rather than arithmetic on
unit IDs. It must clamp at document boundaries, preserve document order, and
coalesce overlapping or adjacent expanded windows without duplicating units.
The prepared model input must distinguish originally cited units from
surrounding context.
Only records with a non-empty NPC identity comparison key and a non-empty
source-reference collection that is wholly valid against the current source
document are eligible for semantic reconciliation. Ineligible records remain
unchanged so the configured deterministic validators retain ownership of their
rejection. They are not supplied to the model and cannot participate in a
proposed group.
Surrounding units inform the semantic decision but do not automatically become
durable NPC evidence. An applied group unions only the source references
already present on its member records. The LLM cannot add references from the
context window.
Full-transcript mode and a configurable context radius may be evaluated later.
They are not part of this scope. Provider prompt-cache behavior should be
measured rather than assumed before expanding context solely for cache
economics.
## Deterministic Application
For each approved group, deterministic code should:
- retain the model-selected existing display name;
- union source references from every group member;
- canonicalize and exact-deduplicate those references in source-document order;
- derive the resulting stable NPC ID from the retained display name under the
existing NPC identity policy; and
- emit one bounded duplicate-collapse warning describing the applied group.
The consolidated record should occupy the earliest original member position so
model response ordering cannot reorder the artifact. Unrelated records must
remain present and retain their relative order. Every input record must be
represented by exactly one output record, either unchanged or through one
approved consolidation.
The existing NPC shape, identity, source-reference, schema, and relatedness
validators remain the final artifact boundary. No LLM-backed validator is
needed: model judgment occurs in the normalizer, while deterministic validators
continue to enforce the durable artifact contract.
## Prompt, Provenance, And Diagnostics
NPC semantic-normalization instructions and the private response schema should
be module-owned prompt assets. Stable D&D-wide identity or transcript guidance
may be reused through the existing shared prompt-asset mechanism where its
meaning is genuinely common.
The normalizer should use the configured normalize-stage LLM profile and the
application-wide scheduled LLM client. Invalid structured output and unsafe
semantic proposals use the framework-owned retryable-fallback contract;
operational failures retain existing error semantics.
Manifest metadata and component checkpoint fingerprints should identify the
semantic-normalization policy, prompt identity, private response-schema
identity, deterministic NPC identity policy, and evidence-context policy.
They must not contain transcript text, NPC names, source paths, raw model
responses, or other source content.
Warnings and preparation or runtime errors must follow the established bounded
diagnostic and content-safety policies. Debug artifacts may retain the normal
attempt-local model request, response, proposal decisions, and warnings under
the existing debug sensitivity contract.
## Configuration And Documentation
The maintained complete D&D configuration should demonstrate a normalize-stage
LLM profile and an explicit positive retry count for the NPC lane. The
application-wide default remains zero additional retries. No new configuration
field is introduced for the context radius in this scope.
Canonical documentation ownership is:
- Configuration owns the LLM-backed `dnd/npcs` selection and its use of the
existing normalize binding's profile and retry fields.
- Operations owns the document-level semantic reconciliation call and its
checkpoint behavior.
- Pipeline internals own the provider-neutral invalid-structured-output
classification and normalize retryable-fallback contract.
- The NPC integration contract owns externally observable consolidation,
evidence, ordering, and warning behavior.
- Internal LLM and module documentation own the proposal boundary,
context-window construction, deterministic application, metadata, and
fingerprints.
- The broader generic LLM-assisted deduplication item in
[future.md](future.md) remains future work until another artifact demonstrates
that extracting a shared generic facility is worthwhile.
## Quality Expectations
Tests should protect the semantic and safety boundaries through deterministic
LLM fakes rather than live-provider calls. Coverage should demonstrate:
- distinct display variants can be consolidated when the model proposes a
valid group;
- model-facing requests and responses use display names rather than stable
hash IDs;
- the selected existing canonical name controls ID derivation while all member
evidence is preserved;
- window construction uses source-document position, handles document edges,
coalesces overlap, and distinguishes cited evidence from context;
- no surrounding context is promoted into durable source references;
- empty proposals and fewer-than-two-candidate inputs preserve deterministic
normalization behavior;
- unknown, ambiguous, repeated, overlapping, and otherwise malformed semantic
groups cannot corrupt or reorder the artifact;
- locally valid groups are applied independently, while every group that
participates in a resolved-member conflict is discarded;
- an invalid proposal consumes only configured retry budget, a later valid
proposal can succeed, and exhaustion accepts the final attempt's safe
fallback with one durable warning;
- the default zero-retry binding makes exactly one proposal attempt before
fallback;
- warnings and failures remain bounded and do not expose transcript content;
- prompt, schema, policy, or context-policy changes invalidate relevant
checkpoint reuse; and
- an assembled ordered D&D pipeline supplies the reconciled NPC registry to
downstream consumers.
Prompt tests should assert prepared message structure, supplied materials, and
cache-boundary behavior. They must not act as change detectors for particular
words or phrases in natural-language prompt text.
Human review of representative transcripts should compare missed and false
consolidations, latency, input-token cost, and provider cache use. Probabilistic
model quality is an evaluation activity, not a deterministic CI assertion.
## Non-Goals
This scope does not:
- change the durable NPC artifact schema;
- add a durable alias collection or identity history;
- expose the context radius or full-transcript selection as configuration;
- ask an LLM to read, reproduce, or derive stable NPC IDs;
- permit model-authored source references or arbitrary replacement records;
- add fuzzy deterministic name matching;
- add an LLM-backed normalize validator;
- change the global default retry count;
- reconcile NPCs concurrently across lanes or runs;
- add canonical NPC IDs to downstream artifact schemas;
- implement a general DAG or implicit cross-lane dependency; or
- implement the generic cross-artifact deduplication normalizer described in
`future.md`.
## Completion Criteria
The scope is complete when `dnd/npcs` can use one document-level LLM proposal
to reconcile differently named records conservatively, deterministic code
validates and independently applies only safe non-conflicting name-based
groups, the resulting NPC preserves all member evidence under a newly derived
stable ID, the two-unit context policy is centralized and fingerprinted,
invalid or partially unsafe proposals use configured framework retries and
then an accepted safe fallback, downstream ordered steps receive the
reconciled registry, and the canonical current-behavior documentation reflects
the implemented contract.

130
docs/roadmap/future.md Normal file
View File

@@ -0,0 +1,130 @@
# Future Work
Current Notarius behavior is documented in the canonical README, CLI,
configuration, operations, internal, and integration docs. This roadmap records
future work only. Items are ordered roughly by current value and specificity,
not as committed release dates.
## Near-Term D&D Pipeline
### Evaluate Spell Extraction And Normalization
- Evaluate ordinary extraction retries and the completed normalization path
against a human-reviewed transcript set before adding repair-aware retries or
an LLM-backed semantic validator.
- Maintain a small set of human-reviewed transcripts and outputs for prompt,
validator, and normalizer development. Treat model-quality review as an
iterative human evaluation aid, not a deterministic correctness gate.
### Use D&D Scene Chunking
- Use ordered pipeline steps whenever a later artifact needs an accepted
earlier artifact as context. Keep independent lanes in the same step and do
not introduce a general DAG or concurrent cross-lane reconciliation model.
- Reassess whether one shared scene plan provides enough context for NPC,
spell, combat, interaction, and scene-description lanes after real-world use.
Add more complex chunking only in response to demonstrated failures.
## Shared Normalization And Quality Work
### Generic LLM-Assisted Deduplication
- Add a reusable normalizer that asks an LLM to identify duplicate sets in a
list and propose one replacement element for each set.
- Define the minimum domain-neutral input contract, initially an ordered list
whose elements have stable unique IDs. Artifact-kind registrations or
adapters may expose that structure without moving domain rules into the
generic package.
- Keep mutation deterministic: parse and validate the model's duplicate groups,
require every referenced ID to exist, reject overlapping or malformed groups,
prevent unrelated insertion or deletion, and apply only approved replacement
operations in code.
- Preserve provenance needed for audit and downstream validation, and emit
warnings describing every collapsed group.
- Evaluate batching and context-window limits before applying the normalizer to
large artifact collections.
The model may use its own domain knowledge to judge semantic duplication; the
generic implementation is responsible only for the common proposal contract,
safety checks, and deterministic application of accepted changes.
### Validation And Review
- Add domain validators and production default chains alongside each new D&D
artifact.
- Add production LLM-backed validators only when a concrete review policy
benefits from model judgment and deterministic checks are insufficient.
- Add validator diagnostics and timing summaries if operators need more detail
than the current [durable output bundle](../integrations/json-output.md)
provides.
- Add validator compatibility metadata if deployments need config-time proof
that a validator is suitable for a particular stage, module, or artifact
kind.
- Add media-type validators when non-JSON artifact representations are
introduced.
## Further Reference Evolution
- Make prior-run artifacts easier to bind as references without changing the
existing module-facing reference-item contract.
- Add structured or parsed references, such as typed NPC registries, rosters,
or spell catalogs, when opaque UTF-8 prompt material is no longer sufficient.
- Add per-slot or per-chunk inclusion policies so large references are not
repeated in every prompt unnecessarily.
- Add token budgeting and model context-window management for reference
content.
- Add reference caching, preprocessing, summarization, embedding, or retrieval
only when reference size and observed model behavior justify them.
- Extend generated references to prior-run artifacts or derived summaries only
after same-run ordered handoffs establish the required provenance and
lifecycle semantics.
## Design Considerations To Revisit
These concerns are relevant to ordered artifact dependencies but are not
committed near-term features.
### Cross-artifact identity links
Evaluate whether downstream D&D artifacts should retain canonical NPC IDs from
the generated NPC reference in addition to normalized display names. Any such
contract must define player-character, unknown-actor, missing-NPC, and
superseded-identity behavior before implementation. Deterministic validation
may confirm that a linked ID exists in the consumed NPC artifact, but the link
must never substitute for transcript evidence that the downstream event
occurred.
### Artifact contract evolution
Define compatibility and migration policy before generated-reference chains
must span multiple schema versions or long-lived historical artifacts. The
policy should address stable identifier semantics, which schema changes permit
checkpoint reuse, when an older artifact may be decoded or adapted, and when a
producer or all dependents must be recomputed. Do not add a general migration
framework until an actual contract change requires one.
## Blue-Sky Platform And Operations
These ideas are intentionally less specified. Promote one into an earlier
section only after a concrete workflow, contract, and priority emerge.
### Platform Extensions
- Additional input adapters, such as Markdown or note-export formats.
- Additional output encoders.
- Concurrent cross-lane entity normalization or broader workflow composition.
- Batching or specialized context-window controls for LLM-backed validators.
### Distribution And Operations
- Packaged release artifacts for alpha distribution.
- A documented versioning and release process.
- Optional generated example-output fixtures with a regeneration procedure.
- Additional diagnostics or reporting views.
### Workspace And Storage
- Default-idempotent run behavior with an explicit force override.
- Remote workspace storage.
- Workspace garbage collection and archival policies.
- Cross-machine checkpoint reuse.

View File

@@ -0,0 +1,365 @@
# D&D NPC Semantic Normalization Follow-up Plan
## Status
Ready for implementation.
## Objective
Close the four findings from the post-implementation review of
[D&D NPC Semantic Normalization](dnd-npc-semantic-normalization.md) without
changing its durable artifact schema, deterministic identity rules,
conflict-aware partial-application policy, retry accounting, or generated
reference handoff.
Complete the four follow-up stages below in order. Each stage should leave its
affected packages passing before the next stage begins.
## Completed Work Summary
The original five-stage implementation is complete:
1. **Framework retryable normalize fallback.** The framework now classifies
invalid structured output separately from operational LLM failures.
Normalizers can return a safe candidate and retry directive; the runner owns
retry accounting, attempt debug artifacts, final fallback validation,
accepted-only warning promotion, and checkpoint recording.
2. **NPC prompt and context material.** `dnd/npcs` owns a private normalization
prompt and structural response schema. It sends display-name candidates and
coalesced transcript windows with a fixed two-unit radius, without exposing
stable NPC IDs or using the full source input.
3. **Semantic proposal validation and application.** The normalizer performs
deterministic preprocessing, resolves returned names through the existing
comparison-key policy, applies independent safe groups, discards locally
invalid and conflicting groups, preserves evidence and ordering, and emits
bounded D&D diagnostics.
4. **Production integration and checkpoint identity.** Production registration
includes the normalize prompt and schema. Prompt, schema, identity,
normalization, and context policies contribute checkpoint fingerprints.
Assembled tests prove retry behavior and the ordered normalized-NPC handoff
to downstream consumers.
5. **Configuration and documentation.** The maintained complete D&D example
demonstrates an explicit normalize profile and retry count. Configuration,
operations, integration, LLM, module, pipeline, and component documentation
describe the implemented feature.
The completed implementation passed the full test suite, vet, build, diff
checks, and the targeted race suite. The stages below are limited follow-up
corrections and do not reopen the original feature design.
## Decisions Applying To Every Follow-up Stage
- Retain module key `dnd/npcs`, artifact kind `dnd/npc-list`, durable schema
`notarius.dnd.npcs` v1, prompt ID `dnd.npcs.normalize`, response-schema
identity, empty reference-slot contract, strict empty options, and default
normalize validator chain.
- Retain the context radius of two and the existing candidate and transcript
input shapes.
- Retain conflict-aware partial application: independently safe groups may be
applied, every group participating in a resolved-member conflict is
discarded, retries restart from the original merge input, and final
exhaustion accepts only the final attempt's safe candidate.
- Retain `retries: 0` as the application-wide default. Changing that default
remains separate work.
- Retain accepted-only warning promotion, checkpoint format, public
configuration shape, and durable output JSON.
- Do not add an LLM-backed validator, normalizer-local retry loop, generic
deduplication framework, prompt-language change-detector test, or live
provider test.
## Stage 1: Make Context-Material Failures Content-Safe
### Goal
Prevent arbitrary source metadata keys or other source-derived details from
appearing in errors returned by the NPC normalization context builder.
### Implementation
Keep `source.CloneMetadata` unchanged. Its detailed errors remain useful to
trusted direct callers and its own package tests. Redaction belongs at the
external source-material consumption boundary in
`internal/modules/dnd/normalize/npcs/context_material.go`.
At that boundary:
- When copying source-unit metadata fails, return a fixed category such as
`build NPC normalization context: invalid source metadata`.
- Do not wrap the underlying clone error with `%w`.
- The returned error must not include metadata keys, values, traversal
locations, transcript text, NPC names, source IDs, origin paths, encoded
material, concrete Go types derived from source metadata, or the underlying
error text.
- Apply the same fixed-error treatment to candidate and transcript JSON
encoding failures. Those paths are effectively defensive after metadata
validation, but future DTO changes must not create a content-exposure path.
- Keep the negative-radius error detailed: the radius is code-owned policy,
not source content.
- Keep failure timing unchanged. Context construction still fails before the
normalization LLM call, retry directive creation, artifact validation, or
checkpoint recording.
The outer `Normalize` error may retain its existing operation context because
the nested context-builder error is fixed and content-safe.
### Tests
Add narrow boundary coverage in the NPC normalization package:
- Use source metadata with a recognizable sensitive key and an unsupported or
non-finite value.
- Assert that normalization fails before the LLM fake is called.
- Assert that the error contains a stable context-material failure category
but none of the recognizable key, value, transcript text, NPC names, source
ID, or origin path.
- Retain direct `source.CloneMetadata` tests as the owner of detailed cloning
behavior; do not duplicate its type matrix at the normalizer boundary.
Do not assert the complete error sentence. Protect the fixed category and
absence of source-derived sentinels.
### Completion Gate
Every context-material construction failure exposed by `dnd/npcs` is
content-safe, while trusted source-package callers retain detailed clone
diagnostics.
## Stage 2: Remove Conflicting Shared Identity Prompt Guidance
### Goal
Give the NPC normalizer one unambiguous canonical-name policy and avoid telling
it about campaign references that it does not receive.
### Implementation
The existing `common-dnd-identity.md` fragment serves extraction prompts: it
asks for the most specific supported in-world identity and discusses campaign
and registry references. It is not wholly applicable to reconciliation of an
already extracted NPC list.
Remove `common-dnd-identity.md` from the NPC normalization prompt only:
- Remove its message from
`assets/prompts/dnd.npcs.normalize.yaml`.
- Remove it from the normalization package's `PromptAssetManifest.SharedFiles`.
- Do not change the shared fragment or any extraction prompt that currently
consumes it.
- Do not replace it with a copied or newly shared identity fragment. NPC
extraction has already established that the candidates are in-world NPC
records; normalization needs only the package-owned duplicate and canonical
selection instructions.
- Retain `common-dnd-system.md` and `common-dnd-transcript.md`.
After removing the identity message, preserve two intentional prompt-cache
tiers:
1. Mark the common D&D system message as the end of the stable shared tier.
2. Retain the existing boundary after the package-owned normalization
instructions.
The package-owned instructions remain authoritative:
- consolidate only when transcript context clearly identifies one individual;
- select a supplied canonical name;
- prefer an unadorned proper name over the same name plus a contextual class,
role, title, or relationship descriptor unless established as part of the
name; and
- prefer no consolidation when identity is ambiguous.
Do not add references, stable IDs, new prompt inputs, new natural-language
repair material, or schema constraints. The prompt digest will change and
prior NPC normalize checkpoints will intentionally become cold misses. Keep
the existing prompt ID and v1 version because checkpoint compatibility is
already content-fingerprinted and no external prompt protocol is versioned by
this private asset.
Update the canonical internal LLM documentation to describe the normalization
prompt's actual shared tier and to stop claiming that it uses shared identity
guidance.
### Tests
Update the existing offline prepared-prompt test to prove:
- the prompt prepares with five messages in the intended order;
- the first cache boundary follows the common D&D system message;
- the second follows the package-owned instructions;
- candidate and transcript messages remain in the variable tail without cache
boundaries;
- only the two declared dynamic inputs are rendered in their corresponding
messages; and
- prompt metadata and fingerprint construction remain valid.
Tests may assert asset/message identity, ordering, inputs, and cache-control
structure. They must not require the presence or absence of particular words
or phrases in natural-language prompt content.
### Completion Gate
The normalization prompt contains no extraction-specific identity or reference
guidance, preserves its intended cache structure, and still carries one clear
package-owned canonical-name policy.
## Stage 3: Enforce Generic Normalize-Retry Diagnostic Bounds
### Goal
Make the framework-level `NormalizeRetry` contract safely reusable by enforcing
the mechanical parts of its diagnostic contract at the runner boundary.
### Contract
Add provider-neutral limits in `internal/framework/contracts`:
- maximum retry reason-code length: 128 bytes;
- maximum retry message length: 4,096 bytes; and
- both values must be valid UTF-8 and nonblank after trimming surrounding
whitespace for the blank check.
Expose the limits as named constants alongside `NormalizeRetry` so normalizer
authors can construct compliant diagnostics without importing a D&D package.
Do not import `internal/modules/dnd/shared/diagnostics` into the framework.
These are encoded-byte limits, not rune limits. The framework validates but
does not truncate or rewrite caller-supplied diagnostics. Silent truncation
could collapse stable reason identities or conceal a module defect.
Content safety remains the module's semantic responsibility: the framework
cannot determine whether otherwise valid text contains transcript content,
credentials, paths, names, or other sensitive values.
### Runner Implementation
Extract the current inline blank check into a small framework-owned validator
for `*contracts.NormalizeRetry` and call it before placing the directive in a
debug payload.
The validator must reject:
- a blank reason code;
- a blank message;
- invalid UTF-8 in either field;
- a reason code exceeding 128 bytes; and
- a message exceeding 4,096 bytes.
Return a fixed module-contract error that identifies the invalid field and
failure category without echoing either supplied value. Preserve existing
retry-loop behavior for module contract errors; do not create a new error
class, rejection, or durable warning.
Do not impose a generic count or text-size policy on `FallbackWarnings` in this
stage. Warning limits remain owned by their existing domain and validator
contracts; broad warning-policy unification is separate work.
Update `docs/internal/pipeline.md` to document the normalize-retry limits and
the division of responsibility between mechanical framework validation and
module-owned content safety.
### Tests
Extend the generic runner retry tests with table-driven boundary cases:
- a reason code and message exactly at their byte limits are accepted;
- one byte over either limit is rejected;
- invalid UTF-8 in either field is rejected;
- blank fields remain rejected; and
- errors contain only fixed field/category context and do not echo supplied
diagnostic sentinels.
Use test-controlled strings and the exported contract constants. Keep the
existing NPC diagnostic tests as the owner of D&D aggregation and omission
behavior.
### Completion Gate
Any typed normalizer receives the same reusable retry mechanism, and the runner
cannot write a blank, invalid-UTF-8, or unbounded retry diagnostic into debug
state.
## Stage 4: Correct Safe-Fallback Documentation
### Goal
Make canonical pipeline documentation accurately describe the generic
framework contract and the NPC module's model-derived safe partial fallback.
### Documentation
Update `docs/internal/pipeline.md`:
- replace `deterministic safe candidate` with `module-supplied safe candidate`
or equivalent wording;
- state that the framework treats the candidate as opaque and relies on the
normalizer plus the configured validator chain for its safety;
- retain the rules that intermediate candidates are attempt-local, only the
final fallback is validated for acceptance, and accepted-only warnings and
checkpoints remain unchanged; and
- keep the generic contract separate from the NPC-specific conflict and
partial-application rules.
Review the affected current-behavior documentation for the same inaccurate
determinism claim. The NPC integration and operations documents may describe
deterministic validation and mutation, but must not imply that every safe
partial fallback is independent of the LLM proposal.
Do not modify the completed feature roadmap except if a factual contradiction
with the implemented target state is discovered. Do not add tests for prose
wording.
### Verification
- Validate local documentation links.
- Confirm configuration defaults, module keys, prompt IDs, and schema versions
against current code.
- Run `git diff --check`.
### Completion Gate
Canonical documentation distinguishes the generic opaque safe-candidate
contract from deterministic NPC validation and application, with no duplicated
or contradictory retry semantics.
## Final Verification
From the repository root, run:
```sh
git diff --check
go test ./...
go vet ./...
go build ./cmd/notarius
go test -race ./internal/framework/contracts ./internal/framework/llm ./internal/framework/pipeline ./internal/modules/dnd/normalize/npcs ./internal/modules/dnd/register ./internal/modules/integration
```
Review the final diff for:
- no durable artifact, checkpoint-format, or public configuration change;
- no model-facing stable NPC IDs or full-transcript normalization input;
- no source-derived metadata keys, values, paths, names, or transcript content
in context-material errors;
- no extraction-specific shared identity fragment in the normalize prompt;
- no prompt-wording change-detector or live-provider test;
- no framework dependency on D&D diagnostic packages;
- no silent retry-diagnostic truncation;
- no change to conflict-aware partial application, retry accounting, warning
promotion, validator ordering, or downstream handoff; and
- documentation that describes only implemented behavior in its canonical
owner.
## Assumptions
- Detailed `source.CloneMetadata` errors remain appropriate inside the trusted
source package; only the NPC context-material boundary requires redaction.
- The normalizer does not need general extraction identity guidance because its
candidates are already accepted NPC artifacts.
- Mechanical UTF-8 and byte limits are appropriate framework invariants;
semantic content safety remains a module obligation.
- Prompt-content and cache-boundary changes intentionally invalidate existing
NPC normalize checkpoints through the current fingerprint mechanism.
- Backward compatibility for development checkpoints is not required.
## Open Questions
None. The review findings and decisions above are sufficient to implement the
follow-up work without additional product or architecture choices.

View File

@@ -1,29 +0,0 @@
# Future Work
Current Notarius behavior is documented in the canonical README, CLI,
configuration, operations, internal, and integration docs. This roadmap records
future work only.
## Candidate Product Work
- Additional input adapters, such as Markdown or note-export formats.
- Additional D&D extractors beyond spell casts.
- Cross-lane entity normalization.
- Cross-chunk semantic deduplication.
- Configurable validator chains with production validator modules.
- Multiple effective LLM profiles in one run.
- Parallel execution where it preserves deterministic manifests and diagnostics.
- Additional output encoders.
## Candidate Operational Work
- Packaged release artifacts for alpha distribution.
- A documented versioning and release process.
- Optional generated example output fixtures with a regeneration procedure.
- Additional diagnostics or reporting views if operator workflows need them.
## Non-Goals To Revisit Deliberately
- A general workflow language.
- Structural module selection through ad hoc run flags.
- Storing secrets in config files, diagnostics, manifests, or examples.

View File

@@ -0,0 +1,190 @@
# Scene-Aware Combat Extraction
## Status
Implemented. This document preserves the feature contract and rationale; the
current configuration, operational, integration, and internal behavior is
maintained in their canonical documentation.
## Purpose
Use an accepted D&D scene-description artifact as the eligibility boundary for
combat-turn extraction. The combat LLM should run only for a chunk whose exact
scene record has `kind: combat`; every other chunk should be ignored by the
combat extractor. This work should connect the existing scene-description lane,
ordered generated-reference handoff, and combat-turn lane without expanding the
minimal combat-turn artifact contract or introducing D&D policy into generic
pipeline code.
## Target Workflow
The complete D&D pipeline has two ordered steps:
1. The first step extracts and normalizes NPCs and scene descriptions for the
accepted scene chunks.
2. At the step barrier, the accepted NPC and scene-description artifacts become
generated references.
3. The second step supplies the NPC artifact to its existing consumers and the
scene-description artifact to combat-turn extraction.
4. For each chunk, combat-turn extraction performs its existing LLM extraction
only for an exact `combat` classification. Every other chunk produces a
deterministic empty artifact without an LLM call.
The scene-description dependency must remain explicit in pipeline
configuration. The combat extractor must not discover another lane implicitly,
read output files directly, inspect chunker-private state, or require generic
chunk materialization to interpret D&D scene classifications.
## Scene-Description Reference Contract
The combat extractor should declare a required structured reference slot named
`scene_descriptions`. The slot accepts one approved
`dnd/scene-description-list` artifact using the existing durable scene
description schema.
The reference may be supplied as:
- a generated artifact from an earlier ordered step; or
- an external artifact through the existing reference-materialization
boundary.
External artifacts must be decoded and validated before source parsing or LLM
execution. Generated artifacts must cross the existing typed step-handoff
boundary and be validated before use. A bound artifact that is malformed,
incompatible, or internally inconsistent is an error; it must not be treated as
though the slot were unbound.
Pipeline resolution must reject combat extraction when this slot is not bound.
In an ordered same-run workflow, failure of the configured scene-description
producer to yield an accepted normalized artifact must fail the run before the
combat consumer step starts, consistent with existing required generated
reference semantics.
The prepared reference view should be immutable and safe for concurrent
extract jobs. Its metadata and checkpoint identity should be content-safe and
must not expose scene titles, summaries, paths, or source text.
## Chunk Matching And Gating Policy
A scene record authorizes combat extraction for the current chunk only when all
of the following are true:
- the scene ID exactly equals the current accepted chunk ID;
- the scene source ID exactly equals the chunk source ID;
- the scene start and end unit IDs exactly equal the chunk's inclusive source
range; and
- the approved artifact contains exactly one such record.
The normalized scene-description contract already rejects conflicting IDs and
ranges. The combat extractor must nevertheless require the exact match above at
its own decision boundary rather than relying on array position, range overlap,
title, summary, or inferred chronology.
An exact match with `kind: combat` performs the existing combat-turn LLM
extraction. An exact match with `narrative`, `recap`, or `meta` returns a typed
`dnd/combat-turn-list` containing an empty `combat_turns` array without making
an LLM call.
A valid artifact with no exact match, incomplete coverage, or a chunk identity
or range mismatch also returns the deterministic empty artifact. It must not
infer combat eligibility from an overlapping or adjacent scene. Emit a bounded,
content-safe warning for missing or mismatched coverage so operators can
distinguish an intentional non-combat classification from an unavailable exact
classification. The warning must not include scene prose or transcript text.
This policy relies on the scene contract's mixed-scene precedence: any chunk in
which combat is a substantive central activity is classified as `combat`.
Scene-aware gating must not add a second classification policy.
## Extraction, Validation, And Provenance
The deterministic empty result follows the same typed extractor and validator
boundaries as an LLM-produced empty result. It is not a rejection, does not
consume retry budget, and continues through merge and normalization normally.
The durable combat-turn schema remains unchanged.
Existing combat extraction behavior—including prompt assets, NPC grounding,
candidate mapping, validators, retries, warnings, and normalization—remains
unchanged for chunks classified as combat. The scene-description artifact is
control context only:
- it must not be added to the combat prompt;
- its title or summary must not become combat evidence;
- its source references must not be copied into combat turns; and
- it must not create, repair, or classify a combat turn.
Generated-reference provenance and dependency fingerprints should cover the
scene artifact through the existing ordered-handoff machinery. External
references should contribute their existing materialization provenance plus a
component-local semantic fingerprint sufficient to invalidate combat extract
checkpoints when a classification or chunk identity changes. Checkpoint reuse
must never preserve a skipped result after the effective scene classification
changes.
Run manifests may report bounded module metadata such as the number of approved
scene records. They must not contain scene prose or duplicate the referenced
artifact payload.
## Configuration And Documentation
The maintained complete D&D example should bind the normalized
`scene-descriptions` lane from the first step to the `scene_descriptions` slot
in the second step. The minimal example should remain unchanged.
Current-behavior documentation is maintained in these canonical locations:
- Configuration owns the new selectable reference slot and binding example.
- Operations owns the ordered scene-aware workflow.
- The combat-turn integration contract owns externally observable extraction,
empty-result, and provenance behavior.
- Internal pipeline and module documentation own preparation, handoff, matching,
and checkpoint mechanics.
- `future.md` should remove the completed scene-aware combat item.
## Quality Expectations
Tests should protect behavior and architectural boundaries rather than internal
helper shape. Coverage should demonstrate:
- exact non-combat matches produce accepted empty combat artifacts without an
LLM call;
- exact combat matches retain the existing LLM path;
- an unbound required reference is rejected during pipeline resolution;
- missing and mismatched chunk coverage produces an accepted empty combat
artifact without an LLM call and emits a bounded warning;
- malformed external and generated artifacts fail at the appropriate
preparation or handoff boundary;
- retries are neither consumed nor attempted for deterministic skips;
- scene artifact changes invalidate relevant checkpoint reuse;
- no scene prose is exposed through combat prompts, warnings, metadata, or
manifests; and
- the complete maintained configuration resolves and materializes the intended
ordered dependency.
Model-output fixtures should remain deterministic test doubles. This feature
does not require live-provider tests or assertions over probabilistic model
quality.
## Non-Goals
This scope does not:
- change the durable scene-description or combat-turn schemas;
- add scene fields to combat-turn artifacts;
- annotate generic chunks with D&D classifications;
- make the combat lane depend implicitly on the scene-description lane;
- skip spell, NPC, NPC-interaction, or scene-description extraction;
- infer combat from scene titles, summaries, overlap, or campaign references;
- introduce arbitrary DAG scheduling or concurrent cross-step execution;
- add prior-run artifact discovery or new reference syntax; or
- add an LLM-backed validator, repair pass, or generic deduplication stage.
## Completion Criteria
The scope is complete when an explicitly configured ordered D&D pipeline can
hand an approved scene-description artifact to combat extraction, exact
`combat` scene matches are the only chunks that invoke the combat LLM, every
other chunk produces a deterministic empty result, provenance and checkpoint
identity remain correct, the complete example demonstrates the workflow, and
the canonical current-behavior documentation reflects the implemented
contract.

View File

@@ -1,215 +0,0 @@
# Troubleshooting
This guide maps common implemented failure modes to inspection steps and fixes.
For command syntax, see [CLI Reference](cli.md). For YAML fields and
environment overrides, see [Configuration](config.md). For output and
diagnostics layout, see [Operations](operations.md).
## Config File Not Found
Symptom:
```text
notarius: config file not found; pass --config or set NOTARIUS_CONFIG
```
Fix:
- Pass `--config path/to/config.yml`.
- Or set `NOTARIUS_CONFIG` to a readable file.
- Or install a config at `/usr/local/etc/notarius/config.yml`.
If the message says the config path is a directory or is not available, correct
the path or file permissions.
## Unsupported Or Invalid Config
Symptoms include:
- `unsupported config version`
- `config version is required`
- `field <name> not found`
- `total LLM concurrency must be greater than zero`
- `diagnostics retention "<value>" is not supported`
Fix:
- Use `version: 1`.
- Remove unknown YAML fields.
- Validate with:
```sh
go run ./cmd/notarius config validate --config path/to/config.yml
```
## Unknown Pipeline
Symptom:
```text
notarius: pipeline "..." is not configured
```
Fix:
- List configured pipeline IDs:
```sh
go run ./cmd/notarius pipelines list --config path/to/config.yml
```
- Use one of those IDs in `notarius run <pipeline-id>`.
- Check indentation under the top-level `pipelines` map.
## Unknown Or Incompatible Module
Symptoms mention a module key, pipeline slot, lane, capability, or `not
registered`.
Fix:
- Validate the pipeline against the production module catalog:
```sh
go run ./cmd/notarius config validate \
--config path/to/config.yml \
--pipeline dnd-session
```
- Use only implemented production module keys listed in
[Configuration](config.md#implemented-production-modules).
- Check that artifact lanes include an `extract` binding.
## Invalid `--only`
Symptoms include:
- `--only must contain comma-separated non-empty artifact lane IDs`
- `--only requires --pipeline`
- `selected artifact lane`
Fix:
- Use comma-separated lane IDs with no empty entries:
```sh
go run ./cmd/notarius run dnd-session \
--config path/to/config.yml \
--input path/to/input.json \
--only spells
```
- For `config validate`, include `--pipeline` when using `--only`.
- Confirm the lane ID exists under `pipelines.<id>.artifacts`.
## Seriatim Input Validation Failure
Symptoms include `seriatim input`, `parse JSON`, `segments must not be empty`,
or validation errors naming a segment field.
Fix:
- Compare the input to
[examples/seriatim-minimal-transcript.json](../examples/seriatim-minimal-transcript.json).
- Ensure the JSON has a `metadata` object and a non-empty `segments` array.
- Each segment needs a non-empty `id`, non-empty `speaker`, non-empty `text`,
non-negative numeric `start`, and non-negative numeric `end`.
- Segment IDs must be unique and must not contain leading or trailing
whitespace.
- `end` must be greater than or equal to `start`.
## Missing LLM Base URL Or Model
Symptoms include:
- `LLM profile "default" base URL must not be empty`
- `LLM profile "default" model must not be empty`
- `base URL must be valid`
Fix:
- Set `base_url` and `model` in `llm_profiles.default`.
- Or set `NOTARIUS_LLM_DEFAULT_BASE_URL` and
`NOTARIUS_LLM_DEFAULT_MODEL`.
- If a profile needs authentication, set `api_key_env` in YAML or set
`NOTARIUS_LLM_DEFAULT_API_KEY`.
## LLM Profile Override Failure
Symptom:
```text
notarius: LLM profile override "..." is not configured
```
Fix:
- Add the profile under `llm_profiles`.
- Or use an existing profile ID with `--llm-profile`.
Current runs require exactly one distinct effective LLM profile. If a pipeline
uses several profiles, run with `--llm-profile <id>` or align the bindings in
configuration.
## Provider HTTP Or Response Failure
Symptoms include:
- `provider request failed`
- `provider returned status 400`
- `provider returned status 403`
- `provider response missing choices`
- `provider response assistant message content is not valid JSON`
- `decode structured output`
Fix:
- Confirm the `base_url` points to an OpenAI-compatible endpoint root. Notarius
posts to `<base_url>/chat/completions`.
- Check `model` and provider credentials.
- Inspect the retained diagnostics `error.log`.
- For 400 and 403 responses, fix the request configuration or credentials.
- For 429 and 5xx responses, the client retries according to `max_retries`; if
the failure persists, inspect the provider response and adjust capacity,
credentials, or model settings.
- The assistant message content must decode as JSON matching the extractor's
structured response schema.
Provider error messages are redacted for configured API key values.
## Output Write Failure
Symptoms include:
- `create output directory`
- `write output file`
- `output file name must`
Fix:
- Ensure `--output-dir` points to a directory path or a path that can be
created.
- Check filesystem permissions and available disk space.
- If diagnostics were retained, inspect `run-report.json`, `run-manifest.json`,
and `error.log`.
The CLI rejects unsafe logical output paths before writing files.
## Diagnostics Directory Surprise
Symptom: the diagnostics directory is missing after a successful run.
Fix:
- Check `diagnostics.retention`.
- With `auto`, successful runs without warnings are removed.
- Use `diagnostics.retention: always` when every diagnostics run directory
should be kept.
- Use `--diagnostics-dir` to override the configured work directory for a run.
Symptom: diagnostics exist even with `retention: never`.
Explanation:
- Failed runs are retained so that `error.log` and available context can be
inspected.

View File

@@ -0,0 +1,92 @@
version: 3
concurrency:
total_llm: 2
stage_workers:
extract: 2
output:
directory: ./notarius-output
cache:
chunk_plans:
mode: auto
directory: ./notarius-cache/chunk-plans
checkpoints:
enabled: true
directory: ./notarius-cache/checkpoints
debug:
directory: ./notarius-debug
pipelines:
dnd-session:
input: seriatim
# Stable campaign context is shared by every module that accepts these slots.
references:
party: ./dnd-party.txt
glossary: ./dnd-glossary.txt
chunk:
module: dnd/scenes
retries: 2
output:
module: json
options:
include_chunk_map: true
steps:
# Establish session-wide reference artifacts alongside independent item events.
- id: describe-session
artifacts:
item-events:
extract:
module: dnd/item-events
retries: 2
merge: appendorder
normalize: dnd/item-events
npcs:
extract:
module: dnd/npcs
retries: 2
merge: appendorder
normalize:
module: dnd/npcs
llm_profile: gemini-2-flash
retries: 2
scene-descriptions:
extract:
module: dnd/scene-descriptions
retries: 2
merge: appendorder
normalize: dnd/scene-descriptions
- id: extract-events
# Accepted NPC grounding and scene-description eligibility artifacts are
# supplied in memory to their compatible consumers in this step.
references:
npcs:
artifact:
step: describe-session
lane: npcs
scene_descriptions:
artifact:
step: describe-session
lane: scene-descriptions
artifacts:
spells:
extract:
module: dnd/spells
retries: 2
references:
spell_catalog: ./dnd-spell-catalog.json
merge: appendorder
# Stage-local file references are intentionally bound at each stage.
normalize:
module: dnd/spells
references:
spell_catalog: ./dnd-spell-catalog.json
combat-turns:
extract:
module: dnd/combat-turns
retries: 2
merge: appendorder
normalize: dnd/combat-turns
npc-interactions:
extract:
module: dnd/npc-interactions
retries: 2
merge: appendorder
normalize: dnd/npc-interactions

View File

@@ -0,0 +1,2 @@
Cure Wounds: healing spell cast by touch.
Shield: defensive reaction spell.

View File

@@ -0,0 +1,8 @@
version: 3
pipelines:
dnd-session:
input: seriatim
artifacts:
spells:
extract: dnd/spells
normalize: dnd/spells

3
examples/dnd-party.txt Normal file
View File

@@ -0,0 +1,3 @@
Aria: party cleric and recurring healer.
Borin: fighter ally.
Bandit mage: hostile spellcaster.

View File

@@ -0,0 +1,21 @@
{
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
"catalogs": [
{
"id": "notarius.example-campaign",
"ruleset": "dnd-5e-2014",
"source": {
"title": "Notarius example campaign spell names",
"version": "1",
"url": "",
"license": ""
},
"spells": [
{
"name": "Aegis of Emberfall",
"aliases": ["Emberfall Aegis"]
}
]
}
]
}

View File

@@ -1,16 +0,0 @@
version: 1
llm_profiles:
default:
provider: openai-compatible
base_url: http://127.0.0.1:1
model: fake-model
pipelines:
dnd-session:
input: seriatim
chunk:
module: generic
options:
max_units: 50
artifacts:
spells:
extract: dnd/spells

View File

@@ -5,14 +5,14 @@
},
"segments": [
{
"id": "seg-001",
"id": 1,
"start": 0,
"end": 4,
"speaker": "Aria",
"text": "Aria raises her holy symbol and casts Cure Wounds."
},
{
"id": "seg-002",
"id": 2,
"start": 4,
"end": 8,
"speaker": "DM",

10
go.mod
View File

@@ -1,5 +1,11 @@
module gitea.maximumdirect.net/eric/notarius
go 1.24.0
go 1.25.5
require gopkg.in/yaml.v3 v3.0.1
require (
gitea.maximumdirect.net/eric/scriptorium v0.11.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/text v0.40.0

12
go.sum
View File

@@ -1,3 +1,15 @@
gitea.maximumdirect.net/eric/scriptorium v0.11.0 h1:rjvbt9FTaWHxYlHq7QlUzmMVUt3QdbTmeCkmH81N//o=
gitea.maximumdirect.net/eric/scriptorium v0.11.0/go.mod h1:FQ5lEuNxmrQyNgIomkpZdxvfTC0jWjbXYuq3tbJWF64=
gitea.maximumdirect.net/eric/scriptorium v0.11.1 h1:zBKtB3+fP8FcHGI8DJD99CiTL6crAGitBhWtE+xYJHc=
gitea.maximumdirect.net/eric/scriptorium v0.11.1/go.mod h1:FQ5lEuNxmrQyNgIomkpZdxvfTC0jWjbXYuq3tbJWF64=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -0,0 +1,314 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"sync"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
)
const assembledSpellExtractorKey = "test/dnd/spell-casts"
func TestAssembledSpellPipelineNormalizesMergedCasts(t *testing.T) {
registries, resolved, extractor := assembledSpellPipeline(t, assembledSpellPipelineOptions{})
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
ChunkCacheMode: pipeline.ChunkCacheBypass,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
chunkIndexes := extractor.chunkIndexesSnapshot()
sort.Ints(chunkIndexes)
if !reflect.DeepEqual(chunkIndexes, []int{0, 1}) {
t.Fatalf("extractor chunk indexes = %#v, want two chunk-boundary calls", chunkIndexes)
}
if output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
t.Fatalf("run output = %#v, want approved normalized output without rejections", output)
}
if output.NormalizeOutputs[0].NormalizerKey != spellnormalize.Key {
t.Fatalf("normalized output module = %q, want %q", output.NormalizeOutputs[0].NormalizerKey, spellnormalize.Key)
}
var normalized dnd.SpellList
if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &normalized); err != nil {
t.Fatalf("decode normalized output: %v", err)
}
if len(normalized.SpellCasts) != 2 {
t.Fatalf("normalized casts = %#v, want collapsed duplicate plus distinct evidence", normalized.SpellCasts)
}
first, distinct := normalized.SpellCasts[0], normalized.SpellCasts[1]
if first.Spell != "Cure Wounds" || first.Caster != " Aria \t" {
t.Fatalf("retained cast = %#v, want canonical spell with first occurrence caster", first)
}
if !reflect.DeepEqual(first.SourceRefs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
t.Fatalf("retained refs = %#v, want sorted complete evidence", first.SourceRefs)
}
if distinct.Spell != "Cure Wounds" || distinct.Caster != "aria" || !reflect.DeepEqual(distinct.SourceRefs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
t.Fatalf("distinct cast = %#v, want separate evidence event", distinct)
}
wantWarningReasons := []string{
spellnormalize.ReasonCodeSpellNameCanonicalized,
spellnormalize.ReasonCodeSourceReferencesNormalized,
spellnormalize.ReasonCodeDuplicateSpellCastCollapsed,
"spell_not_near_source",
}
gotWarningReasons := make([]string, len(output.Warnings))
for index, warning := range output.Warnings {
gotWarningReasons[index] = warning.ReasonCode
}
if !reflect.DeepEqual(gotWarningReasons, wantWarningReasons) {
t.Fatalf("warnings = %#v, want deterministic normalize and validation warnings", output.Warnings)
}
if output.Warnings[2].Scope != "spell_casts[0]" || !strings.Contains(output.Warnings[2].Message, "retained input index 0") || !strings.Contains(output.Warnings[2].Message, "removed input indices [1]") {
t.Fatalf("duplicate warning = %#v, want retained and removed merged indices", output.Warnings[2])
}
warningsFile := decodeAssembledOutput[struct {
Warnings []contracts.Warning `json:"warnings"`
}](t, output.OutputFiles, "warnings.json")
if !reflect.DeepEqual(warningsFile.Warnings, output.Warnings) {
t.Fatalf("warnings file = %#v, run warnings = %#v, want manifest output path to preserve warnings", warningsFile.Warnings, output.Warnings)
}
manifest := decodeAssembledOutput[artifacts.RunManifest](t, output.OutputFiles, "manifest.json")
if len(manifest.ArtifactLanes) != 1 || manifest.ArtifactLanes[0].Normalizer != spellnormalize.Key {
t.Fatalf("manifest lanes = %#v, want assembled spell normalizer", manifest.ArtifactLanes)
}
normalizerMetadata, ok := manifest.ArtifactLanes[0].Metadata["normalizer"].(map[string]any)
_, hasOverlayIDs := normalizerMetadata["catalog_overlay_ids"]
if !ok || normalizerMetadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || !strings.HasPrefix(stringValue(normalizerMetadata["catalog_digest"]), "sha256:") || !hasOverlayIDs {
t.Fatalf("normalizer manifest metadata = %#v, want base ID, digest, and overlay IDs", manifest.ArtifactLanes[0].Metadata)
}
}
func TestAssembledSpellPipelineHonorsNormalizeValidatorOverride(t *testing.T) {
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{normalizeValidatorOverride: true})
var normalizeChain *pipeline.ResolvedValidatorChain
for index := range resolved.ValidatorChains {
chain := &resolved.ValidatorChains[index]
if chain.Stage == pipeline.StageNormalize && chain.ModuleKey == spellnormalize.Key && chain.LaneID == "spells" {
normalizeChain = chain
break
}
}
if normalizeChain == nil || len(normalizeChain.Validators) != 1 || normalizeChain.Validators[0].Binding.Module != "generic/always_accept" {
t.Fatalf("normalize validator chain = %#v, want explicit always-accept override", normalizeChain)
}
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
ChunkCacheMode: pipeline.ChunkCacheBypass,
})
if err != nil || output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
t.Fatalf("Run() error = %v output = %#v, want approved override run", err, output)
}
for _, warning := range output.Warnings {
if warning.ReasonCode == "spell_not_near_source" {
t.Fatalf("warnings = %#v, want explicit validator override to replace default relatedness chain", output.Warnings)
}
}
}
func TestAssembledSpellPipelineRejectsUnknownSpellWithoutPromotingAttemptWarning(t *testing.T) {
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{unknownSpell: true})
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
ChunkCacheMode: pipeline.ChunkCacheBypass,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
if output.Manifest.ValidationStatus != "rejected" || len(output.NormalizeOutputs) != 0 || len(output.Rejected) != 1 {
t.Fatalf("run output = %#v, want one rejected normalize candidate and no normalized output", output)
}
rejection := output.Rejected[0]
if rejection.Stage != string(pipeline.StageNormalize) || rejection.LaneID != "spells" || rejection.ModuleKey != spellnormalize.Key || rejection.ValidatorName != "extract/dnd/spells/catalog" || rejection.ReasonCode != "unknown_spell" {
t.Fatalf("rejection = %#v, want durable normalize catalog rejection", rejection)
}
rejectedFile := decodeAssembledOutput[struct {
Rejected []contracts.RejectedOutput `json:"rejected"`
}](t, output.OutputFiles, "rejected.json")
if !reflect.DeepEqual(rejectedFile.Rejected, output.Rejected) {
t.Fatalf("rejected file = %#v, run rejections = %#v, want durable rejection diagnostic", rejectedFile.Rejected, output.Rejected)
}
for _, warning := range output.Warnings {
if warning.ReasonCode == spellnormalize.ReasonCodeSpellNameUnresolved {
t.Fatalf("warnings = %#v, want rejected-attempt warning to remain non-durable", output.Warnings)
}
}
}
func TestAssembledSpellPipelinePromotesUnknownSpellWarningWhenOverrideAccepts(t *testing.T) {
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{normalizeValidatorOverride: true, unknownSpell: true})
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
ChunkCacheMode: pipeline.ChunkCacheBypass,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
if output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
t.Fatalf("run output = %#v, want accepted unknown spell with explicit validator override", output)
}
var normalized dnd.SpellList
if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &normalized); err != nil {
t.Fatalf("decode normalized output: %v", err)
}
if len(normalized.SpellCasts) != 1 || normalized.SpellCasts[0].Spell != "Mysterious Burst" {
t.Fatalf("normalized casts = %#v, want unresolved name preserved", normalized.SpellCasts)
}
if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != spellnormalize.ReasonCodeSpellNameUnresolved || output.Warnings[0].Scope != "spell_casts[0]" {
t.Fatalf("warnings = %#v, want promoted scoped unresolved-name warning", output.Warnings)
}
warningsFile := decodeAssembledOutput[struct {
Warnings []contracts.Warning `json:"warnings"`
}](t, output.OutputFiles, "warnings.json")
if !reflect.DeepEqual(warningsFile.Warnings, output.Warnings) {
t.Fatalf("warnings file = %#v, run warnings = %#v, want durable unresolved-name warning", warningsFile.Warnings, output.Warnings)
}
}
type assembledSpellPipelineOptions struct {
normalizeValidatorOverride bool
unknownSpell bool
}
func assembledSpellPipeline(t *testing.T, options assembledSpellPipelineOptions) (pipeline.Registries, pipeline.ResolvedPipeline, *assembledSpellExtractor) {
t.Helper()
components := productionTestComponents(t)
extractor := &assembledSpellExtractor{unknownSpell: options.unknownSpell}
if err := pipeline.RegisterExtractor[dnd.SpellList](components.registries.Extractors, pipeline.ModuleSpec{
Key: assembledSpellExtractorKey,
Stage: pipeline.StageExtract,
Requires: []string{"chunks", "source.transcript"},
Provides: []string{"dnd.spell_casts"},
ArtifactKind: dnd.SpellListKind,
}, func() (contracts.Extractor[dnd.SpellList], error) {
return extractor, nil
}); err != nil {
t.Fatalf("register assembled extractor: %v", err)
}
normalize := pipeline.Binding(spellnormalize.Key)
if options.normalizeValidatorOverride {
normalize.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")},
}
}
resolved, err := pipeline.ResolvePipeline(pipeline.PipelineProfile{
ID: "assembled-dnd-spells",
Input: pipeline.Binding("seriatim"),
Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}},
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"spells": {Extract: pipeline.Binding(assembledSpellExtractorKey), Normalize: normalize},
},
Output: pipeline.Binding("json"),
}, pipeline.ResolveOptions{}, catalogFromRegistries(components.registries))
if err != nil {
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
}
return components.registries, resolved, extractor
}
type assembledSpellExtractor struct {
mu sync.Mutex
chunkIndexes []int
unknownSpell bool
}
func (e *assembledSpellExtractor) Key() string { return assembledSpellExtractorKey }
func (*assembledSpellExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (e *assembledSpellExtractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.SpellList], error) {
if err := ctx.Err(); err != nil {
return contracts.TypedExtractionResult[dnd.SpellList]{}, err
}
if req.Chunk == nil || req.Source == nil {
return contracts.TypedExtractionResult[dnd.SpellList]{}, fmt.Errorf("assembled extractor requires source and chunk")
}
e.mu.Lock()
e.chunkIndexes = append(e.chunkIndexes, req.Chunk.Index)
e.mu.Unlock()
refOne := source.SourceRef{SourceID: req.Source.ID, StartUnitID: 1, EndUnitID: 1}
refTwo := source.SourceRef{SourceID: req.Source.ID, StartUnitID: 2, EndUnitID: 2}
if e.unknownSpell {
if req.Chunk.Index == 0 {
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: "Aria", Spell: "Mysterious Burst", SourceRefs: []source.SourceRef{refOne},
}}}}, nil
}
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}, nil
}
switch req.Chunk.Index {
case 0:
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: " Aria \t", Spell: " cure wounds ", SourceRefs: []source.SourceRef{refTwo, refOne},
}}}}, nil
case 1:
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{
{Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refOne, refTwo}},
{Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refTwo}},
}}}, nil
default:
return contracts.TypedExtractionResult[dnd.SpellList]{}, fmt.Errorf("unexpected assembled chunk index %d", req.Chunk.Index)
}
}
func (e *assembledSpellExtractor) chunkIndexesSnapshot() []int {
e.mu.Lock()
defer e.mu.Unlock()
return append([]int(nil), e.chunkIndexes...)
}
func decodeAssembledOutput[T any](t *testing.T, files []contracts.OutputFile, name string) T {
t.Helper()
for _, file := range files {
if file.Name != name {
continue
}
var value T
if err := json.Unmarshal(file.Bytes, &value); err != nil {
t.Fatalf("decode %s: %v", name, err)
}
return value
}
t.Fatalf("output files = %#v, want %q", files, name)
return *new(T)
}

View File

@@ -0,0 +1,371 @@
package cli
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestRunChunkPlanModePrecedenceAndValidation(t *testing.T) {
tests := []struct {
name string
fileMode string
envMode string
cliMode string
wantStores int
}{
{name: "default", wantStores: 1},
{name: "file", fileMode: "bypass"},
{name: "environment", envMode: "bypass"},
{name: "cli", envMode: "refresh", cliMode: "bypass"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
if tt.name == "default" {
removeStateTestConfigLine(t, roots.config, " mode: auto\n")
} else if tt.fileMode != "" {
replaceStateTestConfigLine(t, roots.config, " mode: auto\n", " mode: "+tt.fileMode+"\n")
}
var stores []string
opts := newStateTestHarness().options()
opts.LookupEnv = func(name string) (string, bool) {
if name == "NOTARIUS_CACHE_CHUNK_PLANS_MODE" && tt.envMode != "" {
return tt.envMode, true
}
return "", false
}
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
stores = append(stores, root)
return chunkplan.NewFilesystemStore(root)
}
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input}
if tt.cliMode != "" {
args = append(args, "--chunk_cache", tt.cliMode)
}
var stdout, stderr bytes.Buffer
if code := RunWithOptions(args, &stdout, &stderr, opts); code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertStateTestOutput(t, roots.output)
if len(stores) != tt.wantStores {
t.Fatalf("chunk plan store roots = %v, want %d stores", stores, tt.wantStores)
}
if tt.wantStores == 1 && stores[0] != roots.plans {
t.Fatalf("chunk plan store root = %q, want %q", stores[0], roots.plans)
}
if tt.wantStores == 0 {
assertAbsent(t, roots.plans)
}
})
}
t.Run("invalid cli syntax is a usage error", func(t *testing.T) {
roots := newStateTestRoots(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "invalid"}, &stdout, &stderr, newStateTestHarness().options())
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertNoRunState(t, roots)
})
for _, tt := range []struct {
name string
fileConfig bool
}{
{name: "invalid environment mode"},
{name: "invalid file mode", fileConfig: true},
} {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
if tt.fileConfig {
replaceStateTestConfigLine(t, roots.config, " mode: auto\n", " mode: invalid\n")
} else {
opts.LookupEnv = func(name string) (string, bool) {
if name == "NOTARIUS_CACHE_CHUNK_PLANS_MODE" {
return "invalid", true
}
return "", false
}
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
if code != 1 || stdout.Len() != 0 || stderr.Len() == 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertNoRunState(t, roots)
})
}
}
func TestRunChunkPlanRootSelectionAndFailures(t *testing.T) {
t.Run("empty configured root uses the per-user cache root", func(t *testing.T) {
roots := newStateTestRoots(t)
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
userCache := filepath.Join(t.TempDir(), "user-cache")
var stores []string
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) { return userCache, nil }
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
stores = append(stores, root)
return chunkplan.NewFilesystemStore(root)
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
wantRoot := filepath.Join(userCache, "notarius", "chunk-plans")
if len(stores) != 1 || stores[0] != wantRoot {
t.Fatalf("chunk plan store roots = %v, want [%q]", stores, wantRoot)
}
assertFile(t, filepath.Join(wantRoot, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json"))
assertAbsent(t, roots.plans)
})
t.Run("bypass avoids default cache dependencies", func(t *testing.T) {
roots := newStateTestRoots(t)
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
userCacheCalls := 0
storeCalls := 0
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) {
userCacheCalls++
return "", errors.New("user cache must not be resolved")
}
opts.ChunkPlanStoreFactory = func(string) (pipeline.ChunkPlanStore, error) {
storeCalls++
return nil, errors.New("chunk plan store must not be constructed")
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if userCacheCalls != 0 || storeCalls != 0 {
t.Fatalf("user cache calls=%d store calls=%d, want none", userCacheCalls, storeCalls)
}
assertStateTestOutput(t, roots.output)
assertAbsent(t, roots.plans)
})
t.Run("user cache resolution failure has context and no output", func(t *testing.T) {
roots := newStateTestRoots(t)
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) { return "", errors.New("cache home unavailable") }
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
if code != 1 || !strings.Contains(stderr.String(), "resolve chunk plan root") || !strings.Contains(stderr.String(), "cache home unavailable") {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertNoRunState(t, roots)
})
t.Run("store construction failure has context and no output", func(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
return nil, fmt.Errorf("store unavailable")
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
want := fmt.Sprintf("create chunk plan store at %q", roots.plans)
if code != 1 || !strings.Contains(stderr.String(), want) || !strings.Contains(stderr.String(), "store unavailable") {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertNoRunState(t, roots)
})
t.Run("checkpoint root resolution failure has context and no output", func(t *testing.T) {
roots := newStateTestRoots(t)
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) { return "", errors.New("checkpoint cache unavailable") }
result := runStateTest(t, roots, opts, false, true, "bypass")
if result.code != 1 || !strings.Contains(result.stderr, "resolve checkpoint root") || !strings.Contains(result.stderr, "checkpoint cache unavailable") {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertNoRunState(t, roots)
})
}
func TestRunAutoReusesPlanWhenRunInputsChange(t *testing.T) {
roots := newStateTestRoots(t)
data, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
configText := replaceRequiredOnce(t, string(data), " chunk: test/chunk\n", ` chunk:
module: test/chunk
options:
strategy: first
`)
configText = replaceRequiredOnce(t, configText, " output: test/output\n", ` other:
extract: test/extract
merge: test/merge
normalize: test/normalize
output: test/output
`)
if err := os.WriteFile(roots.config, []byte(configText), 0o600); err != nil {
t.Fatal(err)
}
referencePath := filepath.Join(filepath.Dir(roots.input), "reference.txt")
if err := os.WriteFile(referencePath, []byte("reference content"), 0o600); err != nil {
t.Fatal(err)
}
harness := newStateTestHarness()
var firstStdout, firstStderr bytes.Buffer
first := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &firstStdout, &firstStderr, harness.options())
if first != 0 {
t.Fatalf("first run code=%d stdout=%q stderr=%q", first, firstStdout.String(), firstStderr.String())
}
configData, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
configText = replaceRequiredOnce(t, string(configData), "strategy: first", "strategy: second")
if err := os.WriteFile(roots.config, []byte(configText), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
second := RunWithOptions([]string{
"run", "sample", "--config", roots.config, "--input", roots.input,
"--only", "items", "--reference", "chunk.cache-reference=" + referencePath,
}, &stdout, &stderr, harness.options())
if second != 0 {
t.Fatalf("second run code=%d stdout=%q stderr=%q", second, stdout.String(), stderr.String())
}
harness.mu.Lock()
chunkCalls := harness.chunkCalls
harness.mu.Unlock()
if chunkCalls != 1 {
t.Fatalf("chunk calls across changed run inputs = %d, want 1", chunkCalls)
}
assertFile(t, filepath.Join(roots.plans, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json"))
assertAnyFile(t, roots.output)
}
func TestRunResumeSelectsConfiguredOrPerUserCheckpointRoot(t *testing.T) {
for _, configured := range []bool{true, false} {
name := "per-user root"
if configured {
name = "configured root"
}
t.Run(name, func(t *testing.T) {
roots := newStateTestRoots(t)
if !configured {
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
}
userCache := filepath.Join(t.TempDir(), "user-cache")
userCacheCalls := 0
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) {
userCacheCalls++
return userCache, nil
}
result := runStateTest(t, roots, opts, false, true, "bypass")
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
wantRoot := roots.checkpoints
wantCalls := 0
if !configured {
wantRoot = filepath.Join(userCache, "notarius", "checkpoints")
wantCalls = 1
}
if userCacheCalls != wantCalls {
t.Fatalf("user cache calls = %d, want %d", userCacheCalls, wantCalls)
}
assertAnyFile(t, wantRoot)
if !configured {
assertAbsent(t, roots.checkpoints)
}
assertStateTestOutput(t, roots.output)
})
}
t.Run("disabled avoids checkpoint root resolution", func(t *testing.T) {
roots := newStateTestRoots(t)
replaceStateTestConfigLine(t, roots.config, " enabled: true\n", " enabled: false\n")
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) { return "", errors.New("checkpoint cache must not be resolved") }
result := runStateTest(t, roots, opts, false, false, "bypass")
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertStateTestOutput(t, roots.output)
assertAbsent(t, roots.checkpoints)
})
t.Run("resume requires enabled checkpoint recording", func(t *testing.T) {
roots := newStateTestRoots(t)
replaceStateTestConfigLine(t, roots.config, " enabled: true\n", " enabled: false\n")
result := runStateTest(t, roots, newStateTestHarness().options(), true, true, "bypass")
if result.code != 1 || !strings.Contains(result.stderr, "--resume requires cache.checkpoints.enabled: true") {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertNoRunState(t, roots)
})
}
func TestConfigCommandsDoNotResolveRunState(t *testing.T) {
for _, args := range [][]string{
{"config", "validate", "--config"},
{"pipelines", "list", "--config"},
} {
name := strings.Join(args[:2], "-")
t.Run(name, func(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) { return "", errors.New("state root must not be resolved") }
opts.ChunkPlanStoreFactory = func(string) (pipeline.ChunkPlanStore, error) {
return nil, errors.New("chunk plan store must not be constructed")
}
command := append([]string(nil), args...)
command = append(command, roots.config)
var stdout, stderr bytes.Buffer
code := RunWithOptions(command, &stdout, &stderr, opts)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertNoRunState(t, roots)
})
}
}
func replaceStateTestConfigLine(t *testing.T, path, old, new string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := replaceRequiredOnce(t, string(data), old, new)
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
t.Fatal(err)
}
}
func removeStateTestConfigLine(t *testing.T, path, line string) {
replaceStateTestConfigLine(t, path, line, "")
}
func assertNoRunState(t *testing.T, roots stateTestRoots) {
t.Helper()
assertAbsent(t, roots.output)
assertAbsent(t, roots.plans)
assertAbsent(t, roots.checkpoints)
assertAbsent(t, roots.debug)
}

View File

@@ -3,50 +3,54 @@ package cli
import (
"context"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/generic"
"gitea.maximumdirect.net/eric/notarius/internal/modules/extract/dnd/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/input/seriatim"
"gitea.maximumdirect.net/eric/notarius/internal/modules/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/normalize/noop"
jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/output/json"
dndregister "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/register"
genericregister "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/register"
seriatimregister "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/register"
)
func productionRegistries() (pipeline.Registries, error) {
type productionComponents struct {
registries pipeline.Registries
assets *llm.AssetRegistry
}
func newProductionComponents() (productionComponents, error) {
registries := pipeline.Registries{
Inputs: pipeline.NewInputAdapterRegistry(),
Chunkers: pipeline.NewChunkerRegistry(),
ArtifactCodecs: pipeline.NewArtifactCodecRegistry(),
Extractors: pipeline.NewExtractorRegistry(),
Mergers: pipeline.NewMergerRegistry(),
Normalizers: pipeline.NewNormalizerRegistry(),
Validators: pipeline.NewValidatorRegistry(),
ValidatorChains: pipeline.NewValidatorChainRegistry(),
Outputs: pipeline.NewOutputEncoderRegistry(),
}
if err := seriatim.Register(registries.Inputs); err != nil {
return pipeline.Registries{}, fmt.Errorf("register seriatim input: %w", err)
assets := llm.NewAssetRegistry()
registrars := []struct {
name string
register func(pipeline.Registries, *llm.AssetRegistry) error
}{
{name: "generic", register: genericregister.Register},
{name: "seriatim", register: seriatimregister.Register},
{name: "dnd", register: dndregister.Register},
}
if err := generic.Register(registries.Chunkers); err != nil {
return pipeline.Registries{}, fmt.Errorf("register generic chunker: %w", err)
for _, registrar := range registrars {
if err := registrar.register(registries, assets); err != nil {
return productionComponents{}, fmt.Errorf("register %s module family: %w", registrar.name, err)
}
if err := spells.Register(registries.Extractors); err != nil {
return pipeline.Registries{}, fmt.Errorf("register dnd spells extractor: %w", err)
}
if err := appendorder.Register(registries.Mergers); err != nil {
return pipeline.Registries{}, fmt.Errorf("register appendorder merger: %w", err)
return productionComponents{registries: registries, assets: assets}, nil
}
if err := noop.Register(registries.Normalizers); err != nil {
return pipeline.Registries{}, fmt.Errorf("register noop normalizer: %w", err)
}
if err := jsonoutput.Register(registries.Outputs); err != nil {
return pipeline.Registries{}, fmt.Errorf("register json output encoder: %w", err)
}
return registries, nil
func productionRegistries() (pipeline.Registries, error) {
components, err := newProductionComponents()
return components.registries, err
}
func productionCatalog() (pipeline.ModuleCatalog, error) {
@@ -57,6 +61,11 @@ func productionCatalog() (pipeline.ModuleCatalog, error) {
return catalogFromRegistries(registries), nil
}
func productionPromptAssets() (*llm.AssetRegistry, error) {
components, err := newProductionComponents()
return components.assets, err
}
func effectiveCatalog(opts Options) (pipeline.ModuleCatalog, error) {
if !isEmptyCatalog(opts.Catalog) {
return opts.Catalog, nil
@@ -81,10 +90,12 @@ func catalogFromRegistries(registries pipeline.Registries) pipeline.ModuleCatalo
return pipeline.ModuleCatalog{
Inputs: registries.Inputs,
Chunkers: registries.Chunkers,
ArtifactCodecs: registries.ArtifactCodecs,
Extractors: registries.Extractors,
Mergers: registries.Mergers,
Normalizers: registries.Normalizers,
Validators: registries.Validators,
ValidatorChains: registries.ValidatorChains,
Outputs: registries.Outputs,
}
}
@@ -93,10 +104,12 @@ func registriesFromCatalog(catalog pipeline.ModuleCatalog) pipeline.Registries {
return pipeline.Registries{
Inputs: catalog.Inputs,
Chunkers: catalog.Chunkers,
ArtifactCodecs: catalog.ArtifactCodecs,
Extractors: catalog.Extractors,
Mergers: catalog.Mergers,
Normalizers: catalog.Normalizers,
Validators: catalog.Validators,
ValidatorChains: catalog.ValidatorChains,
Outputs: catalog.Outputs,
}
}
@@ -104,20 +117,24 @@ func registriesFromCatalog(catalog pipeline.ModuleCatalog) pipeline.Registries {
func isEmptyCatalog(catalog pipeline.ModuleCatalog) bool {
return catalog.Inputs == nil &&
catalog.Chunkers == nil &&
catalog.ArtifactCodecs == nil &&
catalog.Extractors == nil &&
catalog.Mergers == nil &&
catalog.Normalizers == nil &&
catalog.Validators == nil &&
catalog.ValidatorChains == nil &&
catalog.Outputs == nil
}
func isEmptyRegistries(registries pipeline.Registries) bool {
return registries.Inputs == nil &&
registries.Chunkers == nil &&
registries.ArtifactCodecs == nil &&
registries.Extractors == nil &&
registries.Mergers == nil &&
registries.Normalizers == nil &&
registries.Validators == nil &&
registries.ValidatorChains == nil &&
registries.Outputs == nil
}
@@ -125,48 +142,39 @@ func productionLLMClientFactory(ctx context.Context, cfg config.Config, profileI
if err := ctx.Err(); err != nil {
return nil, nil, err
}
trimmedID := strings.TrimSpace(profileID)
if trimmedID == "" {
trimmedID = pipeline.DefaultLLMProfile
}
profile, ok := cfg.LLMProfile(trimmedID)
if !ok {
return nil, nil, fmt.Errorf("LLM profile %q is not configured", trimmedID)
}
clientCfg, err := cfg.OpenAICompatibleClientConfig(trimmedID)
assets, err := productionPromptAssets()
if err != nil {
return nil, nil, err
}
client, err := llm.NewOpenAICompatibleClient(clientCfg)
if err != nil {
return nil, nil, fmt.Errorf("create LLM client for profile %q: %w", trimmedID, err)
return buildProductionLLMClient(ctx, cfg, profileID, assets)
}
scheduler, err := llm.NewScheduler(effectiveLLMConcurrency(cfg, profile))
if err != nil {
return nil, nil, fmt.Errorf("create LLM scheduler for profile %q: %w", trimmedID, err)
func productionLLMClientFactoryWithAssets(assets *llm.AssetRegistry) LLMClientFactory {
return func(ctx context.Context, cfg config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
return buildProductionLLMClient(ctx, cfg, profileID, assets)
}
provider := strings.TrimSpace(profile.Provider)
if provider == "" {
provider = "openai-compatible"
}
metadata := []artifacts.LLMProfileManifest{
{
ID: trimmedID,
Provider: provider,
Model: strings.TrimSpace(profile.Model),
},
}
return llm.NewScheduledClient(client, scheduler), metadata, nil
}
func effectiveLLMConcurrency(cfg config.Config, profile config.LLMProfile) int {
if profile.MaxConcurrency > 0 {
return profile.MaxConcurrency
func buildProductionLLMClient(ctx context.Context, cfg config.Config, profileID string, assets *llm.AssetRegistry) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
if cfg.Concurrency.TotalLLM > 0 {
return cfg.Concurrency.TotalLLM
if assets == nil {
return nil, nil, fmt.Errorf("production asset registry must not be nil")
}
return 1
recorder := llm.NewLLMProfileRecorder()
client, err := llm.NewScriptoriumClient(llm.ScriptoriumClientConfig{
ProfileDir: cfg.Scriptorium.ProfileDir,
ProfileFile: cfg.Scriptorium.ProfileFile,
Assets: assets,
Recorder: recorder,
})
if err != nil {
return nil, nil, fmt.Errorf("create Scriptorium-backed LLM client: %w", err)
}
scheduler, err := llm.NewScheduler(cfg.Concurrency.TotalLLM)
if err != nil {
return nil, nil, fmt.Errorf("create LLM scheduler: %w", err)
}
return llm.NewScheduledClient(client, scheduler), nil, nil
}

View File

@@ -0,0 +1,240 @@
package cli
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCommandHelpSpellingsWriteUsageToStdout(t *testing.T) {
tests := [][]string{nil, {"help"}, {"--help"}, {"-h"}}
for _, args := range tests {
name := "no arguments"
if len(args) > 0 {
name = args[0]
}
t.Run(name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := RunWithOptions(args, &stdout, &stderr, commandContractOptions(t))
if code != 0 || !strings.Contains(stdout.String(), "Usage:") || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
}
func TestCommandSyntaxErrorsUseStderrAndExitTwo(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "unknown command", args: []string{"unknown"}, want: "unknown command"},
{name: "missing config subcommand", args: []string{"config"}, want: "config requires a subcommand"},
{name: "unknown pipelines subcommand", args: []string{"pipelines", "unknown"}, want: "unknown pipelines subcommand"},
{name: "malformed run flag", args: []string{"run", "demo", "--chunk_cache", "invalid"}, want: "not supported"},
{name: "unknown flag", args: []string{"config", "validate", "--unknown"}, want: "flag provided but not defined"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := RunWithOptions(tt.args, &stdout, &stderr, commandContractOptions(t))
if code != 2 || !strings.Contains(stderr.String(), tt.want) || stdout.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
}
func TestConfigDiscoveryPrefersExplicitPathThenEnvironment(t *testing.T) {
explicit := writeCommandConfig(t, "explicit", "alpha")
environment := writeCommandConfig(t, "environment", "beta")
lookup := func(name string) (string, bool) {
if name == "NOTARIUS_CONFIG" {
return environment, true
}
return "", false
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"pipelines", "list", "--config", explicit}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup))
if code != 0 || stdout.String() != "alpha\nexplicit\n" || stderr.Len() != 0 {
t.Fatalf("explicit config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"pipelines", "list"}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup))
if code != 0 || stdout.String() != "beta\nenvironment\n" || stderr.Len() != 0 {
t.Fatalf("environment config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}
func TestConfigDiscoveryUsesCompiledDefaultOnlyWhenAvailable(t *testing.T) {
info, statErr := os.Stat(defaultConfigPath)
if statErr != nil && !os.IsNotExist(statErr) {
t.Fatalf("stat compiled default config: %v", statErr)
}
if statErr == nil && !info.Mode().IsRegular() {
t.Skipf("compiled default config has unexpected host state: %s", info.Mode())
}
path, err := discoverConfigPath("", commandContractOptions(t))
if statErr == nil {
if err != nil || path != defaultConfigPath {
t.Fatalf("discoverConfigPath() = %q, %v; want compiled default", path, err)
}
return
}
if err == nil || !strings.Contains(err.Error(), "config file not found") {
t.Fatalf("discoverConfigPath() error = %v, want documented not-found context", err)
}
}
func TestConfigLoadingFailuresReturnOneWithPathContext(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing.yml")
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", missing}, &stdout, &stderr, commandContractOptions(t))
if code != 1 || !strings.Contains(stderr.String(), missing) || stdout.Len() != 0 {
t.Fatalf("missing config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
malformed := filepath.Join(t.TempDir(), "malformed.yml")
if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"config", "validate", "--config", malformed}, &stdout, &stderr, commandContractOptions(t))
if code != 1 || !strings.Contains(stderr.String(), malformed) || !strings.Contains(stderr.String(), "parse config file") || stdout.Len() != 0 {
t.Fatalf("malformed config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}
func TestConfigValidateResolvesPipelineAndChecksSelection(t *testing.T) {
configPath := writeResolvableCommandConfig(t)
options := commandContractOptions(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells"}, &stdout, &stderr, options)
if code != 0 || !strings.Contains(stdout.String(), "valid for pipeline \"demo\"") || stderr.Len() != 0 {
t.Fatalf("valid resolution: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "missing"}, &stdout, &stderr, options)
if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") {
t.Fatalf("unknown pipeline: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "missing"}, &stdout, &stderr, options)
if code != 1 || !strings.Contains(stderr.String(), "lane \"missing\"") {
t.Fatalf("unknown lane: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--only", "spells"}, &stdout, &stderr, options)
if code != 2 || !strings.Contains(stderr.String(), "--only requires --pipeline") {
t.Fatalf("missing pipeline for only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells,,other"}, &stdout, &stderr, options)
if code != 2 || !strings.Contains(stderr.String(), "--only must contain") {
t.Fatalf("malformed only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}
func TestPipelinesListSortsNormalizedIDsInTextAndJSON(t *testing.T) {
configPath := writeCommandConfig(t, " zeta ", "alpha")
options := commandContractOptions(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, options)
if code != 0 || stdout.String() != "alpha\nzeta\n" || stderr.Len() != 0 {
t.Fatalf("text list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"pipelines", "list", "--config", configPath, "--json"}, &stdout, &stderr, options)
var payload struct {
Pipelines []string `json:"pipelines"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("JSON list = %q: %v", stdout.String(), err)
}
if code != 0 || len(payload.Pipelines) != 2 || payload.Pipelines[0] != "alpha" || payload.Pipelines[1] != "zeta" || stderr.Len() != 0 {
t.Fatalf("JSON list: code=%d payload=%#v stderr=%q", code, payload, stderr.String())
}
}
func TestRemovedStructuralFlagsAndRuntimeFailuresKeepExitClasses(t *testing.T) {
configPath := writeResolvableCommandConfig(t)
options := commandContractOptions(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "demo", "--input", "missing-input", "--config", configPath, "--diagnostics-dir", t.TempDir()}, &stdout, &stderr, options)
if code != 2 || !strings.Contains(stderr.String(), "flag provided but not defined") {
t.Fatalf("removed flag: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"run", "missing", "--input", "missing-input", "--config", configPath, "--chunk_cache", "bypass"}, &stdout, &stderr, options)
if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") || stdout.Len() != 0 {
t.Fatalf("valid-runtime failure: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}
func commandContractOptions(t *testing.T) Options {
return commandContractOptionsWithLookup(t, emptyLookup)
}
func commandContractOptionsWithLookup(t *testing.T, lookup func(string) (string, bool)) Options {
t.Helper()
components, err := newProductionComponents()
if err != nil {
t.Fatal(err)
}
return Options{
Catalog: catalogFromRegistries(components.registries),
Registries: components.registries,
LookupEnv: lookup,
}
}
func writeCommandConfig(t *testing.T, firstID, secondID string) string {
t.Helper()
content := fmt.Sprintf("version: 3\npipelines:\n %q:\n input: seriatim\n %q:\n input: seriatim\n", firstID, secondID)
return writeCommandConfigContent(t, content)
}
func writeResolvableCommandConfig(t *testing.T) string {
t.Helper()
return writeCommandConfigContent(t, `version: 3
pipelines:
demo:
input: seriatim
artifacts:
spells:
extract: dnd/spells
`)
}
func writeCommandConfigContent(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}

View File

@@ -0,0 +1,16 @@
package cli
import (
"strings"
"testing"
)
func replaceRequiredOnce(t *testing.T, input, old, replacement string) string {
t.Helper()
if count := strings.Count(input, old); count != 1 {
t.Fatalf("replacement marker %q occurs %d times, want exactly once", old, count)
}
return strings.Replace(input, old, replacement, 1)
}
func emptyLookup(string) (string, bool) { return "", false }

View File

@@ -0,0 +1,208 @@
package cli
import (
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
)
func TestProductionCombatConfigurationResolvesTypedLane(t *testing.T) {
components := productionTestComponents(t)
cfg := productionCombatContractConfig()
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err)
}
if effective.ResolvedPipeline.Chunk.Module != pipeline.DefaultChunkModule {
t.Fatalf("chunk module = %q, want %q", effective.ResolvedPipeline.Chunk.Module, pipeline.DefaultChunkModule)
}
if len(effective.ResolvedPipeline.Steps[0].ArtifactLanes) != 1 {
t.Fatalf("artifact lanes = %#v, want one combat lane", effective.ResolvedPipeline.Steps[0].ArtifactLanes)
}
lane := effective.ResolvedPipeline.Steps[0].ArtifactLanes[0]
if lane.ID != "combat" || lane.ArtifactKind != dnd.CombatTurnListKind || lane.Extract.Module != combatextract.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != combatnormalize.Key {
t.Fatalf("resolved combat lane = %#v, want typed production composition", lane)
}
catalog := catalogFromRegistries(components.registries)
extractSpec, ok := catalog.Extractors.Spec(combatextract.Key)
if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.combat_turns"}) {
t.Fatalf("combat extractor spec = %#v, want source and artifact capabilities", extractSpec)
}
normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(combatnormalize.Key, dnd.CombatTurnListKind)
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
t.Fatalf("combat normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
}
mergeSpec, ok := catalog.Mergers.SpecForArtifact(pipeline.DefaultMergeModule, dnd.CombatTurnListKind)
if !ok || !reflect.DeepEqual(mergeSpec.Provides, []string{"merged"}) {
t.Fatalf("combat merger spec = %#v, want merged capability", mergeSpec)
}
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.CombatTurnListKind)
if !ok || codecSpec.Schema.ID != "notarius.dnd.combat_turns" || codecSpec.Schema.Version != "v1" {
t.Fatalf("combat codec spec = %#v, want compatible durable schema", codecSpec)
}
if !hasReferenceSlot(extractSpec.ReferenceSlots, "npcs") || !hasReferenceSlot(extractSpec.ReferenceSlots, "scene_descriptions") || !hasReferenceSlot(normalizeSpec.ReferenceSlots, "npcs") {
t.Fatalf("combat reference slots = %#v / %#v, want extraction scene and NPC slots plus normalization NPC slot", extractSpec.ReferenceSlots, normalizeSpec.ReferenceSlots)
}
sceneSlot := referenceSlot(extractSpec.ReferenceSlots, "scene_descriptions")
if !sceneSlot.Required || !reflect.DeepEqual(sceneSlot.AcceptedMediaTypes, []string{"application/json"}) || !reflect.DeepEqual(sceneSlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) || sceneSlot.MaxBytes != 1048576 {
t.Fatalf("scene description slot = %#v, want required approved scene artifact", sceneSlot)
}
wantExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
}
wantNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, wantExtractChain) {
t.Fatalf("combat extract chain = %#v, want %#v", got, wantExtractChain)
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, wantNormalizeChain) {
t.Fatalf("combat normalize chain = %#v, want %#v", got, wantNormalizeChain)
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageMerge, pipeline.DefaultMergeModule); len(got) != 0 {
t.Fatalf("combat merge chain = %#v, want empty", got)
}
bound, err := cfg.Resolve(config.ResolveInput{
PipelineID: "dnd-combat",
Catalog: catalog,
ReferenceOverrides: []pipeline.ReferenceBinding{
{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npcs", Source: "npc-run/lanes/npcs.json", BindingSource: contracts.ReferenceBindingSourceCLI},
{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npcs", Source: "npc-run/lanes/npcs.json", BindingSource: contracts.ReferenceBindingSourceCLI},
},
})
if err != nil {
t.Fatalf("Resolve(bound references) error = %v, want nil", err)
}
boundLane := bound.ResolvedPipeline.Steps[0].ArtifactLanes[0]
if len(boundLane.ExtractReferences.Bindings) != 2 || len(boundLane.NormalizeReferences.Bindings) != 1 || !hasReferenceBinding(boundLane.ExtractReferences.Bindings, "npcs") || !hasReferenceBinding(boundLane.ExtractReferences.Bindings, "scene_descriptions") || !hasReferenceBinding(boundLane.NormalizeReferences.Bindings, "npcs") {
t.Fatalf("bound combat references = %#v / %#v, want extraction scene and NPC bindings plus normalization NPC binding", boundLane.ExtractReferences, boundLane.NormalizeReferences)
}
}
func TestProductionCombatConfigurationRequiresSceneDescriptions(t *testing.T) {
components := productionTestComponents(t)
cfg := productionCombatContractConfig()
profile := cfg.Pipelines["dnd-combat"]
profile.References = nil
cfg.Pipelines["dnd-combat"] = profile
if _, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)}); err == nil || !strings.Contains(err.Error(), "scene_descriptions") || !strings.Contains(err.Error(), "required") {
t.Fatalf("Resolve() error = %v, want required scene reference failure", err)
}
}
func TestProductionCombatConfigurationRejectsLooseOptionsAndLaneValidators(t *testing.T) {
components := productionTestComponents(t)
resolve := func(mutate func(*pipeline.PipelineProfile)) error {
cfg := productionCombatContractConfig()
profile := cfg.Pipelines["dnd-combat"]
mutate(&profile)
cfg.Pipelines["dnd-combat"] = profile
_, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
return err
}
if err := resolve(func(profile *pipeline.PipelineProfile) {
lane := profile.Artifacts["combat"]
lane.Extract.Options = map[string]any{"unexpected": true}
profile.Artifacts["combat"] = lane
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("unknown extractor option error = %v, want strict option rejection", err)
}
if err := resolve(func(profile *pipeline.PipelineProfile) {
lane := profile.Artifacts["combat"]
lane.Normalize.Options = map[string]any{"unexpected": true}
profile.Artifacts["combat"] = lane
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("unknown normalizer option error = %v, want strict option rejection", err)
}
if err := resolve(func(profile *pipeline.PipelineProfile) {
lane := profile.Artifacts["combat"]
lane.Validators = []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}
profile.Artifacts["combat"] = lane
}); err == nil || !strings.Contains(err.Error(), "artifact lane level") {
t.Fatalf("lane-level validator error = %v, want invalid placement rejection", err)
}
}
func TestProductionCombatConfigurationResolvesTypedUnconditionalValidators(t *testing.T) {
components := productionTestComponents(t)
cfg := productionCombatContractConfig()
profile := cfg.Pipelines["dnd-combat"]
lane := profile.Artifacts["combat"]
lane.Extract.Validators = pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}}
lane.Normalize.Validators = pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_reject")}}
profile.Artifacts["combat"] = lane
cfg.Pipelines["dnd-combat"] = profile
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
if err != nil {
t.Fatalf("Resolve() error = %v, want typed unconditional validators to resolve", err)
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}) {
t.Fatalf("extract override chain = %#v, want typed always-accept", got)
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, []pipeline.ModuleBinding{pipeline.Binding("generic/always_reject")}) {
t.Fatalf("normalize override chain = %#v, want typed always-reject", got)
}
}
func productionCombatContractConfig() config.Config {
cfg := config.Default()
cfg.Pipelines["dnd-combat"] = pipeline.PipelineProfile{
ID: "dnd-combat",
Input: pipeline.Binding("seriatim"),
Chunk: pipeline.Binding(pipeline.DefaultChunkModule),
References: map[string]pipeline.ReferenceSource{"scene_descriptions": pipeline.ExternalReference("scenes.json")},
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"combat": {
Extract: pipeline.ModuleBinding{Module: combatextract.Key, Retries: 2},
Normalize: pipeline.Binding(combatnormalize.Key),
},
},
}
return cfg
}
func hasReferenceSlot(slots []contracts.ReferenceSlot, name string) bool {
for _, slot := range slots {
if slot.Name == name {
return true
}
}
return false
}
func hasReferenceBinding(bindings []pipeline.ReferenceBinding, name string) bool {
for _, binding := range bindings {
if binding.SlotName == name {
return true
}
}
return false
}
func referenceSlot(slots []contracts.ReferenceSlot, name string) contracts.ReferenceSlot {
for _, slot := range slots {
if slot.Name == name {
return slot
}
}
return contracts.ReferenceSlot{}
}

View File

@@ -0,0 +1,135 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
)
func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) {
components := productionTestComponents(t)
resolved, err := pipeline.ResolvePipeline(npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")), pipeline.ResolveOptions{}, catalogFromRegistries(components.registries))
if err != nil {
t.Fatalf("ResolvePipeline() error = %v", err)
}
if len(resolved.Steps) != 2 || len(resolved.Steps[1].ArtifactLanes) != 1 {
t.Fatalf("resolved pipeline = %#v", resolved)
}
lane := resolved.Steps[1].ArtifactLanes[0]
if lane.ArtifactKind != dnd.NPCInteractionListKind || lane.Extract.Module != interactionextract.Key || lane.Normalize.Module != interactionnormalize.Key {
t.Fatalf("interaction lane = %#v", lane)
}
for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} {
if len(bindings) != 1 || bindings[0].SlotName != "npcs" || bindings[0].Artifact == nil || bindings[0].Artifact.Step != "npcs" || bindings[0].Artifact.Lane != "npcs" {
t.Fatalf("generated bindings = %#v", bindings)
}
}
if _, err := pipeline.Prepare(resolved, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
t.Fatalf("Prepare() error = %v", err)
}
catalog := catalogFromRegistries(components.registries)
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCInteractionListKind)
if !ok || codecSpec.Schema.ID != interactioncodec.SchemaID || codecSpec.Schema.Version != interactioncodec.SchemaVersion {
t.Fatalf("NPC interaction codec spec = %#v", codecSpec)
}
}
func TestProductionNPCInteractionReferencesRequireEarlierCompatibleProducer(t *testing.T) {
components := productionTestComponents(t)
catalog := catalogFromRegistries(components.registries)
laterProfile := npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs"))
laterProfile.Steps[0].ID = "seed"
laterProfile.Steps[0].Artifacts["seed"] = laterProfile.Steps[0].Artifacts["npcs"]
delete(laterProfile.Steps[0].Artifacts, "npcs")
laterProfile.Steps = append(laterProfile.Steps, pipeline.PipelineStepProfile{ID: "future", Artifacts: map[string]pipeline.ArtifactLaneProfile{
"npcs": {Extract: pipeline.Binding(npcextract.Key), Normalize: pipeline.Binding(npcnormalize.Key)},
}})
laterProfile.Steps[1].References["npcs"] = pipeline.GeneratedReference("future", "npcs")
tests := []struct {
name string
profile pipeline.PipelineProfile
want string
}{
{name: "missing", profile: npcInteractionProfile(pipeline.ReferenceSource{}), want: "source must not be empty"},
{name: "same step", profile: npcInteractionProfile(pipeline.GeneratedReference("interactions", "interactions")), want: "earlier step"},
{name: "later step", profile: laterProfile, want: "earlier step"},
{name: "wrong artifact kind", profile: npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")), want: "does not accept artifact kind"},
}
tests[3].profile.Steps[0].Artifacts["npcs"] = pipeline.ArtifactLaneProfile{Extract: pipeline.Binding("dnd/spells")}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := pipeline.ResolvePipeline(test.profile, pipeline.ResolveOptions{}, catalog)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("ResolvePipeline() error = %v, want %q", err, test.want)
}
})
}
}
func TestProductionNPCInteractionReferencesRejectIncompatibleExternalRegistries(t *testing.T) {
components := productionTestComponents(t)
catalog := catalogFromRegistries(components.registries)
root := t.TempDir()
for _, test := range []struct {
name string
file string
content string
prepare bool
want string
}{
{name: "media type", file: "registry.txt", content: "not JSON", want: "media type"},
{name: "artifact schema", file: "registry.json", content: `{"npcs":[{"name":"missing required fields"}]}`, prepare: true, want: "NPC registry"},
} {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(root, test.file)
if err := os.WriteFile(path, []byte(test.content), 0o600); err != nil {
t.Fatal(err)
}
resolved, err := pipeline.ResolvePipeline(npcInteractionProfile(pipeline.ExternalReference(path)), pipeline.ResolveOptions{}, catalog)
if err != nil {
t.Fatalf("ResolvePipeline() error = %v", err)
}
materialized, _, err := pipeline.MaterializeReferences(resolved, catalog, pipeline.ReferenceMaterializationOptions{})
if !test.prepare {
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("MaterializeReferences() error = %v, want %q", err, test.want)
}
return
}
if err != nil {
t.Fatalf("MaterializeReferences() error = %v", err)
}
if _, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Prepare() error = %v, want %q", err, test.want)
}
})
}
}
func npcInteractionProfile(reference pipeline.ReferenceSource) pipeline.PipelineProfile {
profile := pipeline.PipelineProfile{
ID: "dnd-npc-interactions",
Input: pipeline.Binding("seriatim"),
Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}},
Output: pipeline.Binding("json"),
Steps: []pipeline.PipelineStepProfile{
{ID: "npcs", Artifacts: map[string]pipeline.ArtifactLaneProfile{
"npcs": {Extract: pipeline.Binding(npcextract.Key), Normalize: pipeline.Binding(npcnormalize.Key)},
}},
{ID: "interactions", References: map[string]pipeline.ReferenceSource{"npcs": reference}, Artifacts: map[string]pipeline.ArtifactLaneProfile{
"interactions": {Extract: pipeline.Binding(interactionextract.Key), Normalize: pipeline.Binding(interactionnormalize.Key)},
}},
},
}
return profile
}

View File

@@ -0,0 +1,151 @@
package cli
import (
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
)
func TestProductionNPCConfigurationResolvesTypedLane(t *testing.T) {
components := productionTestComponents(t)
catalog := catalogFromRegistries(components.registries)
cfg := productionNPCContractConfig()
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalog})
if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err)
}
if effective.ResolvedPipeline.Chunk.Module != pipeline.DefaultChunkModule {
t.Fatalf("chunk module = %q, want %q", effective.ResolvedPipeline.Chunk.Module, pipeline.DefaultChunkModule)
}
if len(effective.ResolvedPipeline.Steps[0].ArtifactLanes) != 1 {
t.Fatalf("artifact lanes = %#v, want one NPC lane", effective.ResolvedPipeline.Steps[0].ArtifactLanes)
}
lane := effective.ResolvedPipeline.Steps[0].ArtifactLanes[0]
if lane.ID != "npcs" || lane.ArtifactKind != dnd.NPCListKind || lane.Extract.Module != npcextract.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != npcnormalize.Key {
t.Fatalf("resolved NPC lane = %#v, want typed production composition", lane)
}
if len(lane.ExtractReferences.Bindings) != 0 || len(lane.NormalizeReferences.Bindings) != 0 {
t.Fatalf("unbound NPC references = %#v / %#v, want none", lane.ExtractReferences, lane.NormalizeReferences)
}
extractSpec, ok := catalog.Extractors.Spec(npcextract.Key)
if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.npcs"}) {
t.Fatalf("NPC extractor spec = %#v, want source and artifact capabilities", extractSpec)
}
mergeSpec, ok := catalog.Mergers.SpecForArtifact(pipeline.DefaultMergeModule, dnd.NPCListKind)
if !ok || !reflect.DeepEqual(mergeSpec.Provides, []string{"merged"}) {
t.Fatalf("NPC merger spec = %#v, want merged capability", mergeSpec)
}
normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(npcnormalize.Key, dnd.NPCListKind)
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
t.Fatalf("NPC normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
}
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCListKind)
if !ok || codecSpec.Kind != dnd.NPCListKind || codecSpec.Schema.ID != npccodec.SchemaID || codecSpec.Schema.Version != npccodec.SchemaVersion {
t.Fatalf("NPC codec spec = %#v, want typed v1 durable schema", codecSpec)
}
wantExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/npcs/shape"),
pipeline.Binding("extract/dnd/npcs/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npcs/source_relatedness"),
}
wantNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/npcs/shape"),
pipeline.Binding("normalize/dnd/npcs/identity"),
pipeline.Binding("extract/dnd/npcs/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npcs/source_relatedness"),
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, npcextract.Key); !reflect.DeepEqual(got, wantExtractChain) {
t.Fatalf("NPC extract chain = %#v, want %#v", got, wantExtractChain)
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, npcnormalize.Key); !reflect.DeepEqual(got, wantNormalizeChain) {
t.Fatalf("NPC normalize chain = %#v, want %#v", got, wantNormalizeChain)
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageMerge, pipeline.DefaultMergeModule); len(got) != 0 {
t.Fatalf("NPC merge chain = %#v, want empty", got)
}
}
func TestProductionNPCConfigurationValidatesOptionsReferencesAndPlacement(t *testing.T) {
components := productionTestComponents(t)
resolve := func(mutate func(*pipeline.PipelineProfile)) error {
cfg := productionNPCContractConfig()
profile := cfg.Pipelines["dnd-session"]
mutate(&profile)
cfg.Pipelines["dnd-session"] = profile
_, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalogFromRegistries(components.registries)})
return err
}
if err := resolve(func(profile *pipeline.PipelineProfile) {
lane := profile.Artifacts["npcs"]
lane.Extract.Options = map[string]any{"unexpected": true}
profile.Artifacts["npcs"] = lane
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("unknown extractor option error = %v, want strict option rejection", err)
}
if err := resolve(func(profile *pipeline.PipelineProfile) {
lane := profile.Artifacts["npcs"]
lane.Normalize.Options = map[string]any{"unexpected": true}
profile.Artifacts["npcs"] = lane
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("unknown normalizer option error = %v, want strict option rejection", err)
}
if err := resolve(func(profile *pipeline.PipelineProfile) {
profile.References = pipeline.ExternalReferenceMap(map[string]string{
"players": "players.txt",
"party": "party.txt",
"glossary": "glossary.txt",
})
}); err != nil {
t.Fatalf("optional NPC references error = %v, want resolution success", err)
}
if err := resolve(func(profile *pipeline.PipelineProfile) {
lane := profile.Artifacts["npcs"]
lane.Validators = []pipeline.ModuleBinding{pipeline.Binding("normalize/dnd/npcs/identity")}
profile.Artifacts["npcs"] = lane
}); err == nil || !strings.Contains(err.Error(), "artifact lane level") {
t.Fatalf("lane-level validator error = %v, want invalid placement rejection", err)
}
}
func productionNPCContractConfig() config.Config {
cfg := config.Default()
cfg.Pipelines["dnd-session"] = pipeline.PipelineProfile{
ID: "dnd-session",
Input: pipeline.Binding("seriatim"),
Chunk: pipeline.Binding(pipeline.DefaultChunkModule),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"npcs": {
Extract: pipeline.ModuleBinding{Module: npcextract.Key, Retries: 2},
Normalize: pipeline.Binding(npcnormalize.Key),
},
},
}
return cfg
}
func validatorChain(resolved pipeline.ResolvedPipeline, stage pipeline.ModuleStage, module string) []pipeline.ModuleBinding {
for _, chain := range resolved.ValidatorChains {
if chain.Stage == stage && chain.ModuleKey == module {
bindings := make([]pipeline.ModuleBinding, len(chain.Validators))
for index, validator := range chain.Validators {
bindings[index] = validator.Binding
}
return bindings
}
}
return nil
}

View File

@@ -0,0 +1,111 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
sceneextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
scenenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions"
)
func TestProductionSceneDescriptionWorkflow(t *testing.T) {
components := productionTestComponents(t)
cfg := config.Default()
cfg.Pipelines["scene-descriptions"] = pipeline.PipelineProfile{
ID: "scene-descriptions",
Input: pipeline.Binding("seriatim"),
Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}},
Output: pipeline.Binding("json"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"scene-descriptions": {
Extract: pipeline.Binding(sceneextract.Key),
Normalize: pipeline.Binding(scenenormalize.Key),
},
},
}
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "scene-descriptions", Catalog: catalogFromRegistries(components.registries)})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
lane := effective.ResolvedPipeline.Steps[0].ArtifactLanes[0]
if lane.ArtifactKind != dnd.SceneDescriptionListKind || lane.Extract.Module != sceneextract.Key || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != scenenormalize.Key {
t.Fatalf("resolved lane = %#v, want production scene-description composition", lane)
}
if len(lane.ExtractReferences.Bindings) != 0 || len(lane.NormalizeReferences.Bindings) != 0 {
t.Fatalf("resolved references = %#v / %#v, want no generated or required references", lane.ExtractReferences, lane.NormalizeReferences)
}
prepared, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: sceneDescriptionLLM{}})
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
ChunkCacheMode: pipeline.ChunkCacheBypass,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
t.Fatalf("run output = %#v, want one approved normalized artifact", output)
}
normalizedOutput := output.NormalizeOutputs[0]
if normalizedOutput.NormalizerKey != scenenormalize.Key || normalizedOutput.Artifact.Kind != dnd.SceneDescriptionListKind || normalizedOutput.Artifact.Schema.ID != scenecodec.SchemaID || normalizedOutput.Artifact.Schema.Name != scenecodec.SchemaName || normalizedOutput.Artifact.Schema.Version != scenecodec.SchemaVersion {
t.Fatalf("normalized output = %#v, want registered durable scene-description schema", normalizedOutput)
}
var value dnd.SceneDescriptionList
if err := json.Unmarshal(normalizedOutput.Artifact.Content, &value); err != nil {
t.Fatalf("decode normalized artifact: %v", err)
}
want := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{
{ID: "chunk-000001", SourceRef: source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}, Kind: dnd.SceneKindNarrative, Title: "Aria casts Cure Wounds", Summary: "Aria casts Cure Wounds."},
{ID: "chunk-000002", SourceRef: source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}, Kind: dnd.SceneKindCombat, Title: "Bandit mage casts Shield", Summary: "The bandit mage casts Shield."},
}}
if !reflect.DeepEqual(value, want) {
t.Fatalf("normalized scene descriptions = %#v, want %#v", value, want)
}
durable := decodeAssembledOutput[dnd.SceneDescriptionList](t, output.OutputFiles, "lanes/scene-descriptions.json")
if !reflect.DeepEqual(durable, want) {
t.Fatalf("durable output payload = %#v, want %#v", durable, want)
}
if len(output.Warnings) != 0 {
t.Fatalf("warnings = %#v, want grounded descriptions without warnings", output.Warnings)
}
}
type sceneDescriptionLLM struct{}
func (sceneDescriptionLLM) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
if err := ctx.Err(); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if req.PromptID != sceneextract.PromptID {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
}
transcript := string(req.Inputs["transcript"].Content)
var content string
switch {
case strings.Contains(transcript, "Cure Wounds"):
content = `{"kind":"narrative","title":" Aria casts Cure Wounds ","summary":" Aria casts Cure Wounds. "}`
case strings.Contains(transcript, "Shield"):
content = `{"kind":"combat","title":"Bandit mage casts Shield","summary":"The bandit mage casts Shield."}`
default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected transcript material %q", transcript)
}
if err := json.Unmarshal([]byte(content), out); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate structured response: %w", err)
}
return contracts.StructuredCompletionResponse{Content: []byte(content), Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil
}

View File

@@ -0,0 +1,218 @@
package cli
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
)
func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
components := productionTestComponents(t)
for _, example := range maintainedExampleFiles(t) {
t.Run(example.name, func(t *testing.T) {
cfg := loadMaintainedExample(t, example.path)
for _, pipelineID := range example.pipelineIDs {
effective, err := cfg.Resolve(resolveInputForMaintainedExample(components, pipelineID))
if err != nil {
t.Fatalf("resolve maintained example %q: %v", pipelineID, err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
ConfigPath: example.path,
WorkingDir: filepath.Dir(example.path),
})
if err != nil {
t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err)
}
if example.name == "complete" {
if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-events,npcs,scene-descriptions|extract-events:combat-turns,npc-interactions,spells" {
t.Fatalf("complete example steps and lanes = %v, want every D&D extractor in the documented two-step composition", got)
}
spellLane := referenceContractLane(t, materialized, "spells")
if len(spellLane.ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 ||
len(spellLane.NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 {
t.Fatalf("complete example spell catalog reference was not materialized: %#v", spellLane)
}
itemEventLane := referenceContractLane(t, materialized, "item-events")
for _, references := range []pipeline.ResolvedReferenceTarget{itemEventLane.ExtractReferences, itemEventLane.NormalizeReferences} {
if _, found := references.ReferenceSet.Slots["npcs"]; found {
t.Fatalf("item event lane unexpectedly depends on generated NPCs: %#v", itemEventLane)
}
if _, found := references.ReferenceSet.Slots["scene_descriptions"]; found {
t.Fatalf("item event lane unexpectedly depends on generated scene descriptions: %#v", itemEventLane)
}
}
}
}
var stdout, stderr strings.Builder
code := RunWithOptions([]string{"pipelines", "list", "--config", example.path}, &stdout, &stderr, productionOptionsFromComponents(components))
if code != 0 || stdout.String() != strings.Join(example.pipelineIDs, "\n")+"\n" || stderr.Len() != 0 {
t.Fatalf("pipelines list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
}
func TestMaintainedConfigurationExampleSet(t *testing.T) {
entries, err := os.ReadDir(repositoryPath("examples"))
if err != nil {
t.Fatal(err)
}
var names []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".config.yml") {
names = append(names, entry.Name())
}
}
sort.Strings(names)
if got := strings.Join(names, ","); got != "dnd-complete.config.yml,dnd-minimal.config.yml" {
t.Fatalf("maintained configuration examples = %q, want only the minimal and complete D&D examples", got)
}
}
func exampleStepLaneIDs(resolved pipeline.ResolvedPipeline) []string {
result := make([]string, 0, len(resolved.Steps))
for _, step := range resolved.Steps {
laneIDs := make([]string, 0, len(step.ArtifactLanes))
for _, lane := range step.ArtifactLanes {
laneIDs = append(laneIDs, lane.ID)
}
sort.Strings(laneIDs)
result = append(result, step.ID+":"+strings.Join(laneIDs, ","))
}
return result
}
func TestMaintainedMinimalInvocationProducesJSONBundle(t *testing.T) {
outputRoot := filepath.Join(t.TempDir(), "output")
fake := &productionFakeLLMClient{}
options := productionRunOptions(t, fake)
var stdout, stderr strings.Builder
code := RunWithOptions([]string{
"run", "dnd-session",
"--config", repositoryPath("examples", "dnd-minimal.config.yml"),
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
"--only", "spells", "--chunk_cache", "bypass", "--output-dir", outputRoot,
}, &stdout, &stderr, options)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), `pipeline "dnd-session"`) || !strings.Contains(stdout.String(), "outputs=1 rejected=0") {
t.Fatalf("stdout=%q, want completed pipeline and counts", stdout.String())
}
runRoot := filepath.Join(outputRoot, productionRunID)
index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json"))
if index.ManifestFile != "manifest.json" || index.RejectedFile != "rejected.json" || index.WarningsFile != "warnings.json" || len(index.OutputFiles) != 1 {
t.Fatalf("index = %#v, want one spells output and fixed companion files", index)
}
entry := index.OutputFiles[0]
if entry.LaneID != "spells" || entry.File != "lanes/spells.json" || entry.MediaType != "application/json" || entry.SchemaID != "notarius.dnd.spells" || entry.SchemaVersion != "v1" {
t.Fatalf("index output entry = %#v, want spells JSON contract", entry)
}
manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(runRoot, "manifest.json"))
if manifest.PipelineID != "dnd-session" || manifest.InputModule != "seriatim" || manifest.Chunker != "generic" || manifest.OutputEncoder != "json" || manifest.ValidationStatus != "approved" || manifest.ChunkPlan == nil || manifest.ChunkPlan.Action != "bypassed" {
t.Fatalf("manifest = %#v, want approved minimal run", manifest)
}
if len(manifest.ArtifactLanes) != 1 {
t.Fatalf("manifest lanes = %#v, want exactly spells", manifest.ArtifactLanes)
}
lane := manifest.ArtifactLanes[0]
if lane.ID != "spells" || lane.Extractor != "dnd/spells" || lane.Merger != "appendorder" || lane.Normalizer != spellnormalize.Key {
t.Fatalf("manifest lane = %#v, want production spells composition", lane)
}
if len(manifest.References) != 0 {
t.Fatalf("base-only manifest references = %#v, want no overlay provenance", manifest.References)
}
extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
if !ok || len(stringValues(extractorMetadata["catalog_overlay_ids"])) != 0 {
t.Fatalf("base-only extractor metadata = %#v, want no overlay IDs", lane.Metadata)
}
artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, entry.File))
if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Cure Wounds" || artifact.SpellCasts[0].SourceRefs[0].SourceID != "session-alpha" {
t.Fatalf("artifact = %#v, want one source-linked Cure Wounds cast", artifact)
}
rejected := readProductionJSON[struct {
Rejected []json.RawMessage `json:"rejected"`
}](t, filepath.Join(runRoot, "rejected.json"))
if len(rejected.Rejected) != 0 {
t.Fatalf("rejected = %#v, want empty rejection list", rejected.Rejected)
}
warnings := readProductionJSON[struct {
Warnings []json.RawMessage `json:"warnings"`
}](t, filepath.Join(runRoot, "warnings.json"))
if len(warnings.Warnings) != 0 {
t.Fatalf("warnings = %#v, want empty warning list", warnings.Warnings)
}
}
func TestMaintainedMalformedInputOnlyRecordsDebugFailureWhenRequested(t *testing.T) {
malformed := filepath.Join(t.TempDir(), "malformed.json")
if err := os.WriteFile(malformed, []byte("{not valid json"), 0o600); err != nil {
t.Fatal(err)
}
for _, debug := range []bool{false, true} {
name := "without debug"
if debug {
name = "with debug"
}
t.Run(name, func(t *testing.T) {
outputRoot := filepath.Join(t.TempDir(), "output")
debugRoot := filepath.Join(t.TempDir(), "debug")
options := productionRunOptions(t, &productionFakeLLMClient{})
args := []string{
"run", "dnd-session",
"--config", repositoryPath("examples", "dnd-minimal.config.yml"),
"--input", malformed, "--chunk_cache", "bypass", "--output-dir", outputRoot,
}
if debug {
args = append(args, "--debug", "--debug-dir", debugRoot)
}
var stdout, stderr strings.Builder
code := RunWithOptions(args, &stdout, &stderr, options)
if code != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "parse input") {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertAbsent(t, outputRoot)
if !debug {
assertAbsent(t, debugRoot)
return
}
bundle := onlyChildDir(t, debugRoot)
report := readProductionJSON[debugbundle.RunReport](t, filepath.Join(bundle, "summary", "run-report.json"))
if report.Succeeded || report.PipelineID != "dnd-session" {
t.Fatalf("failure report = %#v, want failed dnd-session report", report)
}
})
}
}
type exampleOutputIndex struct {
ManifestFile string `json:"manifest_file"`
OutputFiles []exampleOutputIndexEntry `json:"output_files"`
RejectedFile string `json:"rejected_file"`
WarningsFile string `json:"warnings_file"`
}
type exampleOutputIndexEntry struct {
LaneID string `json:"lane_id"`
MediaType string `json:"media_type"`
File string `json:"file"`
SchemaID string `json:"schema_id"`
SchemaVersion string `json:"schema_version"`
}
func resolveInputForMaintainedExample(components productionComponents, pipelineID string) config.ResolveInput {
return config.ResolveInput{PipelineID: pipelineID, Catalog: catalogFromRegistries(components.registries)}
}

View File

@@ -0,0 +1,80 @@
package cli
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestOversizedNPCRegistryFailsBeforeRuntimeAndCheckpointConstruction(t *testing.T) {
components := productionTestComponents(t)
npcPath := filepath.Join(t.TempDir(), "npcs.json")
if err := os.WriteFile(npcPath, []byte(strings.Repeat("x", 1048577)), 0o600); err != nil {
t.Fatal(err)
}
checkpointRoot := filepath.Join(t.TempDir(), "checkpoints")
content := fmt.Sprintf(`version: 3
cache:
chunk_plans:
mode: bypass
checkpoints:
enabled: true
directory: %q
pipelines:
dnd-session:
input: seriatim
artifacts:
spells:
extract:
module: dnd/spells
references:
npcs: %q
normalize: dnd/spells
`, checkpointRoot, npcPath)
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
llmConstructed := false
chunkStoreConstructed := false
options := Options{
Catalog: catalogFromRegistries(components.registries),
Registries: components.registries,
LLMClientFactory: func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
llmConstructed = true
return nil, nil, errors.New("LLM client must not be constructed")
},
ChunkPlanStoreFactory: func(string) (pipeline.ChunkPlanStore, error) {
chunkStoreConstructed = true
return nil, errors.New("chunk-plan store must not be constructed")
},
}
var stdout, stderr strings.Builder
code := RunWithOptions([]string{
"run", "dnd-session", "--config", configPath,
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
"--chunk_cache", "bypass", "--output-dir", t.TempDir(),
}, &stdout, &stderr, options)
for _, fragment := range []string{`pipeline "dnd-session"`, `reference slot "npcs"`, "1048577 bytes", "limit 1048576"} {
if code == 0 || !strings.Contains(stderr.String(), fragment) {
t.Fatalf("RunWithOptions() code = %d stderr = %q, want context fragment %q", code, stderr.String(), fragment)
}
}
if llmConstructed || chunkStoreConstructed {
t.Fatalf("runtime construction = LLM %t, chunk store %t; want materialization failure first", llmConstructed, chunkStoreConstructed)
}
if _, err := os.Stat(checkpointRoot); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("checkpoint root stat error = %v, want no checkpoint allocation", err)
}
}

View File

@@ -0,0 +1,850 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
"sync"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents"
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
)
func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
components := productionTestComponents(t)
registries := components.registries
assertProductionContains(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"})
assertProductionContains(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"})
assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key, itemeventextract.Key})
assertProductionContains(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"})
assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key, itemeventnormalize.Key})
assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"})
assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{
"extract/dnd/spells/catalog",
"extract/dnd/spells/shape",
"extract/dnd/spells/source_refs",
"extract/dnd/spells/source_relatedness",
"extract/dnd/combat-turns/shape",
"extract/dnd/combat-turns/source_refs",
"extract/dnd/combat-turns/source_relatedness",
"normalize/dnd/combat-turns/invariants",
"extract/dnd/item-events/shape",
"extract/dnd/item-events/source_refs",
"extract/dnd/item-events/source_relatedness",
"normalize/dnd/item-events/invariants",
"generic/always_accept",
"generic/always_reject",
"generic/valid_json",
"generic/valid_json_schema",
})
assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind})
assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind})
assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind})
assertProductionContains(t, "spell normalizer variants", registries.Normalizers.RegisteredArtifactKinds(spellnormalize.Key), []contracts.ArtifactKind{dnd.SpellListKind})
assertProductionContains(t, "combat normalizer variants", registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
assertProductionContains(t, "item event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind})
wantChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/spells/shape"),
pipeline.Binding("extract/dnd/spells/catalog"),
pipeline.Binding("extract/dnd/spells/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/spells/source_relatedness"),
}
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
t.Fatalf("spell validator chain = %#v, want %#v", got, wantChain)
}
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) {
t.Fatalf("spell normalize validator chain = %#v, want %#v", got, wantChain)
}
combatExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
}
combatNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
}
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, combatExtractChain) {
t.Fatalf("combat extract validator chain = %#v, want %#v", got, combatExtractChain)
}
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain)
}
itemEventExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/item-events/shape"),
pipeline.Binding("extract/dnd/item-events/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/item-events/source_relatedness"),
}
itemEventNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/item-events/shape"),
pipeline.Binding("normalize/dnd/item-events/invariants"),
pipeline.Binding("extract/dnd/item-events/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/item-events/source_relatedness"),
}
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemEventExtractChain) {
t.Fatalf("item event extract validator chain = %#v, want %#v", got, itemEventExtractChain)
}
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemEventNormalizeChain) {
t.Fatalf("item event normalize validator chain = %#v, want %#v", got, itemEventNormalizeChain)
}
assetNames := productionAssetNames(t, components.assets.PromptFS)
requiredAssets := []string{
"dnd.scenes/dnd.scenes.yaml",
"dnd.scenes/instructions.md",
"dnd.scenes/sharedassets/common-dnd-references.md",
"dnd.scenes/sharedassets/common-dnd-system.md",
"dnd.scenes/sharedassets/common-dnd-transcript.md",
"dnd.scenes/task.md",
"dnd.spells/dnd.spells.yaml",
"dnd.spells/catalog.md",
"dnd.spells/instructions.md",
"dnd.spells/sharedassets/common-dnd-references.md",
"dnd.spells/sharedassets/common-dnd-system.md",
"dnd.spells/sharedassets/common-dnd-transcript.md",
"dnd.spells/task.md",
"dnd.combat_turns/dnd.combat_turns.yaml",
"dnd.combat_turns/instructions.md",
"dnd.combat_turns/sharedassets/common-dnd-references.md",
"dnd.combat_turns/sharedassets/common-dnd-system.md",
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
"dnd.combat_turns/task.md",
"dnd.item_events/dnd.item_events.yaml",
"dnd.item_events/instructions.md",
"dnd.item_events/sharedassets/common-dnd-extraction-evidence.md",
"dnd.item_events/sharedassets/common-dnd-identity.md",
"dnd.item_events/sharedassets/common-dnd-references.md",
"dnd.item_events/sharedassets/common-dnd-system.md",
"dnd.item_events/sharedassets/common-dnd-transcript.md",
"dnd.item_events/task.md",
}
assertProductionContains(t, "production prompt assets", assetNames, requiredAssets)
catalog := catalogFromRegistries(registries)
converted := registriesFromCatalog(catalog)
if converted.ArtifactCodecs != registries.ArtifactCodecs || converted.ValidatorChains != registries.ValidatorChains {
t.Fatal("catalog/registry conversion did not preserve codec and validator-chain registries")
}
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.SpellListKind)
if !ok || codecSpec.Kind != dnd.SpellListKind || codecSpec.Schema.ID != spellcodec.SchemaID {
t.Fatalf("catalog codec spec = %#v, ok=%t, want typed D&D spell codec", codecSpec, ok)
}
combatCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.CombatTurnListKind)
if !ok || combatCodecSpec.Kind != dnd.CombatTurnListKind || combatCodecSpec.Schema.ID != combatcodec.SchemaID {
t.Fatalf("combat codec spec = %#v, ok=%t, want typed D&D combat codec", combatCodecSpec, ok)
}
itemEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.ItemEventListKind)
if !ok || itemEventCodecSpec.Kind != dnd.ItemEventListKind || itemEventCodecSpec.Schema.ID != itemeventcodec.SchemaID {
t.Fatalf("item event codec spec = %#v, ok=%t, want typed D&D item-event codec", itemEventCodecSpec, ok)
}
if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain)
}
if got := catalog.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) {
t.Fatalf("catalog spell normalize validator chain = %#v, want %#v", got, wantChain)
}
}
func TestDefaultCLICompositionValidatesRepresentativeConfiguration(t *testing.T) {
var stdout, stderr strings.Builder
code := RunWithOptions([]string{
"config", "validate", "--config", repositoryPath("examples", "dnd-minimal.config.yml"), "--pipeline", "dnd-session",
}, &stdout, &stderr, Options{})
if code != 0 || stderr.Len() != 0 {
t.Fatalf("validate representative config with default composition: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}
func TestProductionPromptAssetsPrepareWithoutProviderCredentials(t *testing.T) {
components := productionTestComponents(t)
cfg := config.Default()
cfg.Pipelines["dnd-scenes"] = pipeline.PipelineProfile{
ID: "dnd-scenes",
Input: pipeline.Binding("seriatim"),
Chunk: pipeline.Binding("dnd/scenes"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"spells": {Extract: pipeline.Binding("dnd/spells")},
},
}
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-scenes", Catalog: catalogFromRegistries(components.registries)})
if err != nil {
t.Fatalf("resolve production scene pipeline: %v", err)
}
if _, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
t.Fatalf("prepare production scene and spell modules: %v", err)
}
}
func TestProductionSpellValidatorsPrepareFromMaterializedCatalog(t *testing.T) {
components := productionTestComponents(t)
configPath := writeProductionSpellCatalogContractConfig(t)
effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
if err != nil {
t.Fatalf("resolve production spell configuration: %v", err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
ConfigPath: configPath,
WorkingDir: filepath.Dir(configPath),
})
if err != nil {
t.Fatalf("materialize production spell references: %v", err)
}
extractItems := materialized.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items
normalizeItems := materialized.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items
if len(extractItems) != 1 || extractItems[0].MediaType != "application/json" || len(extractItems[0].Content) == 0 {
t.Fatalf("materialized extract spell catalog items = %#v, want one JSON item", extractItems)
}
if len(normalizeItems) != 1 || normalizeItems[0].MediaType != "application/json" || !reflect.DeepEqual(normalizeItems[0].Content, extractItems[0].Content) {
t.Fatalf("materialized normalize spell catalog items = %#v, want an independent binding of the extract catalog", normalizeItems)
}
if _, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
t.Fatalf("prepare production spell pipeline from materialized catalog: %v", err)
}
}
func TestProductionSpellNormalizerRejectsInvalidCatalogReferencesBeforeExecution(t *testing.T) {
components := productionTestComponents(t)
configPath := writeProductionSpellCatalogContractConfig(t)
resolve := func(t *testing.T) pipeline.ResolvedPipeline {
t.Helper()
effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
if err != nil {
t.Fatalf("resolve production spell configuration: %v", err)
}
return effective.ResolvedPipeline
}
materialize := func(resolved pipeline.ResolvedPipeline) (pipeline.ResolvedPipeline, error) {
materialized, _, err := pipeline.MaterializeReferences(resolved, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
ConfigPath: configPath,
WorkingDir: filepath.Dir(configPath),
})
return materialized, err
}
t.Run("malformed catalog fails preparation", func(t *testing.T) {
catalogPath := filepath.Join(t.TempDir(), "malformed.json")
if err := os.WriteFile(catalogPath, []byte(`{"schema_version":`), 0o600); err != nil {
t.Fatal(err)
}
resolved := resolve(t)
setNormalizeSpellCatalogSource(t, &resolved, catalogPath)
materialized, err := materialize(resolved)
if err != nil {
t.Fatalf("MaterializeReferences() error = %v, want malformed JSON to reach preparation", err)
}
_, err = pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}})
for _, fragment := range []string{`pipeline "dnd-session"`, `lane "spells"`, "normalize", `module "dnd/spells"`, "decode spell catalog overlay"} {
if err == nil || !strings.Contains(err.Error(), fragment) {
t.Fatalf("Prepare() error = %v, want context fragment %q", err, fragment)
}
}
})
t.Run("multiple catalog items fail preparation", func(t *testing.T) {
materialized, err := materialize(resolve(t))
if err != nil {
t.Fatal(err)
}
slot := materialized.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["spell_catalog"]
slot.Items = append(slot.Items, slot.Items[0])
materialized.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["spell_catalog"] = slot
_, err = pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}})
for _, fragment := range []string{"normalize", `module "dnd/spells"`, "zero or one item"} {
if err == nil || !strings.Contains(err.Error(), fragment) {
t.Fatalf("Prepare() error = %v, want context fragment %q", err, fragment)
}
}
})
t.Run("oversized catalog fails materialization", func(t *testing.T) {
catalogPath := filepath.Join(t.TempDir(), "oversized.json")
if err := os.WriteFile(catalogPath, []byte(strings.Repeat("x", 1048577)), 0o600); err != nil {
t.Fatal(err)
}
checkpointRoot := filepath.Join(t.TempDir(), "checkpoints")
content := productionSpellCatalogContractConfig(t)
catalogSource := repositoryPath("examples", "dnd-spell-catalog.json")
if count := strings.Count(content, catalogSource); count != 2 {
t.Fatalf("spell catalog source occurs %d times, want extract and normalize bindings", count)
}
content = strings.Replace(content, catalogSource, "__extract_catalog__", 1)
content = replaceRequiredOnce(t, content, catalogSource, catalogPath)
content = replaceRequiredOnce(t, content, "__extract_catalog__", catalogSource)
content = replaceRequiredOnce(t, content, " enabled: false\n directory: \"\"", " enabled: true\n directory: "+checkpointRoot)
configFile := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configFile, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
llmConstructed := false
chunkStoreConstructed := false
options := Options{
Catalog: catalogFromRegistries(components.registries),
Registries: components.registries,
LLMClientFactory: func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
llmConstructed = true
return nil, nil, errors.New("LLM client must not be constructed")
},
ChunkPlanStoreFactory: func(string) (pipeline.ChunkPlanStore, error) {
chunkStoreConstructed = true
return nil, errors.New("chunk-plan store must not be constructed")
},
}
var stdout, stderr strings.Builder
code := RunWithOptions([]string{
"run", "dnd-session", "--config", configFile,
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
}, &stdout, &stderr, options)
errText := stderr.String()
for _, fragment := range []string{"normalize", `lane "spells"`, `reference slot "spell_catalog"`, "1048577 bytes", "limit 1048576"} {
if code == 0 || !strings.Contains(errText, fragment) {
t.Fatalf("RunWithOptions() code = %d stderr = %q, want context fragment %q", code, errText, fragment)
}
}
if llmConstructed || chunkStoreConstructed {
t.Fatalf("runtime construction = LLM %t, chunk store %t; want materialization failure first", llmConstructed, chunkStoreConstructed)
}
if _, err := os.Stat(checkpointRoot); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("checkpoint root stat error = %v, want no checkpoint allocation", err)
}
})
}
func setNormalizeSpellCatalogSource(t *testing.T, resolved *pipeline.ResolvedPipeline, sourcePath string) {
t.Helper()
if resolved == nil || len(resolved.Steps[0].ArtifactLanes) != 1 {
t.Fatalf("resolved pipeline = %#v, want one artifact lane", resolved)
}
bindings := resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings
matches := 0
for index := range bindings {
if bindings[index].SlotName == "spell_catalog" {
bindings[index].Source = sourcePath
matches++
}
}
if matches != 1 {
t.Fatalf("normalize reference bindings = %#v, want exactly one spell_catalog binding", bindings)
}
resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings = bindings
}
func TestProductionLLMClientFactoriesBuildOfflineRuntime(t *testing.T) {
components := productionTestComponents(t)
factories := []struct {
name string
factory LLMClientFactory
}{
{name: "default production assets", factory: productionLLMClientFactory},
{name: "provided production assets", factory: productionLLMClientFactoryWithAssets(components.assets)},
}
for _, tt := range factories {
t.Run(tt.name, func(t *testing.T) {
client, manifests, err := tt.factory(context.Background(), config.Default(), "test-profile")
if err != nil {
t.Fatalf("build production LLM runtime: %v", err)
}
if client == nil {
t.Fatal("production LLM runtime returned a nil client")
}
if len(manifests) != 0 {
t.Fatalf("eager profile manifests = %#v, want none", manifests)
}
if _, ok := client.(contracts.LLMProfileManifestProvider); !ok {
t.Fatalf("production LLM client %T does not provide profile manifests", client)
}
})
}
}
func TestProductionLLMClientFactoriesRejectInvalidConstruction(t *testing.T) {
t.Run("canceled context", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
client, manifests, err := productionLLMClientFactory(ctx, config.Default(), "test-profile")
if !errors.Is(err, context.Canceled) || client != nil || len(manifests) != 0 {
t.Fatalf("client=%T manifests=%#v error=%v, want canceled construction", client, manifests, err)
}
})
t.Run("nil assets", func(t *testing.T) {
client, manifests, err := productionLLMClientFactoryWithAssets(nil)(context.Background(), config.Default(), "test-profile")
if err == nil || !strings.Contains(err.Error(), "asset registry must not be nil") || client != nil || len(manifests) != 0 {
t.Fatalf("client=%T manifests=%#v error=%v, want nil-assets failure", client, manifests, err)
}
})
t.Run("invalid scheduler concurrency", func(t *testing.T) {
components := productionTestComponents(t)
cfg := config.Default()
cfg.Concurrency.TotalLLM = 0
client, manifests, err := productionLLMClientFactoryWithAssets(components.assets)(context.Background(), cfg, "test-profile")
if err == nil || !strings.Contains(err.Error(), "create LLM scheduler") || !strings.Contains(err.Error(), "greater than zero") || client != nil || len(manifests) != 0 {
t.Fatalf("client=%T manifests=%#v error=%v, want scheduler-construction failure", client, manifests, err)
}
})
}
func TestProductionConfigValidationCoversModuleAndVariantFailures(t *testing.T) {
base := string(readRepositoryFile(t, "examples", "dnd-minimal.config.yml"))
validPath := writeProductionContractConfig(t, base)
options := productionCLIOptions(t)
var stdout, stderr strings.Builder
if code := RunWithOptions([]string{"config", "validate", "--config", validPath, "--pipeline", "dnd-session"}, &stdout, &stderr, options); code != 0 {
t.Fatalf("valid production config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
tests := []struct {
name string
content string
options Options
fragments []string
}{
{
name: "unknown module",
content: replaceRequiredOnce(t, base, " input: seriatim\n", " input: missing/input\n"),
options: productionCLIOptions(t),
fragments: []string{"pipeline \"dnd-session\"", "input", "missing/input"},
},
{
name: "unknown validator",
content: replaceRequiredOnce(t, base, " extract: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: missing/validator\n"),
options: productionCLIOptions(t),
fragments: []string{"validator", "missing/validator"},
},
{
name: "invalid artifact variant",
content: base,
options: productionCLIOptionsWithoutSpellNormalizer(t),
fragments: []string{"normalizer", spellnormalize.Key, string(dnd.SpellListKind), "variant"},
},
{
name: "deterministic validator with profile",
content: replaceRequiredOnce(t, base, " extract: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: generic/valid_json\n llm_profile: forbidden-profile\n"),
options: productionCLIOptions(t),
fragments: []string{"deterministic validator", "llm_profile"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := writeProductionContractConfig(t, tt.content)
var stdout, stderr strings.Builder
code := RunWithOptions([]string{"config", "validate", "--config", path, "--pipeline", "dnd-session"}, &stdout, &stderr, tt.options)
if code != 1 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
for _, fragment := range tt.fragments {
if !strings.Contains(stderr.String(), fragment) {
t.Fatalf("stderr=%q, want %q", stderr.String(), fragment)
}
}
})
}
}
func TestProductionNormalizeValidatorOverrideRemainsAuthoritative(t *testing.T) {
base := string(readRepositoryFile(t, "examples", "dnd-minimal.config.yml"))
content := replaceRequiredOnce(t, base, " normalize: dnd/spells\n", " normalize:\n module: dnd/spells\n validators:\n - module: generic/always_accept\n - module: generic/valid_json\n")
path := writeProductionContractConfig(t, content)
components := productionTestComponents(t)
effective, err := loadMaintainedExample(t, path).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
if err != nil {
t.Fatalf("resolve normalize override: %v", err)
}
for _, chain := range effective.ResolvedPipeline.ValidatorChains {
if chain.Stage != pipeline.StageNormalize || chain.ModuleKey != spellnormalize.Key {
continue
}
if len(chain.Validators) != 2 || chain.Validators[0].Binding.Module != "generic/always_accept" || chain.Validators[1].Binding.Module != "generic/valid_json" {
t.Fatalf("normalize validator chain = %#v, want explicit validator order", chain)
}
return
}
t.Fatalf("resolved validator chains = %#v, want normalize chain for %q", effective.ResolvedPipeline.ValidatorChains, spellnormalize.Key)
}
func TestProductionSceneRunRecordsAnnotationFreeChunkPlanAndProvenance(t *testing.T) {
outputRoot := filepath.Join(t.TempDir(), "output")
configPath := writeProductionContractConfig(t, productionRunConfig(outputRoot, "dnd/scenes"))
fake := &productionFakeLLMClient{}
options := productionRunOptions(t, fake)
var stdout, stderr strings.Builder
code := RunWithOptions([]string{
"run", "dnd-session", "--config", configPath,
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
"--chunk_cache", "bypass", "--session-id", "offline-session",
}, &stdout, &stderr, options)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(outputRoot, productionRunID, "manifest.json"))
if manifest.Chunker != scenes.Key || manifest.ChunkPlan == nil || manifest.ChunkPlan.Action != "bypassed" || manifest.ChunkPlan.ProducerModule != scenes.Key {
t.Fatalf("chunk manifest = %#v, want dnd scene producer", manifest.ChunkPlan)
}
if got := manifest.ModuleMetadata["chunker"]["prompt_id"]; got != scenes.PromptID {
t.Fatalf("chunker prompt metadata = %#v, want %q", got, scenes.PromptID)
}
if got := manifest.ChunkPlan.ProducerMetadata["response_schema_id"]; got != scenes.ResponseSchemaID {
t.Fatalf("chunk producer schema metadata = %#v, want %q", got, scenes.ResponseSchemaID)
}
index := readProductionJSON[productionChunkMapIndex](t, filepath.Join(outputRoot, productionRunID, "index.json"))
if index.ChunkMap == nil || index.ChunkMap.ArtifactKind != chunkmap.ArtifactKind || index.ChunkMap.File != "chunk-map.json" || index.ChunkMap.MediaType != chunkmap.MediaType || index.ChunkMap.SchemaID != chunkmap.SchemaID || index.ChunkMap.SchemaName != chunkmap.SchemaName || index.ChunkMap.SchemaVersion != chunkmap.SchemaVersion {
t.Fatalf("chunk map index = %#v, want fixed chunk map descriptor", index.ChunkMap)
}
for _, output := range index.OutputFiles {
if output.File == index.ChunkMap.File {
t.Fatalf("lane output files = %#v, want no chunk map", index.OutputFiles)
}
}
content, err := os.ReadFile(filepath.Join(outputRoot, productionRunID, index.ChunkMap.File))
if err != nil {
t.Fatal(err)
}
chunkMap, err := chunkmap.New().Decode(content)
if err != nil {
t.Fatalf("Decode(chunk map) error = %v", err)
}
if chunkMap.SourceID != "session-alpha" || chunkMap.SourceDigest != manifest.ChunkPlan.SourceDigest || chunkMap.PlanDigest != manifest.ChunkPlan.PlanDigest || chunkMap.RequestedChunker != scenes.Key || chunkMap.Producer.InputModule != "seriatim" || chunkMap.Producer.ChunkModule != scenes.Key || chunkMap.Producer.LLMProfile != manifest.ChunkPlan.ProducerLLMProfile {
t.Fatalf("chunk map identity and producer = %#v, want accepted scene plan provenance", chunkMap)
}
if len(chunkMap.Chunks) != 1 || chunkMap.Chunks[0].ID != "chunk-000001" || chunkMap.Chunks[0].Index != 0 || chunkMap.Chunks[0].SourceRef.SourceID != "session-alpha" || chunkMap.Chunks[0].SourceRef.StartUnitID != 1 || chunkMap.Chunks[0].SourceRef.EndUnitID != 2 || chunkMap.Chunks[0].UnitCount != 2 {
t.Fatalf("chunk map chunks = %#v, want one stable accepted scene range", chunkMap.Chunks)
}
if len(chunkMap.PlanAnnotations) != 0 {
t.Fatalf("chunk map plan annotations = %#v, want none", chunkMap.PlanAnnotations)
}
if len(chunkMap.Chunks[0].Annotations) != 0 {
t.Fatalf("chunk map range annotations = %#v, want none", chunkMap.Chunks[0].Annotations)
}
warnings := readProductionJSON[struct {
Warnings []contracts.Warning `json:"warnings"`
}](t, filepath.Join(outputRoot, productionRunID, "warnings.json"))
if len(warnings.Warnings) != 0 {
t.Fatalf("warnings = %#v, want none", warnings.Warnings)
}
if len(fake.requestsFor(scenes.PromptID)) != 1 || len(fake.requestsFor(spells.PromptID)) != 1 || len(fake.requestsFor(itemeventextract.PromptID)) != 1 {
t.Fatalf("fake prompt requests = %#v, want one scene, spell, and item-event request", fake.requestPrompts())
}
}
type maintainedExample struct {
name string
path string
pipelineIDs []string
}
func maintainedExampleFiles(t *testing.T) []maintainedExample {
t.Helper()
return []maintainedExample{
{name: "minimal", path: repositoryPath("examples", "dnd-minimal.config.yml"), pipelineIDs: []string{"dnd-session"}},
{name: "complete", path: repositoryPath("examples", "dnd-complete.config.yml"), pipelineIDs: []string{"dnd-session"}},
}
}
func productionSpellCatalogContractConfig(t *testing.T) string {
t.Helper()
return fmt.Sprintf(`version: 3
cache:
chunk_plans:
mode: bypass
checkpoints:
enabled: false
directory: ""
pipelines:
dnd-session:
input: seriatim
references:
party: %q
glossary: %q
artifacts:
spells:
extract:
module: dnd/spells
retries: 2
references:
spell_catalog: %q
normalize:
module: dnd/spells
references:
spell_catalog: %q
`, repositoryPath("examples", "dnd-party.txt"), repositoryPath("examples", "dnd-glossary.txt"), repositoryPath("examples", "dnd-spell-catalog.json"), repositoryPath("examples", "dnd-spell-catalog.json"))
}
func writeProductionSpellCatalogContractConfig(t *testing.T) string {
t.Helper()
return writeProductionContractConfig(t, productionSpellCatalogContractConfig(t))
}
func loadMaintainedExample(t *testing.T, path string) config.Config {
t.Helper()
fileConfig, err := config.LoadFileConfig(path)
if err != nil {
t.Fatalf("load maintained config %q: %v", path, err)
}
cfg := config.Default()
if err := cfg.ApplyFileConfig(fileConfig); err != nil {
t.Fatalf("apply maintained config %q: %v", path, err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("validate maintained config %q: %v", path, err)
}
return cfg
}
func productionTestComponents(t *testing.T) productionComponents {
t.Helper()
components, err := newProductionComponents()
if err != nil {
t.Fatalf("new production components: %v", err)
}
return components
}
func productionCLIOptions(t *testing.T) Options {
t.Helper()
components := productionTestComponents(t)
return productionOptionsFromComponents(components)
}
func productionOptionsFromComponents(components productionComponents) Options {
return Options{
Catalog: catalogFromRegistries(components.registries),
Registries: components.registries,
LookupEnv: emptyLookup,
}
}
func productionCLIOptionsWithoutSpellNormalizer(t *testing.T) Options {
t.Helper()
components := productionTestComponents(t)
registries := components.registries
registries.Normalizers = pipeline.NewNormalizerRegistry()
if err := noop.RegisterTyped[dnd.SpellList](registries.Normalizers, contracts.ArtifactKind("test/other")); err != nil {
t.Fatalf("register mismatched normalizer: %v", err)
}
return productionOptionsFromComponents(productionComponents{registries: registries, assets: components.assets})
}
const productionRunID = "run-1700000000000000000-0123456789abcdef0123456789abcdef"
func productionRunOptions(t *testing.T, fake *productionFakeLLMClient) Options {
t.Helper()
options := productionCLIOptions(t)
options.Now = func() time.Time { return time.Unix(1700000000, 0).UTC() }
options.RunIDGenerator = func(time.Time) (string, error) { return productionRunID, nil }
options.UserCacheDir = func() (string, error) { return "", errors.New("user cache must not be used") }
options.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
return fake, nil, nil
}
return options
}
func productionRunConfig(outputRoot, chunkModule string) string {
return fmt.Sprintf(`version: 3
output:
directory: %q
cache:
chunk_plans:
mode: bypass
checkpoints: {}
debug:
directory: %q
pipelines:
dnd-session:
input: seriatim
chunk: %s
output:
module: json
options:
include_chunk_map: true
artifacts:
spells:
extract: dnd/spells
item-events:
extract: dnd/item-events
`, outputRoot, filepath.Join(filepath.Dir(outputRoot), "debug"), chunkModule)
}
type productionChunkMapIndex struct {
OutputFiles []struct {
File string `json:"file"`
} `json:"output_files"`
ChunkMap *struct {
ArtifactKind contracts.ArtifactKind `json:"artifact_kind"`
File string `json:"file"`
MediaType string `json:"media_type"`
SchemaID string `json:"schema_id"`
SchemaName string `json:"schema_name"`
SchemaVersion string `json:"schema_version"`
} `json:"chunk_map"`
}
func writeProductionContractConfig(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func productionAssetNames(t *testing.T, getFS func() (fs.FS, error)) []string {
t.Helper()
fileSystem, err := getFS()
if err != nil {
t.Fatalf("load production prompt assets: %v", err)
}
var names []string
if err := fs.WalkDir(fileSystem, ".", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() {
names = append(names, path)
}
return nil
}); err != nil {
t.Fatalf("walk production prompt assets: %v", err)
}
sort.Strings(names)
return names
}
func assertProductionContains[T comparable](t *testing.T, name string, got, required []T) {
t.Helper()
available := make(map[T]struct{}, len(got))
for _, entry := range got {
available[entry] = struct{}{}
}
var missing []T
for _, entry := range required {
if _, ok := available[entry]; !ok {
missing = append(missing, entry)
}
}
if len(missing) > 0 {
t.Fatalf("%s missing required entries %#v; registered entries are %#v", name, missing, got)
}
}
func readProductionJSON[T any](t *testing.T, path string) T {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var value T
if err := json.Unmarshal(data, &value); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return value
}
type productionFakeLLMClient struct {
mu sync.Mutex
requests []contracts.StructuredCompletionRequest
spellResponse string
}
func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
if err := ctx.Err(); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
var content []byte
switch req.PromptID {
case scenes.PromptID:
content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2}]}`)
case spells.PromptID:
if client.spellResponse != "" {
content = []byte(client.spellResponse)
} else {
content = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
}
case itemeventextract.PromptID:
content = []byte(`{"events":[{"name":"Cure Wounds","kind":"acquired","to":"party","source_refs":[{"start_segment":1,"end_segment":1}]}]}`)
default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
}
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate fake structured target: %w", err)
}
client.mu.Lock()
client.requests = append(client.requests, req)
client.mu.Unlock()
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil
}
func (client *productionFakeLLMClient) requestsFor(promptID string) []contracts.StructuredCompletionRequest {
client.mu.Lock()
defer client.mu.Unlock()
var requests []contracts.StructuredCompletionRequest
for _, req := range client.requests {
if req.PromptID == promptID {
requests = append(requests, req)
}
}
return requests
}
func (client *productionFakeLLMClient) requestPrompts() []string {
client.mu.Lock()
defer client.mu.Unlock()
prompts := make([]string, 0, len(client.requests))
for _, req := range client.requests {
prompts = append(prompts, req.PromptID)
}
return prompts
}
func repositoryPath(parts ...string) string {
_, file, _, _ := runtime.Caller(0)
return filepath.Join(append([]string{filepath.Dir(file), "..", ".."}, parts...)...)
}
func readRepositoryFile(t *testing.T, parts ...string) []byte {
t.Helper()
data, err := os.ReadFile(repositoryPath(parts...))
if err != nil {
t.Fatal(err)
}
return data
}

View File

@@ -0,0 +1,323 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"sync"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestRecomputeStepRecoversThroughFilesystemCheckpoints(t *testing.T) {
tests := []struct {
name string
invalidateOutput bool
wantCode int
}{
{name: "accepted producer is hydrated", wantCode: 0},
{name: "invalid producer stops dependents", invalidateOutput: true, wantCode: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
roots := newRecomputeTestRoots(t)
harness := newRecomputeTestHarness()
fresh := runRecomputeCommand(roots, harness.options(), false)
if fresh.code != 0 {
t.Fatalf("fresh run code=%d stderr=%q", fresh.code, fresh.stderr)
}
removeCheckpointLaneStage(t, roots.checkpoints, "extract", "first", "producer")
removeCheckpointLaneStage(t, roots.checkpoints, "merge", "first", "producer")
if tt.invalidateOutput {
path := findCheckpointFile(t, roots.checkpoints, "normalize", "first", "producer", "output.json")
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
}
harness.resetCalls()
resumed := runRecomputeCommand(roots, harness.options(), true)
if resumed.code != tt.wantCode {
t.Fatalf("resumed code=%d stdout=%q stderr=%q", resumed.code, resumed.stdout, resumed.stderr)
}
events := readLatestCheckpointEvents(t, roots.debug)
if tt.invalidateOutput {
if harness.callsFor("test/extract/middle") != 0 || harness.callsFor("test/extract/dependent") != 0 {
t.Fatalf("dependent calls after invalid producer = %#v", harness.callsSnapshot())
}
if !strings.Contains(resumed.stderr, string(pipeline.CheckpointReasonDecodeFailed)) {
t.Fatalf("stderr=%q, want stable checkpoint reason", resumed.stderr)
}
assertNormalizeDecisionSequence(t, events, []checkpointDecisionExpectation{{"first", "producer", pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonDecodeFailed}})
return
}
if got := harness.callsSnapshot(); !reflect.DeepEqual(got, map[string]int{"test/extract/dependent": 1, "test/extract/middle": 1}) {
t.Fatalf("resumed extractor calls = %#v", got)
}
outputPath := filepath.Join(latestChildDir(t, roots.output), "result.json")
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatal(err)
}
if string(data) != "[\"producer\",\"unrelated\",\"middle\",\"dependent\"]\n" {
t.Fatalf("ordered output = %q", data)
}
assertNormalizeDecisionSequence(t, events, []checkpointDecisionExpectation{
{"first", "producer", pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonAcceptedArtifactReused},
{"first", "unrelated", pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonReused},
{"second", "middle", pipeline.CheckpointDecisionForcedRecompute, pipeline.CheckpointReasonRecomputeStep},
{"third", "dependent", pipeline.CheckpointDecisionForcedRecompute, pipeline.CheckpointReasonRecomputeStep},
})
})
}
}
type checkpointDecisionExpectation struct {
step, lane string
action pipeline.CheckpointDecisionCategory
reason pipeline.CheckpointReasonCode
}
func assertNormalizeDecisionSequence(t *testing.T, events []pipeline.CheckpointEvent, want []checkpointDecisionExpectation) {
t.Helper()
var got []checkpointDecisionExpectation
for _, event := range events {
if event.Stage == string(pipeline.StageNormalize) {
got = append(got, checkpointDecisionExpectation{event.StepID, event.LaneID, event.Action, event.ReasonCode})
}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalize decisions = %#v, want %#v", got, want)
}
}
type recomputeTestHarness struct {
base *stateTestHarness
mu sync.Mutex
calls map[string]int
}
func newRecomputeTestHarness() *recomputeTestHarness {
return &recomputeTestHarness{base: newStateTestHarness(), calls: make(map[string]int)}
}
func (h *recomputeTestHarness) options() Options {
opts := h.base.options()
for _, key := range []string{"test/extract/producer", "test/extract/unrelated", "test/extract/middle", "test/extract/dependent"} {
moduleKey := key
spec := pipeline.ModuleSpec{
Key: moduleKey, Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind,
ReferenceSlots: []contracts.ReferenceSlot{{Name: "upstream", AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{stateTestArtifactKind}}},
}
if err := pipeline.RegisterExtractor(opts.Registries.Extractors, spec, func() (contracts.Extractor[stateTestArtifact], error) {
return recomputeTestExtractor{key: moduleKey, harness: h}, nil
}); err != nil {
panic(err)
}
}
if err := opts.Registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/recompute-output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) {
return recomputeTestOutput{}, nil
}); err != nil {
panic(err)
}
opts.Catalog = catalogFromRegistries(opts.Registries)
return opts
}
func (h *recomputeTestHarness) record(key string) {
h.mu.Lock()
defer h.mu.Unlock()
h.calls[key]++
}
func (h *recomputeTestHarness) resetCalls() {
h.mu.Lock()
defer h.mu.Unlock()
h.calls = make(map[string]int)
}
func (h *recomputeTestHarness) callsFor(key string) int {
h.mu.Lock()
defer h.mu.Unlock()
return h.calls[key]
}
func (h *recomputeTestHarness) callsSnapshot() map[string]int {
h.mu.Lock()
defer h.mu.Unlock()
result := make(map[string]int, len(h.calls))
for key, value := range h.calls {
result[key] = value
}
return result
}
type recomputeTestExtractor struct {
key string
harness *recomputeTestHarness
}
func (e recomputeTestExtractor) Key() string { return e.key }
func (e recomputeTestExtractor) ReferenceSlots() []contracts.ReferenceSlot {
return []contracts.ReferenceSlot{{Name: "upstream", AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{stateTestArtifactKind}}}
}
func (e recomputeTestExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[stateTestArtifact], error) {
e.harness.record(e.key)
return contracts.TypedExtractionResult[stateTestArtifact]{Value: stateTestArtifact{Value: e.key}}, nil
}
type recomputeTestOutput struct{}
func (recomputeTestOutput) Key() string { return "test/recompute-output" }
func (recomputeTestOutput) Encode(_ context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) {
lanes := make([]string, 0, len(req.NormalizeOutputs))
for _, output := range req.NormalizeOutputs {
lanes = append(lanes, output.LaneID)
}
data, err := json.Marshal(lanes)
if err != nil {
return contracts.OutputResult{}, err
}
return contracts.OutputResult{Files: []contracts.OutputFile{{Name: "result.json", Bytes: append(data, '\n')}}}, nil
}
func newRecomputeTestRoots(t *testing.T) stateTestRoots {
t.Helper()
roots := newStateTestRoots(t)
config := fmt.Sprintf(`version: 3
output:
directory: %q
cache:
chunk_plans:
directory: %q
mode: bypass
checkpoints:
enabled: true
directory: %q
debug:
directory: %q
pipelines:
sample:
input: test/input
chunk: test/chunk
steps:
- id: first
artifacts:
producer:
extract: test/extract/producer
merge: test/merge
normalize: test/normalize
unrelated:
extract: test/extract/unrelated
merge: test/merge
normalize: test/normalize
- id: second
references:
upstream:
artifact:
step: first
lane: producer
artifacts:
middle:
extract: test/extract/middle
merge: test/merge
normalize: test/normalize
- id: third
references:
upstream:
artifact:
step: second
lane: middle
artifacts:
dependent:
extract: test/extract/dependent
merge: test/merge
normalize: test/normalize
output: test/recompute-output
`, roots.output, roots.plans, roots.checkpoints, roots.debug)
if err := os.WriteFile(roots.config, []byte(config), 0o600); err != nil {
t.Fatal(err)
}
return roots
}
func runRecomputeCommand(roots stateTestRoots, opts Options, recompute bool) stateTestResult {
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}
if recompute {
args = append(args, "--resume", "--recompute-step", "second", "--debug")
}
var stdout, stderr bytes.Buffer
return stateTestResult{code: RunWithOptions(args, &stdout, &stderr, opts), stdout: stdout.String(), stderr: stderr.String()}
}
func removeCheckpointLaneStage(t *testing.T, root, stage, step, lane string) {
t.Helper()
dir := filepath.Dir(findCheckpointFile(t, root, stage, step, lane, "manifest.json"))
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
}
func findCheckpointFile(t *testing.T, root, stage, step, lane, name string) string {
t.Helper()
want := filepath.Join(stage, step, lane, name)
var matches []string
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() && strings.HasSuffix(path, want) {
matches = append(matches, path)
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(matches) != 1 {
t.Fatalf("checkpoint files ending in %q = %v", want, matches)
}
return matches[0]
}
func latestChildDir(t *testing.T, root string) string {
t.Helper()
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
var dirs []string
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, filepath.Join(root, entry.Name()))
}
}
if len(dirs) == 0 {
t.Fatal("no child directory")
}
sort.Strings(dirs)
return dirs[len(dirs)-1]
}
func readLatestCheckpointEvents(t *testing.T, root string) []pipeline.CheckpointEvent {
t.Helper()
var events []pipeline.CheckpointEvent
data, err := os.ReadFile(filepath.Join(latestChildDir(t, root), "summary", "checkpoint-events.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, &events); err != nil {
t.Fatal(err)
}
return events
}

View File

@@ -0,0 +1,56 @@
package cli
import (
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestRecomputePolicyIncludesDependentLanesAndReusablePredecessors(t *testing.T) {
producer := pipeline.ResolvedArtifactLane{StepID: "first", ID: "producer"}
unrelated := pipeline.ResolvedArtifactLane{StepID: "first", ID: "unrelated"}
consumer := pipeline.ResolvedArtifactLane{
StepID: "second", ID: "consumer",
ExtractReferences: pipeline.ResolvedReferenceTarget{Bindings: []pipeline.ReferenceBinding{{Artifact: &pipeline.ArtifactReference{Step: "first", Lane: "producer"}}}},
}
downstream := pipeline.ResolvedArtifactLane{
StepID: "third", ID: "downstream",
ExtractReferences: pipeline.ResolvedReferenceTarget{Bindings: []pipeline.ReferenceBinding{{Artifact: &pipeline.ArtifactReference{Step: "second", Lane: "consumer"}}}},
}
independent := pipeline.ResolvedArtifactLane{StepID: "third", ID: "independent"}
resolved := pipeline.ResolvedPipeline{Steps: []pipeline.ResolvedPipelineStep{
{ID: "first", ArtifactLanes: []pipeline.ResolvedArtifactLane{producer, unrelated}},
{ID: "second", ArtifactLanes: []pipeline.ResolvedArtifactLane{consumer}},
{ID: "third", ArtifactLanes: []pipeline.ResolvedArtifactLane{downstream, independent}},
}}
policy, err := recomputePolicy(resolved, "second")
if err != nil {
t.Fatal(err)
}
if _, ok := policy.ForcedLanes[pipeline.CheckpointLaneKey("second", "consumer")]; !ok {
t.Fatal("selected lane was not forced")
}
if _, ok := policy.ForcedLanes[pipeline.CheckpointLaneKey("third", "downstream")]; !ok {
t.Fatal("transitive dependent lane was not forced")
}
if _, ok := policy.ForcedLanes[pipeline.CheckpointLaneKey("first", "producer")]; ok {
t.Fatal("predecessor was implicitly forced")
}
if _, ok := policy.RequireReusableLanes[pipeline.CheckpointLaneKey("first", "producer")]; !ok {
t.Fatal("required predecessor was not marked reusable")
}
if _, ok := policy.ForcedLanes[pipeline.CheckpointLaneKey("first", "unrelated")]; ok {
t.Fatal("unrelated lane was forced")
}
if _, ok := policy.ForcedLanes[pipeline.CheckpointLaneKey("third", "independent")]; ok {
t.Fatal("unrelated later lane was forced")
}
}
func TestRecomputePolicyRejectsUnknownStep(t *testing.T) {
_, err := recomputePolicy(pipeline.ResolvedPipeline{Steps: []pipeline.ResolvedPipelineStep{{ID: "known"}}}, "missing")
if err == nil {
t.Fatal("unknown step was accepted")
}
}

View File

@@ -0,0 +1,456 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestReferenceSelectorsParseAndApplyAllDocumentedForms(t *testing.T) {
tests := []struct {
name string
selector string
only []string
wantStage pipeline.ModuleStage
wantLane string
wantSlot string
}{
{name: "flat", selector: "alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
{name: "chunk", selector: "chunk.chunk-slot", wantStage: pipeline.StageChunk, wantSlot: "chunk-slot"},
{name: "merge", selector: "merge.alpha-merge", only: []string{"alpha"}, wantStage: pipeline.StageMerge, wantLane: "alpha", wantSlot: "alpha-merge"},
{name: "lane", selector: "alpha.alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
{name: "lane extract", selector: "alpha.extract.alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
{name: "lane merge", selector: "alpha.merge.alpha-merge", wantStage: pipeline.StageMerge, wantLane: "alpha", wantSlot: "alpha-merge"},
{name: "lane normalize", selector: "alpha.normalize.alpha-normalize", wantStage: pipeline.StageNormalize, wantLane: "alpha", wantSlot: "alpha-normalize"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := referenceContractConfig()
catalog := referenceContractCatalog(t, true, true)
selector, err := parseReferenceSelector(tt.selector, "--reference")
if err != nil {
t.Fatal(err)
}
overrides, _, err := resolveCLIReferenceRequests(cfg, "demo", tt.only, catalog, []cliReferenceRequest{{Selector: selector, Source: "reference.txt"}}, nil)
if err != nil {
t.Fatalf("resolve selector: %v", err)
}
if len(overrides) != 1 {
t.Fatalf("overrides = %#v, want one binding", overrides)
}
got := overrides[0]
if got.Stage != tt.wantStage || got.LaneID != tt.wantLane || got.SlotName != tt.wantSlot || got.BindingSource != contracts.ReferenceBindingSourceCLI {
t.Fatalf("binding = %#v, want %s/%s/%s from CLI", got, tt.wantStage, tt.wantLane, tt.wantSlot)
}
})
}
}
func TestReferenceSelectorsRejectAmbiguityWithSpecificSuggestions(t *testing.T) {
cfg := referenceContractConfig()
catalog := referenceContractCatalog(t, true, true)
for _, tt := range []struct {
name string
selector string
want []string
}{
{name: "flat shared slot", selector: "shared", want: []string{"alpha.extract.shared", "beta.extract.shared"}},
{name: "lane shared slot", selector: "alpha.shared", want: []string{"alpha.extract.shared", "alpha.merge.shared", "alpha.normalize.shared"}},
{name: "all mergers", selector: "merge.shared", want: []string{"alpha.merge.shared", "beta.merge.shared"}},
} {
t.Run(tt.name, func(t *testing.T) {
selector, err := parseReferenceSelector(tt.selector, "--reference")
if err != nil {
t.Fatal(err)
}
_, _, err = resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{{Selector: selector, Source: "reference.txt"}}, nil)
if err == nil {
t.Fatal("resolve selector succeeded, want ambiguity error")
}
for _, fragment := range tt.want {
if !strings.Contains(err.Error(), fragment) {
t.Fatalf("error = %q, want suggestion %q", err, fragment)
}
}
})
}
}
func TestReferenceSelectorsRespectSelectedLanesBeforeMaterialization(t *testing.T) {
cfg := referenceContractConfig()
catalog := referenceContractCatalog(t, true, true)
for _, tt := range []struct {
name string
selector string
want string
}{
{name: "unselected lane", selector: "beta.extract.beta-slot", want: `reference lane "beta" is not selected`},
{name: "unknown lane", selector: "missing.extract.beta-slot", want: `reference lane "missing" is not selected`},
} {
t.Run(tt.name, func(t *testing.T) {
selector, err := parseReferenceSelector(tt.selector, "--reference")
if err != nil {
t.Fatal(err)
}
_, _, err = resolveCLIReferenceRequests(cfg, "demo", []string{"alpha"}, catalog, []cliReferenceRequest{{Selector: selector, Source: filepath.Join(t.TempDir(), "missing.txt")}}, nil)
if err == nil || !strings.Contains(err.Error(), tt.want) || strings.Contains(err.Error(), "missing.txt") {
t.Fatalf("error = %v, want selection failure before file access", err)
}
})
}
}
func TestReferenceSyntaxErrorsReturnTwo(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "reference missing value", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference"}},
{name: "reference missing selector", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "=path.txt"}},
{name: "reference missing separator", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "slot"}},
{name: "reference missing path", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "slot="}},
{name: "reference excess segments", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "a.b.c.d=path.txt"}},
{name: "unbind with path", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference", "slot=path.txt"}},
{name: "unbind excess segments", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference", "a.b.c.d"}},
{name: "unbind missing value", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := RunWithOptions(tt.args, &stdout, &stderr, Options{LookupEnv: emptyLookup})
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
}
func TestReferenceOverridesUseFinalExactTargetBinding(t *testing.T) {
cfg := referenceContractConfig()
catalog := referenceContractCatalog(t, true, true)
alphaShared, err := parseReferenceSelector("alpha.extract.shared", "--reference")
if err != nil {
t.Fatal(err)
}
betaShared, err := parseReferenceSelector("beta.extract.shared", "--reference")
if err != nil {
t.Fatal(err)
}
overrides, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{
{Selector: alphaShared, Source: "alpha-first.txt"},
{Selector: alphaShared, Source: "alpha-final.txt"},
{Selector: betaShared, Source: "beta-only.txt"},
}, nil)
if err != nil {
t.Fatal(err)
}
if len(unbinds) != 0 {
t.Fatalf("unbinds = %#v, want none", unbinds)
}
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceOverrides: overrides})
if err != nil {
t.Fatalf("resolve pipeline: %v", err)
}
alpha := referenceContractLane(t, effective.ResolvedPipeline, "alpha")
beta := referenceContractLane(t, effective.ResolvedPipeline, "beta")
if source := referenceContractBindingSource(alpha.ExtractReferences.Bindings, "shared"); source != "alpha-final.txt" {
t.Fatalf("alpha shared source = %q, want final exact-target override", source)
}
if source := referenceContractBindingSource(beta.ExtractReferences.Bindings, "shared"); source != "beta-only.txt" {
t.Fatalf("beta shared source = %q, want target-specific override", source)
}
}
func TestReferenceUnbindsRemoveOptionalAndProtectRequiredSlots(t *testing.T) {
cfg := referenceContractConfig()
catalog := referenceContractCatalog(t, true, true)
optional, err := parseReferenceSelector("alpha.extract.alpha-slot", "--without-reference")
if err != nil {
t.Fatal(err)
}
_, without, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, nil, []cliReferenceUnbindRequest{{Selector: optional}})
if err != nil {
t.Fatal(err)
}
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceUnbinds: without})
if err != nil {
t.Fatalf("optional unbind: %v", err)
}
if binding := referenceContractFindBinding(referenceContractLane(t, effective.ResolvedPipeline, "alpha").ExtractReferences.Bindings, "alpha-slot"); binding != nil {
t.Fatalf("optional binding after unbind = %#v, want absent", binding)
}
for _, tt := range []struct {
name string
selector string
}{
{name: "chunk", selector: "chunk.required-chunk"},
{name: "extract", selector: "alpha.extract.required-extract"},
{name: "merge", selector: "alpha.merge.required-merge"},
{name: "normalize", selector: "alpha.normalize.required-normalize"},
} {
t.Run(tt.name, func(t *testing.T) {
selector, err := parseReferenceSelector(tt.selector, "--without-reference")
if err != nil {
t.Fatal(err)
}
_, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, nil, []cliReferenceUnbindRequest{{Selector: selector}})
if err != nil {
t.Fatal(err)
}
_, err = cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceUnbinds: unbinds})
if err == nil || !strings.Contains(err.Error(), "required reference slot") {
t.Fatalf("resolve error = %v, want required-slot failure", err)
}
})
}
}
func TestReferenceMaterializationSeparatesCLIAndConfigPathOrigins(t *testing.T) {
configDir := t.TempDir()
workingDir := t.TempDir()
cfg := referenceContractConfig()
configPath := filepath.Join(configDir, "config.yml")
if err := os.WriteFile(configPath, []byte("version: 3\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "required.txt"), []byte("config reference"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "optional.txt"), []byte("optional reference"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(workingDir, "cli-reference.txt"), []byte("CLI reference"), 0o600); err != nil {
t.Fatal(err)
}
catalog := referenceContractCatalog(t, true, true)
selector, err := parseReferenceSelector("alpha.extract.alpha-slot", "--reference")
if err != nil {
t.Fatal(err)
}
overrides, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{{Selector: selector, Source: "cli-reference.txt"}}, nil)
if err != nil {
t.Fatal(err)
}
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceOverrides: overrides, ReferenceUnbinds: unbinds})
if err != nil {
t.Fatalf("resolve pipeline: %v", err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: workingDir})
if err != nil {
t.Fatalf("materialize references: %v", err)
}
alpha := referenceContractLane(t, materialized, "alpha")
cliItem := alpha.ExtractReferences.ReferenceSet.Slots["alpha-slot"].Items[0]
if string(cliItem.Content) != "CLI reference" || cliItem.BindingSource != contracts.ReferenceBindingSourceCLI || cliItem.Origin.URI != referenceContractFileURI(filepath.Join(workingDir, "cli-reference.txt")) {
t.Fatalf("CLI materialization = %#v, want working-directory provenance", cliItem)
}
configItem := alpha.ExtractReferences.ReferenceSet.Slots["required-extract"].Items[0]
if string(configItem.Content) != "config reference" || configItem.BindingSource != contracts.ReferenceBindingSourceConfig || configItem.Origin.URI != referenceContractFileURI(filepath.Join(configDir, "required.txt")) {
t.Fatalf("config materialization = %#v, want config-directory provenance", configItem)
}
}
func TestReferenceTargetLookupUsesArtifactVariantsAndReportsMissingContext(t *testing.T) {
cfg := referenceContractConfig()
full := referenceContractCatalog(t, true, true)
targets, err := selectedReferenceTargets(cfg, "demo", nil, full)
if err != nil {
t.Fatalf("select reference targets: %v", err)
}
var alphaMerge, betaMerge selectedReferenceTarget
for _, target := range targets {
if target.stage == pipeline.StageMerge && target.laneID == "alpha" {
alphaMerge = target
}
if target.stage == pipeline.StageMerge && target.laneID == "beta" {
betaMerge = target
}
}
if _, ok := alphaMerge.slots["alpha-merge"]; !ok {
t.Fatalf("alpha merger slots = %#v, want alpha artifact variant", alphaMerge.slots)
}
if _, ok := betaMerge.slots["beta-merge"]; !ok {
t.Fatalf("beta merger slots = %#v, want beta artifact variant", betaMerge.slots)
}
if _, ok := betaMerge.slots["alpha-merge"]; ok {
t.Fatalf("beta merger slots = %#v, must not use alpha variant", betaMerge.slots)
}
missingMerger := referenceContractCatalog(t, false, true)
_, err = selectedReferenceTargets(cfg, "demo", nil, missingMerger)
if err == nil || !strings.Contains(err.Error(), "merger") || !strings.Contains(err.Error(), string(referenceContractKindBeta)) {
t.Fatalf("missing merger error = %v, want artifact variant context", err)
}
missingNormalizer := referenceContractCatalog(t, true, false)
_, err = selectedReferenceTargets(cfg, "demo", nil, missingNormalizer)
if err == nil || !strings.Contains(err.Error(), "normalizer") || !strings.Contains(err.Error(), string(referenceContractKindBeta)) {
t.Fatalf("missing normalizer error = %v, want artifact variant context", err)
}
missingExtractor := referenceContractCatalog(t, true, true)
missingExtractor.Extractors = pipeline.NewExtractorRegistry()
_, err = selectedReferenceTargets(cfg, "demo", nil, missingExtractor)
if err == nil || !strings.Contains(err.Error(), `lane "alpha" extract module`) || !strings.Contains(err.Error(), "not registered") {
t.Fatalf("missing extractor error = %v, want lane/module context", err)
}
}
const (
referenceContractKindAlpha contracts.ArtifactKind = "reference/alpha"
referenceContractKindBeta contracts.ArtifactKind = "reference/beta"
)
func referenceContractConfig() config.Config {
cfg := config.Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{
"demo": {
ID: "demo",
Input: pipeline.Binding("reference/input"),
Chunk: pipeline.Binding("reference/chunk"),
Output: pipeline.Binding("reference/output"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"alpha": {
Extract: pipeline.Binding("reference/extract-alpha"),
Merge: pipeline.Binding("reference/shared-merge"),
Normalize: pipeline.Binding("reference/shared-normalize"),
References: pipeline.ExternalReferenceMap(map[string]string{"required-extract": "required.txt"}),
},
"beta": {
Extract: pipeline.Binding("reference/extract-beta"),
Merge: pipeline.Binding("reference/shared-merge"),
Normalize: pipeline.Binding("reference/shared-normalize"),
References: pipeline.ExternalReferenceMap(map[string]string{"required-extract": "required.txt"}),
},
},
},
}
profile := cfg.Pipelines["demo"]
profile.Chunk.References = pipeline.ExternalReferenceMap(map[string]string{"required-chunk": "required.txt"})
alpha := profile.Artifacts["alpha"]
alpha.Extract.References = pipeline.ExternalReferenceMap(map[string]string{"required-extract": "required.txt", "alpha-slot": "optional.txt"})
alpha.Merge.References = pipeline.ExternalReferenceMap(map[string]string{"required-merge": "required.txt"})
alpha.Normalize.References = pipeline.ExternalReferenceMap(map[string]string{"required-normalize": "required.txt"})
profile.Artifacts["alpha"] = alpha
beta := profile.Artifacts["beta"]
beta.Extract.References = pipeline.ExternalReferenceMap(map[string]string{"required-extract": "required.txt"})
beta.Merge.References = pipeline.ExternalReferenceMap(map[string]string{"required-merge": "required.txt"})
beta.Normalize.References = pipeline.ExternalReferenceMap(map[string]string{"required-normalize": "required.txt"})
profile.Artifacts["beta"] = beta
cfg.Pipelines["demo"] = profile
return cfg
}
func referenceContractCatalog(t *testing.T, includeBetaMerger, includeBetaNormalizer bool) pipeline.ModuleCatalog {
t.Helper()
registries := pipeline.Registries{
Inputs: pipeline.NewInputAdapterRegistry(),
Chunkers: pipeline.NewChunkerRegistry(),
ArtifactCodecs: pipeline.NewArtifactCodecRegistry(),
Extractors: pipeline.NewExtractorRegistry(),
Mergers: pipeline.NewMergerRegistry(),
Normalizers: pipeline.NewNormalizerRegistry(),
Validators: pipeline.NewValidatorRegistry(),
ValidatorChains: pipeline.NewValidatorChainRegistry(),
Outputs: pipeline.NewOutputEncoderRegistry(),
}
register := func(err error) {
if err != nil {
t.Fatal(err)
}
}
register(registries.Inputs.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "reference/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.InputAdapter, error) { return stateTestInput{}, nil }))
register(registries.Chunkers.RegisterWithSpec(pipeline.ModuleSpec{Key: "reference/chunk", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "chunk-slot"}, {Name: "required-chunk", Required: true}}}, func() (contracts.Chunker, error) { return stateTestChunker{}, nil }))
register(pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, referenceContractCodecA{}))
register(pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, referenceContractCodecB{}))
register(pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "reference/extract-alpha", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-slot"}, {Name: "required-extract", Required: true}}}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{}, nil }))
register(pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "reference/extract-beta", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-slot"}, {Name: "required-extract", Required: true}}}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{}, nil }))
register(pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "reference/shared-merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-merge"}, {Name: "required-merge", Required: true}}}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil }))
if includeBetaMerger {
register(pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "reference/shared-merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-merge"}, {Name: "required-merge", Required: true}}}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil }))
}
register(pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "reference/shared-normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-normalize"}, {Name: "required-normalize", Required: true}}}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil }))
if includeBetaNormalizer {
register(pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "reference/shared-normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-normalize"}, {Name: "required-normalize", Required: true}}}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil }))
}
register(registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "reference/output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) { return stateTestOutput{}, nil }))
return catalogFromRegistries(registries)
}
type referenceContractCodecB struct{}
type referenceContractCodecA struct{}
func (referenceContractCodecA) Kind() contracts.ArtifactKind { return referenceContractKindAlpha }
func (referenceContractCodecA) Schema() contracts.ArtifactSchema {
return contracts.ArtifactSchema{ID: "reference.alpha", Name: "reference_alpha", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)}
}
func (referenceContractCodecA) MediaType() string { return "application/json" }
func (referenceContractCodecA) EncodeCandidate(stateTestArtifact) ([]byte, error) {
return []byte(`{"value":"ok"}`), nil
}
func (referenceContractCodecA) Encode(stateTestArtifact) ([]byte, error) {
return []byte(`{"value":"ok"}`), nil
}
func (referenceContractCodecA) Decode([]byte) (stateTestArtifact, error) {
return stateTestArtifact{Value: "ok"}, nil
}
func (referenceContractCodecB) Kind() contracts.ArtifactKind { return referenceContractKindBeta }
func (referenceContractCodecB) Schema() contracts.ArtifactSchema {
return contracts.ArtifactSchema{ID: "reference.beta", Name: "reference_beta", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)}
}
func (referenceContractCodecB) MediaType() string { return "application/json" }
func (referenceContractCodecB) EncodeCandidate(stateTestArtifact) ([]byte, error) {
return []byte(`{"value":"ok"}`), nil
}
func (referenceContractCodecB) Encode(stateTestArtifact) ([]byte, error) {
return []byte(`{"value":"ok"}`), nil
}
func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) {
return stateTestArtifact{Value: "ok"}, nil
}
func referenceContractLane(t *testing.T, resolved pipeline.ResolvedPipeline, id string) pipeline.ResolvedArtifactLane {
t.Helper()
for _, step := range resolved.Steps {
for _, lane := range step.ArtifactLanes {
if lane.ID == id {
return lane
}
}
}
t.Fatalf("lane %q not found", id)
return pipeline.ResolvedArtifactLane{}
}
func referenceContractBindingSource(bindings []pipeline.ReferenceBinding, slot string) string {
for _, binding := range bindings {
if binding.SlotName == slot {
return binding.Source
}
}
return ""
}
func referenceContractFindBinding(bindings []pipeline.ReferenceBinding, slot string) *pipeline.ReferenceBinding {
for i := range bindings {
if bindings[i].SlotName == slot {
return &bindings[i]
}
}
return nil
}
func referenceContractFileURI(path string) string {
absolute, err := filepath.Abs(path)
if err != nil {
absolute = path
}
return "file://" + filepath.ToSlash(absolute)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,501 @@
package cli
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestRunControlsRejectSyntaxWithoutAllocatingState(t *testing.T) {
tests := []struct {
name string
args func(stateTestRoots) []string
}{
{name: "missing pipeline", args: func(roots stateTestRoots) []string {
return []string{"run", "--config", roots.config, "--input", roots.input}
}},
{name: "missing input", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config}
}},
{name: "unknown flag", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--unknown"}
}},
{name: "blank output directory", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--output-dir", ""}
}},
{name: "blank debug directory", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--debug-dir", ""}
}},
{name: "debug directory without debug", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--debug-dir", filepath.Join(filepath.Dir(roots.debug), "requested-debug")}
}},
{name: "blank session ID", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--session-id", ""}
}},
{name: "multiple pipeline IDs", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input}
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions(tt.args(roots), &stdout, &stderr, newStateTestHarness().options())
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertAbsent(t, roots.output)
assertAbsent(t, roots.debug)
})
}
}
func TestRecomputeStepCLIContract(t *testing.T) {
tests := []struct {
name string
configure func(*testing.T, stateTestRoots)
flags []string
wantCode int
wantOutput string
wantError string
}{
{
name: "explicit step",
configure: func(t *testing.T, roots stateTestRoots) {
replaceStateTestConfigLine(t, roots.config, " artifacts:\n items:\n extract: test/extract\n merge: test/merge\n normalize: test/normalize\n", " steps:\n - id: chosen\n artifacts:\n items:\n extract: test/extract\n merge: test/merge\n normalize: test/normalize\n")
},
flags: []string{"--resume", "--recompute-step", "chosen"},
wantCode: 0,
wantOutput: "outputs=1",
},
{name: "implicit default step", flags: []string{"--resume", "--recompute-step", "default"}, wantCode: 0, wantOutput: "outputs=1"},
{name: "repeated flag", flags: []string{"--resume", "--recompute-step", "default", "--recompute-step", "default"}, wantCode: 2, wantError: "specified only once"},
{name: "empty step", flags: []string{"--resume", "--recompute-step", ""}, wantCode: 2, wantError: "must not be empty"},
{name: "unknown step", flags: []string{"--resume", "--recompute-step", "missing"}, wantCode: 1, wantError: "unknown pipeline step"},
{name: "without resume", flags: []string{"--recompute-step", "default"}, wantCode: 2, wantError: "requires --resume"},
{
name: "checkpoint recording disabled",
configure: func(t *testing.T, roots stateTestRoots) {
replaceStateTestConfigLine(t, roots.config, " enabled: true\n", " enabled: false\n")
},
flags: []string{"--resume", "--recompute-step", "default"}, wantCode: 1, wantError: "cache.checkpoints.enabled",
},
{name: "with only", flags: []string{"--resume", "--recompute-step", "default", "--only", "items"}, wantCode: 2, wantError: "cannot be combined with --only"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
if tt.configure != nil {
tt.configure(t, roots)
}
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}
args = append(args, tt.flags...)
var stdout, stderr bytes.Buffer
code := RunWithOptions(args, &stdout, &stderr, newStateTestHarness().options())
if code != tt.wantCode || (tt.wantOutput != "" && !strings.Contains(stdout.String(), tt.wantOutput)) || (tt.wantError != "" && !strings.Contains(stderr.String(), tt.wantError)) {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
}
func TestRunValidFailuresClassifyAndReportDebug(t *testing.T) {
tests := []struct {
name string
args func(stateTestRoots) []string
wantError string
wantDebug bool
}{
{name: "unknown pipeline", args: func(roots stateTestRoots) []string {
return []string{"run", "missing", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}
}, wantError: `pipeline "missing"`},
{name: "unknown lane", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--only", "missing", "--chunk_cache", "bypass", "--debug"}
}, wantError: `lane "missing"`, wantDebug: true},
{name: "unreadable input", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", filepath.Join(filepath.Dir(roots.input), "unreadable.txt"), "--chunk_cache", "bypass", "--debug"}
}, wantError: "read input", wantDebug: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions(tt.args(roots), &stdout, &stderr, newStateTestHarness().options())
if code != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), tt.wantError) {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if tt.wantDebug {
if !strings.Contains(stderr.String(), "debug=") {
t.Fatalf("stderr=%q, want debug path", stderr.String())
}
onlyChildDir(t, roots.debug)
} else {
assertAbsent(t, roots.debug)
}
assertAbsent(t, roots.output)
})
}
}
func TestRunOnlyExecutesSelectedLanes(t *testing.T) {
roots := newStateTestRoots(t)
data, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
data = []byte(replaceRequiredOnce(t, string(data), " output: test/output\n", " other:\n extract: test/extract\n output: test/output\n"))
if err := os.WriteFile(roots.config, data, 0o600); err != nil {
t.Fatal(err)
}
harness := newStateTestHarness()
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--only", "items", "--chunk_cache", "bypass"}, &stdout, &stderr, harness.options())
if code != 0 || !strings.Contains(stdout.String(), "outputs=1") || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
harness.mu.Lock()
extractCalls := harness.extractCalls
harness.mu.Unlock()
if extractCalls != 1 {
t.Fatalf("extract calls = %d, want only the selected lane", extractCalls)
}
}
func TestRunStateRootsHonorEnvironmentFlagsAndDefaults(t *testing.T) {
t.Run("environment roots", func(t *testing.T) {
roots := newStateTestRoots(t)
environmentOutput := filepath.Join(t.TempDir(), "environment-output")
environmentDebug := filepath.Join(t.TempDir(), "environment-debug")
opts := newStateTestHarness().options()
opts.LookupEnv = lookupRunContractEnv(map[string]string{
"NOTARIUS_OUTPUT_DIR": environmentOutput,
"NOTARIUS_DEBUG_DIR": environmentDebug,
})
result := runWithStateRoots(t, roots, opts, nil)
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertFile(t, filepath.Join(environmentOutput, filepath.Base(onlyChildDir(t, environmentOutput)), "result.json"))
onlyChildDir(t, environmentDebug)
assertAbsent(t, roots.output)
assertAbsent(t, roots.debug)
})
t.Run("command flags override environment", func(t *testing.T) {
roots := newStateTestRoots(t)
environmentOutput := filepath.Join(t.TempDir(), "environment-output")
environmentDebug := filepath.Join(t.TempDir(), "environment-debug")
flagOutput := filepath.Join(t.TempDir(), "flag-output")
flagDebug := filepath.Join(t.TempDir(), "flag-debug")
opts := newStateTestHarness().options()
opts.LookupEnv = lookupRunContractEnv(map[string]string{
"NOTARIUS_OUTPUT_DIR": environmentOutput,
"NOTARIUS_DEBUG_DIR": environmentDebug,
})
result := runWithStateRoots(t, roots, opts, []string{"--output-dir", flagOutput, "--debug-dir", flagDebug})
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertFile(t, filepath.Join(flagOutput, filepath.Base(onlyChildDir(t, flagOutput)), "result.json"))
onlyChildDir(t, flagDebug)
assertAbsent(t, environmentOutput)
assertAbsent(t, environmentDebug)
})
t.Run("built-in roots", func(t *testing.T) {
roots := newStateTestRoots(t)
data, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
text := string(data)
text = replaceRequiredOnce(t, text, fmt.Sprintf(" directory: %q\n", roots.output), "")
text = replaceRequiredOnce(t, text, fmt.Sprintf(" directory: %q\n", roots.debug), "")
if err := os.WriteFile(roots.config, []byte(text), 0o600); err != nil {
t.Fatal(err)
}
workDir := t.TempDir()
t.Chdir(workDir)
opts := newStateTestHarness().options()
result := runWithStateRoots(t, roots, opts, nil)
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertFile(t, filepath.Join(workDir, "notarius-output", filepath.Base(onlyChildDir(t, filepath.Join(workDir, "notarius-output"))), "result.json"))
onlyChildDir(t, filepath.Join(workDir, "notarius-debug"))
})
}
func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) {
t.Run("one effective profile reaches the factory and modules", func(t *testing.T) {
roots := newStateTestRoots(t)
profileDir := writeRunContractProfiles(t, "override-profile")
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
harness := newStateTestHarness()
var factoryProfiles []string
opts := harness.options()
opts.LLMClientFactory = func(_ context.Context, _ config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
factoryProfiles = append(factoryProfiles, profileID)
return nil, nil, nil
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "override-profile"}, &stdout, &stderr, opts)
if code != 0 || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if len(factoryProfiles) != 1 || factoryProfiles[0] != "override-profile" {
t.Fatalf("factory profiles = %#v, want one override profile", factoryProfiles)
}
harness.mu.Lock()
profiles := append([]string(nil), harness.moduleProfiles...)
harness.mu.Unlock()
if len(profiles) < 4 {
t.Fatalf("module profiles = %#v, want chunk and lane stage requests", profiles)
}
for _, profile := range profiles {
if profile != "override-profile" {
t.Fatalf("module profiles = %#v, want override on every request", profiles)
}
}
})
t.Run("validator profile remains distinct", func(t *testing.T) {
roots := newStateTestRoots(t)
profileDir := writeRunContractProfiles(t, "override-profile", "validator-profile")
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
harness := newStateTestHarness()
var validatorProfiles []string
opts := harness.options()
registerRunContractValidator(t, &opts, &validatorProfiles)
factoryProfiles := []string{}
opts.LLMClientFactory = func(_ context.Context, _ config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
factoryProfiles = append(factoryProfiles, profileID)
return nil, nil, nil
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "override-profile"}, &stdout, &stderr, opts)
if code != 0 || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if len(factoryProfiles) != 1 || factoryProfiles[0] != "" {
t.Fatalf("factory profiles = %#v, want one call without a unique profile", factoryProfiles)
}
if len(validatorProfiles) != 1 || validatorProfiles[0] != "validator-profile" {
t.Fatalf("validator profiles = %#v, want configured validator profile", validatorProfiles)
}
})
t.Run("unknown profile is rejected without factory access", func(t *testing.T) {
roots := newStateTestRoots(t)
profileDir := writeRunContractProfiles(t, "override-profile")
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
factoryCalls := 0
opts := newStateTestHarness().options()
opts.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
factoryCalls++
return nil, nil, nil
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "missing-profile"}, &stdout, &stderr, opts)
if code != 1 || !strings.Contains(stderr.String(), "not configured") || factoryCalls != 0 || stdout.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q factoryCalls=%d", code, stdout.String(), stderr.String(), factoryCalls)
}
})
}
func TestEffectiveLLMProfileIDsAreSortedDeduplicatedAndLLMOnly(t *testing.T) {
resolved := pipeline.ResolvedPipeline{
Input: pipeline.ModuleBinding{LLMProfile: "input-profile"},
Chunk: pipeline.ModuleBinding{LLMProfile: " zeta "},
Steps: []pipeline.ResolvedPipelineStep{{
ID: "default",
ArtifactLanes: []pipeline.ResolvedArtifactLane{{
Extract: pipeline.ModuleBinding{LLMProfile: "alpha"},
Merge: pipeline.ModuleBinding{LLMProfile: "zeta"},
Normalize: pipeline.ModuleBinding{LLMProfile: " gamma "},
}},
}},
ValidatorChains: []pipeline.ResolvedValidatorChain{{Validators: []pipeline.ResolvedValidator{
{Binding: pipeline.ModuleBinding{LLMProfile: "deterministic-profile"}, ExecutionClass: contracts.ExecutionClassDeterministic},
{Binding: pipeline.ModuleBinding{LLMProfile: "beta"}, ExecutionClass: contracts.ExecutionClassLLMBacked},
}}},
Output: pipeline.ModuleBinding{LLMProfile: "output-profile"},
}
got := effectiveLLMProfileIDs(resolved)
want := []string{"alpha", "beta", "gamma", "zeta"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("effective profiles = %#v, want %#v", got, want)
}
}
func TestRunSessionIDUsesExplicitValueOrSourceDocumentID(t *testing.T) {
for _, tt := range []struct {
name string
args []string
want string
}{
{name: "source document", want: "source"},
{name: "explicit trimmed value", args: []string{"--session-id", " explicit-session "}, want: "explicit-session"},
} {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
args := append([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, tt.args...)
var stdout, stderr bytes.Buffer
code := RunWithOptions(args, &stdout, &stderr, harness.options())
if code != 0 || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
harness.mu.Lock()
sessions := append([]string(nil), harness.sessionIDs...)
harness.mu.Unlock()
if len(sessions) < 4 {
t.Fatalf("session IDs = %#v, want all prompt-facing module requests", sessions)
}
for _, session := range sessions {
if session != tt.want {
t.Fatalf("session IDs = %#v, want %q", sessions, tt.want)
}
}
})
}
}
func TestRunFactoryAndPreparationFailuresAreProcessFailures(t *testing.T) {
t.Run("LLM factory", func(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
opts.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
return nil, nil, errors.New("injected LLM factory failure")
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
if code != 1 || !strings.Contains(stderr.String(), "injected LLM factory failure") || stdout.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
t.Run("pipeline preparation", func(t *testing.T) {
roots := newStateTestRoots(t)
data, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
data = []byte(replaceRequiredOnce(t, string(data), "extract: test/extract", "extract: test/failing-extract"))
if err := os.WriteFile(roots.config, data, 0o600); err != nil {
t.Fatal(err)
}
opts := newStateTestHarness().options()
if err := pipeline.RegisterExtractorBuilder(opts.Registries.Extractors, pipeline.ModuleSpec{Key: "test/failing-extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.Extractor[stateTestArtifact], error) {
return nil, errors.New("injected extractor construction failure")
}); err != nil {
t.Fatal(err)
}
opts.Catalog = catalogFromRegistries(opts.Registries)
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
if code != 1 || !strings.Contains(stderr.String(), "injected extractor construction failure") || stdout.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
func TestRunWarningsRemainSuccessfulAndReachDurableSurfaces(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
harness.includeWarnings = true
harness.chunkWarnings = []contracts.Warning{{Scope: "chunk", ReasonCode: "contract-warning", Message: "warning retained"}}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}, &stdout, &stderr, harness.options())
if code != 0 || !strings.Contains(stdout.String(), "outputs=1") || !strings.Contains(stderr.String(), "1 warning(s)") {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
outputPath := filepath.Join(onlyChildDir(t, roots.output), "result.json")
output, err := os.ReadFile(outputPath)
if err != nil || !strings.Contains(string(output), "contract-warning") {
t.Fatalf("durable output = %q, %v", output, err)
}
bundle := onlyChildDir(t, roots.debug)
var warnings []contracts.Warning
readStateTestSummaryJSON(t, bundle, "warnings.json", &warnings)
if len(warnings) != 1 || warnings[0].ReasonCode != "contract-warning" {
t.Fatalf("debug warnings = %#v", warnings)
}
}
func runWithStateRoots(t *testing.T, roots stateTestRoots, opts Options, extra []string) stateTestResult {
t.Helper()
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}
args = append(args, extra...)
var stdout, stderr bytes.Buffer
return stateTestResult{code: RunWithOptions(args, &stdout, &stderr, opts), stdout: stdout.String(), stderr: stderr.String()}
}
func lookupRunContractEnv(values map[string]string) func(string) (string, bool) {
return func(name string) (string, bool) {
value, ok := values[name]
return value, ok
}
}
func prependRunContractConfig(t *testing.T, roots stateTestRoots, prefix string) {
t.Helper()
data, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(roots.config, append([]byte(prefix), data...), 0o600); err != nil {
t.Fatal(err)
}
}
func writeRunContractProfiles(t *testing.T, ids ...string) string {
t.Helper()
dir := t.TempDir()
for _, id := range ids {
profile := fmt.Sprintf("id: %s\nendpoint: http://127.0.0.1:1/v1\nmodel: %s-model\n", id, id)
if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(profile), 0o600); err != nil {
t.Fatal(err)
}
}
return dir
}
func registerRunContractValidator(t *testing.T, opts *Options, profiles *[]string) {
t.Helper()
if err := pipeline.RegisterTypedValidatorBuilder(opts.Registries.Validators, stateTestArtifactKind, pipeline.ValidatorSpec{Key: "run-contract-validator", ExecutionClass: contracts.ExecutionClassLLMBacked}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.TypedValidator[stateTestArtifact], error) {
return runContractValidator{profiles: profiles}, nil
}); err != nil {
t.Fatal(err)
}
if err := opts.Registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{Stage: pipeline.StageExtract, Module: "test/extract", Validators: []pipeline.ModuleBinding{{Module: "run-contract-validator", LLMProfile: "validator-profile"}}}); err != nil {
t.Fatal(err)
}
opts.Catalog = catalogFromRegistries(opts.Registries)
}
type runContractValidator struct {
profiles *[]string
}
func (v runContractValidator) Name() string { return "run-contract-validator" }
func (v runContractValidator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassLLMBacked
}
func (v runContractValidator) Validate(_ context.Context, req contracts.TypedValidationRequest[stateTestArtifact]) (contracts.ValidationResult, error) {
*v.profiles = append(*v.profiles, req.LLMProfile)
return contracts.ValidationResult{Approved: true}, nil
}

34
internal/cli/run_id.go Normal file
View File

@@ -0,0 +1,34 @@
package cli
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"path/filepath"
"strings"
"time"
)
type RunIDGenerator func(time.Time) (string, error)
func defaultRunIDGenerator(startedAt time.Time) (string, error) {
var suffix [16]byte
if _, err := io.ReadFull(rand.Reader, suffix[:]); err != nil {
return "", fmt.Errorf("read random run ID suffix: %w", err)
}
return fmt.Sprintf("run-%d-%s", startedAt.UnixNano(), hex.EncodeToString(suffix[:])), nil
}
func validateRunID(runID string) error {
if runID == "" {
return fmt.Errorf("run ID must not be empty")
}
if runID != strings.TrimSpace(runID) {
return fmt.Errorf("run ID %q must not have surrounding whitespace", runID)
}
if strings.ContainsAny(runID, `/\\`) || filepath.IsAbs(runID) || filepath.Clean(runID) != runID || runID == "." || runID == ".." {
return fmt.Errorf("run ID %q must be one safe path component", runID)
}
return nil
}

View File

@@ -0,0 +1,87 @@
package cli
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestDefaultRunIDGeneratorProducesUniqueSafeIDs(t *testing.T) {
startedAt := time.Unix(0, 123456789).UTC()
pattern := regexp.MustCompile(`^run-123456789-[0-9a-f]{32}$`)
seen := make(map[string]struct{}, 256)
for i := 0; i < 256; i++ {
runID, err := defaultRunIDGenerator(startedAt)
if err != nil {
t.Fatal(err)
}
if !pattern.MatchString(runID) {
t.Fatalf("run ID %q does not match production format", runID)
}
if err := validateRunID(runID); err != nil {
t.Fatalf("run ID %q is not path-safe: %v", runID, err)
}
if _, exists := seen[runID]; exists {
t.Fatalf("duplicate run ID %q", runID)
}
seen[runID] = struct{}{}
}
}
func TestWriteOutputFilesSupportsNestedLogicalPaths(t *testing.T) {
runPath := filepath.Join(t.TempDir(), "output", "run-safe")
if err := writeOutputFiles(runPath, []contracts.OutputFile{{Name: "nested/result.json", Bytes: []byte("result")}}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(runPath, "nested", "result.json"))
if err != nil || string(data) != "result" {
t.Fatalf("nested output = %q, %v", data, err)
}
}
func TestWriteOutputFilesRejectsUnsafeNamesBeforeAllocatingRunDirectory(t *testing.T) {
outputRoot := filepath.Join(t.TempDir(), "output")
runPath := filepath.Join(outputRoot, "run-safe")
for _, name := range []string{"", "../outside", "/absolute", `nested\\outside`, "nested/../outside"} {
t.Run(name, func(t *testing.T) {
if err := writeOutputFiles(runPath, []contracts.OutputFile{{Name: "safe.json"}, {Name: name}}); err == nil {
t.Fatalf("writeOutputFiles accepted %q", name)
}
if _, err := os.Stat(outputRoot); !os.IsNotExist(err) {
t.Fatalf("output root exists or stat failed after %q: %v", name, err)
}
})
}
}
func TestWriteOutputFilesRetainsNewPartialDirectoryAndPreservesSibling(t *testing.T) {
outputRoot := filepath.Join(t.TempDir(), "output")
siblingPath := filepath.Join(outputRoot, "sibling")
if err := os.MkdirAll(siblingPath, 0o755); err != nil {
t.Fatal(err)
}
sentinelPath := filepath.Join(siblingPath, "sentinel")
if err := os.WriteFile(sentinelPath, []byte("preserve sibling"), 0o644); err != nil {
t.Fatal(err)
}
runPath := filepath.Join(outputRoot, "run-safe")
err := writeOutputFiles(runPath, []contracts.OutputFile{
{Name: "blocked", Bytes: []byte("partial output")},
{Name: "blocked/nested.json", Bytes: []byte("unreachable")},
})
if err == nil || !strings.Contains(err.Error(), "create output directory") {
t.Fatalf("writeOutputFiles() error = %v, want later directory failure", err)
}
if got, err := os.ReadFile(filepath.Join(runPath, "blocked")); err != nil || string(got) != "partial output" {
t.Fatalf("partial output = %q, %v", got, err)
}
if got, err := os.ReadFile(sentinelPath); err != nil || string(got) != "preserve sibling" {
t.Fatalf("sibling sentinel = %q, %v", got, err)
}
}

View File

@@ -0,0 +1,90 @@
package cli
import (
"errors"
"fmt"
"io"
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
type DebugTerminalWriter interface {
WriteRunReport(debugbundle.RunReport) error
WriteError(string) error
}
type pipelineCommandState struct {
report debugbundle.RunReport
terminalized bool
}
func newPipelineCommandState(runID, pipelineID, outputPath string) *pipelineCommandState {
return &pipelineCommandState{report: debugbundle.RunReport{
RunID: runID,
PipelineID: pipelineID,
OutputPath: outputPath,
}}
}
func (s *pipelineCommandState) setDebugPath(debugPath string) {
if s != nil {
s.report.DebugPath = debugPath
}
}
func (s *pipelineCommandState) observeOutput(output pipeline.RunOutput) {
if s == nil {
return
}
s.report.OutputCount = len(output.NormalizeOutputs)
s.report.RejectedCount = len(output.Rejected)
s.report.WarningCount = len(output.Warnings)
s.report.ValidationStatus = output.Manifest.ValidationStatus
}
func (s *pipelineCommandState) terminalize(writer DebugTerminalWriter, primaryErr error) (error, error) {
if s == nil || s.terminalized {
return primaryErr, nil
}
s.terminalized = true
if writer == nil {
return primaryErr, nil
}
report := s.report
report.Succeeded = primaryErr == nil
reportErr := writer.WriteRunReport(report)
if reportErr != nil {
reportErr = fmt.Errorf("write debug run report: %w", reportErr)
if primaryErr == nil {
primaryErr = reportErr
reportErr = nil
}
}
var errorLogErr error
if primaryErr != nil {
if err := writer.WriteError(primaryErr.Error()); err != nil {
errorLogErr = fmt.Errorf("write debug error log: %w", err)
}
}
return primaryErr, errors.Join(reportErr, errorLogErr)
}
func failPipelineCommand(stderr io.Writer, state *pipelineCommandState, writer DebugTerminalWriter, primaryErr error, persistenceErrs ...error) int {
primaryErr, terminalErr := state.terminalize(writer, primaryErr)
persistenceErrs = append(persistenceErrs, terminalErr)
return writePipelineCommandFailure(stderr, state, primaryErr, errors.Join(persistenceErrs...))
}
func writePipelineCommandFailure(stderr io.Writer, state *pipelineCommandState, primaryErr, persistenceErr error) int {
fmt.Fprintf(stderr, "notarius: %v\n", primaryErr)
if persistenceErr != nil {
fmt.Fprintf(stderr, "notarius: %v\n", persistenceErr)
}
if state != nil && state.report.DebugPath != "" {
fmt.Fprintf(stderr, "notarius: debug=%s\n", state.report.DebugPath)
}
return 1
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
package cli
import (
"context"
"errors"
"fmt"
"testing/fstest"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/scriptorium"
)
const profileCheckPromptID = "notarius.profile.check"
var profileCheckPromptFS = fstest.MapFS{
"prompts/profile-check.yaml": &fstest.MapFile{Data: []byte(`id: notarius.profile.check
version: "1.0.0"
default_profile: mistral-small-3
inputs:
- name: transcript
required: true
messages:
- role: user
content: "{{input \"transcript\"}}"
output:
format: text
validation_mode: none
repair_attempts: 0
`)},
}
func validateExplicitScriptoriumProfiles(ctx context.Context, cfg config.Config, profileIDs []string) error {
if len(profileIDs) == 0 {
return nil
}
engine, err := newProfileValidationEngine(cfg)
if err != nil {
return fmt.Errorf("load Scriptorium profiles: %w", err)
}
for _, profileID := range profileIDs {
if _, err := engine.Prepare(ctx, scriptorium.RunRequest{
PromptID: profileCheckPromptID,
ProfileID: profileID,
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("profile check"),
},
}); err != nil {
if errors.Is(err, scriptorium.ErrProfileNotFound) {
return fmt.Errorf("Scriptorium profile %q is not configured", profileID)
}
return fmt.Errorf("validate Scriptorium profile %q: %w", profileID, err)
}
}
return nil
}
func newProfileValidationEngine(cfg config.Config) (*scriptorium.Engine, error) {
opts := []scriptorium.Option{
scriptorium.WithPromptFS(profileCheckPromptFS, "prompts"),
}
if cfg.Scriptorium.ProfileFile != "" {
opts = append(opts, scriptorium.WithProfileFile(cfg.Scriptorium.ProfileFile))
}
return scriptorium.NewEngine(scriptorium.Config{
PromptDir: "unused",
ProfileDir: cfg.Scriptorium.ProfileDir,
}, opts...)
}

View File

@@ -0,0 +1,490 @@
package cli
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/checkpoint"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
)
func TestSpellCatalogBytesAffectCheckpointIdentityButNotSemanticDigest(t *testing.T) {
components := productionTestComponents(t)
configPath := writeProductionSpellCatalogContractConfig(t)
effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
if err != nil {
t.Fatalf("resolve production configuration: %v", err)
}
overlayPath := filepath.Join(t.TempDir(), "catalog.json")
resolved := effective.ResolvedPipeline
bindings := resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings
catalogBindingIndex := -1
for index, binding := range bindings {
if binding.SlotName == spellcatalog.SpellCatalogReferenceSlot {
catalogBindingIndex = index
break
}
}
if catalogBindingIndex < 0 {
t.Fatalf("spell catalog bindings = %#v, want catalog binding", bindings)
}
resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings[catalogBindingIndex].Source = overlayPath
normalizeBindings := resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings
normalizeCatalogBindingIndex := -1
for index, binding := range normalizeBindings {
if binding.SlotName == spellcatalog.SpellCatalogReferenceSlot {
normalizeCatalogBindingIndex = index
break
}
}
if normalizeCatalogBindingIndex < 0 {
t.Fatalf("normalize spell catalog bindings = %#v, want catalog binding", normalizeBindings)
}
resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings[normalizeCatalogBindingIndex].Source = overlayPath
if err := os.WriteFile(overlayPath, []byte(reorderedOverlayA), 0o600); err != nil {
t.Fatal(err)
}
materializedA, _, err := pipeline.MaterializeReferences(resolved, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: filepath.Dir(configPath)})
if err != nil {
t.Fatalf("materialize first catalog: %v", err)
}
identityA := catalogCheckpointIdentity(t, materializedA)
metadataA := catalogExtractorMetadata(t, materializedA)
normalizerMetadataA := catalogNormalizerMetadata(t, materializedA)
referenceA := catalogReference(t, materializedA)
if err := os.WriteFile(overlayPath, []byte(reorderedOverlayB), 0o600); err != nil {
t.Fatal(err)
}
materializedB, _, err := pipeline.MaterializeReferences(resolved, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: filepath.Dir(configPath)})
if err != nil {
t.Fatalf("materialize reordered catalog: %v", err)
}
identityB := catalogCheckpointIdentity(t, materializedB)
metadataB := catalogExtractorMetadata(t, materializedB)
normalizerMetadataB := catalogNormalizerMetadata(t, materializedB)
referenceB := catalogReference(t, materializedB)
if identityA.Digest == identityB.Digest {
t.Fatalf("checkpoint identity digest = %q for both raw catalog files, want invalidation", identityA.Digest)
}
if referenceA.Digest == referenceB.Digest || referenceA.OriginURI != referenceB.OriginURI {
t.Fatalf("catalog reference provenance changed from %#v to %#v, want same origin and different raw digest", referenceA, referenceB)
}
digestA, ok := metadataA["catalog_digest"].(string)
if !ok {
t.Fatalf("first extractor catalog metadata = %#v, want digest", metadataA)
}
digestB, ok := metadataB["catalog_digest"].(string)
if !ok || digestA != digestB {
t.Fatalf("extractor catalog digests = %q and %q, want same semantic digest", digestA, digestB)
}
if got, want := metadataA["catalog_overlay_ids"], []string{"campaign.a", "campaign.b"}; !reflect.DeepEqual(got, want) || !reflect.DeepEqual(metadataB["catalog_overlay_ids"], want) {
t.Fatalf("extractor overlay IDs = %#v and %#v, want %#v", got, metadataB["catalog_overlay_ids"], want)
}
normalizerDigestA, ok := normalizerMetadataA["catalog_digest"].(string)
normalizerDigestB, okB := normalizerMetadataB["catalog_digest"].(string)
if !ok || !okB || normalizerDigestA != digestA || normalizerDigestB != digestB {
t.Fatalf("normalizer catalog digests = %#v and %#v, want extractor semantic digests %q and %q", normalizerMetadataA["catalog_digest"], normalizerMetadataB["catalog_digest"], digestA, digestB)
}
if got, want := normalizerMetadataA["catalog_overlay_ids"], []string{"campaign.a", "campaign.b"}; !reflect.DeepEqual(got, want) || !reflect.DeepEqual(normalizerMetadataB["catalog_overlay_ids"], want) {
t.Fatalf("normalizer overlay IDs = %#v and %#v, want %#v", got, normalizerMetadataB["catalog_overlay_ids"], want)
}
}
func TestConfiguredSpellCatalogBindingChangesResolvedPipelineIdentity(t *testing.T) {
base := productionSpellCatalogContractConfig(t)
changed := strings.Replace(base, repositoryPath("examples", "dnd-spell-catalog.json"), filepath.Join(t.TempDir(), "alternate-spell-catalog.json"), 1)
if changed == base {
t.Fatal("production configuration did not contain the maintained catalog binding")
}
root := t.TempDir()
firstPath := filepath.Join(root, "first.yml")
secondPath := filepath.Join(root, "second.yml")
if err := os.WriteFile(firstPath, []byte(base), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(secondPath, []byte(changed), 0o600); err != nil {
t.Fatal(err)
}
components := productionTestComponents(t)
first, err := loadMaintainedExample(t, firstPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
if err != nil {
t.Fatalf("resolve first configuration: %v", err)
}
second, err := loadMaintainedExample(t, secondPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
if err != nil {
t.Fatalf("resolve changed configuration: %v", err)
}
if first.ResolvedPipeline.Digest == second.ResolvedPipeline.Digest {
t.Fatalf("resolved pipeline digest = %q for different catalog bindings, want change", first.ResolvedPipeline.Digest)
}
}
func TestSemanticSpellCatalogFingerprintChangesCheckpointIdentityWithoutReferenceChange(t *testing.T) {
components := productionTestComponents(t)
configPath := writeProductionSpellCatalogContractConfig(t)
effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
if err != nil {
t.Fatal(err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: filepath.Dir(configPath)})
if err != nil {
t.Fatal(err)
}
prepared, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}})
if err != nil {
t.Fatal(err)
}
fingerprints := prepared.CheckpointFingerprints()
wantNames := map[string]struct{}{
"extract:spells:" + spells.Key + ":effective_catalog": {},
"extract:spells:" + spells.Key + ":validator:2:extract/dnd/spells/catalog:effective_catalog": {},
"normalize:spells:" + spellnormalize.Key + ":effective_catalog": {},
"normalize:spells:" + spellnormalize.Key + ":validator:2:extract/dnd/spells/catalog:effective_catalog": {},
}
seen := make(map[string]string, len(fingerprints))
for _, fingerprint := range fingerprints {
if _, ok := wantNames[fingerprint.Name]; ok {
seen[fingerprint.Name] = fingerprint.Value
}
}
if len(seen) != len(wantNames) {
t.Fatalf("prepared fingerprints = %#v, want scoped extractor and normalize catalog identities", fingerprints)
}
var catalogDigest string
for name, value := range seen {
if catalogDigest == "" {
catalogDigest = value
} else if value != catalogDigest {
t.Fatalf("prepared fingerprint %q = %q, want shared semantic catalog digest %q", name, value, catalogDigest)
}
}
identityFor := func(values []pipeline.CheckpointFingerprint) checkpoint.Identity {
identity, identityErr := checkpoint.NewIdentity(checkpoint.IdentityInput{
Pipeline: materialized,
InputKey: materialized.Input.Module,
RawInputDigest: "sha256:unchanged-input",
References: pipeline.ReferenceProvenance(materialized),
ProvenanceFingerprints: checkpointIdentityFingerprints(values),
})
if identityErr != nil {
t.Fatal(identityErr)
}
return identity
}
first := identityFor(fingerprints)
changed := replaceCheckpointFingerprintValue(t, fingerprints, normalizeSpellCatalogFingerprintName(), "sha256:changed-effective-catalog")
assertOnlyCheckpointFingerprintChanged(t, fingerprints, changed, normalizeSpellCatalogFingerprintName())
second := identityFor(changed)
if first.Digest == second.Digest || reflect.DeepEqual(first.ReferenceDigests, nil) || !reflect.DeepEqual(first.ReferenceDigests, second.ReferenceDigests) {
t.Fatalf("identities = %#v / %#v, want semantic invalidation with unchanged reference provenance", first, second)
}
}
func TestChangedSemanticSpellCatalogFingerprintCannotResumeRecordedCheckpoint(t *testing.T) {
components := productionTestComponents(t)
configPath := writeProductionSpellCatalogContractConfig(t)
effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
if err != nil {
t.Fatal(err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: filepath.Dir(configPath)})
if err != nil {
t.Fatal(err)
}
prepared, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}})
if err != nil {
t.Fatal(err)
}
fingerprints := prepared.CheckpointFingerprints()
settings := config.CheckpointCacheConfig{Enabled: true, Directory: t.TempDir()}
recorder, _, err := checkpointHandlersForRun(settings, Options{}, materialized, fingerprints, []byte("same input"), nil, nil, "", "", false)
if err != nil {
t.Fatal(err)
}
doc := source.SourceDocument{ID: "source", Kind: "transcript", Format: "application/json"}
doc.Units = []source.SourceUnit{{ID: 1, Kind: "turn", Text: "Aria casts Cure Wounds.", Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}
doc.Digest, err = source.DigestDocument(&doc)
if err != nil {
t.Fatal(err)
}
if err := recorder.SourceSucceeded(materialized.Input.Module, &doc); err != nil {
t.Fatal(err)
}
normalizeDependencies := []pipeline.CheckpointFingerprint{{Name: "artifact[0]", Value: "sha256:merged-artifact"}}
normalizeSchema := contracts.ArtifactSchema{ID: "notarius.dnd.spells", Name: "notarius_dnd_spells", Version: "v1"}
normalizeArtifact := pipeline.CheckpointArtifact{
LaneID: "spells", ModuleKey: spellnormalize.Key, SourceID: doc.ID,
SchemaDigest: contracts.DigestArtifactSchema(normalizeSchema),
Artifact: contracts.SerializedArtifact{
Kind: dnd.SpellListKind, Schema: normalizeSchema, MediaType: "application/json", Content: []byte(`{"spell_casts":[]}`),
},
}
if err := recorder.NormalizeSucceeded("spells", spellnormalize.Key, normalizeDependencies, normalizeArtifact, nil); err != nil {
t.Fatal(err)
}
_, sameLoader, err := checkpointHandlersForRun(settings, Options{}, materialized, fingerprints, []byte("same input"), nil, nil, "", "", true)
if err != nil {
t.Fatal(err)
}
if _, decision := sameLoader.Source(materialized.Input.Module); !decision.Reused {
t.Fatalf("same fingerprint decision = %#v, want reuse", decision)
}
if restored, decision := sameLoader.Normalize("spells", spellnormalize.Key, normalizeDependencies); !decision.Reused || string(restored.Output.Artifact.Content) != `{"spell_casts":[]}` {
t.Fatalf("same normalize checkpoint = %#v, decision=%#v, want reuse", restored, decision)
}
changed := replaceCheckpointFingerprintValue(t, fingerprints, normalizeSpellCatalogFingerprintName(), "sha256:changed-effective-catalog")
assertOnlyCheckpointFingerprintChanged(t, fingerprints, changed, normalizeSpellCatalogFingerprintName())
_, changedLoader, err := checkpointHandlersForRun(settings, Options{}, materialized, changed, []byte("same input"), nil, nil, "", "", true)
if err != nil {
t.Fatal(err)
}
if _, decision := changedLoader.Source(materialized.Input.Module); decision.Reused {
t.Fatalf("changed fingerprint decision = %#v, want cold miss", decision)
}
if _, decision := changedLoader.Normalize("spells", spellnormalize.Key, normalizeDependencies); decision.Reused {
t.Fatalf("changed normalize fingerprint decision = %#v, want normalize checkpoint cold miss", decision)
}
changedMapping := replaceCheckpointFingerprintValue(t, fingerprints, extractSpellMappingFingerprintName(), "dnd.spells.extract_mapping.v3")
assertOnlyCheckpointFingerprintChanged(t, fingerprints, changedMapping, extractSpellMappingFingerprintName())
_, mappingLoader, err := checkpointHandlersForRun(settings, Options{}, materialized, changedMapping, []byte("same input"), nil, nil, "", "", true)
if err != nil {
t.Fatal(err)
}
if _, decision := mappingLoader.Source(materialized.Input.Module); decision.Reused {
t.Fatalf("changed mapping policy decision = %#v, want cold miss", decision)
}
}
func normalizeSpellCatalogFingerprintName() string {
return "normalize:spells:" + spellnormalize.Key + ":effective_catalog"
}
func extractSpellMappingFingerprintName() string {
return "extract:spells:" + spells.Key + ":mapping_policy"
}
func replaceCheckpointFingerprintValue(t *testing.T, fingerprints []pipeline.CheckpointFingerprint, name, value string) []pipeline.CheckpointFingerprint {
t.Helper()
changed := append([]pipeline.CheckpointFingerprint(nil), fingerprints...)
matches := 0
for index := range changed {
if changed[index].Name == name {
changed[index].Value = value
matches++
}
}
if matches != 1 {
t.Fatalf("checkpoint fingerprints = %#v, want exactly one fingerprint named %q", fingerprints, name)
}
return changed
}
func assertOnlyCheckpointFingerprintChanged(t *testing.T, before, after []pipeline.CheckpointFingerprint, changedName string) {
t.Helper()
if len(before) != len(after) {
t.Fatalf("fingerprint lengths = %d and %d, want equal", len(before), len(after))
}
changes := 0
for index := range before {
if before[index].Name != after[index].Name {
t.Fatalf("fingerprint[%d] name changed from %q to %q", index, before[index].Name, after[index].Name)
}
if before[index].Value == after[index].Value {
continue
}
changes++
if before[index].Name != changedName {
t.Fatalf("fingerprint %q changed unexpectedly", before[index].Name)
}
}
if changes != 1 {
t.Fatalf("fingerprints changed %d values, want exactly %q", changes, changedName)
}
}
func TestMaintainedProductionOverlayRunAlignsGroundingValidationAndProvenance(t *testing.T) {
outputRoot := filepath.Join(t.TempDir(), "output")
fake := &productionFakeLLMClient{spellResponse: productionSpellResponse("Aegis of Emberfall")}
options := productionRunOptions(t, fake)
var stdout, stderr strings.Builder
code := RunWithOptions([]string{
"run", "dnd-session",
"--config", writeProductionSpellCatalogContractConfig(t),
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
"--only", "spells", "--chunk_cache", "bypass", "--output-dir", outputRoot,
}, &stdout, &stderr, options)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
runRoot := filepath.Join(outputRoot, productionRunID)
manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(runRoot, "manifest.json"))
if manifest.ValidationStatus != "approved" || len(manifest.References) == 0 || len(manifest.ArtifactLanes) != 1 {
t.Fatalf("manifest = %#v, want approved overlay run with one lane and references", manifest)
}
lane := manifest.ArtifactLanes[0]
extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
if !ok {
t.Fatalf("lane metadata = %#v, want extractor metadata", lane.Metadata)
}
if extractorMetadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || !strings.HasPrefix(stringValue(extractorMetadata["catalog_digest"]), "sha256:") {
t.Fatalf("extractor catalog metadata = %#v, want base ID and semantic digest", extractorMetadata)
}
if got := stringValues(extractorMetadata["catalog_overlay_ids"]); !reflect.DeepEqual(got, []string{"notarius.example-campaign"}) {
t.Fatalf("catalog overlay IDs = %#v, want maintained overlay", got)
}
normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any)
if !ok {
t.Fatalf("lane metadata = %#v, want normalizer metadata", lane.Metadata)
}
if normalizerMetadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || !strings.HasPrefix(stringValue(normalizerMetadata["catalog_digest"]), "sha256:") || !reflect.DeepEqual(stringValues(normalizerMetadata["catalog_overlay_ids"]), []string{"notarius.example-campaign"}) {
t.Fatalf("normalizer catalog metadata = %#v, want base ID, semantic digest, and overlay IDs", normalizerMetadata)
}
if normalizerMetadata["catalog_digest"] != extractorMetadata["catalog_digest"] || !reflect.DeepEqual(stringValues(normalizerMetadata["catalog_overlay_ids"]), stringValues(extractorMetadata["catalog_overlay_ids"])) {
t.Fatalf("extractor metadata = %#v, normalizer metadata = %#v, want shared catalog identity", extractorMetadata, normalizerMetadata)
}
var catalogProvenances []artifacts.ReferenceProvenance
for index := range manifest.References {
reference := &manifest.References[index]
if reference.SlotName == spellcatalog.SpellCatalogReferenceSlot {
catalogProvenances = append(catalogProvenances, *reference)
}
}
if len(catalogProvenances) != 2 {
t.Fatalf("manifest references = %#v, want independently materialized extract and normalize catalog provenance", manifest.References)
}
overlayBytes := readRepositoryFile(t, "examples", "dnd-spell-catalog.json")
for _, catalogProvenance := range catalogProvenances {
if catalogProvenance.Stage != "extract" && catalogProvenance.Stage != "normalize" {
t.Fatalf("catalog provenance = %#v, want extract or normalize scope", catalogProvenance)
}
if catalogProvenance.LaneID != "spells" || catalogProvenance.OriginType != "file" || catalogProvenance.MediaType != "application/json" || catalogProvenance.SizeBytes != int64(len(overlayBytes)) || catalogProvenance.Digest != digestBytes(overlayBytes) || !strings.Contains(catalogProvenance.OriginURI, "dnd-spell-catalog.json") {
t.Fatalf("catalog provenance = %#v, want raw overlay provenance in both scopes", catalogProvenance)
}
}
manifestBytes, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
for _, leaked := range []string{"Aegis of Emberfall", "Emberfall Aegis", "Notarius example campaign spell names"} {
if strings.Contains(string(manifestBytes), leaked) {
t.Fatalf("manifest leaked overlay content %q", leaked)
}
}
requests := fake.requestsFor(spells.PromptID)
if len(requests) != 1 {
t.Fatalf("spell requests = %d, want one", len(requests))
}
catalogInput, ok := requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
if !ok || !strings.Contains(string(catalogInput.Content), "Aegis of Emberfall") || strings.Contains(string(catalogInput.Content), "Emberfall Aegis") {
t.Fatalf("spell catalog prompt input = %#v, want canonical overlay name without alias", catalogInput)
}
artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, "lanes", "spells.json"))
if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Aegis of Emberfall" {
t.Fatalf("artifact = %#v, want accepted overlay-only canonical spell", artifact)
}
rejected := readProductionJSON[struct {
Rejected []json.RawMessage `json:"rejected"`
}](t, filepath.Join(runRoot, "rejected.json"))
if len(rejected.Rejected) != 0 {
t.Fatalf("rejected = %#v, want no rejected output", rejected.Rejected)
}
}
func catalogCheckpointIdentity(t *testing.T, resolved pipeline.ResolvedPipeline) checkpoint.Identity {
t.Helper()
identity, err := checkpoint.NewIdentity(checkpoint.IdentityInput{
Pipeline: resolved,
InputKey: resolved.Input.Module,
RawInputDigest: "sha256:catalog-test-input",
References: pipeline.ReferenceProvenance(resolved),
})
if err != nil {
t.Fatalf("create checkpoint identity: %v", err)
}
return identity
}
func catalogExtractorMetadata(t *testing.T, resolved pipeline.ResolvedPipeline) map[string]any {
t.Helper()
lane := resolved.Steps[0].ArtifactLanes[0]
extractor, err := spells.New(&productionFakeLLMClient{}, spells.Options{}, lane.ExtractReferences.ReferenceSet)
if err != nil {
t.Fatalf("construct extractor: %v", err)
}
return extractor.ManifestMetadata()
}
func catalogNormalizerMetadata(t *testing.T, resolved pipeline.ResolvedPipeline) map[string]any {
t.Helper()
lane := resolved.Steps[0].ArtifactLanes[0]
normalizer, err := spellnormalize.New(spellnormalize.Options{}, lane.NormalizeReferences.ReferenceSet)
if err != nil {
t.Fatalf("construct normalizer: %v", err)
}
return normalizer.ManifestMetadata()
}
func catalogReference(t *testing.T, resolved pipeline.ResolvedPipeline) artifacts.ReferenceProvenance {
t.Helper()
for _, reference := range pipeline.ReferenceProvenance(resolved) {
if reference.SlotName == spellcatalog.SpellCatalogReferenceSlot && reference.Stage == "extract" && reference.LaneID == "spells" {
return reference
}
}
t.Fatalf("resolved references = %#v, want spell catalog provenance", pipeline.ReferenceProvenance(resolved))
return artifacts.ReferenceProvenance{}
}
func stringValue(value any) string {
result, _ := value.(string)
return result
}
func stringValues(value any) []string {
raw, err := json.Marshal(value)
if err != nil {
return nil
}
var values []string
if err := json.Unmarshal(raw, &values); err != nil {
return nil
}
return values
}
func digestBytes(value []byte) string {
sum := sha256.Sum256(value)
return "sha256:" + hex.EncodeToString(sum[:])
}
const reorderedOverlayA = `{
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
"catalogs": [
{"id":"campaign.a","ruleset":"dnd-5e-2014","source":{"title":"Campaign A"},"spells":[{"name":"Aegis of Emberfall","aliases":["Emberfall Aegis"]}]},
{"id":"campaign.b","ruleset":"dnd-5e-2014","source":{"title":"Campaign B"},"spells":[{"name":"Cinder Veil","aliases":["Veil of Cinder","Cinder Shroud"]}]}
]
}`
const reorderedOverlayB = `{"catalogs":[{"spells":[{"aliases":["Cinder Shroud","Veil of Cinder"],"name":"Cinder Veil"}],"source":{"title":"Campaign B"},"ruleset":"dnd-5e-2014","id":"campaign.b"},{"spells":[{"aliases":["Emberfall Aegis"],"name":"Aegis of Emberfall"}],"source":{"title":"Campaign A"},"ruleset":"dnd-5e-2014","id":"campaign.a"}],"schema_version":"notarius.dnd.spell-catalog-overlay.v1"}`

View File

@@ -0,0 +1,160 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"sync"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
)
func TestProductionSpellCatalogValidationRetries(t *testing.T) {
const retries = 2
tests := []struct {
name string
responses []string
wantCalls int
wantRejected bool
wantSpell string
wantWarningCode string
}{
{
name: "unknown spell remains rejected after exhaustion",
responses: []string{
productionSpellResponse("Unknown Spell"),
productionSpellResponse("Unknown Spell"),
productionSpellResponse("Unknown Spell"),
},
wantCalls: retries + 1,
wantRejected: true,
},
{
name: "overlay spell becomes valid on retry",
responses: []string{
productionSpellResponse("Unknown Spell"),
productionSpellResponse("Aegis of Emberfall"),
},
wantCalls: 2,
wantSpell: "Aegis of Emberfall",
wantWarningCode: "spell_not_near_source",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
components := productionTestComponents(t)
configPath := writeProductionSpellCatalogContractConfig(t)
cfg := loadMaintainedExample(t, configPath)
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalogFromRegistries(components.registries)})
if err != nil {
t.Fatalf("resolve production configuration: %v", err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
ConfigPath: configPath,
WorkingDir: filepath.Dir(configPath),
})
if err != nil {
t.Fatalf("materialize production references: %v", err)
}
materialized.Steps[0].ArtifactLanes[0].Extract.Retries = retries
llmClient := &catalogRetryLLMClient{responses: tt.responses}
prepared, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: llmClient})
if err != nil {
t.Fatalf("prepare production pipeline: %v", err)
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
ChunkCacheMode: pipeline.ChunkCacheBypass,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
if calls := llmClient.CallCount(); calls > retries+1 || calls != tt.wantCalls {
t.Fatalf("LLM calls = %d, want %d and no more than %d", calls, tt.wantCalls, retries+1)
}
if tt.wantRejected {
if len(output.Rejected) != 1 || len(output.NormalizeOutputs) != 0 {
t.Fatalf("rejected = %#v normalized = %#v, want one nonfatal rejection and no merge output", output.Rejected, output.NormalizeOutputs)
}
rejection := output.Rejected[0]
if rejection.ReasonCode != "unknown_spell" || rejection.AttemptCount != retries+1 {
t.Fatalf("rejection = %#v, want exhausted unknown-spell rejection", rejection)
}
if len(output.Warnings) != 0 {
t.Fatalf("warnings = %#v, want no warnings from rejected attempts", output.Warnings)
}
return
}
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
t.Fatalf("rejected = %#v normalized = %#v, want only accepted output", output.Rejected, output.NormalizeOutputs)
}
var value dnd.SpellList
if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &value); err != nil {
t.Fatalf("decode normalized spell list: %v", err)
}
if len(value.SpellCasts) != 1 || value.SpellCasts[0].Spell != tt.wantSpell {
t.Fatalf("normalized spell list = %#v, want accepted overlay spell", value)
}
if len(output.Warnings) != 2 || output.Warnings[0].ReasonCode != tt.wantWarningCode || output.Warnings[1].ReasonCode != tt.wantWarningCode {
t.Fatalf("warnings = %#v, want accepted-attempt warnings from extract and normalize validation", output.Warnings)
}
})
}
}
type catalogRetryLLMClient struct {
mu sync.Mutex
responses []string
calls int
}
func (client *catalogRetryLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
if err := ctx.Err(); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if req.PromptID != spells.PromptID {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
}
client.mu.Lock()
index := client.calls
client.calls++
client.mu.Unlock()
if index >= len(client.responses) {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("missing fake response %d", index)
}
content := []byte(client.responses[index])
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate fake structured target: %w", err)
}
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil
}
func (client *catalogRetryLLMClient) CallCount() int {
client.mu.Lock()
defer client.mu.Unlock()
return client.calls
}
func productionSpellResponse(name string) string {
content, err := json.Marshal(dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: "Aria",
Spell: name,
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}},
}}})
if err != nil {
panic(err)
}
return string(content)
}

View File

@@ -0,0 +1,988 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
frameworkdebug "gitea.maximumdirect.net/eric/notarius/internal/framework/debug"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
const stateTestDigest = "sha256:e511d8906649b78eb639b11215fa57a9652a1a64f4aefa3ed68320dbda46f439"
func TestRunStateSurfaceMatrix(t *testing.T) {
for _, debug := range []bool{false, true} {
for _, resume := range []bool{false, true} {
for _, mode := range []string{"auto", "bypass", "refresh"} {
name := fmt.Sprintf("debug=%t/resume=%t/cache=%s", debug, resume, mode)
t.Run(name, func(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
opts := harness.options()
var storeRoots []string
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
storeRoots = append(storeRoots, root)
return chunkplan.NewFilesystemStore(root)
}
result := runStateTest(t, roots, opts, debug, resume, mode)
if result.code != 0 {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
assertStateTestOutput(t, roots.output)
if mode == "bypass" {
assertAbsent(t, roots.plans)
if len(storeRoots) != 0 {
t.Fatalf("chunk plan store roots = %v, want none", storeRoots)
}
} else {
assertFile(t, filepath.Join(roots.plans, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json"))
if len(storeRoots) != 1 || storeRoots[0] != roots.plans {
t.Fatalf("chunk plan store roots = %v, want [%q]", storeRoots, roots.plans)
}
}
assertAnyFile(t, roots.checkpoints)
assertRestrictedTree(t, roots.checkpoints)
if debug {
bundle := onlyChildDir(t, roots.debug)
assertFile(t, filepath.Join(bundle, "summary", "invocation.json"))
assertAnyFile(t, filepath.Join(bundle, "trace"))
assertRestrictedTree(t, roots.debug)
} else {
assertAbsent(t, roots.debug)
}
})
}
}
}
}
func TestRunKeepsStateRootsIndependentAndReusesSelectedCheckpointRoot(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
first := runStateTest(t, roots, harness.options(), true, false, "auto")
if first.code != 0 {
t.Fatalf("first run code=%d stderr=%q", first.code, first.stderr)
}
planPath := filepath.Join(roots.plans, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json")
initialPlan, err := os.ReadFile(planPath)
if err != nil {
t.Fatal(err)
}
firstBundle := onlyChildDir(t, roots.debug)
second := runStateTest(t, roots, harness.options(), false, false, "auto")
if second.code != 0 {
t.Fatalf("second run code=%d stderr=%q", second.code, second.stderr)
}
if harness.chunkCalls != 1 {
t.Fatalf("chunk calls after debug toggle = %d, want 1", harness.chunkCalls)
}
if harness.extractCalls != 2 {
t.Fatalf("extract calls after two recording-only runs = %d, want 2", harness.extractCalls)
}
if got, err := os.ReadFile(planPath); err != nil || !bytes.Equal(got, initialPlan) {
t.Fatalf("chunk plan changed after debug toggle: %v", err)
}
if _, err := os.Stat(firstBundle); err != nil {
t.Fatalf("initial debug bundle was removed: %v", err)
}
checkpointRoot := roots.checkpoints
extractCallsBeforeResume := harness.extractCalls
seed := runStateTest(t, roots, harness.options(), false, true, "auto")
if seed.code != 0 {
t.Fatalf("checkpoint seed code=%d stderr=%q", seed.code, seed.stderr)
}
if harness.extractCalls != extractCallsBeforeResume {
t.Fatalf("extract calls after reusing recording-only checkpoint = %d, want %d", harness.extractCalls, extractCallsBeforeResume)
}
extractCalls := harness.extractCalls
checkpointFiles := readTree(t, checkpointRoot)
reused := runStateTest(t, roots, harness.options(), false, true, "auto")
if reused.code != 0 {
t.Fatalf("checkpoint reuse code=%d stderr=%q", reused.code, reused.stderr)
}
if harness.extractCalls != extractCalls {
t.Fatalf("extract calls after checkpoint reuse = %d, want %d", harness.extractCalls, extractCalls)
}
if got := readTree(t, checkpointRoot); !sameFiles(got, checkpointFiles) {
t.Fatal("reused checkpoint was rewritten")
}
}
func TestRunRecomputesOnlyAfterExplicitChunkPlanRemoval(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
first := runStateTest(t, roots, harness.options(), true, false, "auto")
if first.code != 0 {
t.Fatalf("first run code=%d stderr=%q", first.code, first.stderr)
}
firstOutput := onlyChildDir(t, roots.output)
firstBundle := onlyChildDir(t, roots.debug)
entry := filepath.Join(roots.plans, strings.TrimPrefix(stateTestDigest, "sha256:"))
if err := os.RemoveAll(entry); err != nil {
t.Fatal(err)
}
second := runStateTest(t, roots, harness.options(), false, false, "auto")
if second.code != 0 {
t.Fatalf("second run code=%d stderr=%q", second.code, second.stderr)
}
if harness.chunkCalls != 2 {
t.Fatalf("chunk calls = %d, want 2 after removing exact cache entry", harness.chunkCalls)
}
assertFile(t, filepath.Join(firstOutput, "result.json"))
assertFile(t, filepath.Join(firstBundle, "summary", "run-report.json"))
}
func TestRunRetainsDebugBundlesAcrossFailures(t *testing.T) {
t.Run("configuration failure precedes allocation", func(t *testing.T) {
root := filepath.Join(t.TempDir(), "debug")
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", filepath.Join(t.TempDir(), "missing.yml"), "--input", "missing", "--debug", "--debug-dir", root}, &stdout, &stderr, newStateTestHarness().options())
if code != 1 || !strings.Contains(stderr.String(), "config file") {
t.Fatalf("code=%d stderr=%q", code, stderr.String())
}
assertAbsent(t, root)
})
for _, failure := range []struct {
name string
expected string
setup func(*testing.T, stateTestRoots, *stateTestHarness) Options
}{
{"resolution", "pipeline \"missing\"", func(t *testing.T, roots stateTestRoots, h *stateTestHarness) Options { return h.options() }},
{"pipeline", "synthetic extraction failure", func(t *testing.T, roots stateTestRoots, h *stateTestHarness) Options {
h.extractErr = errors.New("synthetic extraction failure")
return h.options()
}},
{"output", "create output parent", func(t *testing.T, roots stateTestRoots, h *stateTestHarness) Options {
if err := os.WriteFile(roots.output, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
return h.options()
}},
{"summary", "write debug invocation metadata", func(t *testing.T, roots stateTestRoots, h *stateTestHarness) Options {
opts := h.options()
opts.DebugRecorderFactory = func(traceRoot string) (pipeline.DebugRecorder, error) {
if err := os.RemoveAll(filepath.Join(filepath.Dir(traceRoot), "summary")); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(filepath.Dir(traceRoot), "summary"), []byte("blocked"), 0o600); err != nil {
return nil, err
}
return frameworkdebug.NewFilesystemRecorder(traceRoot)
}
return opts
}},
{"trace", "trace unavailable", func(t *testing.T, roots stateTestRoots, h *stateTestHarness) Options {
opts := h.options()
opts.DebugRecorderFactory = func(string) (pipeline.DebugRecorder, error) { return failingDebugRecorder{}, nil }
return opts
}},
} {
t.Run(failure.name, func(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
opts := failure.setup(t, roots, harness)
failureStderr := ""
if failure.name == "resolution" {
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "missing", "--config", roots.config, "--input", roots.input, "--debug"}, &stdout, &stderr, opts)
if code != 1 {
t.Fatalf("code=%d stderr=%q", code, stderr.String())
}
failureStderr = stderr.String()
} else {
result := runStateTest(t, roots, opts, true, false, "bypass")
if result.code != 1 {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
failureStderr = result.stderr
}
if !strings.Contains(failureStderr, failure.expected) || !strings.Contains(failureStderr, "debug=") {
t.Fatalf("stderr=%q, want %q and debug path", failureStderr, failure.expected)
}
bundle := onlyChildDir(t, roots.debug)
if !strings.Contains(readAllFiles(t, bundle), "synthetic") && failure.name == "pipeline" {
t.Fatal("pipeline failure was not retained in debug bundle")
}
})
}
}
func TestRunDebugArtifactsRedactSecretsButRetainApplicationData(t *testing.T) {
roots := newStateTestRoots(t)
t.Setenv("STATE_TEST_UNRELATED_ENV", "HOST_ONLY_SENTINEL")
if err := os.WriteFile(filepath.Join(filepath.Dir(roots.input), "unrelated.txt"), []byte("HOST_ONLY_FILE_SENTINEL"), 0o600); err != nil {
t.Fatal(err)
}
harness := newStateTestHarness()
result := runStateTest(t, roots, harness.options(), true, false, "bypass")
if result.code != 0 {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
bundle := onlyChildDir(t, roots.debug)
summary := readAllFiles(t, filepath.Join(bundle, "summary"))
trace := readAllFiles(t, filepath.Join(bundle, "trace"))
for _, forbidden := range []string{"sk-secretvalue", "Bearer secretvalue", "HOST_ONLY_SENTINEL", "HOST_ONLY_FILE_SENTINEL"} {
if strings.Contains(summary, forbidden) || strings.Contains(trace, forbidden) {
t.Fatalf("debug bundle contains %q", forbidden)
}
}
if strings.Contains(summary, "application content") {
t.Fatal("summary contains raw application input")
}
if !strings.Contains(trace, "application content") {
t.Fatal("trace does not retain expected application input")
}
}
func TestRunRedactsSensitiveModuleOptionsFromConfigAndPipelineSummaries(t *testing.T) {
roots := newStateTestRoots(t)
data, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
configText := replaceRequiredOnce(t, string(data), " input: test/input\n", ` input:
module: test/input
options:
api_key: CONFIG_SUMMARY_SECRET_SENTINEL
safe: SAFE_OPTION_SENTINEL
nested:
- - password: PIPELINE_SUMMARY_SECRET_SENTINEL
neighbor: SAFE_NESTED_OPTION_SENTINEL
`)
if err := os.WriteFile(roots.config, []byte(configText), 0o600); err != nil {
t.Fatal(err)
}
result := runStateTest(t, roots, newStateTestHarness().options(), true, false, "bypass")
if result.code != 0 {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
summaryRoot := filepath.Join(onlyChildDir(t, roots.debug), "summary")
for _, name := range []string{"effective-config.json", "resolved-pipeline.json"} {
contents, err := os.ReadFile(filepath.Join(summaryRoot, name))
if err != nil {
t.Fatal(err)
}
text := string(contents)
for _, secret := range []string{"CONFIG_SUMMARY_SECRET_SENTINEL", "PIPELINE_SUMMARY_SECRET_SENTINEL"} {
if strings.Contains(text, secret) {
t.Fatalf("%s contains %q: %s", name, secret, text)
}
}
for _, retained := range []string{"[REDACTED]", "SAFE_OPTION_SENTINEL", "SAFE_NESTED_OPTION_SENTINEL"} {
if !strings.Contains(text, retained) {
t.Fatalf("%s does not contain %q: %s", name, retained, text)
}
}
}
}
func TestRunUsesOneInjectedIdentityForDebugOutputAndManifest(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
opts := harness.options()
const runID = "run-1000000000-11111111111111111111111111111111"
opts.RunIDGenerator = func(time.Time) (string, error) { return runID, nil }
result := runStateTest(t, roots, opts, true, false, "bypass")
if result.code != 0 {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
outputPath := filepath.Join(roots.output, runID)
debugPath := filepath.Join(roots.debug, runID)
assertFile(t, filepath.Join(outputPath, "result.json"))
assertFile(t, filepath.Join(debugPath, "summary", "run-manifest.json"))
if !strings.Contains(result.stdout, "output="+outputPath) || !strings.Contains(result.stdout, "debug="+debugPath) {
t.Fatalf("stdout=%q, want shared run identity", result.stdout)
}
data, err := os.ReadFile(filepath.Join(debugPath, "summary", "run-manifest.json"))
if err != nil {
t.Fatal(err)
}
var manifest artifacts.RunManifest
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatal(err)
}
if manifest.RunID != runID {
t.Fatalf("manifest run ID = %q, want %q", manifest.RunID, runID)
}
wantStartedAt := time.Unix(1, 0).UTC()
if manifest.StartedAt == nil || !manifest.StartedAt.Equal(wantStartedAt) {
t.Fatalf("manifest started at = %v, want %v", manifest.StartedAt, wantStartedAt)
}
var invocation debugbundle.Invocation
readStateTestSummaryJSON(t, debugPath, "invocation.json", &invocation)
if invocation.RunID != runID || !invocation.StartedAt.Equal(wantStartedAt) {
t.Fatalf("debug invocation identity = %#v, want run %q at %v", invocation, runID, wantStartedAt)
}
report := readStateTestRunReport(t, debugPath)
if !report.Succeeded || report.RunID != runID || report.PipelineID != "sample" || report.OutputPath != outputPath || report.DebugPath != debugPath || report.OutputCount != 1 || report.RejectedCount != 0 || report.WarningCount != 0 || report.ValidationStatus != "approved" {
t.Fatalf("success report = %#v", report)
}
if !strings.Contains(result.stdout, "outputs=1 rejected=0") {
t.Fatalf("stdout=%q, want report counts", result.stdout)
}
}
func TestRunWritesTerminalArtifactsForResolutionPipelineAndOutputFailures(t *testing.T) {
for _, tc := range []struct {
name string
pipelineID string
wantError string
wantOutputs int
wantValidation string
configureFailure func(*testing.T, stateTestRoots, *stateTestHarness)
}{
{name: "resolution", pipelineID: "missing", wantError: `pipeline "missing"`},
{name: "pipeline", pipelineID: "sample", wantError: "synthetic extraction failure", wantValidation: "failed", configureFailure: func(_ *testing.T, _ stateTestRoots, h *stateTestHarness) {
h.extractErr = errors.New("synthetic extraction failure")
}},
{name: "output", pipelineID: "sample", wantError: "create output parent", wantOutputs: 1, wantValidation: "approved", configureFailure: func(t *testing.T, roots stateTestRoots, _ *stateTestHarness) {
if err := os.WriteFile(roots.output, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
}},
} {
t.Run(tc.name, func(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
if tc.configureFailure != nil {
tc.configureFailure(t, roots, harness)
}
opts := harness.options()
var stdout, stderr bytes.Buffer
args := []string{"run", tc.pipelineID, "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}
code := RunWithOptions(args, &stdout, &stderr, opts)
if code != 1 || !strings.Contains(stderr.String(), tc.wantError) {
t.Fatalf("code=%d stderr=%q", code, stderr.String())
}
bundlePath := onlyChildDir(t, roots.debug)
runID := filepath.Base(bundlePath)
report := readStateTestRunReport(t, bundlePath)
if report.Succeeded || report.RunID != runID || report.PipelineID != tc.pipelineID || report.OutputPath != filepath.Join(roots.output, runID) || report.DebugPath != bundlePath || report.OutputCount != tc.wantOutputs || report.RejectedCount != 0 || report.WarningCount != 0 || report.ValidationStatus != tc.wantValidation {
t.Fatalf("failure report = %#v", report)
}
errorLog, err := os.ReadFile(filepath.Join(bundlePath, "summary", "error.log"))
if err != nil || !strings.Contains(string(errorLog), tc.wantError) {
t.Fatalf("error log = %q, %v", errorLog, err)
}
})
}
}
func TestRunRetainsPartialPipelineOutcomeInFailureSummary(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
harness.chunkWarnings = []contracts.Warning{{Scope: "chunk", ReasonCode: "partial-warning", Message: "warning retained before failure"}}
harness.extractErr = errors.New("synthetic partial pipeline failure")
result := runStateTest(t, roots, harness.options(), true, true, "bypass")
if result.code != 1 {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
bundlePath := onlyChildDir(t, roots.debug)
report := readStateTestRunReport(t, bundlePath)
if report.Succeeded || report.OutputCount != 0 || report.RejectedCount != 0 || report.WarningCount != 1 || report.ValidationStatus != "failed" {
t.Fatalf("partial failure report = %#v", report)
}
var manifest artifacts.RunManifest
readStateTestSummaryJSON(t, bundlePath, "run-manifest.json", &manifest)
if manifest.RunID != report.RunID || manifest.PipelineID != "sample" || manifest.ValidationStatus != "failed" {
t.Fatalf("partial manifest = %#v", manifest)
}
var warnings []contracts.Warning
readStateTestSummaryJSON(t, bundlePath, "warnings.json", &warnings)
if len(warnings) != 1 || warnings[0].ReasonCode != "partial-warning" {
t.Fatalf("partial warnings = %#v", warnings)
}
var events []pipeline.CheckpointEvent
readStateTestSummaryJSON(t, bundlePath, "checkpoint-events.json", &events)
if len(events) == 0 || events[0].Stage != "source" {
t.Fatalf("partial checkpoint events = %#v, want retained source decision", events)
}
var chunkPlan artifacts.ChunkPlanSummary
readStateTestSummaryJSON(t, bundlePath, "chunk-plan.json", &chunkPlan)
if chunkPlan.Mode != "bypass" || chunkPlan.ValidationStatus == "not_run" {
t.Fatalf("partial chunk plan = %#v", chunkPlan)
}
}
func TestRunTerminalPersistenceFailuresDoNotRecurseOrHidePrimaryError(t *testing.T) {
for _, tc := range []struct {
name string
reportErr error
errorLogErr error
wantSecondary string
}{
{name: "run report", reportErr: errors.New("injected run report failure"), wantSecondary: "injected run report failure"},
{name: "error log", errorLogErr: errors.New("injected error log failure"), wantSecondary: "injected error log failure"},
} {
t.Run(tc.name, func(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
harness.extractErr = errors.New("primary pipeline failure")
opts := harness.options()
var terminal *recordingTerminalWriter
opts.DebugTerminalFactory = func(delegate *debugbundle.SummaryWriter) DebugTerminalWriter {
terminal = &recordingTerminalWriter{delegate: delegate, reportErr: tc.reportErr, errorLogErr: tc.errorLogErr}
return terminal
}
result := runStateTest(t, roots, opts, true, false, "bypass")
if result.code != 1 {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
if terminal == nil {
t.Fatal("terminal writer was not constructed")
}
if terminal.reportCalls != 1 || terminal.errorLogCalls != 1 {
t.Fatalf("terminal calls = report:%d error:%d", terminal.reportCalls, terminal.errorLogCalls)
}
primaryIndex := strings.Index(result.stderr, "primary pipeline failure")
secondaryIndex := strings.Index(result.stderr, tc.wantSecondary)
debugIndex := strings.Index(result.stderr, "debug=")
if primaryIndex < 0 || secondaryIndex <= primaryIndex || debugIndex <= secondaryIndex {
t.Fatalf("stderr order = %q", result.stderr)
}
})
}
}
func TestRunReportFailureOnSuccessIsTerminalizedWithoutRetry(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
var terminal *recordingTerminalWriter
opts.DebugTerminalFactory = func(delegate *debugbundle.SummaryWriter) DebugTerminalWriter {
terminal = &recordingTerminalWriter{delegate: delegate, reportErr: errors.New("injected success report failure")}
return terminal
}
result := runStateTest(t, roots, opts, true, false, "bypass")
if result.code != 1 || !strings.Contains(result.stderr, "write debug run report") || !strings.Contains(result.stderr, "injected success report failure") {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
if terminal == nil {
t.Fatal("terminal writer was not constructed")
}
if terminal.reportCalls != 1 || terminal.errorLogCalls != 1 {
t.Fatalf("terminal calls = report:%d error:%d", terminal.reportCalls, terminal.errorLogCalls)
}
if result.stdout != "" {
t.Fatalf("stdout=%q, want no success message", result.stdout)
}
bundlePath := onlyChildDir(t, roots.debug)
errorLog, err := os.ReadFile(filepath.Join(bundlePath, "summary", "error.log"))
if err != nil || !strings.Contains(string(errorLog), "injected success report failure") {
t.Fatalf("error log = %q, %v", errorLog, err)
}
}
func TestRunWithoutDebugDoesNotUseTerminalSummaryWriter(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
harness.extractErr = errors.New("non-debug pipeline failure")
opts := harness.options()
factoryCalls := 0
opts.DebugTerminalFactory = func(delegate *debugbundle.SummaryWriter) DebugTerminalWriter {
factoryCalls++
return delegate
}
result := runStateTest(t, roots, opts, false, false, "bypass")
if result.code != 1 || !strings.Contains(result.stderr, "non-debug pipeline failure") {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
if factoryCalls != 0 {
t.Fatalf("terminal summary factory calls = %d, want 0", factoryCalls)
}
assertAbsent(t, roots.debug)
}
func TestRunRefusesExistingOutputDirectoryWithoutChangingIt(t *testing.T) {
roots := newStateTestRoots(t)
const runID = "run-1000000000-22222222222222222222222222222222"
runPath := filepath.Join(roots.output, runID)
if err := os.MkdirAll(filepath.Join(runPath, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(runPath, "sentinel"), []byte("existing output"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(runPath, "nested", "data"), []byte("preserve me"), 0o644); err != nil {
t.Fatal(err)
}
before := readTree(t, runPath)
opts := newStateTestHarness().options()
opts.RunIDGenerator = func(time.Time) (string, error) { return runID, nil }
result := runStateTest(t, roots, opts, true, false, "bypass")
if result.code != 1 || !strings.Contains(result.stderr, "output run directory") || !strings.Contains(result.stderr, "already exists") {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
if after := readTree(t, runPath); !sameFiles(after, before) {
t.Fatalf("existing output changed: before=%v after=%v", before, after)
}
bundlePath := filepath.Join(roots.debug, runID)
report := readStateTestRunReport(t, bundlePath)
if report.Succeeded || report.RunID != runID || report.OutputPath != runPath || report.DebugPath != bundlePath || report.OutputCount != 1 || report.ValidationStatus != "approved" {
t.Fatalf("output collision report = %#v", report)
}
errorLog, err := os.ReadFile(filepath.Join(bundlePath, "summary", "error.log"))
if err != nil || !strings.Contains(string(errorLog), "already exists") {
t.Fatalf("output collision error log = %q, %v", errorLog, err)
}
}
func TestRepeatedRunIdentityCannotOverwriteFirstOutput(t *testing.T) {
roots := newStateTestRoots(t)
const runID = "run-1000000000-33333333333333333333333333333333"
harness := newStateTestHarness()
opts := harness.options()
opts.RunIDGenerator = func(time.Time) (string, error) { return runID, nil }
first := runStateTest(t, roots, opts, false, false, "bypass")
if first.code != 0 {
t.Fatalf("first code=%d stderr=%q", first.code, first.stderr)
}
runPath := filepath.Join(roots.output, runID)
before := readTree(t, runPath)
second := runStateTest(t, roots, opts, false, false, "bypass")
if second.code != 1 || !strings.Contains(second.stderr, "already exists") {
t.Fatalf("second code=%d stderr=%q", second.code, second.stderr)
}
if after := readTree(t, runPath); !sameFiles(after, before) {
t.Fatalf("first output changed: before=%v after=%v", before, after)
}
}
func TestRunRefusesExistingDebugBundleWithoutChangingIt(t *testing.T) {
roots := newStateTestRoots(t)
const runID = "run-1000000000-44444444444444444444444444444444"
bundlePath := filepath.Join(roots.debug, runID)
if err := os.MkdirAll(bundlePath, 0o700); err != nil {
t.Fatal(err)
}
sentinelPath := filepath.Join(bundlePath, "sentinel")
if err := os.WriteFile(sentinelPath, []byte("existing debug"), 0o600); err != nil {
t.Fatal(err)
}
opts := newStateTestHarness().options()
opts.RunIDGenerator = func(time.Time) (string, error) { return runID, nil }
result := runStateTest(t, roots, opts, true, false, "bypass")
if result.code != 1 || !strings.Contains(result.stderr, "debug bundle") || !strings.Contains(result.stderr, "already exists") {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
if got, err := os.ReadFile(sentinelPath); err != nil || string(got) != "existing debug" {
t.Fatalf("sentinel = %q, %v", got, err)
}
assertAbsent(t, roots.output)
}
func TestRunIDGenerationFailurePrecedesDebugAllocation(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
opts.RunIDGenerator = func(time.Time) (string, error) { return "", errors.New("random source unavailable") }
result := runStateTest(t, roots, opts, true, false, "bypass")
if result.code != 1 || !strings.Contains(result.stderr, "generate run ID: random source unavailable") {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
assertAbsent(t, roots.debug)
assertAbsent(t, roots.output)
}
func TestRunRejectsUnsafeGeneratedIdentityBeforePathUse(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
opts.RunIDGenerator = func(time.Time) (string, error) { return "../outside", nil }
result := runStateTest(t, roots, opts, true, false, "bypass")
if result.code != 1 || !strings.Contains(result.stderr, "invalid generated run ID") || !strings.Contains(result.stderr, "one safe path component") {
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
assertAbsent(t, roots.debug)
assertAbsent(t, roots.output)
}
type stateTestRoots struct{ config, input, output, plans, checkpoints, debug string }
func newStateTestRoots(t *testing.T) stateTestRoots {
t.Helper()
base := t.TempDir()
roots := stateTestRoots{input: filepath.Join(base, "input.txt"), output: filepath.Join(base, "output"), plans: filepath.Join(base, "plans"), checkpoints: filepath.Join(base, "checkpoints"), debug: filepath.Join(base, "debug")}
if err := os.WriteFile(roots.input, []byte("application content Bearer secretvalue sk-secretvalue"), 0o600); err != nil {
t.Fatal(err)
}
roots.config = filepath.Join(base, "config.yml")
config := fmt.Sprintf("version: 3\noutput:\n directory: %q\ncache:\n chunk_plans:\n directory: %q\n mode: auto\n checkpoints:\n enabled: true\n directory: %q\ndebug:\n directory: %q\npipelines:\n sample:\n input: test/input\n chunk: test/chunk\n artifacts:\n items:\n extract: test/extract\n merge: test/merge\n normalize: test/normalize\n output: test/output\n", roots.output, roots.plans, roots.checkpoints, roots.debug)
if err := os.WriteFile(roots.config, []byte(config), 0o600); err != nil {
t.Fatal(err)
}
return roots
}
type stateTestResult struct {
code int
stdout, stderr string
}
func runStateTest(t *testing.T, roots stateTestRoots, opts Options, debug, resume bool, mode string) stateTestResult {
t.Helper()
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", mode}
if debug {
args = append(args, "--debug")
}
if resume {
args = append(args, "--resume")
}
var stdout, stderr bytes.Buffer
return stateTestResult{RunWithOptions(args, &stdout, &stderr, opts), stdout.String(), stderr.String()}
}
func assertStateTestOutput(t *testing.T, root string) {
t.Helper()
output := onlyChildDir(t, root)
data, err := os.ReadFile(filepath.Join(output, "result.json"))
if err != nil || string(data) != "{\"ok\":true}\n" {
t.Fatalf("output = %q, %v", data, err)
}
}
func onlyChildDir(t *testing.T, root string) string {
t.Helper()
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
var dirs []string
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, filepath.Join(root, entry.Name()))
}
}
if len(dirs) != 1 {
t.Fatalf("directories in %q = %v, want one", root, dirs)
}
return dirs[0]
}
func assertFile(t *testing.T, path string) {
t.Helper()
if info, err := os.Stat(path); err != nil || info.IsDir() {
t.Fatalf("file %q: %v", path, err)
}
}
func assertAbsent(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("%q exists or stat failed: %v", path, err)
}
}
func assertAnyFile(t *testing.T, root string) {
t.Helper()
if text := readAllFiles(t, root); text == "" {
t.Fatalf("no files under %q", root)
}
}
func readAllFiles(t *testing.T, root string) string {
t.Helper()
var content strings.Builder
if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
content.Write(data)
return nil
}); err != nil {
t.Fatal(err)
}
return content.String()
}
func readStateTestRunReport(t *testing.T, bundlePath string) debugbundle.RunReport {
t.Helper()
var report debugbundle.RunReport
readStateTestSummaryJSON(t, bundlePath, "run-report.json", &report)
return report
}
func readStateTestSummaryJSON(t *testing.T, bundlePath, name string, target any) {
t.Helper()
data, err := os.ReadFile(filepath.Join(bundlePath, "summary", name))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, target); err != nil {
t.Fatal(err)
}
}
func assertRestrictedTree(t *testing.T, root string) {
t.Helper()
if runtime.GOOS == "windows" {
return
}
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
want := os.FileMode(0o600)
if info.IsDir() {
want = 0o700
}
if info.Mode().Perm() != want {
return fmt.Errorf("%s has mode %o, want %o", path, info.Mode().Perm(), want)
}
return nil
}); err != nil {
t.Fatal(err)
}
}
func readTree(t *testing.T, root string) map[string][]byte {
t.Helper()
files := map[string][]byte{}
if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
files[relative] = data
return nil
}); err != nil {
t.Fatal(err)
}
return files
}
func sameFiles(left, right map[string][]byte) bool {
if len(left) != len(right) {
return false
}
for path, data := range left {
if !bytes.Equal(data, right[path]) {
return false
}
}
return true
}
type stateTestHarness struct {
mu sync.Mutex
chunkCalls, extractCalls int
runIDCalls uint64
extractErr error
chunkWarnings []contracts.Warning
moduleProfiles []string
sessionIDs []string
outputWarnings []contracts.Warning
includeWarnings bool
}
func newStateTestHarness() *stateTestHarness { return &stateTestHarness{} }
func (h *stateTestHarness) options() Options {
registries := pipeline.Registries{Inputs: pipeline.NewInputAdapterRegistry(), Chunkers: pipeline.NewChunkerRegistry(), ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), Extractors: pipeline.NewExtractorRegistry(), Mergers: pipeline.NewMergerRegistry(), Normalizers: pipeline.NewNormalizerRegistry(), Validators: pipeline.NewValidatorRegistry(), ValidatorChains: pipeline.NewValidatorChainRegistry(), Outputs: pipeline.NewOutputEncoderRegistry()}
if err := pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, stateTestCodec{}); err != nil {
panic(err)
}
if err := registries.Inputs.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "test/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.InputAdapter, error) { return stateTestInput{}, nil }); err != nil {
panic(err)
}
if err := registries.Chunkers.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "test/chunk", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "cache-reference"}}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.Chunker, error) { return stateTestChunker{h}, nil }); err != nil {
panic(err)
}
if err := pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "test/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{h}, nil }); err != nil {
panic(err)
}
if err := pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "test/merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{harness: h}, nil }); err != nil {
panic(err)
}
if err := pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "test/normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{harness: h}, nil }); err != nil {
panic(err)
}
if err := registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) {
return stateTestOutput{harness: h, includeWarnings: h.includeWarnings}, nil
}); err != nil {
panic(err)
}
return Options{Catalog: catalogFromRegistries(registries), Registries: registries, LookupEnv: emptyLookup, Now: func() time.Time { return time.Unix(1, 0) }, RunIDGenerator: func(startedAt time.Time) (string, error) {
h.mu.Lock()
defer h.mu.Unlock()
h.runIDCalls++
return fmt.Sprintf("run-%d-%032x", startedAt.UnixNano(), h.runIDCalls), nil
}, UserCacheDir: func() (string, error) { return "", errors.New("unexpected user cache lookup") }, LLMClientFactory: func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
return nil, nil, nil
}}
}
type stateTestInput struct{}
func (stateTestInput) Key() string { return "test/input" }
func (stateTestInput) Parse(_ context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) {
doc := &source.SourceDocument{ID: "source", Kind: "text", Format: "text/plain", Units: []source.SourceUnit{{ID: 1, Kind: "text", Text: string(req.Raw), Ref: source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}}}}
digest, err := source.DigestDocument(doc)
if err != nil {
return nil, err
}
doc.Digest = digest
return doc, nil
}
type stateTestChunker struct{ harness *stateTestHarness }
func (stateTestChunker) Key() string { return "test/chunk" }
func (stateTestChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (c stateTestChunker) Plan(_ context.Context, req contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
c.harness.mu.Lock()
c.harness.moduleProfiles = append(c.harness.moduleProfiles, req.LLMProfile)
c.harness.sessionIDs = append(c.harness.sessionIDs, req.SessionID)
c.harness.mu.Unlock()
c.harness.mu.Lock()
c.harness.chunkCalls++
c.harness.mu.Unlock()
return contracts.ChunkPlanResult{Plan: source.ChunkPlan{SourceDigest: req.Source.Digest, Ranges: []source.ChunkRange{{StartUnitID: 1, EndUnitID: 1}}}, Warnings: append([]contracts.Warning(nil), c.harness.chunkWarnings...)}, nil
}
const stateTestArtifactKind contracts.ArtifactKind = "test/artifact"
type stateTestArtifact struct {
Value string `json:"value"`
}
type stateTestCodec struct{}
func (stateTestCodec) Kind() contracts.ArtifactKind { return stateTestArtifactKind }
func (stateTestCodec) Schema() contracts.ArtifactSchema {
return contracts.ArtifactSchema{ID: "test.artifact", Name: "test_artifact", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)}
}
func (stateTestCodec) MediaType() string { return "application/json" }
func (stateTestCodec) EncodeCandidate(v stateTestArtifact) ([]byte, error) {
return []byte(`{"value":"ok"}`), nil
}
func (stateTestCodec) Encode(v stateTestArtifact) ([]byte, error) {
return []byte(`{"value":"ok"}`), nil
}
func (stateTestCodec) Decode([]byte) (stateTestArtifact, error) {
return stateTestArtifact{Value: "ok"}, nil
}
type stateTestExtractor struct{ harness *stateTestHarness }
func (stateTestExtractor) Key() string { return "test/extract" }
func (stateTestExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (e stateTestExtractor) Extract(_ context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[stateTestArtifact], error) {
e.harness.mu.Lock()
defer e.harness.mu.Unlock()
e.harness.extractCalls++
e.harness.moduleProfiles = append(e.harness.moduleProfiles, req.LLMProfile)
e.harness.sessionIDs = append(e.harness.sessionIDs, req.SessionID)
if e.harness.extractErr != nil {
return contracts.TypedExtractionResult[stateTestArtifact]{}, e.harness.extractErr
}
return contracts.TypedExtractionResult[stateTestArtifact]{Value: stateTestArtifact{Value: "ok"}}, nil
}
type stateTestMerger struct{ harness *stateTestHarness }
func (stateTestMerger) Key() string { return "test/merge" }
func (m stateTestMerger) Merge(_ context.Context, req contracts.TypedMergeRequest[stateTestArtifact]) (contracts.TypedMergeResult[stateTestArtifact], error) {
m.harness.mu.Lock()
m.harness.moduleProfiles = append(m.harness.moduleProfiles, req.LLMProfile)
m.harness.sessionIDs = append(m.harness.sessionIDs, req.SessionID)
m.harness.mu.Unlock()
return contracts.TypedMergeResult[stateTestArtifact]{Value: req.ExtractOutputs[0].Value}, nil
}
type stateTestNormalizer struct{ harness *stateTestHarness }
func (stateTestNormalizer) Key() string { return "test/normalize" }
func (stateTestNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n stateTestNormalizer) Normalize(_ context.Context, req contracts.TypedNormalizeRequest[stateTestArtifact]) (contracts.TypedNormalizeResult[stateTestArtifact], error) {
n.harness.mu.Lock()
n.harness.moduleProfiles = append(n.harness.moduleProfiles, req.LLMProfile)
n.harness.sessionIDs = append(n.harness.sessionIDs, req.SessionID)
n.harness.mu.Unlock()
return contracts.TypedNormalizeResult[stateTestArtifact]{Value: req.MergeOutput.Value}, nil
}
type stateTestOutput struct {
harness *stateTestHarness
includeWarnings bool
}
func (o stateTestOutput) Key() string { return "test/output" }
func (o stateTestOutput) Encode(_ context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) {
o.harness.mu.Lock()
o.harness.outputWarnings = append([]contracts.Warning(nil), req.Warnings...)
o.harness.mu.Unlock()
data := []byte("{\"ok\":true}\n")
if o.includeWarnings && len(req.Warnings) > 0 {
data = []byte(fmt.Sprintf("{\"ok\":true,\"warnings\":%q}\n", req.Warnings[0].ReasonCode))
}
return contracts.OutputResult{Files: []contracts.OutputFile{{Name: "result.json", Bytes: data}}}, nil
}
type failingDebugRecorder struct{}
func (failingDebugRecorder) Enabled() bool { return true }
func (failingDebugRecorder) WriteJSON(string, any) error { return errors.New("trace unavailable") }
func (failingDebugRecorder) WriteBytes(string, []byte) error { return errors.New("trace unavailable") }
type recordingTerminalWriter struct {
delegate DebugTerminalWriter
reportErr, errorLogErr error
reportCalls, errorLogCalls int
}
func (w *recordingTerminalWriter) WriteRunReport(report debugbundle.RunReport) error {
w.reportCalls++
if w.reportErr != nil {
return w.reportErr
}
return w.delegate.WriteRunReport(report)
}
func (w *recordingTerminalWriter) WriteError(message string) error {
w.errorLogCalls++
if w.errorLogErr != nil {
return w.errorLogErr
}
return w.delegate.WriteError(message)
}

View File

@@ -1,91 +1,149 @@
package artifacts
import (
"encoding/json"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
type ArtifactCandidate struct {
Index int `json:"index"`
ExtractorKey string `json:"extractor_key"`
ArtifactType string `json:"artifact_type"`
SchemaVersion string `json:"schema_version"`
Payload json.RawMessage `json:"payload"`
SourceRefs []source.SourceRef `json:"source_refs,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type Artifact struct {
ExtractorKey string `json:"extractor_key"`
ArtifactType string `json:"artifact_type"`
SchemaVersion string `json:"schema_version"`
Payload json.RawMessage `json:"payload"`
SourceRefs []source.SourceRef `json:"source_refs,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type RejectedArtifact struct {
Candidate ArtifactCandidate `json:"candidate"`
ValidatorName string `json:"validator_name"`
ReasonCode string `json:"reason_code"`
Message string `json:"message"`
}
type ArtifactLaneManifest struct {
StepID string `json:"step_id,omitempty"`
ID string `json:"id"`
Extractor string `json:"extractor"`
Merger string `json:"merger"`
Normalizer string `json:"normalizer"`
Validators []string `json:"validators,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type ValidatorChainManifest struct {
Stage string `json:"stage"`
LaneID string `json:"lane_id,omitempty"`
ModuleKey string `json:"module_key"`
Validators []ValidatorManifest `json:"validators"`
}
type ValidatorManifest struct {
Key string `json:"key"`
ExecutionClass string `json:"execution_class"`
}
type LLMProfileManifest struct {
ID string `json:"id"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
}
type ReferenceProvenance struct {
Stage string `json:"stage,omitempty"`
StepID string `json:"step_id,omitempty"`
LaneID string `json:"lane_id,omitempty"`
SlotName string `json:"slot_name"`
OriginType string `json:"origin_type"`
OriginURI string `json:"origin_uri,omitempty"`
Digest string `json:"digest,omitempty"`
MediaType string `json:"media_type,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
BindingSource string `json:"binding_source,omitempty"`
ArtifactKind string `json:"artifact_kind,omitempty"`
SchemaID string `json:"schema_id,omitempty"`
SchemaName string `json:"schema_name,omitempty"`
SchemaVersion string `json:"schema_version,omitempty"`
SchemaDigest string `json:"schema_digest,omitempty"`
ProducerPipeline string `json:"producer_pipeline_id,omitempty"`
ProducerStep string `json:"producer_step_id,omitempty"`
ProducerLane string `json:"producer_lane_id,omitempty"`
ProducerModule string `json:"producer_module_key,omitempty"`
}
type OutputSchemaProvenance struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
}
type NormalizedOutputManifest struct {
StepID string `json:"step_id,omitempty"`
LaneID string `json:"lane_id"`
ModuleKey string `json:"module_key,omitempty"`
SourceID string `json:"source_id,omitempty"`
MediaType string `json:"media_type,omitempty"`
Schema OutputSchemaProvenance `json:"schema,omitempty"`
}
type RejectedOutputManifest struct {
Stage string `json:"stage"`
StepID string `json:"step_id,omitempty"`
LaneID string `json:"lane_id,omitempty"`
ModuleKey string `json:"module_key,omitempty"`
ChunkID string `json:"chunk_id,omitempty"`
ChunkIndex int `json:"chunk_index,omitempty"`
ValidatorName string `json:"validator_name,omitempty"`
ReasonCode string `json:"reason_code,omitempty"`
Message string `json:"message,omitempty"`
AttemptCount int `json:"attempt_count,omitempty"`
DiagnosticArtifactPath string `json:"diagnostic_artifact_path,omitempty"`
}
type CheckpointDecisionManifest struct {
Stage string `json:"stage"`
StepID string `json:"step_id,omitempty"`
LaneID string `json:"lane_id,omitempty"`
ModuleKey string `json:"module_key,omitempty"`
Category string `json:"category"`
ReasonCode string `json:"reason_code,omitempty"`
Detail string `json:"detail,omitempty"`
}
type ChunkPlanManifest struct {
Mode string `json:"mode"`
Action string `json:"action,omitempty"`
SourceDigest string `json:"source_digest,omitempty"`
PlanDigest string `json:"plan_digest,omitempty"`
PlanSchemaVersion string `json:"plan_schema_version,omitempty"`
RequestedModule string `json:"requested_module"`
ProducerInputModule string `json:"producer_input_module,omitempty"`
ProducerModule string `json:"producer_module,omitempty"`
ProducerLLMProfile string `json:"producer_llm_profile,omitempty"`
ProducerReferences []ReferenceProvenance `json:"producer_references,omitempty"`
ProducerMetadata map[string]any `json:"producer_metadata,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
// ChunkPlanSummary is deliberately limited to cache and validation decisions.
// It must never contain plan units, source content, annotations, or model I/O.
type ChunkPlanSummary struct {
Mode string `json:"mode"`
SourceDigest string `json:"source_digest,omitempty"`
CandidateDigest string `json:"candidate_digest,omitempty"`
RequestedModule string `json:"requested_module"`
LookupStatus string `json:"lookup_status"`
LookupReason string `json:"lookup_reason,omitempty"`
Action string `json:"action,omitempty"`
ValidationStatus string `json:"validation_status"`
PublicationStatus string `json:"publication_status"`
}
type RunManifest struct {
RunID string `json:"run_id,omitempty"`
PipelineID string `json:"pipeline_id,omitempty"`
PipelineDigest string `json:"pipeline_digest,omitempty"`
InputModule string `json:"input_module,omitempty"`
Chunker string `json:"chunker,omitempty"`
ChunkPlan *ChunkPlanManifest `json:"chunk_plan,omitempty"`
SourceDigests []string `json:"source_digests,omitempty"`
Extractors []string `json:"extractors,omitempty"`
Merger string `json:"merger,omitempty"`
Normalizer string `json:"normalizer,omitempty"`
OutputEncoder string `json:"output_encoder,omitempty"`
ModuleMetadata map[string]map[string]any `json:"module_metadata,omitempty"`
ArtifactLanes []ArtifactLaneManifest `json:"artifact_lanes,omitempty"`
ValidatorChains []ValidatorChainManifest `json:"validator_chains,omitempty"`
References []ReferenceProvenance `json:"references,omitempty"`
NormalizedOutputs []NormalizedOutputManifest `json:"normalized_outputs,omitempty"`
RejectedOutputs []RejectedOutputManifest `json:"rejected_outputs,omitempty"`
CheckpointDecisions []CheckpointDecisionManifest `json:"checkpoint_decisions,omitempty"`
LLMProfiles []LLMProfileManifest `json:"llm_profiles,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SchemaVersion string `json:"schema_version,omitempty"`
ValidationStatus string `json:"validation_status,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
func ArtifactFromCandidate(candidate ArtifactCandidate) Artifact {
return Artifact{
ExtractorKey: candidate.ExtractorKey,
ArtifactType: candidate.ArtifactType,
SchemaVersion: candidate.SchemaVersion,
Payload: append(json.RawMessage(nil), candidate.Payload...),
SourceRefs: append([]source.SourceRef(nil), candidate.SourceRefs...),
Metadata: copyMetadata(candidate.Metadata),
}
}
func copyMetadata(metadata map[string]any) map[string]any {
if len(metadata) == 0 {
return nil
}
copied := make(map[string]any, len(metadata))
for key, value := range metadata {
copied[key] = value
}
return copied
}

View File

@@ -2,116 +2,10 @@ package artifacts
import (
"encoding/json"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestArtifactFromCandidatePreservesCandidateFields(t *testing.T) {
candidate := ArtifactCandidate{
Index: 7,
ExtractorKey: "generic-extractor",
ArtifactType: "generic-artifact",
SchemaVersion: "v1",
Payload: json.RawMessage(`{"name":"example"}`),
SourceRefs: []source.SourceRef{
{SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u2"},
},
Metadata: map[string]any{
"confidence": 0.75,
},
}
artifact := ArtifactFromCandidate(candidate)
if artifact.ExtractorKey != candidate.ExtractorKey {
t.Fatalf("ExtractorKey = %q, want %q", artifact.ExtractorKey, candidate.ExtractorKey)
}
if artifact.ArtifactType != candidate.ArtifactType {
t.Fatalf("ArtifactType = %q, want %q", artifact.ArtifactType, candidate.ArtifactType)
}
if artifact.SchemaVersion != candidate.SchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", artifact.SchemaVersion, candidate.SchemaVersion)
}
if string(artifact.Payload) != string(candidate.Payload) {
t.Fatalf("Payload = %s, want %s", artifact.Payload, candidate.Payload)
}
if !reflect.DeepEqual(artifact.SourceRefs, candidate.SourceRefs) {
t.Fatalf("SourceRefs = %#v, want %#v", artifact.SourceRefs, candidate.SourceRefs)
}
if !reflect.DeepEqual(artifact.Metadata, candidate.Metadata) {
t.Fatalf("Metadata = %#v, want %#v", artifact.Metadata, candidate.Metadata)
}
candidate.Payload[0] = '['
candidate.SourceRefs[0].StartUnitID = "changed"
candidate.Metadata["confidence"] = 0.5
if string(artifact.Payload) != `{"name":"example"}` {
t.Fatalf("Payload changed after candidate mutation: %s", artifact.Payload)
}
if artifact.SourceRefs[0].StartUnitID != "u1" {
t.Fatalf("SourceRefs changed after candidate mutation: %#v", artifact.SourceRefs)
}
if artifact.Metadata["confidence"] != 0.75 {
t.Fatalf("Metadata changed after candidate mutation: %#v", artifact.Metadata)
}
}
func TestJSONMarshalUsesExpectedFieldNames(t *testing.T) {
candidate := ArtifactCandidate{
Index: 1,
ExtractorKey: "generic-extractor",
ArtifactType: "generic-artifact",
SchemaVersion: "v1",
Payload: json.RawMessage(`{"value":true}`),
SourceRefs: []source.SourceRef{
{SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u1"},
},
Metadata: map[string]any{
"reviewed": true,
},
}
rejected := RejectedArtifact{
Candidate: candidate,
ValidatorName: "generic-validator",
ReasonCode: "invalid",
Message: "candidate was not accepted",
}
gotJSON, err := json.Marshal(rejected)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
var got map[string]any
if err := json.Unmarshal(gotJSON, &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
assertHasKeys(t, got, "candidate", "validator_name", "reason_code", "message")
gotCandidate, ok := got["candidate"].(map[string]any)
if !ok {
t.Fatalf("candidate = %#v, want object", got["candidate"])
}
assertHasKeys(t, gotCandidate, "index", "extractor_key", "artifact_type", "schema_version", "payload", "source_refs", "metadata")
gotRefs, ok := gotCandidate["source_refs"].([]any)
if !ok {
t.Fatalf("source_refs = %#v, want array", gotCandidate["source_refs"])
}
if len(gotRefs) != 1 {
t.Fatalf("len(source_refs) = %d, want 1", len(gotRefs))
}
gotRef, ok := gotRefs[0].(map[string]any)
if !ok {
t.Fatalf("source_refs[0] = %#v, want object", gotRefs[0])
}
assertHasKeys(t, gotRef, "source_id", "start_unit_id", "end_unit_id")
}
func TestRunManifestOmitsEmptyOptionalFields(t *testing.T) {
gotJSON, err := json.Marshal(RunManifest{})
if err != nil {
@@ -123,12 +17,43 @@ func TestRunManifestOmitsEmptyOptionalFields(t *testing.T) {
}
}
func TestRunManifestChunkPlanIsAdditiveAndOmitsPlanContent(t *testing.T) {
manifest := RunManifest{ChunkPlan: &ChunkPlanManifest{
Mode: "auto", Action: "reused", SourceDigest: "sha256:source", PlanDigest: "sha256:plan",
PlanSchemaVersion: "notarius.chunk-plan.v2", RequestedModule: "chunk/current",
ProducerInputModule: "input/original", ProducerModule: "chunk/original",
}}
encoded, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
text := string(encoded)
for _, want := range []string{`"chunk_plan"`, `"action":"reused"`, `"requested_module":"chunk/current"`, `"producer_module":"chunk/original"`} {
if !strings.Contains(text, want) {
t.Fatalf("manifest JSON %s does not contain %s", text, want)
}
}
for _, forbidden := range []string{`"plan"`, `"units"`, `"annotations"`} {
if strings.Contains(text, forbidden) {
t.Fatalf("manifest JSON contains forbidden field %s: %s", forbidden, text)
}
}
var legacy RunManifest
if err := json.Unmarshal([]byte(`{"pipeline_id":"legacy"}`), &legacy); err != nil {
t.Fatal(err)
}
if legacy.PipelineID != "legacy" || legacy.ChunkPlan != nil {
t.Fatalf("legacy manifest = %#v", legacy)
}
}
func TestRunManifestIncludesPipelineAndArtifactLaneFields(t *testing.T) {
manifest := RunManifest{
PipelineID: "pipeline-1",
PipelineDigest: "sha256:abc123",
LLMProfiles: []LLMProfileManifest{
{ID: "default", Provider: "openai-compatible", Model: "model-a"},
{ID: "default", Provider: "scriptorium", Model: "model-a"},
},
ArtifactLanes: []ArtifactLaneManifest{
{
@@ -136,12 +61,21 @@ func TestRunManifestIncludesPipelineAndArtifactLaneFields(t *testing.T) {
Extractor: "event-extractor",
Merger: "appendorder",
Normalizer: "noop",
Validators: []string{"grounded"},
Metadata: map[string]any{
"extractor": map[string]any{"prompt_id": "test.prompt"},
},
},
},
ValidatorChains: []ValidatorChainManifest{
{
Stage: "extract",
LaneID: "events",
ModuleKey: "event-extractor",
Validators: []ValidatorManifest{
{Key: "grounded", ExecutionClass: "deterministic"},
},
},
},
}
gotJSON, err := json.Marshal(manifest)
@@ -154,7 +88,7 @@ func TestRunManifestIncludesPipelineAndArtifactLaneFields(t *testing.T) {
t.Fatalf("json.Unmarshal() error = %v", err)
}
assertHasKeys(t, got, "pipeline_id", "pipeline_digest", "artifact_lanes", "llm_profiles")
assertHasKeys(t, got, "pipeline_id", "pipeline_digest", "artifact_lanes", "validator_chains", "llm_profiles")
profiles, ok := got["llm_profiles"].([]any)
if !ok {
@@ -180,7 +114,94 @@ func TestRunManifestIncludesPipelineAndArtifactLaneFields(t *testing.T) {
if !ok {
t.Fatalf("artifact_lanes[0] = %#v, want object", lanes[0])
}
assertHasKeys(t, lane, "id", "extractor", "merger", "normalizer", "validators", "metadata")
assertHasKeys(t, lane, "id", "extractor", "merger", "normalizer", "metadata")
chains, ok := got["validator_chains"].([]any)
if !ok {
t.Fatalf("validator_chains = %#v, want array", got["validator_chains"])
}
if len(chains) != 1 {
t.Fatalf("len(validator_chains) = %d, want 1", len(chains))
}
chain, ok := chains[0].(map[string]any)
if !ok {
t.Fatalf("validator_chains[0] = %#v, want object", chains[0])
}
assertHasKeys(t, chain, "stage", "lane_id", "module_key", "validators")
}
func TestRunManifestIncludesReferenceProvenance(t *testing.T) {
manifest := RunManifest{
References: []ReferenceProvenance{
{
Stage: "extract",
LaneID: "events",
SlotName: "roster",
OriginType: "file",
OriginURI: "file:///tmp/roster.txt",
Digest: "sha256:reference",
MediaType: "text/plain; charset=utf-8",
SizeBytes: 12,
BindingSource: "config",
},
},
}
gotJSON, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
var got RunManifest
if err := json.Unmarshal(gotJSON, &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if len(got.References) != 1 {
t.Fatalf("len(References) = %d, want 1", len(got.References))
}
reference := got.References[0]
if reference.Stage != "extract" || reference.LaneID != "events" || reference.SlotName != "roster" || reference.OriginType != "file" || reference.OriginURI != "file:///tmp/roster.txt" {
t.Fatalf("reference provenance = %#v, want lane-scoped origin details", reference)
}
if reference.Digest != "sha256:reference" || reference.MediaType != "text/plain; charset=utf-8" || reference.SizeBytes != 12 || reference.BindingSource != "config" {
t.Fatalf("reference provenance = %#v, want digest/media/size/source details", reference)
}
}
func TestRunManifestIncludesTopLevelModuleMetadata(t *testing.T) {
manifest := RunManifest{
ModuleMetadata: map[string]map[string]any{
"chunker": {
"prompt_id": "dnd.scenes",
"prompt_version": "v1",
"prompt_sha256": "sha256:abc123",
"response_schema_key": "dnd_scenes",
"response_schema_name": "dnd_scenes",
},
},
}
gotJSON, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
var got map[string]any
if err := json.Unmarshal(gotJSON, &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
moduleMetadata, ok := got["module_metadata"].(map[string]any)
if !ok {
t.Fatalf("module_metadata = %#v, want object", got["module_metadata"])
}
assertHasKeys(t, moduleMetadata, "chunker")
chunkerMetadata, ok := moduleMetadata["chunker"].(map[string]any)
if !ok {
t.Fatalf("module_metadata.chunker = %#v, want object", moduleMetadata["chunker"])
}
assertHasKeys(t, chunkerMetadata, "prompt_id", "prompt_version", "prompt_sha256", "response_schema_key", "response_schema_name")
}
func assertHasKeys(t *testing.T, values map[string]any, keys ...string) {

View File

@@ -0,0 +1,31 @@
package config
import (
"fmt"
"path/filepath"
"strings"
)
// DefaultChunkPlanRoot resolves the existing per-user chunk-plan cache root.
func DefaultChunkPlanRoot(userCacheDir func() (string, error)) (string, error) {
return defaultCacheFamilyRoot(userCacheDir, "chunk-plans")
}
func DefaultCheckpointRoot(userCacheDir func() (string, error)) (string, error) {
return defaultCacheFamilyRoot(userCacheDir, "checkpoints")
}
func defaultCacheFamilyRoot(userCacheDir func() (string, error), family string) (string, error) {
if userCacheDir == nil {
return "", fmt.Errorf("user cache directory resolver must not be nil")
}
root, err := userCacheDir()
if err != nil {
return "", fmt.Errorf("resolve user cache directory: %w", err)
}
root = strings.TrimSpace(root)
if root == "" {
return "", fmt.Errorf("user cache directory must not be empty")
}
return filepath.Join(filepath.Clean(root), "notarius", family), nil
}

View File

@@ -1,66 +1,72 @@
package config
import (
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
const SupportedFileConfigVersion = 1
const SupportedFileConfigVersion = 3
type Config struct {
LLMProfiles map[string]LLMProfile `json:"llm_profiles"`
Scriptorium ScriptoriumConfig `json:"scriptorium,omitempty"`
Pipelines map[string]pipeline.PipelineProfile `json:"pipelines"`
Concurrency ConcurrencyConfig `json:"concurrency"`
Diagnostics DiagnosticsConfig `json:"diagnostics"`
Output OutputConfig `json:"output"`
Cache CacheConfig `json:"cache"`
Debug DebugConfig `json:"debug"`
}
type LLMProfile struct {
Provider string `json:"provider,omitempty"`
BaseURL string `json:"base_url,omitempty"`
Model string `json:"model,omitempty"`
APIKey string `json:"api_key,omitempty"`
APIKeyEnv string `json:"api_key_env,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
MaxConcurrency int `json:"max_concurrency,omitempty"`
type ScriptoriumConfig struct {
ProfileDir string `json:"profile_dir,omitempty"`
ProfileFile string `json:"profile_file,omitempty"`
}
type ConcurrencyConfig struct {
TotalLLM int `json:"total_llm"`
StageWorkers map[string]int `json:"stage_workers"`
extractWorkersConfigured bool
defaultedExtractWorkers int
}
type DiagnosticsConfig struct {
WorkDir string `json:"work_dir"`
Retention diagnostics.RetentionMode `json:"retention"`
type OutputConfig struct {
Directory string `json:"directory"`
}
type CacheConfig struct {
ChunkPlans ChunkPlanCacheConfig `json:"chunk_plans"`
Checkpoints CheckpointCacheConfig `json:"checkpoints"`
}
type ChunkPlanCacheConfig struct {
Directory string `json:"directory,omitempty"`
Mode pipeline.ChunkCacheMode `json:"mode"`
}
type CheckpointCacheConfig struct {
Enabled bool `json:"enabled"`
Directory string `json:"directory,omitempty"`
}
type DebugConfig struct {
Directory string `json:"directory"`
}
func Default() Config {
return Config{
LLMProfiles: map[string]LLMProfile{
pipeline.DefaultLLMProfile: {
Provider: "openai-compatible",
TimeoutSeconds: 600,
MaxRetries: 3,
MaxConcurrency: 1,
},
},
Pipelines: map[string]pipeline.PipelineProfile{},
Concurrency: ConcurrencyConfig{
TotalLLM: 1,
StageWorkers: map[string]int{"extract": 1},
defaultedExtractWorkers: 1,
},
Diagnostics: DiagnosticsConfig{
WorkDir: "/tmp/notarius",
Retention: diagnostics.RetentionAuto,
},
Output: OutputConfig{Directory: "./notarius-output"},
Cache: CacheConfig{ChunkPlans: ChunkPlanCacheConfig{Mode: pipeline.ChunkCacheAuto}},
Debug: DebugConfig{Directory: "./notarius-debug"},
}
}
func cloneConfig(in Config) Config {
out := in
out.LLMProfiles = make(map[string]LLMProfile, len(in.LLMProfiles))
for key, profile := range in.LLMProfiles {
out.LLMProfiles[key] = profile
}
out.Concurrency.StageWorkers = cloneIntMap(in.Concurrency.StageWorkers)
out.Pipelines = make(map[string]pipeline.PipelineProfile, len(in.Pipelines))
for key, profile := range in.Pipelines {
out.Pipelines[key] = clonePipelineProfile(profile)
@@ -68,11 +74,59 @@ func cloneConfig(in Config) Config {
return out
}
func cloneIntMap(in map[string]int) map[string]int {
if len(in) == 0 {
return nil
}
out := make(map[string]int, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func (c *ConcurrencyConfig) recomputeStageWorkerDefaults() {
if c == nil {
return
}
if c.StageWorkers == nil {
c.StageWorkers = make(map[string]int)
}
if !c.extractWorkersConfigured {
if value, ok := c.StageWorkers["extract"]; ok && (c.defaultedExtractWorkers == 0 || value != c.defaultedExtractWorkers) {
c.extractWorkersConfigured = true
return
}
c.StageWorkers["extract"] = c.TotalLLM
c.defaultedExtractWorkers = c.TotalLLM
}
}
func clonePipelineProfile(in pipeline.PipelineProfile) pipeline.PipelineProfile {
out := in
out.Input = cloneModuleBinding(in.Input)
out.Chunk = cloneModuleBinding(in.Chunk)
out.Output = cloneModuleBinding(in.Output)
out.References = cloneReferenceSourceMap(in.References)
if len(in.Artifacts) > 0 {
out.Artifacts = make(map[string]pipeline.ArtifactLaneProfile, len(in.Artifacts))
for key, lane := range in.Artifacts {
out.Artifacts[key] = cloneArtifactLaneProfile(lane)
}
}
if in.Steps != nil {
out.Steps = make([]pipeline.PipelineStepProfile, len(in.Steps))
for i, step := range in.Steps {
out.Steps[i] = clonePipelineStepProfile(step)
}
}
return out
}
func clonePipelineStepProfile(in pipeline.PipelineStepProfile) pipeline.PipelineStepProfile {
out := in
out.ID = in.ID
out.References = cloneReferenceSourceMap(in.References)
if len(in.Artifacts) > 0 {
out.Artifacts = make(map[string]pipeline.ArtifactLaneProfile, len(in.Artifacts))
for key, lane := range in.Artifacts {
@@ -87,6 +141,7 @@ func cloneArtifactLaneProfile(in pipeline.ArtifactLaneProfile) pipeline.Artifact
out.Extract = cloneModuleBinding(in.Extract)
out.Merge = cloneModuleBinding(in.Merge)
out.Normalize = cloneModuleBinding(in.Normalize)
out.References = cloneReferenceSourceMap(in.References)
if len(in.Validators) > 0 {
out.Validators = make([]pipeline.ModuleBinding, len(in.Validators))
for i, binding := range in.Validators {
@@ -96,11 +151,55 @@ func cloneArtifactLaneProfile(in pipeline.ArtifactLaneProfile) pipeline.Artifact
return out
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneReferenceSourceMap(in map[string]pipeline.ReferenceSource) map[string]pipeline.ReferenceSource {
if len(in) == 0 {
return nil
}
out := make(map[string]pipeline.ReferenceSource, len(in))
for key, source := range in {
out[key] = cloneReferenceSource(source)
}
return out
}
func cloneReferenceSource(in pipeline.ReferenceSource) pipeline.ReferenceSource {
out := in
if in.Artifact != nil {
artifact := *in.Artifact
out.Artifact = &artifact
}
return out
}
func cloneModuleBinding(in pipeline.ModuleBinding) pipeline.ModuleBinding {
out := in
if len(in.Options) > 0 {
out.Options = cloneOptions(in.Options)
}
out.References = cloneReferenceSourceMap(in.References)
out.Validators = cloneValidatorOverride(in.Validators)
return out
}
func cloneValidatorOverride(in pipeline.ValidatorOverride) pipeline.ValidatorOverride {
out := pipeline.ValidatorOverride{Set: in.Set}
if len(in.Validators) > 0 {
out.Validators = make([]pipeline.ModuleBinding, len(in.Validators))
for i, binding := range in.Validators {
out.Validators[i] = cloneModuleBinding(binding)
}
}
return out
}

View File

@@ -1,78 +0,0 @@
package config
import (
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestDefaultValues(t *testing.T) {
cfg := Default()
defaultProfile, ok := cfg.LLMProfiles[pipeline.DefaultLLMProfile]
if !ok {
t.Fatalf("expected default LLM profile")
}
if defaultProfile.Provider != "openai-compatible" {
t.Fatalf("unexpected provider: %q", defaultProfile.Provider)
}
if defaultProfile.BaseURL != "" || defaultProfile.Model != "" {
t.Fatalf("default profile should not require base URL/model yet: %+v", defaultProfile)
}
if defaultProfile.TimeoutSeconds != 600 || defaultProfile.MaxRetries != 3 || defaultProfile.MaxConcurrency != 1 {
t.Fatalf("unexpected default LLM operational values: %+v", defaultProfile)
}
if len(cfg.Pipelines) != 0 {
t.Fatalf("expected no built-in pipeline profiles, got %v", cfg.Pipelines)
}
if cfg.Concurrency.TotalLLM != 1 {
t.Fatalf("unexpected total LLM concurrency: %d", cfg.Concurrency.TotalLLM)
}
if cfg.Diagnostics.WorkDir != "/tmp/notarius" {
t.Fatalf("unexpected diagnostics work dir: %q", cfg.Diagnostics.WorkDir)
}
if cfg.Diagnostics.Retention != diagnostics.RetentionAuto {
t.Fatalf("unexpected diagnostics retention: %q", cfg.Diagnostics.Retention)
}
}
func TestApplyFileConfigMergesWithDefaults(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1
llm_profiles:
default:
model: test-model
pipelines:
example:
input: fake/input
artifacts:
events:
extract: fake/extract
`))
if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err)
}
cfg := Default()
if err := cfg.applyFileConfigWithLookup(fileCfg, emptyLookup); err != nil {
t.Fatalf("ApplyFileConfig: %v", err)
}
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile]
if profile.Model != "test-model" {
t.Fatalf("expected file model, got %+v", profile)
}
if profile.Provider != "openai-compatible" || profile.TimeoutSeconds != 600 || profile.MaxRetries != 3 {
t.Fatalf("expected default LLM fields to be preserved, got %+v", profile)
}
if cfg.Concurrency.TotalLLM != 1 {
t.Fatalf("expected default concurrency preserved, got %d", cfg.Concurrency.TotalLLM)
}
if cfg.Diagnostics.Retention != diagnostics.RetentionAuto {
t.Fatalf("expected default diagnostics retention preserved, got %q", cfg.Diagnostics.Retention)
}
if _, ok := cfg.Pipelines["example"]; !ok {
t.Fatalf("expected file pipeline to be applied")
}
}

View File

@@ -3,9 +3,7 @@ package config
import (
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
@@ -14,16 +12,21 @@ type ResolveInput struct {
Only []string
Catalog pipeline.ModuleCatalog
LLMProfileOverride string
ReferenceOverrides []pipeline.ReferenceBinding
ReferenceUnbinds []pipeline.ReferenceUnbind
}
type EffectiveConfig struct {
Config Config
PipelineID string
Only []string
ReferenceOverrides []pipeline.ReferenceBinding
ReferenceUnbinds []pipeline.ReferenceUnbind
ResolvedPipeline pipeline.ResolvedPipeline
}
func (c Config) Resolve(input ResolveInput) (EffectiveConfig, error) {
c.Concurrency.recomputeStageWorkerDefaults()
if err := c.Validate(); err != nil {
return EffectiveConfig{}, err
}
@@ -40,13 +43,14 @@ func (c Config) Resolve(input ResolveInput) (EffectiveConfig, error) {
profile = clonePipelineProfile(profile)
profile.ID = pipelineID
if override := strings.TrimSpace(input.LLMProfileOverride); override != "" {
if !hasLLMProfile(c.LLMProfiles, override) {
return EffectiveConfig{}, fmt.Errorf("LLM profile override %q is not configured", override)
}
applyLLMProfileOverride(&profile, override)
}
resolved, err := pipeline.ResolvePipeline(profile, pipeline.ResolveOptions{Only: input.Only}, input.Catalog)
resolved, err := pipeline.ResolvePipeline(profile, pipeline.ResolveOptions{
Only: input.Only,
ReferenceOverrides: append([]pipeline.ReferenceBinding(nil), input.ReferenceOverrides...),
ReferenceUnbinds: append([]pipeline.ReferenceUnbind(nil), input.ReferenceUnbinds...),
}, input.Catalog)
if err != nil {
return EffectiveConfig{}, fmt.Errorf("resolve pipeline %q: %w", pipelineID, err)
}
@@ -55,22 +59,25 @@ func (c Config) Resolve(input ResolveInput) (EffectiveConfig, error) {
Config: cloneConfig(c),
PipelineID: pipelineID,
Only: append([]string(nil), input.Only...),
ReferenceOverrides: append([]pipeline.ReferenceBinding(nil), input.ReferenceOverrides...),
ReferenceUnbinds: append([]pipeline.ReferenceUnbind(nil), input.ReferenceUnbinds...),
ResolvedPipeline: resolved,
}, nil
}
func applyLLMProfileOverride(profile *pipeline.PipelineProfile, profileID string) {
profile.Input.LLMProfile = profileID
profile.Chunk.LLMProfile = profileID
profile.Output.LLMProfile = profileID
for laneID, lane := range profile.Artifacts {
apply := func(artifacts map[string]pipeline.ArtifactLaneProfile) {
for laneID, lane := range artifacts {
lane.Extract.LLMProfile = profileID
lane.Merge.LLMProfile = profileID
lane.Normalize.LLMProfile = profileID
for i := range lane.Validators {
lane.Validators[i].LLMProfile = profileID
artifacts[laneID] = lane
}
profile.Artifacts[laneID] = lane
}
apply(profile.Artifacts)
for index := range profile.Steps {
apply(profile.Steps[index].Artifacts)
}
}
@@ -83,36 +90,3 @@ func lookupPipelineProfile(profiles map[string]pipeline.PipelineProfile, pipelin
}
return pipeline.PipelineProfile{}, false
}
func (c Config) OpenAICompatibleClientConfig(profileID string) (llm.OpenAICompatibleClientConfig, error) {
trimmedID := strings.TrimSpace(profileID)
profile, ok := c.LLMProfile(trimmedID)
if !ok {
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q is not configured", trimmedID)
}
provider := strings.TrimSpace(profile.Provider)
if provider == "" {
provider = providerOpenAICompatible
}
if provider != providerOpenAICompatible {
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q provider %q is not supported", trimmedID, provider)
}
baseURL := strings.TrimSpace(profile.BaseURL)
if baseURL == "" {
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q base URL must not be empty", trimmedID)
}
model := strings.TrimSpace(profile.Model)
if model == "" {
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q model must not be empty", trimmedID)
}
return llm.OpenAICompatibleClientConfig{
BaseURL: baseURL,
Model: model,
APIKey: profile.APIKey,
MaxRetries: profile.MaxRetries,
RequestTimeout: time.Duration(profile.TimeoutSeconds) * time.Second,
}, nil
}

View File

@@ -0,0 +1,514 @@
package config
import (
"context"
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestEffectiveConfigRejectsEmptyAndUnknownPipelineIDs(t *testing.T) {
cfg := configForEffectiveTests(t, effectiveProfile())
for _, pipelineID := range []string{"", "missing"} {
name := pipelineID
if name == "" {
name = "empty"
}
t.Run(name, func(t *testing.T) {
_, err := cfg.Resolve(ResolveInput{PipelineID: pipelineID, Catalog: effectiveCatalog(t)})
if err == nil || !strings.Contains(err.Error(), "pipeline") {
t.Fatalf("Resolve(%q) error = %v, want pipeline context", pipelineID, err)
}
})
}
}
func TestEffectiveConfigResolvesTrimmedPipelineMapKeys(t *testing.T) {
profile := effectiveProfile()
profile.ID = " main "
cfg := Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{" main ": profile}
effective, err := cfg.Resolve(ResolveInput{PipelineID: "main", Catalog: effectiveCatalog(t)})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if effective.PipelineID != "main" || effective.ResolvedPipeline.ID != "main" {
t.Fatalf("resolved IDs = %q, %q", effective.PipelineID, effective.ResolvedPipeline.ID)
}
}
func TestEffectiveConfigOnlySelectsRequestedLanesWithoutMutatingSource(t *testing.T) {
profile := effectiveProfile()
profile.Artifacts["other"] = pipeline.ArtifactLaneProfile{Extract: pipeline.Binding("extract")}
cfg := configForEffectiveTests(t, profile)
effective, err := cfg.Resolve(ResolveInput{
PipelineID: "main",
Only: []string{"other"},
Catalog: effectiveCatalog(t),
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if len(effective.ResolvedPipeline.Steps[0].ArtifactLanes) != 1 || effective.ResolvedPipeline.Steps[0].ArtifactLanes[0].ID != "other" {
t.Fatalf("resolved lanes = %#v", effective.ResolvedPipeline.Steps[0].ArtifactLanes)
}
if len(cfg.Pipelines["main"].Artifacts) != 2 {
t.Fatalf("source lanes were mutated: %#v", cfg.Pipelines["main"].Artifacts)
}
_, err = cfg.Resolve(ResolveInput{
PipelineID: "main",
Only: []string{"missing"},
Catalog: effectiveCatalog(t),
})
if err == nil || !strings.Contains(err.Error(), "lane \"missing\"") {
t.Fatalf("unknown lane error = %v", err)
}
}
func TestEffectiveConfigMaterializesDefaultBindingsThroughCatalog(t *testing.T) {
effective, err := resolveEffectiveProfile(t, effectiveProfile(), ResolveInput{})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
resolved := effective.ResolvedPipeline
if resolved.Chunk.Module != pipeline.DefaultChunkModule || resolved.Output.Module != pipeline.DefaultOutputModule {
t.Fatalf("default pipeline bindings = %#v, %#v", resolved.Chunk, resolved.Output)
}
if len(resolved.Steps[0].ArtifactLanes) != 1 || resolved.Steps[0].ArtifactLanes[0].Merge.Module != pipeline.DefaultMergeModule || resolved.Steps[0].ArtifactLanes[0].Normalize.Module != pipeline.DefaultNormalizeModule {
t.Fatalf("default lane bindings = %#v", resolved.Steps[0].ArtifactLanes)
}
}
func TestEffectiveConfigResolutionFailuresRetainContext(t *testing.T) {
tests := []struct {
name string
mutate func(*pipeline.PipelineProfile)
want []string
}{
{
name: "unknown module",
mutate: func(profile *pipeline.PipelineProfile) {
profile.Input.Module = "missing-input"
},
want: []string{"pipeline \"main\"", "input"},
},
{
name: "missing capability",
mutate: func(profile *pipeline.PipelineProfile) {
profile.Chunk.Module = "needs-capability"
},
want: []string{"pipeline \"main\"", "chunk"},
},
{
name: "missing artifact variant",
mutate: func(profile *pipeline.PipelineProfile) {
profile.Artifacts["lane"] = pipeline.ArtifactLaneProfile{
Extract: pipeline.Binding("extract"),
Merge: pipeline.Binding("other-merge"),
}
},
want: []string{"pipeline \"main\"", "lane \"lane\"", "merge"},
},
{
name: "invalid module options",
mutate: func(profile *pipeline.PipelineProfile) {
profile.Chunk = pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"unknown": true}}
},
want: []string{"pipeline \"main\"", "chunk", "generic", "options"},
},
{
name: "invalid validator options",
mutate: func(profile *pipeline.PipelineProfile) {
lane := profile.Artifacts["lane"]
lane.Extract.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "option-validator",
Options: map[string]any{"invalid": true},
}},
}
profile.Artifacts["lane"] = lane
},
want: []string{"pipeline \"main\"", "lane \"lane\"", "extract", "option-validator", "options"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile := effectiveProfile()
tt.mutate(&profile)
_, err := resolveEffectiveProfile(t, profile, ResolveInput{})
if err == nil {
t.Fatal("Resolve() error = nil, want failure")
}
for _, fragment := range tt.want {
if !strings.Contains(err.Error(), fragment) {
t.Fatalf("Resolve() error = %v, want context %q", err, fragment)
}
}
})
}
}
func TestEffectiveConfigLLMProfileOverrideChangesDigestWithoutOverridingValidators(t *testing.T) {
profile := effectiveProfile()
profile.Chunk.LLMProfile = "chunk-profile"
lane := profile.Artifacts["lane"]
lane.Extract.LLMProfile = "extract-profile"
lane.Merge.LLMProfile = "merge-profile"
lane.Normalize.LLMProfile = "normalize-profile"
lane.Extract.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "llm-validator",
LLMProfile: "validator-profile",
}},
}
profile.Artifacts["lane"] = lane
base, err := resolveEffectiveProfile(t, profile, ResolveInput{})
if err != nil {
t.Fatalf("base Resolve() error = %v", err)
}
overridden, err := resolveEffectiveProfile(t, profile, ResolveInput{LLMProfileOverride: "override-profile"})
if err != nil {
t.Fatalf("overridden Resolve() error = %v", err)
}
if base.ResolvedPipeline.Digest == overridden.ResolvedPipeline.Digest {
t.Fatal("LLM profile override did not change the pipeline digest")
}
resolved := overridden.ResolvedPipeline
if resolved.Chunk.LLMProfile != "override-profile" || resolved.Steps[0].ArtifactLanes[0].Extract.LLMProfile != "override-profile" ||
resolved.Steps[0].ArtifactLanes[0].Merge.LLMProfile != "override-profile" || resolved.Steps[0].ArtifactLanes[0].Normalize.LLMProfile != "override-profile" {
t.Fatalf("pipeline profile override was not applied: %#v", resolved)
}
validators := findEffectiveValidatorChain(resolved, pipeline.StageExtract, "lane")
if len(validators.Validators) != 1 || validators.Validators[0].Binding.LLMProfile != "validator-profile" {
t.Fatalf("validator profile was overridden: %#v", validators)
}
}
func TestEffectiveConfigValidatorOverridesRemainDistinctAndOrdered(t *testing.T) {
tests := []struct {
name string
value pipeline.ValidatorOverride
want []string
}{
{
name: "omitted uses default",
want: []string{"default-validator"},
},
{
name: "explicit empty",
value: pipeline.ValidatorOverride{Set: true},
want: nil,
},
{
name: "configured order",
value: pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{
pipeline.Binding("configured-a"),
pipeline.Binding("configured-b"),
},
},
want: []string{"configured-a", "configured-b"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile := effectiveProfile()
lane := profile.Artifacts["lane"]
lane.Extract.Validators = tt.value
profile.Artifacts["lane"] = lane
effective, err := resolveEffectiveProfile(t, profile, ResolveInput{})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
chain := findEffectiveValidatorChain(effective.ResolvedPipeline, pipeline.StageExtract, "lane")
got := make([]string, len(chain.Validators))
for i, validator := range chain.Validators {
got[i] = validator.Binding.Module
}
if len(got) != len(tt.want) {
t.Fatalf("validator chain = %#v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("validator chain = %#v, want %v", got, tt.want)
}
}
})
}
}
func TestEffectiveConfigAndResolutionInputsDoNotAliasSource(t *testing.T) {
profile := effectiveProfile()
profile.Chunk.Options = map[string]any{"nested": map[string]any{"safe": "source"}}
profile.Chunk.References = pipeline.ExternalReferenceMap(map[string]string{"chunk-ref": "chunk.txt"})
lane := profile.Artifacts["lane"]
lane.Extract.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "configured-a",
Options: map[string]any{"nested": map[string]any{"safe": "validator-source"}},
}},
}
profile.Artifacts["lane"] = lane
cfg := configForEffectiveTests(t, profile)
only := []string{"lane"}
overrides := []pipeline.ReferenceBinding{{Stage: pipeline.StageChunk, SlotName: "chunk-ref", Source: "source.txt"}}
effective, err := cfg.Resolve(ResolveInput{
PipelineID: "main",
Only: only,
ReferenceOverrides: overrides,
Catalog: effectiveCatalog(t),
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
effective.Config.Pipelines["main"].Chunk.Options["nested"].(map[string]any)["safe"] = "effective-config"
effective.ResolvedPipeline.Chunk.Options["nested"].(map[string]any)["safe"] = "resolved-pipeline"
effective.ResolvedPipeline.ChunkReferences.Bindings[0].Source = "resolved-reference"
effective.ResolvedPipeline.ValidatorChains[1].Validators[0].Binding.Options["nested"].(map[string]any)["safe"] = "resolved-validator"
effective.Only[0] = "mutated-only"
effective.ReferenceOverrides[0].Source = "mutated-override"
if got := cfg.Pipelines["main"].Chunk.Options["nested"].(map[string]any)["safe"]; got != "source" {
t.Fatalf("source config option was aliased: %v", got)
}
if got := cfg.Pipelines["main"].Chunk.References["chunk-ref"].Path; got != "chunk.txt" {
t.Fatalf("source config references were aliased: %v", got)
}
if only[0] != "lane" || overrides[0].Source != "source.txt" {
t.Fatal("resolution inputs were aliased")
}
}
func effectiveProfile() pipeline.PipelineProfile {
return pipeline.PipelineProfile{
ID: "main",
Input: pipeline.Binding("input"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"lane": {Extract: pipeline.Binding("extract")},
},
}
}
func configForEffectiveTests(t *testing.T, profile pipeline.PipelineProfile) Config {
t.Helper()
cfg := Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
return cfg
}
func resolveEffectiveProfile(t *testing.T, profile pipeline.PipelineProfile, input ResolveInput) (EffectiveConfig, error) {
t.Helper()
cfg := configForEffectiveTests(t, profile)
if input.PipelineID == "" {
input.PipelineID = "main"
}
if input.Catalog.Inputs == nil {
input.Catalog = effectiveCatalog(t)
}
return cfg.Resolve(input)
}
func findEffectiveValidatorChain(resolved pipeline.ResolvedPipeline, stage pipeline.ModuleStage, laneID string) pipeline.ResolvedValidatorChain {
for _, chain := range resolved.ValidatorChains {
if chain.Stage == stage && chain.LaneID == laneID {
return chain
}
}
return pipeline.ResolvedValidatorChain{}
}
type effectiveArtifact struct {
Value string `json:"value"`
}
const effectiveArtifactKind contracts.ArtifactKind = "test/effective"
type effectiveCodec struct{}
func (effectiveCodec) Kind() contracts.ArtifactKind { return effectiveArtifactKind }
func (effectiveCodec) Schema() contracts.ArtifactSchema {
return contracts.ArtifactSchema{
ID: "effective-schema",
Name: "Effective artifact",
Version: "1",
JSONSchema: []byte(`{"type":"object"}`),
}
}
func (effectiveCodec) MediaType() string { return "application/json" }
func (effectiveCodec) EncodeCandidate(value effectiveArtifact) ([]byte, error) {
return json.Marshal(value)
}
func (effectiveCodec) Encode(value effectiveArtifact) ([]byte, error) {
return json.Marshal(value)
}
func (effectiveCodec) Decode(content []byte) (effectiveArtifact, error) {
var value effectiveArtifact
err := json.Unmarshal(content, &value)
return value, err
}
type effectiveInput struct{ key string }
func (m effectiveInput) Key() string { return m.key }
func (m effectiveInput) Parse(context.Context, contracts.ParseRequest) (*source.SourceDocument, error) {
return &source.SourceDocument{}, nil
}
type effectiveChunker struct{ key string }
func (m effectiveChunker) Key() string { return m.key }
func (m effectiveChunker) ReferenceSlots() []contracts.ReferenceSlot {
if m.key == pipeline.DefaultChunkModule {
return []contracts.ReferenceSlot{{Name: "chunk-ref"}}
}
return nil
}
func (m effectiveChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
return contracts.ChunkPlanResult{}, nil
}
type effectiveExtractor struct{ key string }
func (m effectiveExtractor) Key() string { return m.key }
func (m effectiveExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (m effectiveExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[effectiveArtifact], error) {
return contracts.TypedExtractionResult[effectiveArtifact]{}, nil
}
type effectiveMerger struct{ key string }
func (m effectiveMerger) Key() string { return m.key }
func (m effectiveMerger) Merge(context.Context, contracts.TypedMergeRequest[effectiveArtifact]) (contracts.TypedMergeResult[effectiveArtifact], error) {
return contracts.TypedMergeResult[effectiveArtifact]{}, nil
}
type effectiveNormalizer struct{ key string }
func (m effectiveNormalizer) Key() string { return m.key }
func (m effectiveNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (m effectiveNormalizer) Normalize(context.Context, contracts.TypedNormalizeRequest[effectiveArtifact]) (contracts.TypedNormalizeResult[effectiveArtifact], error) {
return contracts.TypedNormalizeResult[effectiveArtifact]{}, nil
}
type effectiveOutput struct{ key string }
func (m effectiveOutput) Key() string { return m.key }
func (m effectiveOutput) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
return contracts.OutputResult{}, nil
}
type effectiveValidator struct {
name string
class contracts.ExecutionClass
}
func (v effectiveValidator) Name() string { return v.name }
func (v effectiveValidator) ExecutionClass() contracts.ExecutionClass { return v.class }
func (v effectiveValidator) Validate(context.Context, contracts.TypedValidationRequest[effectiveArtifact]) (contracts.ValidationResult, error) {
return contracts.ValidationResult{Approved: true}, nil
}
func effectiveCatalog(t *testing.T) pipeline.ModuleCatalog {
t.Helper()
catalog := pipeline.ModuleCatalog{
Inputs: pipeline.NewInputAdapterRegistry(),
Chunkers: pipeline.NewChunkerRegistry(),
ArtifactCodecs: pipeline.NewArtifactCodecRegistry(),
Extractors: pipeline.NewExtractorRegistry(),
Mergers: pipeline.NewMergerRegistry(),
Normalizers: pipeline.NewNormalizerRegistry(),
Validators: pipeline.NewValidatorRegistry(),
ValidatorChains: pipeline.NewValidatorChainRegistry(),
Outputs: pipeline.NewOutputEncoderRegistry(),
}
if err := pipeline.RegisterArtifactCodec(catalog.ArtifactCodecs, effectiveCodec{}); err != nil {
t.Fatal(err)
}
if err := catalog.Inputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func() (contracts.InputAdapter, error) {
return effectiveInput{key: "input"}, nil
}); err != nil {
t.Fatal(err)
}
chunkSpec := pipeline.ModuleSpec{
Key: pipeline.DefaultChunkModule,
Stage: pipeline.StageChunk,
Requires: []string{"source"},
Provides: []string{"chunk"},
ReferenceSlots: []contracts.ReferenceSlot{{Name: "chunk-ref"}},
}
chunkOptions := func(options map[string]any) error { return pipeline.RejectUnknownOptions(options, "size", "nested") }
if err := catalog.Chunkers.RegisterBuilderWithSpec(chunkSpec, chunkOptions, func(pipeline.BuildRequest) (contracts.Chunker, error) {
return effectiveChunker{key: pipeline.DefaultChunkModule}, nil
}); err != nil {
t.Fatal(err)
}
if err := catalog.Chunkers.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "needs-capability", Stage: pipeline.StageChunk, Requires: []string{"missing"}}, chunkOptions, func(pipeline.BuildRequest) (contracts.Chunker, error) {
return effectiveChunker{key: "needs-capability"}, nil
}); err != nil {
t.Fatal(err)
}
if err := pipeline.RegisterExtractor(catalog.Extractors, pipeline.ModuleSpec{Key: "extract", Stage: pipeline.StageExtract, ArtifactKind: effectiveArtifactKind, Requires: []string{"chunk"}, Provides: []string{"candidate"}}, func() (contracts.Extractor[effectiveArtifact], error) {
return effectiveExtractor{key: "extract"}, nil
}); err != nil {
t.Fatal(err)
}
if err := pipeline.RegisterMerger(catalog.Mergers, pipeline.ModuleSpec{Key: pipeline.DefaultMergeModule, Stage: pipeline.StageMerge, ArtifactKind: effectiveArtifactKind, Requires: []string{"candidate"}, Provides: []string{"merged"}}, func() (contracts.Merger[effectiveArtifact], error) {
return effectiveMerger{key: pipeline.DefaultMergeModule}, nil
}); err != nil {
t.Fatal(err)
}
if err := pipeline.RegisterMerger(catalog.Mergers, pipeline.ModuleSpec{Key: "other-merge", Stage: pipeline.StageMerge, ArtifactKind: "other-kind", Requires: []string{"candidate"}, Provides: []string{"merged"}}, func() (contracts.Merger[effectiveArtifact], error) {
return effectiveMerger{key: "other-merge"}, nil
}); err != nil {
t.Fatal(err)
}
if err := pipeline.RegisterNormalizer(catalog.Normalizers, pipeline.ModuleSpec{Key: pipeline.DefaultNormalizeModule, Stage: pipeline.StageNormalize, ArtifactKind: effectiveArtifactKind, Requires: []string{"merged"}, Provides: []string{"normalized"}}, func() (contracts.Normalizer[effectiveArtifact], error) {
return effectiveNormalizer{key: pipeline.DefaultNormalizeModule}, nil
}); err != nil {
t.Fatal(err)
}
if err := catalog.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: pipeline.DefaultOutputModule, Stage: pipeline.StageOutput, Requires: []string{"normalized"}}, func() (contracts.OutputEncoder, error) {
return effectiveOutput{key: pipeline.DefaultOutputModule}, nil
}); err != nil {
t.Fatal(err)
}
for _, validator := range []struct {
key string
class contracts.ExecutionClass
}{
{key: "default-validator", class: contracts.ExecutionClassDeterministic},
{key: "configured-a", class: contracts.ExecutionClassDeterministic},
{key: "configured-b", class: contracts.ExecutionClassDeterministic},
{key: "llm-validator", class: contracts.ExecutionClassLLMBacked},
} {
if err := pipeline.RegisterTypedValidatorBuilder(catalog.Validators, effectiveArtifactKind, pipeline.ValidatorSpec{Key: validator.key, ExecutionClass: validator.class}, func(options map[string]any) error {
return pipeline.RejectUnknownOptions(options, "nested")
}, func(pipeline.BuildRequest) (contracts.TypedValidator[effectiveArtifact], error) {
return effectiveValidator{name: validator.key, class: validator.class}, nil
}); err != nil {
t.Fatal(err)
}
}
if err := pipeline.RegisterTypedValidatorBuilder(catalog.Validators, effectiveArtifactKind, pipeline.ValidatorSpec{Key: "option-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, func(options map[string]any) error {
return pipeline.RejectUnknownOptions(options, "allowed")
}, func(pipeline.BuildRequest) (contracts.TypedValidator[effectiveArtifact], error) {
return effectiveValidator{name: "option-validator", class: contracts.ExecutionClassDeterministic}, nil
}); err != nil {
t.Fatal(err)
}
if err := catalog.ValidatorChains.Register(pipeline.ValidatorChainMapping{Stage: pipeline.StageExtract, Module: "extract", Validators: []pipeline.ModuleBinding{pipeline.Binding("default-validator")}}); err != nil {
t.Fatal(err)
}
return catalog
}

View File

@@ -1,214 +0,0 @@
package config
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestResolveRejectsEmptyAndUnknownPipelineID(t *testing.T) {
tests := []struct {
name string
pipelineID string
want string
}{
{name: "empty", pipelineID: " ", want: "pipeline id"},
{name: "unknown", pipelineID: "missing", want: "not configured"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := validConfig().Resolve(ResolveInput{PipelineID: tc.pipelineID, Catalog: fakeCatalog(t)})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected error containing %q, got %v", tc.want, err)
}
})
}
}
func TestResolveLaneFilteringSuccessAndFailure(t *testing.T) {
effective, err := validConfig().Resolve(ResolveInput{
PipelineID: " example ",
Only: []string{" notes "},
Catalog: fakeCatalog(t),
})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if effective.PipelineID != "example" {
t.Fatalf("unexpected pipeline ID: %q", effective.PipelineID)
}
if len(effective.ResolvedPipeline.ArtifactLanes) != 1 || effective.ResolvedPipeline.ArtifactLanes[0].ID != "notes" {
t.Fatalf("unexpected resolved lanes: %+v", effective.ResolvedPipeline.ArtifactLanes)
}
if effective.ResolvedPipeline.Digest == "" {
t.Fatalf("expected digest")
}
_, err = validConfig().Resolve(ResolveInput{
PipelineID: "example",
Only: []string{"missing"},
Catalog: fakeCatalog(t),
})
if err == nil || !strings.Contains(err.Error(), "selected artifact lane") {
t.Fatalf("expected invalid lane error, got %v", err)
}
}
func TestResolveUsesTrimmedPipelineMapKeys(t *testing.T) {
cfg := validConfig()
cfg.Pipelines[" example "] = cfg.Pipelines["example"]
delete(cfg.Pipelines, "example")
effective, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if effective.PipelineID != "example" {
t.Fatalf("unexpected pipeline ID: %q", effective.PipelineID)
}
}
func TestResolveSurfacesUnknownModuleKeyThroughCatalog(t *testing.T) {
cfg := validConfig()
lane := cfg.Pipelines["example"].Artifacts["events"]
lane.Extract = pipeline.Binding("missing/extract")
cfg.Pipelines["example"].Artifacts["events"] = lane
_, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
if err == nil || !strings.Contains(err.Error(), "missing/extract") || !strings.Contains(err.Error(), "events") {
t.Fatalf("expected unknown module error with lane context, got %v", err)
}
}
func TestResolveSurfacesMissingCapabilityThroughCatalog(t *testing.T) {
_, err := validConfig().Resolve(ResolveInput{
PipelineID: "example",
Catalog: fakeCatalog(t, pipeline.ModuleSpec{
Key: "json",
Stage: pipeline.StageOutput,
Requires: []string{"missing-capability"},
}),
})
if err == nil || !strings.Contains(err.Error(), "missing capability") || !strings.Contains(err.Error(), "json") {
t.Fatalf("expected missing capability error, got %v", err)
}
}
func TestResolveDigestChangesWhenEffectiveConfigChanges(t *testing.T) {
cfg := validConfig()
first, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
if err != nil {
t.Fatalf("Resolve first: %v", err)
}
lane := cfg.Pipelines["example"].Artifacts["events"]
lane.Extract.Options = map[string]any{"temperature": 0.2}
cfg.Pipelines["example"].Artifacts["events"] = lane
second, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
if err != nil {
t.Fatalf("Resolve second: %v", err)
}
if first.ResolvedPipeline.Digest == second.ResolvedPipeline.Digest {
t.Fatalf("expected digest to change, got %q", first.ResolvedPipeline.Digest)
}
}
func TestResolveLLMProfileOverrideAppliesBeforeDigest(t *testing.T) {
cfg := validConfig()
cfg.LLMProfiles["runtime"] = LLMProfile{Provider: "openai-compatible"}
base, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
if err != nil {
t.Fatalf("Resolve base: %v", err)
}
effective, err := cfg.Resolve(ResolveInput{
PipelineID: "example",
Catalog: fakeCatalog(t),
LLMProfileOverride: "runtime",
})
if err != nil {
t.Fatalf("Resolve override: %v", err)
}
if base.ResolvedPipeline.Digest == effective.ResolvedPipeline.Digest {
t.Fatalf("expected digest to change after LLM profile override")
}
for _, binding := range resolvedBindings(effective.ResolvedPipeline) {
if binding.LLMProfile != "runtime" {
t.Fatalf("binding profile = %q, want runtime", binding.LLMProfile)
}
}
_, err = cfg.Resolve(ResolveInput{
PipelineID: "example",
Catalog: fakeCatalog(t),
LLMProfileOverride: "missing",
})
if err == nil || !strings.Contains(err.Error(), "LLM profile override") {
t.Fatalf("expected override profile error, got %v", err)
}
}
func resolvedBindings(resolved pipeline.ResolvedPipeline) []pipeline.ModuleBinding {
bindings := []pipeline.ModuleBinding{resolved.Input, resolved.Chunk, resolved.Output}
for _, lane := range resolved.ArtifactLanes {
bindings = append(bindings, lane.Extract, lane.Merge, lane.Normalize)
bindings = append(bindings, lane.Validators...)
}
return bindings
}
func TestOpenAICompatibleClientConfigRejectsIncompleteDefaultProfile(t *testing.T) {
cfg := Default()
_, err := cfg.OpenAICompatibleClientConfig("default")
if err == nil || !strings.Contains(err.Error(), "base URL") {
t.Fatalf("expected incomplete profile error, got %v", err)
}
}
func TestOpenAICompatibleClientConfigSuccess(t *testing.T) {
cfg := validConfig()
profile := cfg.LLMProfiles["default"]
profile.APIKey = "secret"
profile.TimeoutSeconds = 45
profile.MaxRetries = 4
cfg.LLMProfiles["default"] = profile
llmCfg, err := cfg.OpenAICompatibleClientConfig(" default ")
if err != nil {
t.Fatalf("OpenAICompatibleClientConfig: %v", err)
}
if llmCfg.BaseURL != "https://example.invalid/v1" || llmCfg.Model != "test-model" || llmCfg.APIKey != "secret" {
t.Fatalf("unexpected client config strings: %+v", llmCfg)
}
if llmCfg.MaxRetries != 4 {
t.Fatalf("unexpected max retries: %d", llmCfg.MaxRetries)
}
if llmCfg.RequestTimeout != 45*time.Second {
t.Fatalf("unexpected timeout: %s", llmCfg.RequestTimeout)
}
}
func TestOpenAICompatibleClientConfigRejectsUnknownAndUnsupportedProfiles(t *testing.T) {
_, err := validConfig().OpenAICompatibleClientConfig("missing")
if err == nil || !strings.Contains(err.Error(), "not configured") {
t.Fatalf("expected unknown profile error, got %v", err)
}
cfg := validConfig()
profile := cfg.LLMProfiles["default"]
profile.Provider = "unsupported"
cfg.LLMProfiles["default"] = profile
_, err = cfg.OpenAICompatibleClientConfig("default")
if err == nil || !strings.Contains(err.Error(), "provider") {
t.Fatalf("expected unsupported provider error, got %v", err)
}
}

View File

@@ -6,7 +6,6 @@ import (
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
@@ -30,43 +29,6 @@ func (c *Config) applyEnvOverridesWithLookup(lookup func(string) (string, bool))
if c == nil {
return fmt.Errorf("config must not be nil")
}
if c.LLMProfiles == nil {
c.LLMProfiles = map[string]LLMProfile{}
}
defaultProfile := c.LLMProfiles[pipeline.DefaultLLMProfile]
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_API_KEY"); ok {
defaultProfile.APIKey = raw
}
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_BASE_URL"); ok {
defaultProfile.BaseURL = strings.TrimSpace(raw)
}
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_MODEL"); ok {
defaultProfile.Model = strings.TrimSpace(raw)
}
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_TIMEOUT_SECONDS"); ok {
value, err := parseIntEnv("NOTARIUS_LLM_DEFAULT_TIMEOUT_SECONDS", raw)
if err != nil {
return err
}
defaultProfile.TimeoutSeconds = value
}
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_MAX_RETRIES"); ok {
value, err := parseIntEnv("NOTARIUS_LLM_DEFAULT_MAX_RETRIES", raw)
if err != nil {
return err
}
defaultProfile.MaxRetries = value
}
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_MAX_CONCURRENCY"); ok {
value, err := parseIntEnv("NOTARIUS_LLM_DEFAULT_MAX_CONCURRENCY", raw)
if err != nil {
return err
}
defaultProfile.MaxConcurrency = value
}
c.LLMProfiles[pipeline.DefaultLLMProfile] = defaultProfile
if raw, ok := lookup("NOTARIUS_TOTAL_LLM_CONCURRENCY"); ok {
value, err := parseIntEnv("NOTARIUS_TOTAL_LLM_CONCURRENCY", raw)
if err != nil {
@@ -74,11 +36,60 @@ func (c *Config) applyEnvOverridesWithLookup(lookup func(string) (string, bool))
}
c.Concurrency.TotalLLM = value
}
if raw, ok := lookup("NOTARIUS_WORK_DIR"); ok {
c.Diagnostics.WorkDir = strings.TrimSpace(raw)
if raw, ok := lookup("NOTARIUS_STAGE_WORKERS_EXTRACT"); ok {
value, err := parseIntEnv("NOTARIUS_STAGE_WORKERS_EXTRACT", raw)
if err != nil {
return err
}
if c.Concurrency.StageWorkers == nil {
c.Concurrency.StageWorkers = make(map[string]int)
}
c.Concurrency.StageWorkers["extract"] = value
c.Concurrency.extractWorkersConfigured = true
}
c.Concurrency.recomputeStageWorkerDefaults()
if raw, ok := lookup("NOTARIUS_OUTPUT_DIR"); ok {
c.Output.Directory = strings.TrimSpace(raw)
if c.Output.Directory == "" {
return fmt.Errorf("NOTARIUS_OUTPUT_DIR: must not be empty")
}
if strings.ContainsRune(c.Output.Directory, '\x00') {
return fmt.Errorf("NOTARIUS_OUTPUT_DIR: must not contain NUL")
}
}
if raw, ok := lookup("NOTARIUS_CACHE_CHUNK_PLANS_MODE"); ok {
mode, err := pipeline.ParseChunkCacheMode(raw)
if err != nil {
return fmt.Errorf("NOTARIUS_CACHE_CHUNK_PLANS_MODE: %w", err)
}
c.Cache.ChunkPlans.Mode = mode
}
if raw, ok := lookup("NOTARIUS_CACHE_CHUNK_PLANS_DIR"); ok {
c.Cache.ChunkPlans.Directory = cleanOptionalPath(raw)
if c.Cache.ChunkPlans.Directory == "" {
return fmt.Errorf("NOTARIUS_CACHE_CHUNK_PLANS_DIR: must not be empty")
}
if strings.ContainsRune(c.Cache.ChunkPlans.Directory, '\x00') {
return fmt.Errorf("NOTARIUS_CACHE_CHUNK_PLANS_DIR: must not contain NUL")
}
}
if raw, ok := lookup("NOTARIUS_CACHE_CHECKPOINTS_DIR"); ok {
c.Cache.Checkpoints.Directory = cleanOptionalPath(raw)
if c.Cache.Checkpoints.Directory == "" {
return fmt.Errorf("NOTARIUS_CACHE_CHECKPOINTS_DIR: must not be empty")
}
if strings.ContainsRune(c.Cache.Checkpoints.Directory, '\x00') {
return fmt.Errorf("NOTARIUS_CACHE_CHECKPOINTS_DIR: must not contain NUL")
}
}
if raw, ok := lookup("NOTARIUS_DEBUG_DIR"); ok {
c.Debug.Directory = strings.TrimSpace(raw)
if c.Debug.Directory == "" {
return fmt.Errorf("NOTARIUS_DEBUG_DIR: must not be empty")
}
if strings.ContainsRune(c.Debug.Directory, '\x00') {
return fmt.Errorf("NOTARIUS_DEBUG_DIR: must not contain NUL")
}
if raw, ok := lookup("NOTARIUS_DIAGNOSTICS_RETENTION"); ok {
c.Diagnostics.Retention = diagnostics.RetentionMode(strings.TrimSpace(raw))
}
return nil
}

View File

@@ -0,0 +1,239 @@
package config
import (
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestPrecedenceFileValuesOverrideBuiltInDefaults(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
concurrency:
total_llm: 4
stage_workers:
extract: 2
output:
directory: ./file-output
cache:
chunk_plans:
directory: ./file-plans
mode: refresh
checkpoints:
directory: ./file-checkpoints
debug:
directory: ./file-debug
`)
if cfg.Concurrency.TotalLLM != 4 || cfg.Concurrency.StageWorkers["extract"] != 2 ||
cfg.Output.Directory != "./file-output" || cfg.Cache.ChunkPlans.Directory != "file-plans" ||
cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheRefresh || cfg.Cache.Checkpoints.Directory != "file-checkpoints" ||
cfg.Debug.Directory != "./file-debug" {
t.Fatalf("file values did not override defaults: %#v", cfg)
}
}
func TestPrecedenceOperationalEnvironmentOverridesFileValues(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
concurrency:
total_llm: 2
stage_workers:
extract: 1
output:
directory: ./file-output
cache:
chunk_plans:
directory: ./file-plans
mode: refresh
checkpoints:
directory: ./file-checkpoints
debug:
directory: ./file-debug
`)
env := map[string]string{
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "8",
"NOTARIUS_STAGE_WORKERS_EXTRACT": "6",
"NOTARIUS_OUTPUT_DIR": "/env/output",
"NOTARIUS_CACHE_CHUNK_PLANS_MODE": "bypass",
"NOTARIUS_CACHE_CHUNK_PLANS_DIR": "/env/plans",
"NOTARIUS_CACHE_CHECKPOINTS_DIR": "/env/checkpoints",
"NOTARIUS_DEBUG_DIR": "/env/debug",
}
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(env)); err != nil {
t.Fatal(err)
}
if cfg.Concurrency.TotalLLM != 8 || cfg.Concurrency.StageWorkers["extract"] != 6 ||
cfg.Output.Directory != "/env/output" || cfg.Cache.ChunkPlans.Directory != "/env/plans" ||
cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheBypass || cfg.Cache.Checkpoints.Directory != "/env/checkpoints" ||
cfg.Debug.Directory != "/env/debug" {
t.Fatalf("environment values did not override file values: %#v", cfg)
}
}
func TestPrecedenceExtractWorkersFollowEffectiveConcurrencyUnlessExplicit(t *testing.T) {
tests := []struct {
name string
file string
env map[string]string
wantTotal int
wantWorker int
}{
{
name: "default follows environment total",
file: "version: 3\n",
env: map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "5"},
wantTotal: 5,
wantWorker: 5,
},
{
name: "file worker is retained",
file: "version: 3\nconcurrency:\n total_llm: 3\n stage_workers:\n extract: 2\n",
env: map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "6"},
wantTotal: 6,
wantWorker: 2,
},
{
name: "environment worker is retained",
file: "version: 3\nconcurrency:\n total_llm: 2\n",
env: map[string]string{
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "6",
"NOTARIUS_STAGE_WORKERS_EXTRACT": "4",
},
wantTotal: 6,
wantWorker: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := applyFileConfig(t, tt.file)
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(tt.env)); err != nil {
t.Fatal(err)
}
if cfg.Concurrency.TotalLLM != tt.wantTotal || cfg.Concurrency.StageWorkers["extract"] != tt.wantWorker {
t.Fatalf("concurrency = %#v, want total %d and extract %d", cfg.Concurrency, tt.wantTotal, tt.wantWorker)
}
})
}
}
func TestPrecedenceEmptyFileCacheDirectoriesDeferPerUserResolution(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
cache:
chunk_plans:
directory: ""
checkpoints:
directory: ""
`)
if err := cfg.Validate(); err != nil {
t.Fatalf("empty file cache directories should be valid: %v", err)
}
if cfg.Cache.ChunkPlans.Directory != "" || cfg.Cache.Checkpoints.Directory != "" {
t.Fatalf("empty cache directories were not preserved for deferred resolution: %#v", cfg.Cache)
}
resolver := func() (string, error) { return "/user/cache", nil }
chunkPlans, err := DefaultChunkPlanRoot(resolver)
if err != nil {
t.Fatal(err)
}
checkpoints, err := DefaultCheckpointRoot(resolver)
if err != nil {
t.Fatal(err)
}
if chunkPlans != "/user/cache/notarius/chunk-plans" || checkpoints != "/user/cache/notarius/checkpoints" {
t.Fatalf("deferred cache roots = %q, %q", chunkPlans, checkpoints)
}
}
func TestDefaultCacheRootsRejectInvalidUserCacheResolvers(t *testing.T) {
tests := []struct {
name string
resolver func() (string, error)
want string
}{
{name: "nil resolver", want: "must not be nil"},
{
name: "resolver failure",
resolver: func() (string, error) {
return "", errors.New("cache home unavailable")
},
want: "resolve user cache directory",
},
{name: "empty directory", resolver: func() (string, error) { return " ", nil }, want: "must not be empty"},
}
families := []struct {
name string
root func(func() (string, error)) (string, error)
}{
{name: "chunk plans", root: DefaultChunkPlanRoot},
{name: "checkpoints", root: DefaultCheckpointRoot},
}
for _, family := range families {
for _, tt := range tests {
t.Run(family.name+"/"+tt.name, func(t *testing.T) {
_, err := family.root(tt.resolver)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want substring %q", err, tt.want)
}
})
}
}
}
func TestEnvEmptyDirectoryOverridesAreErrors(t *testing.T) {
tests := []string{
"NOTARIUS_OUTPUT_DIR",
"NOTARIUS_CACHE_CHUNK_PLANS_DIR",
"NOTARIUS_CACHE_CHECKPOINTS_DIR",
"NOTARIUS_DEBUG_DIR",
}
for _, name := range tests {
t.Run(name, func(t *testing.T) {
cfg := Default()
err := cfg.ApplyEnvOverridesWithLookup(lookupValues(map[string]string{name: " \t"}))
if err == nil || !strings.Contains(err.Error(), name) {
t.Fatalf("error = %v, want responsible environment variable", err)
}
})
}
}
func TestEnvInvalidIntegersAndChunkCacheModesReportTheirNames(t *testing.T) {
tests := map[string]string{
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "not-an-integer",
"NOTARIUS_STAGE_WORKERS_EXTRACT": "not-an-integer",
"NOTARIUS_CACHE_CHUNK_PLANS_MODE": "not-a-cache-mode",
}
for name, value := range tests {
t.Run(name, func(t *testing.T) {
cfg := Default()
err := cfg.ApplyEnvOverridesWithLookup(lookupValues(map[string]string{name: value}))
if err == nil || !strings.Contains(err.Error(), name) {
t.Fatalf("error = %v, want responsible environment variable", err)
}
})
}
}
func TestEnvRemovedProviderVariablesAreIgnored(t *testing.T) {
before := Default()
cfg := Default()
removed := map[string]string{
"NOTARIUS_LLM_DEFAULT_ENDPOINT": "ignored-provider-setting",
"NOTARIUS_LLM_DEFAULT_MODEL": "ignored-provider-setting",
}
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(removed)); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(cfg, before) {
t.Fatalf("removed provider variables changed configuration: %#v", cfg)
}
}
func lookupValues(values map[string]string) func(string) (string, bool) {
return func(name string) (string, bool) {
value, ok := values[name]
return value, ok
}
}

View File

@@ -1,69 +0,0 @@
package config
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestApplyEnvOverridesOperationalAndLLMValues(t *testing.T) {
cfg := Default()
cfg.Pipelines["example"] = pipeline.PipelineProfile{ID: "example", Input: pipeline.Binding("before")}
err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{
"NOTARIUS_LLM_DEFAULT_API_KEY": "secret",
"NOTARIUS_LLM_DEFAULT_BASE_URL": "https://example.invalid/v1",
"NOTARIUS_LLM_DEFAULT_MODEL": "test-model",
"NOTARIUS_LLM_DEFAULT_TIMEOUT_SECONDS": "120",
"NOTARIUS_LLM_DEFAULT_MAX_RETRIES": "5",
"NOTARIUS_LLM_DEFAULT_MAX_CONCURRENCY": "2",
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "3",
"NOTARIUS_WORK_DIR": "/tmp/notarius-env",
"NOTARIUS_DIAGNOSTICS_RETENTION": "never",
"NOTARIUS_PIPELINE_INPUT": "after",
}))
if err != nil {
t.Fatalf("ApplyEnvOverrides: %v", err)
}
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile]
if profile.APIKey != "secret" || profile.BaseURL != "https://example.invalid/v1" || profile.Model != "test-model" {
t.Fatalf("unexpected LLM profile strings: %+v", profile)
}
if profile.TimeoutSeconds != 120 || profile.MaxRetries != 5 || profile.MaxConcurrency != 2 {
t.Fatalf("unexpected LLM profile numeric values: %+v", profile)
}
if cfg.Concurrency.TotalLLM != 3 {
t.Fatalf("unexpected total concurrency: %d", cfg.Concurrency.TotalLLM)
}
if cfg.Diagnostics.WorkDir != "/tmp/notarius-env" || cfg.Diagnostics.Retention != diagnostics.RetentionNever {
t.Fatalf("unexpected diagnostics config: %+v", cfg.Diagnostics)
}
if cfg.Pipelines["example"].Input.Module != "before" {
t.Fatalf("environment overrides must not change pipeline wiring: %+v", cfg.Pipelines["example"])
}
}
func TestApplyEnvOverridesRejectsInvalidIntegers(t *testing.T) {
cfg := Default()
err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "many",
}))
if err == nil || !strings.Contains(err.Error(), "NOTARIUS_TOTAL_LLM_CONCURRENCY") {
t.Fatalf("expected named integer error, got %v", err)
}
}
func TestLoadFromEnvUsesDefaultConfig(t *testing.T) {
t.Setenv("NOTARIUS_LLM_DEFAULT_MODEL", "env-model")
cfg, err := LoadFromEnv()
if err != nil {
t.Fatalf("LoadFromEnv: %v", err)
}
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].Model != "env-model" {
t.Fatalf("expected env model, got %+v", cfg.LLMProfiles[pipeline.DefaultLLMProfile])
}
}

View File

@@ -4,41 +4,104 @@ import (
"bytes"
"fmt"
"os"
"regexp"
"path/filepath"
"sort"
"strings"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gopkg.in/yaml.v3"
)
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type FileConfig struct {
Version int `yaml:"version"`
LLMProfiles map[string]FileLLMProfile `yaml:"llm_profiles,omitempty"`
Scriptorium *FileScriptoriumConfig `yaml:"scriptorium,omitempty"`
Pipelines map[string]FilePipelineProfile `yaml:"pipelines,omitempty"`
Concurrency *FileConcurrencyConfig `yaml:"concurrency,omitempty"`
Diagnostics *FileDiagnosticsConfig `yaml:"diagnostics,omitempty"`
Output *FileOutputConfig `yaml:"output,omitempty"`
Cache *FileCacheConfig `yaml:"cache,omitempty"`
Debug *FileDebugConfig `yaml:"debug,omitempty"`
}
type FileLLMProfile struct {
Provider *string `yaml:"provider,omitempty"`
BaseURL *string `yaml:"base_url,omitempty"`
Model *string `yaml:"model,omitempty"`
APIKeyEnv *string `yaml:"api_key_env,omitempty"`
Timeout *fileDurationSeconds `yaml:"timeout,omitempty"`
MaxRetries *int `yaml:"max_retries,omitempty"`
MaxConcurrency *int `yaml:"max_concurrency,omitempty"`
type FileScriptoriumConfig struct {
ProfileDir *string `yaml:"profile_dir,omitempty"`
ProfileFile *string `yaml:"profile_file,omitempty"`
}
type FilePipelineProfile struct {
Input fileModuleBinding `yaml:"input"`
Chunk *fileModuleBinding `yaml:"chunk,omitempty"`
Artifacts map[string]FileArtifactLaneProfile `yaml:"artifacts,omitempty"`
Steps []FilePipelineStepProfile `yaml:"steps,omitempty"`
Output *fileModuleBinding `yaml:"output,omitempty"`
References map[string]fileReferenceSource `yaml:"references,omitempty"`
artifactsSet bool `yaml:"-"`
stepsSet bool `yaml:"-"`
}
func (p *FilePipelineProfile) UnmarshalYAML(node *yaml.Node) error {
type plainFilePipelineProfile FilePipelineProfile
var decoded plainFilePipelineProfile
seen, err := decodeKnownMapping(node, &decoded, map[string]struct{}{
"input": {}, "chunk": {}, "artifacts": {}, "steps": {}, "output": {}, "references": {},
}, "pipeline profile")
if err != nil {
return err
}
*p = FilePipelineProfile(decoded)
_, p.artifactsSet = seen["artifacts"]
_, p.stepsSet = seen["steps"]
return nil
}
func (s *FilePipelineStepProfile) UnmarshalYAML(node *yaml.Node) error {
type plainFilePipelineStepProfile FilePipelineStepProfile
var decoded plainFilePipelineStepProfile
if _, err := decodeKnownMapping(node, &decoded, map[string]struct{}{
"id": {}, "artifacts": {}, "references": {},
}, "pipeline step"); err != nil {
return err
}
*s = FilePipelineStepProfile(decoded)
return nil
}
func (l *FileArtifactLaneProfile) UnmarshalYAML(node *yaml.Node) error {
type plainFileArtifactLaneProfile FileArtifactLaneProfile
var decoded plainFileArtifactLaneProfile
if _, err := decodeKnownMapping(node, &decoded, map[string]struct{}{
"extract": {}, "merge": {}, "normalize": {}, "validators": {}, "references": {},
}, "artifact lane"); err != nil {
return err
}
*l = FileArtifactLaneProfile(decoded)
return nil
}
func decodeKnownMapping(node *yaml.Node, target any, allowed map[string]struct{}, context string) (map[string]struct{}, error) {
if node.Kind != yaml.MappingNode {
return nil, fmt.Errorf("%s must be an object", context)
}
if err := node.Decode(target); err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(node.Content)/2)
for i := 0; i < len(node.Content); i += 2 {
key := node.Content[i].Value
if _, exists := seen[key]; exists {
return nil, fmt.Errorf("%s field %q is duplicated", context, key)
}
if _, ok := allowed[key]; !ok {
return nil, fmt.Errorf("field %s not found in %s", key, context)
}
seen[key] = struct{}{}
}
return seen, nil
}
type FilePipelineStepProfile struct {
ID string `yaml:"id"`
Artifacts map[string]FileArtifactLaneProfile `yaml:"artifacts"`
References map[string]fileReferenceSource `yaml:"references,omitempty"`
}
type FileArtifactLaneProfile struct {
@@ -46,57 +109,120 @@ type FileArtifactLaneProfile struct {
Merge *fileModuleBinding `yaml:"merge,omitempty"`
Normalize *fileModuleBinding `yaml:"normalize,omitempty"`
Validators []fileModuleBinding `yaml:"validators,omitempty"`
References map[string]fileReferenceSource `yaml:"references,omitempty"`
}
type FileConcurrencyConfig struct {
TotalLLM *int `yaml:"total_llm,omitempty"`
StageWorkers map[string]int `yaml:"stage_workers,omitempty"`
}
type FileDiagnosticsConfig struct {
WorkDir *string `yaml:"work_dir,omitempty"`
Retention *string `yaml:"retention,omitempty"`
type FileOutputConfig struct {
Directory *string `yaml:"directory,omitempty"`
}
type fileDurationSeconds struct {
seconds int
type FileCacheConfig struct {
ChunkPlans *FileChunkPlanCacheConfig `yaml:"chunk_plans,omitempty"`
Checkpoints *FileCheckpointCacheConfig `yaml:"checkpoints,omitempty"`
}
func (d *fileDurationSeconds) UnmarshalYAML(node *yaml.Node) error {
if node.Kind != yaml.ScalarNode {
return fmt.Errorf("must be an integer seconds value or duration string")
type FileChunkPlanCacheConfig struct {
Directory *string `yaml:"directory,omitempty"`
Mode *string `yaml:"mode,omitempty"`
}
if node.Tag == "!!int" {
var seconds int
if err := node.Decode(&seconds); err != nil {
return fmt.Errorf("must be an integer seconds value or duration string")
type FileCheckpointCacheConfig struct {
Enabled *bool `yaml:"enabled,omitempty"`
Directory *string `yaml:"directory,omitempty"`
}
d.seconds = seconds
return nil
}
var raw string
if err := node.Decode(&raw); err != nil {
return fmt.Errorf("must be an integer seconds value or duration string")
}
duration, err := time.ParseDuration(strings.TrimSpace(raw))
if err != nil {
return fmt.Errorf("invalid duration %q", raw)
}
if duration%time.Second != 0 {
return fmt.Errorf("duration %q must resolve to whole seconds", raw)
}
d.seconds = int(duration / time.Second)
return nil
}
func (d fileDurationSeconds) Seconds() int {
return d.seconds
type FileDebugConfig struct {
Directory *string `yaml:"directory,omitempty"`
}
type fileModuleBinding struct {
Module string
LLMProfile string
Retries int
Options map[string]any
References map[string]fileReferenceSource
Validators pipeline.ValidatorOverride
}
type fileReferenceSource struct {
path string
artifact *pipeline.ArtifactReference
}
func (source *fileReferenceSource) UnmarshalYAML(node *yaml.Node) error {
if source == nil {
return fmt.Errorf("reference source must not be nil")
}
switch node.Kind {
case yaml.ScalarNode:
if node.Tag != "!!str" {
return fmt.Errorf("external reference path must be a string")
}
path := strings.TrimSpace(node.Value)
if path == "" {
return fmt.Errorf("external reference path must not be empty")
}
source.path = path
source.artifact = nil
return nil
case yaml.MappingNode:
if len(node.Content) != 2 || node.Content[0].Value != "artifact" {
return fmt.Errorf("reference source mapping must contain only artifact")
}
artifactNode := node.Content[1]
if artifactNode.Kind != yaml.MappingNode {
return fmt.Errorf("artifact reference must be an object")
}
var step, lane string
seen := map[string]bool{}
for i := 0; i < len(artifactNode.Content); i += 2 {
key := artifactNode.Content[i].Value
value := artifactNode.Content[i+1]
if seen[key] {
return fmt.Errorf("artifact reference field %q is duplicated", key)
}
seen[key] = true
if value.Tag != "!!str" {
return fmt.Errorf("artifact reference field %q must be a string", key)
}
switch key {
case "step":
step = strings.TrimSpace(value.Value)
case "lane":
lane = strings.TrimSpace(value.Value)
default:
return fmt.Errorf("field %s not found in artifact reference", key)
}
}
if step == "" || lane == "" {
return fmt.Errorf("artifact reference step and lane must not be empty")
}
source.path = ""
source.artifact = &pipeline.ArtifactReference{Step: step, Lane: lane}
return nil
default:
return fmt.Errorf("reference source must be a string or object")
}
}
func (source fileReferenceSource) toPipelineSource() pipeline.ReferenceSource {
if source.artifact != nil {
artifact := *source.artifact
return pipeline.ReferenceSource{Artifact: &artifact}
}
return pipeline.ExternalReference(source.path)
}
func fileReferenceSourcesToPipeline(values map[string]fileReferenceSource) map[string]pipeline.ReferenceSource {
if len(values) == 0 {
return nil
}
out := make(map[string]pipeline.ReferenceSource, len(values))
for key, value := range values {
out[strings.TrimSpace(key)] = value.toPipelineSource()
}
return out
}
func (b *fileModuleBinding) UnmarshalYAML(node *yaml.Node) error {
@@ -125,12 +251,34 @@ func (b *fileModuleBinding) UnmarshalYAML(node *yaml.Node) error {
return err
}
b.LLMProfile = strings.TrimSpace(llmProfile)
case "retries":
var retries int
if err := valueNode.Decode(&retries); err != nil {
return err
}
b.Retries = retries
case "options":
var options map[string]any
if err := valueNode.Decode(&options); err != nil {
return err
}
b.Options = normalizeOptions(options)
case "references":
var references map[string]fileReferenceSource
if err := valueNode.Decode(&references); err != nil {
return err
}
b.References = references
case "validators":
b.Validators.Set = true
var validators []fileModuleBinding
if err := valueNode.Decode(&validators); err != nil {
return err
}
b.Validators.Validators = make([]pipeline.ModuleBinding, len(validators))
for i, validator := range validators {
b.Validators.Validators[i] = validator.toPipelineBinding()
}
default:
return fmt.Errorf("field %s not found in module binding", keyNode.Value)
}
@@ -145,7 +293,10 @@ func (b fileModuleBinding) toPipelineBinding() pipeline.ModuleBinding {
return pipeline.ModuleBinding{
Module: strings.TrimSpace(b.Module),
LLMProfile: strings.TrimSpace(b.LLMProfile),
Retries: b.Retries,
Options: cloneOptions(b.Options),
References: fileReferenceSourcesToPipeline(b.References),
Validators: b.Validators,
}
}
@@ -162,18 +313,27 @@ func LoadFileConfig(path string) (FileConfig, error) {
}
func ParseFileConfigYAML(data []byte) (FileConfig, error) {
var header struct {
Version int `yaml:"version"`
}
if err := yaml.Unmarshal(data, &header); err != nil {
return FileConfig{}, fmt.Errorf("decode yaml version header: %w", err)
}
if header.Version == 0 {
return FileConfig{}, fmt.Errorf("config version is required")
}
if header.Version == 2 {
return FileConfig{}, fmt.Errorf("config version 2 is no longer supported; migrate the file using the version 2-to-3 migration in docs/config.md")
}
if header.Version != SupportedFileConfigVersion {
return FileConfig{}, fmt.Errorf("unsupported config version %d (supported version is %d)", header.Version, SupportedFileConfigVersion)
}
var fileCfg FileConfig
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(&fileCfg); err != nil {
return FileConfig{}, fmt.Errorf("decode yaml: %w", err)
}
if fileCfg.Version == 0 {
return FileConfig{}, fmt.Errorf("config version is required")
}
if fileCfg.Version != SupportedFileConfigVersion {
return FileConfig{}, fmt.Errorf("unsupported config version %d", fileCfg.Version)
}
return fileCfg, nil
}
@@ -186,68 +346,130 @@ func (c *Config) ApplyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin
}
func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(string) (string, bool)) error {
_ = lookup
if c == nil {
return fmt.Errorf("config must not be nil")
}
if fileCfg.Version != SupportedFileConfigVersion {
return fmt.Errorf("unsupported config version %d", fileCfg.Version)
}
if c.LLMProfiles == nil {
c.LLMProfiles = map[string]LLMProfile{}
}
if c.Pipelines == nil {
c.Pipelines = map[string]pipeline.PipelineProfile{}
}
profileIDs, rawLLMProfileIDs, err := normalizedMapKeys(fileCfg.LLMProfiles, "llm profile id")
if err != nil {
return err
}
pipelineIDs, rawPipelineIDs, err := normalizedMapKeys(fileCfg.Pipelines, "pipeline id")
if err != nil {
return err
}
for _, pipelineID := range pipelineIDs {
filePipeline := fileCfg.Pipelines[rawPipelineIDs[pipelineID]]
if _, _, err := normalizedMapKeys(filePipeline.Artifacts, fmt.Sprintf("pipeline %q artifact lane id", pipelineID)); err != nil {
hasArtifacts := filePipeline.artifactsSet || filePipeline.Artifacts != nil
hasSteps := filePipeline.stepsSet || filePipeline.Steps != nil
if hasArtifacts && hasSteps {
return fmt.Errorf("pipeline %q must not declare both artifacts and steps", pipelineID)
}
if hasSteps && len(filePipeline.Steps) == 0 {
return fmt.Errorf("pipeline %q must declare at least one ordered step", pipelineID)
}
if hasSteps {
seenSteps := make(map[string]struct{}, len(filePipeline.Steps))
seenLanes := make(map[string]struct{})
for index, step := range filePipeline.Steps {
stepID := strings.TrimSpace(step.ID)
if stepID == "" {
return fmt.Errorf("pipeline %q step[%d] id must not be empty", pipelineID, index)
}
if _, ok := seenSteps[stepID]; ok {
return fmt.Errorf("pipeline %q step id %q is duplicated after trimming", pipelineID, stepID)
}
seenSteps[stepID] = struct{}{}
laneIDs, rawLaneIDs, err := normalizedMapKeys(step.Artifacts, fmt.Sprintf("pipeline %q step %q artifact lane id", pipelineID, stepID))
if err != nil {
return err
}
for _, laneID := range laneIDs {
if _, ok := seenLanes[laneID]; ok {
return fmt.Errorf("pipeline %q artifact lane id %q is duplicated across steps", pipelineID, laneID)
}
seenLanes[laneID] = struct{}{}
fileLane := step.Artifacts[rawLaneIDs[laneID]]
if err := validateFileLaneReferences(pipelineID, stepID, laneID, fileLane); err != nil {
return err
}
}
if err := validateFileReferenceSources(step.References, fmt.Sprintf("pipeline %q step %q reference slot", pipelineID, stepID)); err != nil {
return err
}
}
}
if _, _, err := normalizedMapKeys(filePipeline.Artifacts, fmt.Sprintf("pipeline %q artifact lane id", pipelineID)); err != nil {
return err
}
if err := validateFileReferenceSources(filePipeline.References, fmt.Sprintf("pipeline %q reference slot", pipelineID)); err != nil {
return err
}
if filePipeline.Chunk != nil {
if _, _, err := normalizedMapKeys(filePipeline.Chunk.References, fmt.Sprintf("pipeline %q chunk reference slot", pipelineID)); err != nil {
return err
}
}
if _, _, err := normalizedMapKeys(filePipeline.Input.References, fmt.Sprintf("pipeline %q input reference slot", pipelineID)); err != nil {
return err
}
if filePipeline.Output != nil {
if _, _, err := normalizedMapKeys(filePipeline.Output.References, fmt.Sprintf("pipeline %q output reference slot", pipelineID)); err != nil {
return err
}
}
for rawLaneID, fileLane := range filePipeline.Artifacts {
laneID := strings.TrimSpace(rawLaneID)
if laneID == "" {
continue
}
if _, _, err := normalizedMapKeys(fileLane.References, fmt.Sprintf("pipeline %q lane %q reference slot", pipelineID, laneID)); err != nil {
return err
}
if _, _, err := normalizedMapKeys(fileLane.Extract.References, fmt.Sprintf("pipeline %q lane %q extract reference slot", pipelineID, laneID)); err != nil {
return err
}
if fileLane.Merge != nil {
if _, _, err := normalizedMapKeys(fileLane.Merge.References, fmt.Sprintf("pipeline %q lane %q merge reference slot", pipelineID, laneID)); err != nil {
return err
}
}
if fileLane.Normalize != nil {
if _, _, err := normalizedMapKeys(fileLane.Normalize.References, fmt.Sprintf("pipeline %q lane %q normalize reference slot", pipelineID, laneID)); err != nil {
return err
}
}
for i, validator := range fileLane.Validators {
if _, _, err := normalizedMapKeys(validator.References, fmt.Sprintf("pipeline %q lane %q validator[%d] reference slot", pipelineID, laneID, i)); err != nil {
return err
}
}
}
}
for _, profileID := range profileIDs {
fileProfile := fileCfg.LLMProfiles[rawLLMProfileIDs[profileID]]
profile := c.LLMProfiles[profileID]
if fileProfile.Provider != nil {
profile.Provider = strings.TrimSpace(*fileProfile.Provider)
if fileCfg.Scriptorium != nil {
if fileCfg.Scriptorium.ProfileDir != nil {
value := strings.TrimSpace(*fileCfg.Scriptorium.ProfileDir)
if value == "" {
return fmt.Errorf("scriptorium.profile_dir must not be empty when set")
}
if fileProfile.BaseURL != nil {
profile.BaseURL = strings.TrimSpace(*fileProfile.BaseURL)
c.Scriptorium.ProfileDir = value
}
if fileProfile.Model != nil {
profile.Model = strings.TrimSpace(*fileProfile.Model)
if fileCfg.Scriptorium.ProfileFile != nil {
value := strings.TrimSpace(*fileCfg.Scriptorium.ProfileFile)
if value == "" {
return fmt.Errorf("scriptorium.profile_file must not be empty when set")
}
if fileProfile.APIKeyEnv != nil {
apiKey, err := resolveAPIKeyEnv(*fileProfile.APIKeyEnv, lookup)
if err != nil {
return fmt.Errorf("llm_profiles.%s.api_key_env: %w", profileID, err)
c.Scriptorium.ProfileFile = value
}
profile.APIKeyEnv = strings.TrimSpace(*fileProfile.APIKeyEnv)
profile.APIKey = apiKey
}
if fileProfile.Timeout != nil {
profile.TimeoutSeconds = fileProfile.Timeout.Seconds()
}
if fileProfile.MaxRetries != nil {
profile.MaxRetries = *fileProfile.MaxRetries
}
if fileProfile.MaxConcurrency != nil {
profile.MaxConcurrency = *fileProfile.MaxConcurrency
}
c.LLMProfiles[profileID] = profile
}
for _, pipelineID := range pipelineIDs {
filePipeline := fileCfg.Pipelines[rawPipelineIDs[pipelineID]]
hasSteps := filePipeline.stepsSet || filePipeline.Steps != nil
laneIDs, rawLaneIDs, err := normalizedMapKeys(filePipeline.Artifacts, fmt.Sprintf("pipeline %q artifact lane id", pipelineID))
if err != nil {
return err
@@ -256,6 +478,7 @@ func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin
ID: pipelineID,
Input: filePipeline.Input.toPipelineBinding(),
Artifacts: make(map[string]pipeline.ArtifactLaneProfile, len(filePipeline.Artifacts)),
References: fileReferenceSourcesToPipeline(filePipeline.References),
}
if filePipeline.Chunk != nil {
profile.Chunk = filePipeline.Chunk.toPipelineBinding()
@@ -265,8 +488,11 @@ func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin
}
for _, laneID := range laneIDs {
fileLane := filePipeline.Artifacts[rawLaneIDs[laneID]]
extract := fileLane.Extract.toPipelineBinding()
extract.References = mergeReferenceSources(fileReferenceSourcesToPipeline(fileLane.References), extract.References)
lane := pipeline.ArtifactLaneProfile{
Extract: fileLane.Extract.toPipelineBinding(),
Extract: extract,
References: fileReferenceSourcesToPipeline(fileLane.References),
}
if fileLane.Merge != nil {
lane.Merge = fileLane.Merge.toPipelineBinding()
@@ -282,24 +508,135 @@ func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin
}
profile.Artifacts[laneID] = lane
}
if hasSteps {
profile.Artifacts = nil
profile.Steps = make([]pipeline.PipelineStepProfile, len(filePipeline.Steps))
for i, fileStep := range filePipeline.Steps {
stepID := strings.TrimSpace(fileStep.ID)
step := pipeline.PipelineStepProfile{
ID: stepID,
Artifacts: make(map[string]pipeline.ArtifactLaneProfile, len(fileStep.Artifacts)),
References: fileReferenceSourcesToPipeline(fileStep.References),
}
stepLaneIDs, stepRawLaneIDs, err := normalizedMapKeys(fileStep.Artifacts, fmt.Sprintf("pipeline %q step %q artifact lane id", pipelineID, stepID))
if err != nil {
return err
}
for _, laneID := range stepLaneIDs {
fileLane := fileStep.Artifacts[stepRawLaneIDs[laneID]]
extract := fileLane.Extract.toPipelineBinding()
extract.References = mergeReferenceSources(fileReferenceSourcesToPipeline(fileLane.References), extract.References)
lane := pipeline.ArtifactLaneProfile{Extract: extract, References: fileReferenceSourcesToPipeline(fileLane.References)}
if fileLane.Merge != nil {
lane.Merge = fileLane.Merge.toPipelineBinding()
}
if fileLane.Normalize != nil {
lane.Normalize = fileLane.Normalize.toPipelineBinding()
}
if len(fileLane.Validators) > 0 {
lane.Validators = make([]pipeline.ModuleBinding, len(fileLane.Validators))
for index, validator := range fileLane.Validators {
lane.Validators[index] = validator.toPipelineBinding()
}
}
step.Artifacts[laneID] = lane
}
profile.Steps[i] = step
}
}
c.Pipelines[pipelineID] = profile
}
if fileCfg.Concurrency != nil && fileCfg.Concurrency.TotalLLM != nil {
c.Concurrency.TotalLLM = *fileCfg.Concurrency.TotalLLM
}
if fileCfg.Diagnostics != nil {
if fileCfg.Diagnostics.WorkDir != nil {
c.Diagnostics.WorkDir = strings.TrimSpace(*fileCfg.Diagnostics.WorkDir)
if fileCfg.Concurrency != nil && fileCfg.Concurrency.StageWorkers != nil {
workers, configured, err := normalizeStageWorkers(fileCfg.Concurrency.StageWorkers)
if err != nil {
return err
}
if fileCfg.Diagnostics.Retention != nil {
c.Diagnostics.Retention = diagnostics.RetentionMode(strings.TrimSpace(*fileCfg.Diagnostics.Retention))
c.Concurrency.StageWorkers = workers
c.Concurrency.extractWorkersConfigured = configured
}
c.Concurrency.recomputeStageWorkerDefaults()
if fileCfg.Output != nil && fileCfg.Output.Directory != nil {
c.Output.Directory = strings.TrimSpace(*fileCfg.Output.Directory)
if c.Output.Directory == "" {
return fmt.Errorf("output.directory must not be empty")
}
if strings.ContainsRune(c.Output.Directory, '\x00') {
return fmt.Errorf("output.directory must not contain NUL")
}
}
if fileCfg.Cache != nil {
if fileCfg.Cache.ChunkPlans != nil {
if fileCfg.Cache.ChunkPlans.Mode != nil {
mode, err := pipeline.ParseChunkCacheMode(*fileCfg.Cache.ChunkPlans.Mode)
if err != nil {
return fmt.Errorf("cache.chunk_plans.mode: %w", err)
}
c.Cache.ChunkPlans.Mode = mode
}
if fileCfg.Cache.ChunkPlans.Directory != nil {
c.Cache.ChunkPlans.Directory = cleanOptionalPath(*fileCfg.Cache.ChunkPlans.Directory)
if strings.ContainsRune(c.Cache.ChunkPlans.Directory, '\x00') {
return fmt.Errorf("cache.chunk_plans.directory must not contain NUL")
}
}
}
if fileCfg.Cache.Checkpoints != nil {
if fileCfg.Cache.Checkpoints.Enabled != nil {
c.Cache.Checkpoints.Enabled = *fileCfg.Cache.Checkpoints.Enabled
}
if fileCfg.Cache.Checkpoints.Directory != nil {
c.Cache.Checkpoints.Directory = cleanOptionalPath(*fileCfg.Cache.Checkpoints.Directory)
if strings.ContainsRune(c.Cache.Checkpoints.Directory, '\x00') {
return fmt.Errorf("cache.checkpoints.directory must not contain NUL")
}
}
}
}
if fileCfg.Debug != nil && fileCfg.Debug.Directory != nil {
c.Debug.Directory = strings.TrimSpace(*fileCfg.Debug.Directory)
if c.Debug.Directory == "" {
return fmt.Errorf("debug.directory must not be empty")
}
if strings.ContainsRune(c.Debug.Directory, '\x00') {
return fmt.Errorf("debug.directory must not contain NUL")
}
}
return nil
}
func cleanOptionalPath(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return filepath.Clean(value)
}
func normalizeStageWorkers(values map[string]int) (map[string]int, bool, error) {
workers := make(map[string]int, len(values))
configured := false
for rawKey, value := range values {
key := strings.TrimSpace(rawKey)
if key == "" {
return nil, false, fmt.Errorf("concurrency.stage_workers key must not be empty")
}
if key != "extract" {
return nil, false, fmt.Errorf("concurrency.stage_workers key %q is not supported", rawKey)
}
if _, exists := workers[key]; exists {
return nil, false, fmt.Errorf("concurrency.stage_workers key %q is duplicated after trimming", key)
}
workers[key] = value
configured = true
}
return workers, configured, nil
}
func normalizedMapKeys[T any](values map[string]T, keyName string) ([]string, map[string]string, error) {
keys := make([]string, 0, len(values))
rawByNormalized := make(map[string]string, len(values))
@@ -318,19 +655,79 @@ func normalizedMapKeys[T any](values map[string]T, keyName string) ([]string, ma
return keys, rawByNormalized, nil
}
func resolveAPIKeyEnv(envName string, lookup func(string) (string, bool)) (string, error) {
name := strings.TrimSpace(envName)
if name == "" {
return "", fmt.Errorf("must not be empty")
func validateFileReferenceSources(values map[string]fileReferenceSource, context string) error {
seen := make(map[string]struct{}, len(values))
for rawSlot, source := range values {
slot := strings.TrimSpace(rawSlot)
if slot == "" {
return fmt.Errorf("%s must not be empty", context)
}
if !envVarNamePattern.MatchString(name) {
return "", fmt.Errorf("must be an environment variable name")
if _, ok := seen[slot]; ok {
return fmt.Errorf("%s %q is duplicated after trimming", context, slot)
}
value, ok := lookup(name)
if !ok {
return "", fmt.Errorf("%s is not set", name)
seen[slot] = struct{}{}
if source.artifact != nil {
if strings.TrimSpace(source.artifact.Step) == "" || strings.TrimSpace(source.artifact.Lane) == "" {
return fmt.Errorf("%s %q artifact selector step and lane must not be empty", context, slot)
}
return value, nil
if strings.TrimSpace(source.path) != "" {
return fmt.Errorf("%s %q must contain either an external path or artifact selector", context, slot)
}
continue
}
if strings.TrimSpace(source.path) == "" {
return fmt.Errorf("%s %q source must not be empty", context, slot)
}
}
return nil
}
func validateFileLaneReferences(pipelineID, stepID, laneID string, lane FileArtifactLaneProfile) error {
prefix := fmt.Sprintf("pipeline %q step %q lane %q", pipelineID, stepID, laneID)
references := []struct {
label string
values map[string]fileReferenceSource
}{
{label: "reference slot", values: lane.References},
{label: "extract reference slot", values: lane.Extract.References},
}
if lane.Merge != nil {
references = append(references, struct {
label string
values map[string]fileReferenceSource
}{label: "merge reference slot", values: lane.Merge.References})
}
if lane.Normalize != nil {
references = append(references, struct {
label string
values map[string]fileReferenceSource
}{label: "normalize reference slot", values: lane.Normalize.References})
}
for _, item := range references {
if err := validateFileReferenceSources(item.values, prefix+" "+item.label); err != nil {
return err
}
}
for index, validator := range lane.Validators {
if err := validateFileReferenceSources(validator.References, fmt.Sprintf("%s validator[%d] reference slot", prefix, index)); err != nil {
return err
}
}
return nil
}
func mergeReferenceSources(base map[string]pipeline.ReferenceSource, override map[string]pipeline.ReferenceSource) map[string]pipeline.ReferenceSource {
if len(base) == 0 && len(override) == 0 {
return nil
}
out := make(map[string]pipeline.ReferenceSource, len(base)+len(override))
for key, value := range base {
out[key] = value
}
for key, value := range override {
out[key] = value
}
return out
}
func normalizeOptions(options map[string]any) map[string]any {

View File

@@ -0,0 +1,470 @@
package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestDefaultReturnsDocumentedValuesAndIndependentMaps(t *testing.T) {
first := Default()
if first.Concurrency.TotalLLM != 1 || first.Concurrency.StageWorkers["extract"] != 1 {
t.Fatalf("concurrency defaults = %#v", first.Concurrency)
}
if first.Output.Directory != "./notarius-output" || first.Debug.Directory != "./notarius-debug" {
t.Fatalf("output/debug defaults = %#v, %#v", first.Output, first.Debug)
}
if first.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto || first.Cache.ChunkPlans.Directory != "" || first.Cache.Checkpoints.Enabled || first.Cache.Checkpoints.Directory != "" {
t.Fatalf("cache defaults = %#v", first.Cache)
}
if len(first.Pipelines) != 0 {
t.Fatalf("pipeline defaults = %#v", first.Pipelines)
}
first.Concurrency.StageWorkers["extract"] = 99
first.Concurrency.StageWorkers["other"] = 100
first.Pipelines["changed"] = pipeline.PipelineProfile{}
second := Default()
if second.Concurrency.StageWorkers["extract"] != 1 || len(second.Concurrency.StageWorkers) != 1 || len(second.Pipelines) != 0 {
t.Fatalf("Default() returned state shared with an earlier result: %#v", second)
}
}
func TestFileConfigMinimalVersion3AppliesOverDefaults(t *testing.T) {
file := parseFileConfig(t, "version: 3\n")
cfg := Default()
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatal(err)
}
if cfg.Output.Directory != "./notarius-output" || cfg.Debug.Directory != "./notarius-debug" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto {
t.Fatalf("minimal file changed unrelated defaults: %#v", cfg)
}
if cfg.Concurrency.TotalLLM != 1 || cfg.Concurrency.StageWorkers["extract"] != 1 || len(cfg.Pipelines) != 0 {
t.Fatalf("minimal file did not retain defaults: %#v", cfg)
}
}
func TestFileConfigMissingVersionIsReportedBeforeFieldDecoding(t *testing.T) {
_, err := ParseFileConfigYAML([]byte("workspace:\n directory: /tmp/old\n"))
if err == nil || !strings.Contains(err.Error(), "config version is required") {
t.Fatalf("missing version error = %v", err)
}
}
func TestFileConfigRejectsUnknownCurrentAndRemovedFields(t *testing.T) {
tests := []struct {
name string
yaml string
want string
}{
{
name: "removed diagnostics",
yaml: "version: 3\ndiagnostics: {}\n",
want: "field diagnostics not found",
},
{
name: "removed llm profiles",
yaml: "version: 3\nllm_profiles: {}\n",
want: "field llm_profiles not found",
},
{
name: "version 2 migration",
yaml: "version: 2\nworkspace:\n directory: /tmp/old\n",
want: "version 2-to-3 migration",
},
{
name: "pipeline field",
yaml: "version: 3\npipelines:\n main:\n unknown: true\n",
want: "field unknown not found",
},
{
name: "lane field",
yaml: "version: 3\npipelines:\n main:\n artifacts:\n spells:\n unknown: true\n",
want: "field unknown not found",
},
{
name: "module binding field",
yaml: "version: 3\npipelines:\n main:\n input:\n module: seriatim\n unknown: true\n",
want: "field unknown not found in module binding",
},
{
name: "checkpoint field",
yaml: "version: 3\ncache:\n checkpoints:\n unknown: true\n",
want: "field unknown not found",
},
{
name: "checkpoint enabled type",
yaml: "version: 3\ncache:\n checkpoints:\n enabled: definitely\n",
want: "cannot unmarshal",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(tt.yaml))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want context %q", err, tt.want)
}
})
}
}
func TestFileConfigModuleBindingsPreserveFormsAndValidatorPresence(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
pipelines:
main:
input: seriatim
chunk:
module: generic
llm_profile: chunk-profile
retries: 2
options:
max_units: 25
references:
glossary: ./glossary.md
validators: []
artifacts:
spells:
extract:
module: dnd/spells
options:
nested:
enabled: true
merge: appendorder
normalize: noop
`)
profile := cfg.Pipelines["main"]
if profile.Input.Module != "seriatim" || profile.Input.Validators.Set {
t.Fatalf("shorthand binding = %#v", profile.Input)
}
if profile.Chunk.Module != "generic" || profile.Chunk.LLMProfile != "chunk-profile" || profile.Chunk.Retries != 2 ||
!reflect.DeepEqual(profile.Chunk.Options, map[string]any{"max_units": 25}) ||
!reflect.DeepEqual(profile.Chunk.References, pipeline.ExternalReferenceMap(map[string]string{"glossary": "./glossary.md"})) {
t.Fatalf("object binding = %#v", profile.Chunk)
}
if !profile.Chunk.Validators.Set || len(profile.Chunk.Validators.Validators) != 0 {
t.Fatalf("explicit empty validators = %#v", profile.Chunk.Validators)
}
if profile.Artifacts["spells"].Extract.Module != "dnd/spells" ||
!reflect.DeepEqual(profile.Artifacts["spells"].Extract.Options, map[string]any{
"nested": map[string]any{"enabled": true},
}) {
t.Fatalf("extract binding = %#v", profile.Artifacts["spells"].Extract)
}
if profile.Artifacts["spells"].Merge.Module != "appendorder" || profile.Artifacts["spells"].Normalize.Module != "noop" {
t.Fatalf("stage shorthand bindings = %#v", profile.Artifacts["spells"])
}
}
func TestFileConfigReferencePrecedenceIsRetained(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
pipelines:
main:
input: seriatim
references:
pipeline-only: ./pipeline.txt
shared: ./pipeline-shared.txt
chunk:
module: generic
references:
chunk-only: ./chunk.txt
artifacts:
spells:
references:
lane-only: ./lane.txt
shared: ./lane-shared.txt
overridden: ./lane.txt
extract:
module: dnd/spells
references:
extract-only: ./extract.txt
overridden: ./extract-overridden.txt
merge:
module: appendorder
references:
merge-only: ./merge.txt
normalize:
module: noop
references:
normalize-only: ./normalize.txt
`)
profile := cfg.Pipelines["main"]
if !reflect.DeepEqual(profile.References, pipeline.ExternalReferenceMap(map[string]string{
"pipeline-only": "./pipeline.txt",
"shared": "./pipeline-shared.txt",
})) {
t.Fatalf("pipeline references = %#v", profile.References)
}
if !reflect.DeepEqual(profile.Chunk.References, pipeline.ExternalReferenceMap(map[string]string{"chunk-only": "./chunk.txt"})) {
t.Fatalf("chunk references = %#v", profile.Chunk.References)
}
lane := profile.Artifacts["spells"]
if !reflect.DeepEqual(lane.References, pipeline.ExternalReferenceMap(map[string]string{
"lane-only": "./lane.txt",
"shared": "./lane-shared.txt",
"overridden": "./lane.txt",
})) {
t.Fatalf("lane compatibility references = %#v", lane.References)
}
if !reflect.DeepEqual(lane.Extract.References, pipeline.ExternalReferenceMap(map[string]string{
"lane-only": "./lane.txt",
"shared": "./lane-shared.txt",
"overridden": "./extract-overridden.txt",
"extract-only": "./extract.txt",
})) {
t.Fatalf("extract references = %#v", lane.Extract.References)
}
if !reflect.DeepEqual(lane.Merge.References, pipeline.ExternalReferenceMap(map[string]string{"merge-only": "./merge.txt"})) ||
!reflect.DeepEqual(lane.Normalize.References, pipeline.ExternalReferenceMap(map[string]string{"normalize-only": "./normalize.txt"})) {
t.Fatalf("merge/normalize references = %#v, %#v", lane.Merge.References, lane.Normalize.References)
}
}
func TestFileConfigStageLocalValidatorsPreserveOrderAndFields(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
pipelines:
main:
input: seriatim
chunk:
module: generic
validators:
- generic/always_accept
- module: generic/valid_json
llm_profile: validator-profile
options:
schema: compact
artifacts:
spells:
extract:
module: dnd/spells
validators:
- module: extract/dnd/spells/shape
options:
strict: true
merge:
module: appendorder
validators:
- generic/always_accept
normalize:
module: noop
validators:
- module: generic/valid_json
options:
mode: normalized
`)
profile := cfg.Pipelines["main"]
chunkValidators := profile.Chunk.Validators.Validators
if !profile.Chunk.Validators.Set || len(chunkValidators) != 2 || chunkValidators[0].Module != "generic/always_accept" ||
chunkValidators[1].Module != "generic/valid_json" || chunkValidators[1].LLMProfile != "validator-profile" ||
!reflect.DeepEqual(chunkValidators[1].Options, map[string]any{"schema": "compact"}) {
t.Fatalf("chunk validators = %#v", profile.Chunk.Validators)
}
lane := profile.Artifacts["spells"]
if len(lane.Extract.Validators.Validators) != 1 || lane.Extract.Validators.Validators[0].Module != "extract/dnd/spells/shape" ||
!reflect.DeepEqual(lane.Extract.Validators.Validators[0].Options, map[string]any{"strict": true}) {
t.Fatalf("extract validators = %#v", lane.Extract.Validators)
}
if len(lane.Merge.Validators.Validators) != 1 || lane.Merge.Validators.Validators[0].Module != "generic/always_accept" {
t.Fatalf("merge validators = %#v", lane.Merge.Validators)
}
if len(lane.Normalize.Validators.Validators) != 1 || lane.Normalize.Validators.Validators[0].Module != "generic/valid_json" ||
!reflect.DeepEqual(lane.Normalize.Validators.Validators[0].Options, map[string]any{"mode": "normalized"}) {
t.Fatalf("normalize validators = %#v", lane.Normalize.Validators)
}
}
func TestFileConfigStateSectionsApplyIndependently(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
scriptorium:
profile_dir: ./profiles
concurrency:
total_llm: 7
output:
directory: ./output
cache:
chunk_plans:
directory: ./plans
mode: bypass
checkpoints:
enabled: true
directory: ./checkpoints
debug:
directory: ./debug
`)
if cfg.Scriptorium.ProfileDir != "./profiles" || cfg.Scriptorium.ProfileFile != "" {
t.Fatalf("scriptorium = %#v", cfg.Scriptorium)
}
if cfg.Concurrency.TotalLLM != 7 || cfg.Concurrency.StageWorkers["extract"] != 7 {
t.Fatalf("concurrency = %#v", cfg.Concurrency)
}
if cfg.Output.Directory != "./output" || cfg.Cache.ChunkPlans.Directory != "plans" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheBypass ||
!cfg.Cache.Checkpoints.Enabled || cfg.Cache.Checkpoints.Directory != "checkpoints" || cfg.Debug.Directory != "./debug" {
t.Fatalf("state sections = %#v, %#v, %#v, %#v", cfg.Output, cfg.Cache, cfg.Debug, cfg.Scriptorium)
}
if cfg.Output.Directory == cfg.Cache.ChunkPlans.Directory || cfg.Cache.ChunkPlans.Directory == cfg.Cache.Checkpoints.Directory || cfg.Cache.Checkpoints.Directory == cfg.Debug.Directory {
t.Fatal("state roots were coupled")
}
}
func TestFileConfigCheckpointEnabledCanBeExplicitlyDisabled(t *testing.T) {
cfg := applyFileConfig(t, "version: 3\ncache:\n checkpoints:\n enabled: true\n")
if !cfg.Cache.Checkpoints.Enabled || !cloneConfig(cfg).Cache.Checkpoints.Enabled {
t.Fatalf("enabled checkpoint config was not retained: %#v", cfg.Cache.Checkpoints)
}
file := parseFileConfig(t, "version: 3\ncache:\n checkpoints:\n enabled: false\n")
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatal(err)
}
if cfg.Cache.Checkpoints.Enabled {
t.Fatalf("explicit false checkpoint config was not applied: %#v", cfg.Cache.Checkpoints)
}
}
func TestFileConfigRejectsTrimmedKeyCollisions(t *testing.T) {
tests := []struct {
name string
yaml string
want string
}{
{
name: "pipeline ids",
yaml: "version: 3\npipelines:\n main: {}\n ' main ': {}\n",
want: "pipeline id \"main\" is duplicated after trimming",
},
{
name: "lane ids",
yaml: "version: 3\npipelines:\n main:\n artifacts:\n spells: {}\n ' spells ': {}\n",
want: "artifact lane id \"spells\" is duplicated after trimming",
},
{
name: "reference slots",
yaml: "version: 3\npipelines:\n main:\n references:\n slot: ./one.txt\n ' slot ': ./two.txt\n",
want: "reference slot \"slot\" is duplicated after trimming",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file := parseFileConfig(t, tt.yaml)
cfg := Default()
err := cfg.ApplyFileConfig(file)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ApplyFileConfig() error = %v, want context %q", err, tt.want)
}
})
}
}
func TestFileConfigParsesOrderedStepsAndReferenceSources(t *testing.T) {
file := parseFileConfig(t, `version: 3
pipelines:
session:
input: seriatim
steps:
- id: identify-npcs
artifacts:
npcs:
extract: dnd/npcs
- id: grounded-events
references:
npcs:
artifact:
step: identify-npcs
lane: npcs
artifacts:
spells:
extract: dnd/spells
`)
cfg := Default()
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatal(err)
}
profile := cfg.Pipelines["session"]
if len(profile.Steps) != 2 || profile.Steps[0].ID != "identify-npcs" || profile.Steps[1].ID != "grounded-events" {
t.Fatalf("steps = %#v", profile.Steps)
}
source := profile.Steps[1].References["npcs"]
if source.Artifact == nil || source.Artifact.Step != "identify-npcs" || source.Artifact.Lane != "npcs" {
t.Fatalf("generated source = %#v", source)
}
}
func TestFileConfigRejectsAmbiguousReferenceSourceForms(t *testing.T) {
for _, source := range []string{
"artifact: {step: a, lane: b, extra: c}",
"artifact: {step: 1, lane: b}",
"1",
} {
_, err := ParseFileConfigYAML([]byte("version: 3\npipelines:\n p:\n input: text\n references:\n slot: " + source + "\n"))
if err == nil {
t.Fatalf("ParseFileConfigYAML(%q) error = nil", source)
}
}
}
func TestFileConfigRejectsEmptyAndAmbiguousPipelineShapes(t *testing.T) {
tests := []struct {
name string
yaml string
want string
}{
{
name: "empty steps",
yaml: "version: 3\npipelines:\n p:\n input: text\n steps: []\n",
want: "at least one ordered step",
},
{
name: "both forms",
yaml: "version: 3\npipelines:\n p:\n input: text\n artifacts: {}\n steps: []\n",
want: "both artifacts and steps",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file := parseFileConfig(t, tt.yaml)
cfg := Default()
err := cfg.ApplyFileConfig(file)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ApplyFileConfig() error = %v, want context %q", err, tt.want)
}
})
}
}
func TestLoadFileConfigReportsPathAndOperationContext(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "missing.yml")
_, err := LoadFileConfig(missing)
if err == nil || !strings.Contains(err.Error(), "read config file") || !strings.Contains(err.Error(), missing) {
t.Fatalf("missing-file error = %v", err)
}
malformed := filepath.Join(dir, "malformed.yml")
if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil {
t.Fatal(err)
}
_, err = LoadFileConfig(malformed)
if err == nil || !strings.Contains(err.Error(), "parse config file") || !strings.Contains(err.Error(), malformed) {
t.Fatalf("malformed-file error = %v", err)
}
}
func parseFileConfig(t *testing.T, source string) FileConfig {
t.Helper()
file, err := ParseFileConfigYAML([]byte(source))
if err != nil {
t.Fatalf("ParseFileConfigYAML() error = %v", err)
}
return file
}
func applyFileConfig(t *testing.T, source string) Config {
t.Helper()
cfg := Default()
if err := cfg.ApplyFileConfig(parseFileConfig(t, source)); err != nil {
t.Fatalf("ApplyFileConfig() error = %v", err)
}
return cfg
}

View File

@@ -1,393 +0,0 @@
package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
)
func TestParseMinimalValidConfig(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1
`))
if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err)
}
if fileCfg.Version != SupportedFileConfigVersion {
t.Fatalf("unexpected version: %d", fileCfg.Version)
}
}
func TestLoadFileConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte("version: 1\n"), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
fileCfg, err := LoadFileConfig(path)
if err != nil {
t.Fatalf("LoadFileConfig: %v", err)
}
if fileCfg.Version != SupportedFileConfigVersion {
t.Fatalf("unexpected version: %d", fileCfg.Version)
}
}
func TestParseFileConfigRejectsUnknownYAMLFields(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(`
version: 1
unexpected: true
`))
if err == nil || !strings.Contains(err.Error(), "field unexpected not found") {
t.Fatalf("expected unknown field error, got %v", err)
}
}
func TestParseFileConfigRejectsUnknownModuleBindingFields(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(`
version: 1
pipelines:
example:
input:
module: fake/input
unexpected: true
artifacts:
events:
extract: fake/extract
`))
if err == nil || !strings.Contains(err.Error(), "field unexpected not found") {
t.Fatalf("expected unknown binding field error, got %v", err)
}
}
func TestParseFileConfigRejectsMissingAndUnsupportedVersion(t *testing.T) {
tests := []struct {
name string
data string
want string
}{
{name: "missing", data: `llm_profiles: {}`, want: "version is required"},
{name: "unsupported", data: `version: 2`, want: "unsupported config version"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(tc.data))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected error containing %q, got %v", tc.want, err)
}
})
}
}
func TestParseFileConfigModuleBindingForms(t *testing.T) {
cfg := parseAndApplyConfig(t, `
version: 1
pipelines:
example:
input: fake/input
chunk:
module: generic
options:
size: 10
flags:
- alpha
nested:
enabled: true
artifacts:
events:
extract:
module: fake/extract
llm_profile: fast
options:
temperature: 0
merge: appendorder
normalize:
module: noop
output: json
`)
profile := cfg.Pipelines["example"]
if profile.Input.Module != "fake/input" {
t.Fatalf("unexpected input binding: %+v", profile.Input)
}
if profile.Chunk.Module != "generic" {
t.Fatalf("unexpected chunk binding: %+v", profile.Chunk)
}
if profile.Chunk.Options["size"] != 10 {
t.Fatalf("expected chunk options to preserve scalar, got %#v", profile.Chunk.Options)
}
if !reflect.DeepEqual(profile.Chunk.Options["flags"], []any{"alpha"}) {
t.Fatalf("expected list option, got %#v", profile.Chunk.Options["flags"])
}
nested, ok := profile.Chunk.Options["nested"].(map[string]any)
if !ok || nested["enabled"] != true {
t.Fatalf("expected nested map option, got %#v", profile.Chunk.Options["nested"])
}
lane := profile.Artifacts["events"]
if lane.Extract.Module != "fake/extract" || lane.Extract.LLMProfile != "fast" {
t.Fatalf("unexpected extract binding: %+v", lane.Extract)
}
if lane.Extract.Options["temperature"] != 0 {
t.Fatalf("expected object options, got %#v", lane.Extract.Options)
}
if lane.Merge.Module != "appendorder" || lane.Normalize.Module != "noop" {
t.Fatalf("unexpected lane defaults: %+v", lane)
}
if profile.Output.Module != "json" {
t.Fatalf("unexpected output binding: %+v", profile.Output)
}
}
func TestParseFileConfigValidatorMixedBindingForms(t *testing.T) {
cfg := parseAndApplyConfig(t, `
version: 1
pipelines:
example:
input: fake/input
artifacts:
events:
extract: fake/extract
validators:
- fake/validator
- module: fake/llm-validator
llm_profile: careful
options:
threshold: 0.7
`)
validators := cfg.Pipelines["example"].Artifacts["events"].Validators
if len(validators) != 2 {
t.Fatalf("expected two validators, got %d", len(validators))
}
if validators[0].Module != "fake/validator" {
t.Fatalf("unexpected shorthand validator: %+v", validators[0])
}
if validators[1].Module != "fake/llm-validator" || validators[1].LLMProfile != "careful" {
t.Fatalf("unexpected object validator: %+v", validators[1])
}
if validators[1].Options["threshold"] != 0.7 {
t.Fatalf("unexpected validator options: %#v", validators[1].Options)
}
}
func TestParseFileConfigDurationParsing(t *testing.T) {
tests := []struct {
name string
raw string
want int
}{
{name: "integer seconds", raw: "600", want: 600},
{name: "duration string", raw: "10m", want: 600},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := parseAndApplyConfig(t, `
version: 1
llm_profiles:
default:
timeout: `+tc.raw+`
`)
if got := cfg.LLMProfiles["default"].TimeoutSeconds; got != tc.want {
t.Fatalf("TimeoutSeconds = %d, want %d", got, tc.want)
}
})
}
}
func TestParseFileConfigRejectsSubsecondDuration(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(`
version: 1
llm_profiles:
default:
timeout: 1500ms
`))
if err == nil || !strings.Contains(err.Error(), "whole seconds") {
t.Fatalf("expected whole-seconds duration error, got %v", err)
}
}
func TestApplyFileConfigResolvesAPIKeyEnv(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1
llm_profiles:
default:
api_key_env: NOTARIUS_TEST_API_KEY
`))
if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err)
}
cfg := Default()
if err := cfg.applyFileConfigWithLookup(fileCfg, mapLookup(map[string]string{"NOTARIUS_TEST_API_KEY": "secret"})); err != nil {
t.Fatalf("ApplyFileConfig: %v", err)
}
profile := cfg.LLMProfiles["default"]
if profile.APIKeyEnv != "NOTARIUS_TEST_API_KEY" || profile.APIKey != "secret" {
t.Fatalf("unexpected resolved API key: %+v", profile)
}
}
func TestApplyFileConfigRejectsDuplicateTrimmedLLMProfileIDs(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1
llm_profiles:
default:
model: first
" default ":
model: second
`))
if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err)
}
cfg := Default()
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
if err == nil || !strings.Contains(err.Error(), "llm profile id") || !strings.Contains(err.Error(), "duplicated") {
t.Fatalf("expected duplicate LLM profile ID error, got %v", err)
}
}
func TestApplyFileConfigRejectsDuplicateTrimmedPipelineIDs(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1
pipelines:
example:
input: fake/input
" example ":
input: fake/other-input
`))
if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err)
}
cfg := Default()
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
if err == nil || !strings.Contains(err.Error(), "pipeline id") || !strings.Contains(err.Error(), "duplicated") {
t.Fatalf("expected duplicate pipeline ID error, got %v", err)
}
}
func TestApplyFileConfigRejectsDuplicateTrimmedArtifactLaneIDs(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1
pipelines:
example:
input: fake/input
artifacts:
events:
extract: fake/extract
" events ":
extract: fake/other-extract
`))
if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err)
}
cfg := Default()
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
if err == nil || !strings.Contains(err.Error(), `pipeline "example" artifact lane id`) || !strings.Contains(err.Error(), "duplicated") {
t.Fatalf("expected duplicate artifact lane ID error, got %v", err)
}
}
func TestApplyFileConfigAllowsRetryOnlyLLMProfile(t *testing.T) {
cfg := parseAndApplyConfig(t, `
version: 1
llm_profiles:
retry-only:
max_retries: 3
`)
profile := cfg.LLMProfiles["retry-only"]
if profile.MaxRetries != 3 {
t.Fatalf("unexpected max retries: %d", profile.MaxRetries)
}
if profile.TimeoutSeconds != 0 {
t.Fatalf("expected unset timeout, got %d", profile.TimeoutSeconds)
}
if profile.MaxConcurrency != 0 {
t.Fatalf("expected unset max concurrency, got %d", profile.MaxConcurrency)
}
}
func TestApplyFileConfigRejectsInvalidAPIKeyEnv(t *testing.T) {
tests := []struct {
name string
env string
want string
}{
{name: "invalid name", env: "NOTARIUS-KEY", want: "environment variable name"},
{name: "not set", env: "NOTARIUS_TEST_API_KEY", want: "is not set"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1
llm_profiles:
default:
api_key_env: ` + tc.env + `
`))
if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err)
}
cfg := Default()
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected error containing %q, got %v", tc.want, err)
}
})
}
}
func TestApplyFileConfigOperationalSections(t *testing.T) {
cfg := parseAndApplyConfig(t, `
version: 1
concurrency:
total_llm: 4
diagnostics:
work_dir: /tmp/notarius-test
retention: always
`)
if cfg.Concurrency.TotalLLM != 4 {
t.Fatalf("unexpected total concurrency: %d", cfg.Concurrency.TotalLLM)
}
if cfg.Diagnostics.WorkDir != "/tmp/notarius-test" {
t.Fatalf("unexpected work dir: %q", cfg.Diagnostics.WorkDir)
}
if cfg.Diagnostics.Retention != diagnostics.RetentionAlways {
t.Fatalf("unexpected retention: %q", cfg.Diagnostics.Retention)
}
}
func parseAndApplyConfig(t *testing.T, raw string) Config {
t.Helper()
fileCfg, err := ParseFileConfigYAML([]byte(raw))
if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err)
}
cfg := Default()
if err := cfg.applyFileConfigWithLookup(fileCfg, emptyLookup); err != nil {
t.Fatalf("ApplyFileConfig: %v", err)
}
return cfg
}
func emptyLookup(string) (string, bool) {
return "", false
}
func mapLookup(values map[string]string) func(string) (string, bool) {
return func(key string) (string, bool) {
value, ok := values[key]
return value, ok
}
}

View File

@@ -1,42 +1,73 @@
package config
import "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
import (
"reflect"
"strings"
const redactedSecret = "[REDACTED]"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func (c Config) Redacted() Config {
redacted := cloneConfig(c)
for id, profile := range redacted.LLMProfiles {
if profile.APIKey != "" {
profile.APIKey = redactedSecret
}
redacted.LLMProfiles[id] = profile
}
return redacted
return redactConfig(cloneConfig(c))
}
func (c Config) RedactedDiagnosticsPayload() any {
func (c Config) RedactedSummaryPayload() any {
return c.Redacted()
}
func (e EffectiveConfig) RedactedDiagnosticsPayload() any {
func (e EffectiveConfig) RedactedSummaryPayload() any {
return EffectiveConfig{
Config: e.Config.Redacted(),
PipelineID: e.PipelineID,
Only: append([]string(nil), e.Only...),
ReferenceOverrides: append([]pipeline.ReferenceBinding(nil), e.ReferenceOverrides...),
ReferenceUnbinds: append([]pipeline.ReferenceUnbind(nil), e.ReferenceUnbinds...),
ResolvedPipeline: cloneResolvedPipeline(e.ResolvedPipeline),
}
}
func (e EffectiveConfig) RedactedResolvedPipelinePayload() pipeline.ResolvedPipeline {
return cloneResolvedPipeline(e.ResolvedPipeline)
}
func cloneResolvedPipeline(in pipeline.ResolvedPipeline) pipeline.ResolvedPipeline {
out := in
out.Input = cloneModuleBinding(in.Input)
out.Chunk = cloneModuleBinding(in.Chunk)
out.Output = cloneModuleBinding(in.Output)
if len(in.ArtifactLanes) > 0 {
out.ArtifactLanes = make([]pipeline.ResolvedArtifactLane, len(in.ArtifactLanes))
for i, lane := range in.ArtifactLanes {
out.ArtifactLanes[i] = cloneResolvedArtifactLane(lane)
out.Input = redactBinding(cloneModuleBinding(in.Input))
out.Chunk = redactBinding(cloneModuleBinding(in.Chunk))
out.ChunkReferences = pipeline.CloneReferenceTarget(in.ChunkReferences)
out.Output = redactBinding(cloneModuleBinding(in.Output))
if len(in.ValidatorChains) > 0 {
out.ValidatorChains = make([]pipeline.ResolvedValidatorChain, len(in.ValidatorChains))
for i, chain := range in.ValidatorChains {
out.ValidatorChains[i] = cloneResolvedValidatorChain(chain)
}
}
if len(in.Steps) > 0 {
out.Steps = make([]pipeline.ResolvedPipelineStep, len(in.Steps))
for i, step := range in.Steps {
out.Steps[i] = pipeline.ResolvedPipelineStep{ID: step.ID}
if len(step.ArtifactLanes) > 0 {
out.Steps[i].ArtifactLanes = make([]pipeline.ResolvedArtifactLane, len(step.ArtifactLanes))
for j, lane := range step.ArtifactLanes {
out.Steps[i].ArtifactLanes[j] = cloneResolvedArtifactLane(lane)
}
}
}
}
return out
}
func cloneResolvedValidatorChain(in pipeline.ResolvedValidatorChain) pipeline.ResolvedValidatorChain {
out := in
if len(in.Validators) > 0 {
out.Validators = make([]pipeline.ResolvedValidator, len(in.Validators))
for i, validator := range in.Validators {
out.Validators[i] = pipeline.ResolvedValidator{
Binding: redactBinding(cloneModuleBinding(validator.Binding)),
ExecutionClass: validator.ExecutionClass,
Target: validator.Target,
ArtifactKind: validator.ArtifactKind,
}
}
}
return out
@@ -44,14 +75,115 @@ func cloneResolvedPipeline(in pipeline.ResolvedPipeline) pipeline.ResolvedPipeli
func cloneResolvedArtifactLane(in pipeline.ResolvedArtifactLane) pipeline.ResolvedArtifactLane {
out := in
out.Extract = cloneModuleBinding(in.Extract)
out.Merge = cloneModuleBinding(in.Merge)
out.Normalize = cloneModuleBinding(in.Normalize)
out.Extract = redactBinding(cloneModuleBinding(in.Extract))
out.Merge = redactBinding(cloneModuleBinding(in.Merge))
out.Normalize = redactBinding(cloneModuleBinding(in.Normalize))
out.ExtractReferences = pipeline.CloneReferenceTarget(in.ExtractReferences)
out.MergeReferences = pipeline.CloneReferenceTarget(in.MergeReferences)
out.NormalizeReferences = pipeline.CloneReferenceTarget(in.NormalizeReferences)
if len(in.Validators) > 0 {
out.Validators = make([]pipeline.ModuleBinding, len(in.Validators))
for i, binding := range in.Validators {
out.Validators[i] = cloneModuleBinding(binding)
out.Validators[i] = redactBinding(cloneModuleBinding(binding))
}
}
return out
}
func redactConfig(cfg Config) Config {
for id, profile := range cfg.Pipelines {
profile.Input = redactBinding(profile.Input)
profile.Chunk = redactBinding(profile.Chunk)
profile.Output = redactBinding(profile.Output)
redactLanes := func(lanes map[string]pipeline.ArtifactLaneProfile) {
for laneID, lane := range lanes {
lane.Extract = redactBinding(lane.Extract)
lane.Merge = redactBinding(lane.Merge)
lane.Normalize = redactBinding(lane.Normalize)
for i := range lane.Validators {
lane.Validators[i] = redactBinding(lane.Validators[i])
}
lanes[laneID] = lane
}
}
redactLanes(profile.Artifacts)
for i := range profile.Steps {
redactLanes(profile.Steps[i].Artifacts)
}
cfg.Pipelines[id] = profile
}
return cfg
}
func redactBinding(binding pipeline.ModuleBinding) pipeline.ModuleBinding {
binding.Options = redactOptions(binding.Options)
for i := range binding.Validators.Validators {
binding.Validators.Validators[i] = redactBinding(binding.Validators.Validators[i])
}
return binding
}
func redactOptions(values map[string]any) map[string]any {
if len(values) == 0 {
return nil
}
out := make(map[string]any, len(values))
for key, value := range values {
if sensitiveConfigKey(key) {
out[key] = "[REDACTED]"
continue
}
out[key] = redactOptionValue(value)
}
return out
}
func redactOptionValue(value any) any {
if value == nil {
return nil
}
reflected := reflect.ValueOf(value)
switch reflected.Kind() {
case reflect.Map:
if reflected.Type().Key().Kind() != reflect.String {
return value
}
if reflected.IsNil() {
return nil
}
out := make(map[string]any, reflected.Len())
iterator := reflected.MapRange()
for iterator.Next() {
key := iterator.Key().String()
if sensitiveConfigKey(key) {
out[key] = "[REDACTED]"
continue
}
out[key] = redactOptionValue(iterator.Value().Interface())
}
return out
case reflect.Slice:
if reflected.IsNil() {
return nil
}
if reflected.Type().Elem().Kind() == reflect.Uint8 {
out := reflect.MakeSlice(reflected.Type(), reflected.Len(), reflected.Len())
reflect.Copy(out, reflected)
return out.Interface()
}
fallthrough
case reflect.Array:
items := make([]any, reflected.Len())
for i := 0; i < reflected.Len(); i++ {
items[i] = redactOptionValue(reflected.Index(i).Interface())
}
return items
default:
return value
}
}
func sensitiveConfigKey(key string) bool {
key = strings.ToLower(key)
return strings.Contains(key, "api_key") || strings.Contains(key, "apikey") || strings.Contains(key, "authorization") || strings.Contains(key, "bearer") || strings.Contains(key, "password") || strings.Contains(key, "secret") || strings.Contains(key, "token")
}

View File

@@ -1,101 +1,266 @@
package config
import (
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestRedactedConfigRemovesAPIKeyValues(t *testing.T) {
cfg := Default()
cfg.LLMProfiles[pipeline.DefaultLLMProfile] = LLMProfile{
Provider: "openai-compatible",
BaseURL: "https://example.invalid/v1",
Model: "test-model",
APIKey: "secret",
APIKeyEnv: "NOTARIUS_TEST_API_KEY",
TimeoutSeconds: 600,
MaxRetries: 3,
MaxConcurrency: 1,
}
cfg.LLMProfiles["other"] = LLMProfile{APIKey: "other-secret", Model: "other-model"}
redacted := cfg.Redacted()
if redacted.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != redactedSecret {
t.Fatalf("expected default API key redacted, got %+v", redacted.LLMProfiles[pipeline.DefaultLLMProfile])
}
if redacted.LLMProfiles["other"].APIKey != redactedSecret {
t.Fatalf("expected other API key redacted, got %+v", redacted.LLMProfiles["other"])
}
if redacted.LLMProfiles[pipeline.DefaultLLMProfile].Model != "test-model" {
t.Fatalf("expected non-secret fields preserved, got %+v", redacted.LLMProfiles[pipeline.DefaultLLMProfile])
}
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != "secret" {
t.Fatalf("redaction mutated original config")
}
func TestRedactedResolvedPipelinePayloadRedactsEveryBinding(t *testing.T) {
bindings := map[string]pipeline.ModuleBinding{}
for _, name := range []string{
"input", "chunk", "output", "extract", "merge", "normalize",
"resolved-validator", "lane-validator",
} {
bindings[name] = redactionTestBinding(name)
}
func TestConfigRedactedDiagnosticsPayloadRedactsAPIKeys(t *testing.T) {
cfg := Default()
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile]
profile.APIKey = "secret"
profile.Model = "test-model"
cfg.LLMProfiles[pipeline.DefaultLLMProfile] = profile
payload, ok := cfg.RedactedDiagnosticsPayload().(Config)
if !ok {
t.Fatalf("expected Config payload, got %T", cfg.RedactedDiagnosticsPayload())
}
if payload.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != redactedSecret {
t.Fatalf("expected API key redacted, got %+v", payload.LLMProfiles[pipeline.DefaultLLMProfile])
}
if payload.LLMProfiles[pipeline.DefaultLLMProfile].Model != "test-model" {
t.Fatalf("expected non-secret fields preserved, got %+v", payload.LLMProfiles[pipeline.DefaultLLMProfile])
}
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != "secret" {
t.Fatalf("redacted diagnostics payload mutated original config")
resolved := pipeline.ResolvedPipeline{
ID: "redaction-test",
Digest: "sha256:safe-digest",
Input: bindings["input"],
Chunk: bindings["chunk"],
ChunkReferences: redactionTestReferenceTarget(pipeline.StageChunk, "", "chunk-reference-content"),
Steps: []pipeline.ResolvedPipelineStep{{
ID: "default",
ArtifactLanes: []pipeline.ResolvedArtifactLane{{
ID: "safe-lane",
ArtifactKind: "safe/artifact",
Extract: bindings["extract"],
Merge: bindings["merge"],
Normalize: bindings["normalize"],
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
ExtractReferences: redactionTestReferenceTarget(pipeline.StageExtract, "safe-lane", "extract-reference-content"),
MergeReferences: redactionTestReferenceTarget(pipeline.StageMerge, "safe-lane", "merge-reference-content"),
NormalizeReferences: redactionTestReferenceTarget(pipeline.StageNormalize, "safe-lane", "normalize-reference-content"),
}},
}},
ValidatorChains: []pipeline.ResolvedValidatorChain{{
Stage: pipeline.StageExtract,
LaneID: "safe-lane",
ModuleKey: "safe-extract-owner",
Validators: []pipeline.ResolvedValidator{{
Binding: bindings["resolved-validator"],
ExecutionClass: contracts.ExecutionClassDeterministic,
Target: pipeline.ValidatorTargetTyped,
ArtifactKind: "safe/artifact",
}},
}},
Output: bindings["output"],
}
effective := EffectiveConfig{
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
"redaction-test": {Input: bindings["input"]},
}},
PipelineID: "redaction-test",
ResolvedPipeline: resolved,
}
func TestEffectiveConfigRedactedDiagnosticsPayloadRedactsAndCopies(t *testing.T) {
cfg := validConfig()
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile]
profile.APIKey = "secret"
cfg.LLMProfiles[pipeline.DefaultLLMProfile] = profile
lane := cfg.Pipelines["example"].Artifacts["events"]
lane.Extract.Options = map[string]any{"temperature": 0.2}
cfg.Pipelines["example"].Artifacts["events"] = lane
effective, err := cfg.Resolve(ResolveInput{
PipelineID: "example",
Only: []string{"events"},
Catalog: fakeCatalog(t),
})
payload := effective.RedactedResolvedPipelinePayload()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("Resolve: %v", err)
t.Fatal(err)
}
text := string(encoded)
for name := range bindings {
for _, forbidden := range []string{name + "-secret", name + "-nested-secret"} {
if strings.Contains(text, forbidden) {
t.Fatalf("resolved pipeline summary contains %q: %s", forbidden, text)
}
}
if !strings.Contains(text, name+"-safe") {
t.Fatalf("resolved pipeline summary does not retain safe option for %q: %s", name, text)
}
}
for _, content := range []string{
"chunk-reference-content", "extract-reference-content",
"merge-reference-content", "normalize-reference-content",
} {
if strings.Contains(text, content) {
t.Fatalf("resolved pipeline summary contains materialized reference content %q", content)
}
}
for _, safe := range []string{"[REDACTED]", "safe-reference-path", "safe-binding-source"} {
if !strings.Contains(text, safe) {
t.Fatalf("resolved pipeline summary does not retain %q: %s", safe, text)
}
}
payload, ok := effective.RedactedDiagnosticsPayload().(EffectiveConfig)
payload.Input.Options["safe"] = "mutated"
nested := payload.Input.Options["nested"].([]any)[0].([]any)[0].(map[string]any)
nested["neighbor"] = "mutated"
payload.ChunkReferences.ReferenceSet.Slots["safe-slot"].Items[0].Content[0] = 'X'
payload.ValidatorChains[0].Validators[0].Binding.Options["safe"] = "mutated"
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.Input, "input")
assertRedactionTestBindingUnchanged(t, effective.Config.Pipelines["redaction-test"].Input, "input")
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.ValidatorChains[0].Validators[0].Binding, "resolved-validator")
if got := string(effective.ResolvedPipeline.ChunkReferences.ReferenceSet.Slots["safe-slot"].Items[0].Content); got != "chunk-reference-content" {
t.Fatalf("source reference content mutated through redacted payload: %q", got)
}
}
func TestRedactedEffectiveConfigPayloadDoesNotAliasSource(t *testing.T) {
binding := redactionTestBinding("effective")
effective := EffectiveConfig{
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
"redaction-test": {Input: binding},
}},
ResolvedPipeline: pipeline.ResolvedPipeline{Input: binding},
}
payload := effective.RedactedSummaryPayload().(EffectiveConfig)
payload.Config.Pipelines["redaction-test"].Input.Options["safe"] = "mutated"
payload.ResolvedPipeline.Input.Options["safe"] = "mutated"
assertRedactionTestBindingUnchanged(t, effective.Config.Pipelines["redaction-test"].Input, "effective")
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.Input, "effective")
}
func TestRedactedSummaryPayloadsCoverEveryEffectiveConfigBinding(t *testing.T) {
bindings := map[string]pipeline.ModuleBinding{}
for _, name := range []string{"input", "chunk", "output", "extract", "merge", "normalize", "lane-validator"} {
bindings[name] = redactionTestBinding("summary-" + name)
}
effective := EffectiveConfig{
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
"redaction-test": {
Input: bindings["input"],
Chunk: bindings["chunk"],
Output: bindings["output"],
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"safe-lane": {
Extract: bindings["extract"],
Merge: bindings["merge"],
Normalize: bindings["normalize"],
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
},
},
},
}},
ResolvedPipeline: pipeline.ResolvedPipeline{
Input: bindings["input"],
Chunk: bindings["chunk"],
Output: bindings["output"],
Steps: []pipeline.ResolvedPipelineStep{{
ID: "default",
ArtifactLanes: []pipeline.ResolvedArtifactLane{{
ID: "safe-lane",
Extract: bindings["extract"],
Merge: bindings["merge"],
Normalize: bindings["normalize"],
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
}},
}},
},
}
payload, ok := effective.RedactedSummaryPayload().(EffectiveConfig)
if !ok {
t.Fatalf("expected EffectiveConfig payload, got %T", effective.RedactedDiagnosticsPayload())
t.Fatal("RedactedSummaryPayload() returned an unexpected type")
}
if payload.Config.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != redactedSecret {
t.Fatalf("expected nested API key redacted, got %+v", payload.Config.LLMProfiles[pipeline.DefaultLLMProfile])
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != "secret" {
t.Fatalf("redacted diagnostics payload mutated source config")
text := string(encoded)
for name := range bindings {
if strings.Contains(text, "summary-"+name+"-secret") || strings.Contains(text, "summary-"+name+"-nested-secret") {
t.Fatalf("summary payload contains sensitive option for %q: %s", name, text)
}
if !strings.Contains(text, "summary-"+name+"-safe") {
t.Fatalf("summary payload omitted safe option for %q: %s", name, text)
}
}
if !strings.Contains(text, "[REDACTED]") {
t.Fatalf("summary payload contains no redaction marker: %s", text)
}
if payload.PipelineID != effective.PipelineID || payload.ResolvedPipeline.Digest != effective.ResolvedPipeline.Digest {
t.Fatalf("expected pipeline metadata preserved, got %+v", payload)
}
payload.Only[0] = "changed"
if effective.Only[0] != "events" {
t.Fatalf("expected only lanes to be copied")
func TestRedactedResolvedPipelinePayloadHandlesTypedOptionContainers(t *testing.T) {
type optionMap map[string]string
type optionList []optionMap
typed := optionList{{
"api_key": "typed-container-secret",
"safe": "typed-container-safe",
}}
effective := EffectiveConfig{ResolvedPipeline: pipeline.ResolvedPipeline{
Input: pipeline.ModuleBinding{Options: map[string]any{"nested": typed}},
}}
payload := effective.RedactedResolvedPipelinePayload()
nested, ok := payload.Input.Options["nested"].([]any)
if !ok || len(nested) != 1 {
t.Fatalf("redacted typed list = %#v", payload.Input.Options["nested"])
}
payload.ResolvedPipeline.ArtifactLanes[0].Extract.Options["temperature"] = 1.0
if effective.ResolvedPipeline.ArtifactLanes[0].Extract.Options["temperature"] != 0.2 {
t.Fatalf("expected resolved pipeline options to be copied")
item, ok := nested[0].(map[string]any)
if !ok {
t.Fatalf("redacted typed map = %#v", nested[0])
}
if got := item["api_key"]; got != "[REDACTED]" {
t.Fatalf("redacted api_key = %v", got)
}
if got := item["safe"]; got != "typed-container-safe" {
t.Fatalf("safe option = %v", got)
}
item["safe"] = "mutated"
if got := typed[0]["safe"]; got != "typed-container-safe" {
t.Fatalf("source typed map mutated through redacted payload: %q", got)
}
}
func redactionTestBinding(name string) pipeline.ModuleBinding {
return pipeline.ModuleBinding{
Module: "safe-" + name,
Options: map[string]any{
"api_key": name + "-secret",
"safe": name + "-safe",
"nested": []any{[]any{map[string]any{
"password": name + "-nested-secret",
"neighbor": name + "-nested-safe",
}}},
},
}
}
func redactionTestReferenceTarget(stage pipeline.ModuleStage, laneID, content string) pipeline.ResolvedReferenceTarget {
return pipeline.ResolvedReferenceTarget{
Stage: stage,
LaneID: laneID,
Module: "safe-reference-module",
Bindings: []pipeline.ReferenceBinding{{
Stage: stage,
LaneID: laneID,
SlotName: "safe-slot",
Source: "safe-reference-path",
BindingSource: "safe-binding-source",
}},
ReferenceSet: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
"safe-slot": {
Slot: contracts.ReferenceSlot{Name: "safe-slot"},
Items: []contracts.ReferenceItem{{
SlotName: "safe-slot",
Content: []byte(content),
Digest: "sha256:safe-reference-digest",
BindingSource: "safe-binding-source",
}},
},
}},
}
}
func assertRedactionTestBindingUnchanged(t *testing.T, binding pipeline.ModuleBinding, name string) {
t.Helper()
if got := binding.Options["safe"]; got != name+"-safe" {
t.Fatalf("source safe option = %v, want %q", got, name+"-safe")
}
nested := binding.Options["nested"].([]any)[0].([]any)[0].(map[string]any)
if got := nested["neighbor"]; got != name+"-nested-safe" {
t.Fatalf("source nested safe option = %v, want %q", got, name+"-nested-safe")
}
}

View File

@@ -2,79 +2,84 @@ package config
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
const providerOpenAICompatible = "openai-compatible"
func (c Config) Validate() error {
if err := validateLLMProfiles(c.LLMProfiles); err != nil {
c.Concurrency.recomputeStageWorkerDefaults()
if err := validateScriptorium(c.Scriptorium); err != nil {
return err
}
if err := validateDiagnostics(c.Diagnostics); err != nil {
if err := validateStateSurfaces(c); err != nil {
return err
}
if c.Concurrency.TotalLLM <= 0 {
return fmt.Errorf("total LLM concurrency must be greater than zero")
}
return validatePipelineProfiles(c.Pipelines, c.LLMProfiles)
if err := validateStageWorkers(c.Concurrency); err != nil {
return err
}
return validatePipelineProfiles(c.Pipelines)
}
func (c Config) LLMProfile(id string) (LLMProfile, bool) {
trimmedID := strings.TrimSpace(id)
for rawID, profile := range c.LLMProfiles {
if strings.TrimSpace(rawID) == trimmedID {
return profile, true
func validateStageWorkers(cfg ConcurrencyConfig) error {
keys := make([]string, 0, len(cfg.StageWorkers))
for key := range cfg.StageWorkers {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("concurrency.stage_workers key must not be empty")
}
if key != "extract" {
return fmt.Errorf("concurrency.stage_workers key %q is not supported", key)
}
}
return LLMProfile{}, false
extractWorkers, ok := cfg.StageWorkers["extract"]
if !ok {
extractWorkers = cfg.TotalLLM
}
if extractWorkers < 1 || extractWorkers > cfg.TotalLLM {
return fmt.Errorf("concurrency.stage_workers.extract must be between 1 and concurrency.total_llm (%d)", cfg.TotalLLM)
}
return nil
}
func validateLLMProfiles(profiles map[string]LLMProfile) error {
seen := make(map[string]struct{}, len(profiles))
for rawID, profile := range profiles {
id := strings.TrimSpace(rawID)
if id == "" {
return fmt.Errorf("LLM profile id must not be empty")
func validateScriptorium(cfg ScriptoriumConfig) error {
if strings.TrimSpace(cfg.ProfileDir) != "" && strings.TrimSpace(cfg.ProfileFile) != "" {
return fmt.Errorf("scriptorium profile_dir and profile_file are mutually exclusive")
}
if _, ok := seen[id]; ok {
return fmt.Errorf("LLM profile id %q is duplicated after trimming", id)
return nil
}
seen[id] = struct{}{}
provider := strings.TrimSpace(profile.Provider)
if provider != "" && provider != providerOpenAICompatible {
return fmt.Errorf("LLM profile %q provider %q is not supported", id, provider)
func validateStateSurfaces(cfg Config) error {
if strings.TrimSpace(cfg.Output.Directory) == "" {
return fmt.Errorf("output.directory must not be empty")
}
if profile.TimeoutSeconds < 0 {
return fmt.Errorf("LLM profile %q timeout seconds must not be negative", id)
if strings.TrimSpace(cfg.Debug.Directory) == "" {
return fmt.Errorf("debug.directory must not be empty")
}
if profile.MaxRetries < 0 {
return fmt.Errorf("LLM profile %q max retries must not be negative", id)
if err := cfg.Cache.ChunkPlans.Mode.Validate(); err != nil {
return fmt.Errorf("cache.chunk_plans.mode: %w", err)
}
if profile.MaxConcurrency < 0 {
return fmt.Errorf("LLM profile %q max concurrency must not be negative", id)
for name, value := range map[string]string{
"output.directory": cfg.Output.Directory,
"cache.chunk_plans.directory": cfg.Cache.ChunkPlans.Directory,
"cache.checkpoints.directory": cfg.Cache.Checkpoints.Directory,
"debug.directory": cfg.Debug.Directory,
} {
if strings.ContainsRune(value, '\x00') {
return fmt.Errorf("%s must not contain NUL", name)
}
}
return nil
}
func validateDiagnostics(cfg DiagnosticsConfig) error {
if strings.TrimSpace(cfg.WorkDir) == "" {
return fmt.Errorf("diagnostics work dir must not be empty")
}
switch cfg.Retention {
case "", diagnostics.RetentionAuto, diagnostics.RetentionAlways, diagnostics.RetentionNever:
return nil
default:
return fmt.Errorf("diagnostics retention %q is not supported", cfg.Retention)
}
}
func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile, llmProfiles map[string]LLMProfile) error {
func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile) error {
seen := make(map[string]struct{}, len(profiles))
for rawID, profile := range profiles {
id := strings.TrimSpace(rawID)
@@ -89,65 +94,197 @@ func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile, llmP
if profile.ID != "" && strings.TrimSpace(profile.ID) != id {
return fmt.Errorf("pipeline %q profile id %q does not match map key", id, profile.ID)
}
if err := validateBindingLLMProfile(id, "", "input", profile.Input, llmProfiles); err != nil {
if err := validateBinding(id, "", "input", profile.Input, false); err != nil {
return err
}
if err := validateBindingLLMProfile(id, "", "chunk", profile.Chunk, llmProfiles); err != nil {
if err := validateBinding(id, "", "chunk", profile.Chunk, true); err != nil {
return err
}
if err := validateBindingLLMProfile(id, "", "output", profile.Output, llmProfiles); err != nil {
if err := validateBinding(id, "", "output", profile.Output, false); err != nil {
return err
}
for rawLaneID, lane := range profile.Artifacts {
if err := validateReferenceMap(id, "", profile.References); err != nil {
return err
}
explicitSteps := profile.Steps != nil
if len(profile.Artifacts) > 0 && explicitSteps {
return fmt.Errorf("pipeline %q must not declare both artifacts and steps", id)
}
steps := profile.Steps
if !explicitSteps {
steps = []pipeline.PipelineStepProfile{{ID: "default", Artifacts: profile.Artifacts}}
}
if explicitSteps && len(steps) == 0 {
return fmt.Errorf("pipeline %q must declare at least one ordered step", id)
}
seenSteps := make(map[string]struct{}, len(steps))
seenLanes := make(map[string]struct{})
for index, step := range steps {
stepID := strings.TrimSpace(step.ID)
if stepID == "" {
return fmt.Errorf("pipeline %q step[%d] id must not be empty", id, index)
}
if _, ok := seenSteps[stepID]; ok {
return fmt.Errorf("pipeline %q step id %q is duplicated after trimming", id, stepID)
}
seenSteps[stepID] = struct{}{}
if explicitSteps {
if err := validateReferenceMapForContext(id, "", "step "+stepID, step.References, true); err != nil {
return err
}
}
for rawLaneID, lane := range step.Artifacts {
laneID := strings.TrimSpace(rawLaneID)
if laneID == "" {
return fmt.Errorf("pipeline %q artifact lane id must not be empty", id)
}
if err := validateBindingLLMProfile(id, laneID, "extract", lane.Extract, llmProfiles); err != nil {
if _, ok := seenLanes[laneID]; ok {
if !explicitSteps {
return fmt.Errorf("pipeline %q artifact lane id %q is duplicated after trimming", id, laneID)
}
return fmt.Errorf("pipeline %q artifact lane id %q is duplicated across steps", id, laneID)
}
seenLanes[laneID] = struct{}{}
if err := validateReferenceMapForContext(id, laneID, "", lane.References, true); err != nil {
return err
}
if err := validateBindingLLMProfile(id, laneID, "merge", lane.Merge, llmProfiles); err != nil {
if err := validateBinding(id, laneID, "extract", lane.Extract, true); err != nil {
return err
}
if err := validateBindingLLMProfile(id, laneID, "normalize", lane.Normalize, llmProfiles); err != nil {
if err := validateBinding(id, laneID, "merge", lane.Merge, true); err != nil {
return err
}
for i, validator := range lane.Validators {
if err := validateBindingLLMProfile(id, laneID, fmt.Sprintf("validator[%d]", i), validator, llmProfiles); err != nil {
if err := validateBinding(id, laneID, "normalize", lane.Normalize, true); err != nil {
return err
}
if len(lane.Validators) > 0 {
return fmt.Errorf("pipeline %q lane %q validators are not supported at artifact lane level; use extract.validators, merge.validators, or normalize.validators", id, laneID)
}
}
}
}
return nil
}
func validateBinding(
pipelineID string,
laneID string,
slot string,
binding pipeline.ModuleBinding,
referencesAllowed bool,
) error {
if err := validateBindingLLMProfile(pipelineID, laneID, slot, binding); err != nil {
return err
}
if binding.Retries < 0 {
if laneID != "" {
return fmt.Errorf("pipeline %q lane %q %s retries must be greater than or equal to zero", pipelineID, laneID, slot)
}
return fmt.Errorf("pipeline %q %s retries must be greater than or equal to zero", pipelineID, slot)
}
if err := validateValidatorOverride(pipelineID, laneID, slot, binding.Validators); err != nil {
return err
}
if len(binding.References) == 0 {
return nil
}
if !referencesAllowed {
if laneID != "" {
return fmt.Errorf("pipeline %q lane %q %s references are not supported", pipelineID, laneID, slot)
}
return fmt.Errorf("pipeline %q %s references are not supported", pipelineID, slot)
}
return validateReferenceMapForContext(pipelineID, laneID, slot, binding.References, true)
}
func validateValidatorOverride(pipelineID string, laneID string, slot string, override pipeline.ValidatorOverride) error {
if !override.Set {
return nil
}
switch slot {
case "chunk", "extract", "merge", "normalize":
default:
return fmt.Errorf("%s validators are not supported", referenceContext(pipelineID, laneID, slot))
}
for i, validator := range override.Validators {
context := fmt.Sprintf("%s validators[%d]", referenceContext(pipelineID, laneID, slot), i)
if strings.TrimSpace(validator.Module) == "" {
return fmt.Errorf("%s module must not be empty", context)
}
if len(validator.References) > 0 {
return fmt.Errorf("%s references are not supported", context)
}
if validator.Validators.Set {
return fmt.Errorf("%s nested validators are not supported", context)
}
if validator.Retries != 0 {
return fmt.Errorf("%s retries are not supported", context)
}
if validator.LLMProfile != "" && strings.TrimSpace(validator.LLMProfile) == "" {
return fmt.Errorf("%s llm_profile must not be empty when set", context)
}
}
return nil
}
func validateReferenceMap(pipelineID string, laneID string, references map[string]pipeline.ReferenceSource) error {
return validateReferenceMapForContext(pipelineID, laneID, "", references, false)
}
func validateReferenceMapForContext(pipelineID string, laneID string, slot string, references map[string]pipeline.ReferenceSource, generatedAllowed bool) error {
seen := make(map[string]struct{}, len(references))
for rawSlotName, source := range references {
slotName := strings.TrimSpace(rawSlotName)
if slotName == "" {
return fmt.Errorf("%s reference slot name must not be empty", referenceContext(pipelineID, laneID, slot))
}
if _, ok := seen[slotName]; ok {
return fmt.Errorf("%s reference slot %q is duplicated after trimming", referenceContext(pipelineID, laneID, slot), slotName)
}
seen[slotName] = struct{}{}
if source.Artifact != nil {
if !generatedAllowed {
return fmt.Errorf("%s reference slot %q must use an external path", referenceContext(pipelineID, laneID, slot), slotName)
}
if strings.TrimSpace(source.Artifact.Step) == "" || strings.TrimSpace(source.Artifact.Lane) == "" {
return fmt.Errorf("%s reference slot %q artifact selector step and lane must not be empty", referenceContext(pipelineID, laneID, slot), slotName)
}
if strings.TrimSpace(source.Path) != "" {
return fmt.Errorf("%s reference slot %q must contain exactly one source form", referenceContext(pipelineID, laneID, slot), slotName)
}
continue
}
if strings.TrimSpace(source.Path) == "" {
return fmt.Errorf("%s reference slot %q source must not be empty", referenceContext(pipelineID, laneID, slot), slotName)
}
}
return nil
}
func referenceContext(pipelineID string, laneID string, slot string) string {
if laneID != "" && slot != "" {
return fmt.Sprintf("pipeline %q lane %q %s", pipelineID, laneID, slot)
}
if laneID != "" {
return fmt.Sprintf("pipeline %q lane %q", pipelineID, laneID)
}
if slot != "" {
return fmt.Sprintf("pipeline %q %s", pipelineID, slot)
}
return fmt.Sprintf("pipeline %q", pipelineID)
}
func validateBindingLLMProfile(
pipelineID string,
laneID string,
slot string,
binding pipeline.ModuleBinding,
profiles map[string]LLMProfile,
) error {
profileID := strings.TrimSpace(binding.LLMProfile)
if profileID == "" {
profileID = pipeline.DefaultLLMProfile
if binding.LLMProfile != "" && strings.TrimSpace(binding.LLMProfile) == "" {
if laneID != "" {
return fmt.Errorf("pipeline %q lane %q %s llm_profile must not be empty when set", pipelineID, laneID, slot)
}
return fmt.Errorf("pipeline %q %s llm_profile must not be empty when set", pipelineID, slot)
}
if hasLLMProfile(profiles, profileID) {
return nil
}
if laneID != "" {
return fmt.Errorf("pipeline %q lane %q %s references unknown LLM profile %q", pipelineID, laneID, slot, profileID)
}
return fmt.Errorf("pipeline %q %s references unknown LLM profile %q", pipelineID, slot, profileID)
}
func hasLLMProfile(profiles map[string]LLMProfile, profileID string) bool {
profileID = strings.TrimSpace(profileID)
for rawID := range profiles {
if strings.TrimSpace(rawID) == profileID {
return true
}
}
return false
}

View File

@@ -0,0 +1,446 @@
package config
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestValidateConcurrencyRules(t *testing.T) {
tests := []struct {
name string
setup func(*Config)
want string
}{
{
name: "non-positive total",
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 0
},
want: "total LLM concurrency must be greater than zero",
},
{
name: "worker below one",
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 0}
cfg.Concurrency.extractWorkersConfigured = true
},
want: "stage_workers.extract must be between 1",
},
{
name: "worker above total",
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 4}
cfg.Concurrency.extractWorkersConfigured = true
},
want: "stage_workers.extract must be between 1",
},
{
name: "worker lower boundary",
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 1}
cfg.Concurrency.extractWorkersConfigured = true
},
},
{
name: "worker upper boundary",
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 3}
cfg.Concurrency.extractWorkersConfigured = true
},
},
{
name: "unknown worker key",
setup: func(cfg *Config) {
cfg.Concurrency.StageWorkers = map[string]int{"worker": 1}
},
want: "stage_workers key \"worker\" is not supported",
},
{
name: "blank worker key",
setup: func(cfg *Config) {
cfg.Concurrency.StageWorkers = map[string]int{" ": 1}
},
want: "stage_workers key must not be empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Default()
tt.setup(&cfg)
err := cfg.Validate()
if tt.want == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Validate() error = %v, want context %q", err, tt.want)
}
})
}
}
func TestValidateScriptoriumSourcesAreMutuallyExclusive(t *testing.T) {
cfg := Default()
cfg.Scriptorium = ScriptoriumConfig{ProfileDir: "./profiles", ProfileFile: "./profile.yml"}
assertValidationContains(t, cfg, "scriptorium profile_dir and profile_file are mutually exclusive")
}
func TestValidateStateSurfaceRules(t *testing.T) {
tests := []struct {
name string
setup func(*Config)
want string
}{
{
name: "blank output root",
setup: func(cfg *Config) {
cfg.Output.Directory = " "
},
want: "output.directory must not be empty",
},
{
name: "blank debug root",
setup: func(cfg *Config) {
cfg.Debug.Directory = " "
},
want: "debug.directory must not be empty",
},
{
name: "NUL in output root",
setup: func(cfg *Config) {
cfg.Output.Directory = "./out\x00put"
},
want: "output.directory must not contain NUL",
},
{
name: "NUL in chunk plan root",
setup: func(cfg *Config) {
cfg.Cache.ChunkPlans.Directory = "./plans\x00"
},
want: "cache.chunk_plans.directory must not contain NUL",
},
{
name: "NUL in checkpoint root",
setup: func(cfg *Config) {
cfg.Cache.Checkpoints.Directory = "./checkpoints\x00"
},
want: "cache.checkpoints.directory must not contain NUL",
},
{
name: "NUL in debug root",
setup: func(cfg *Config) {
cfg.Debug.Directory = "./debug\x00"
},
want: "debug.directory must not contain NUL",
},
{
name: "invalid chunk plan mode",
setup: func(cfg *Config) {
cfg.Cache.ChunkPlans.Mode = pipeline.ChunkCacheMode("invalid")
},
want: "cache.chunk_plans.mode:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Default()
tt.setup(&cfg)
assertValidationContains(t, cfg, tt.want)
})
}
}
func TestValidateIdentifiersAfterTrimming(t *testing.T) {
tests := []struct {
name string
setup func(*Config)
want string
}{
{
name: "empty pipeline id",
setup: func(cfg *Config) {
cfg.Pipelines = map[string]pipeline.PipelineProfile{" ": {}}
},
want: "pipeline id must not be empty",
},
{
name: "duplicate pipeline ids",
setup: func(cfg *Config) {
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": {}, " main ": {}}
},
want: "pipeline id \"main\" is duplicated after trimming",
},
{
name: "empty lane id",
setup: func(cfg *Config) {
profile := validationProfile()
profile.Artifacts = map[string]pipeline.ArtifactLaneProfile{" ": {}}
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "artifact lane id must not be empty",
},
{
name: "duplicate lane ids",
setup: func(cfg *Config) {
profile := validationProfile()
profile.Artifacts = map[string]pipeline.ArtifactLaneProfile{"spells": {}, " spells ": {}}
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "artifact lane id \"spells\" is duplicated after trimming",
},
{
name: "empty reference slot",
setup: func(cfg *Config) {
profile := validationProfile()
profile.References = pipeline.ExternalReferenceMap(map[string]string{" ": "source.txt"})
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "reference slot name must not be empty",
},
{
name: "duplicate reference slots",
setup: func(cfg *Config) {
profile := validationProfile()
profile.References = pipeline.ExternalReferenceMap(map[string]string{"slot": "one.txt", " slot ": "two.txt"})
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "reference slot \"slot\" is duplicated after trimming",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Default()
tt.setup(&cfg)
assertValidationContains(t, cfg, tt.want)
})
}
}
func TestValidateBindingRetriesAndProfiles(t *testing.T) {
tests := []struct {
name string
setup func(*Config)
want string
}{
{
name: "negative retries",
setup: func(cfg *Config) {
profile := validationProfile()
profile.Input.Retries = -1
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "input retries must be greater than or equal to zero",
},
{
name: "whitespace-only input profile",
setup: func(cfg *Config) {
profile := validationProfile()
profile.Input.LLMProfile = " "
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "input llm_profile must not be empty when set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Default()
tt.setup(&cfg)
assertValidationContains(t, cfg, tt.want)
})
}
}
func TestValidateReferencesAreUnsupportedOnInputAndOutput(t *testing.T) {
tests := []struct {
name string
set func(*pipeline.PipelineProfile)
want string
}{
{
name: "input references",
set: func(profile *pipeline.PipelineProfile) {
profile.Input.References = pipeline.ExternalReferenceMap(map[string]string{"slot": "source.txt"})
},
want: "input references are not supported",
},
{
name: "output references",
set: func(profile *pipeline.PipelineProfile) {
profile.Output.References = pipeline.ExternalReferenceMap(map[string]string{"slot": "source.txt"})
},
want: "output references are not supported",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile := validationProfile()
tt.set(&profile)
cfg := Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
assertValidationContains(t, cfg, tt.want)
})
}
}
func TestValidateValidatorBindingRules(t *testing.T) {
tests := []struct {
name string
setup func(*pipeline.PipelineProfile)
want string
}{
{
name: "empty validator module",
setup: func(profile *pipeline.PipelineProfile) {
profile.Chunk.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{}},
}
},
want: "chunk validators[0] module must not be empty",
},
{
name: "validator retries",
setup: func(profile *pipeline.PipelineProfile) {
profile.Chunk.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "validator",
Retries: 1,
}},
}
},
want: "chunk validators[0] retries are not supported",
},
{
name: "validator references",
setup: func(profile *pipeline.PipelineProfile) {
profile.Chunk.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "validator",
References: pipeline.ExternalReferenceMap(map[string]string{"slot": "source.txt"}),
}},
}
},
want: "chunk validators[0] references are not supported",
},
{
name: "nested validators",
setup: func(profile *pipeline.PipelineProfile) {
profile.Chunk.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "validator",
Validators: pipeline.ValidatorOverride{Set: true},
}},
}
},
want: "chunk validators[0] nested validators are not supported",
},
{
name: "input validator chain",
setup: func(profile *pipeline.PipelineProfile) {
profile.Input.Validators = pipeline.ValidatorOverride{Set: true}
},
want: "input validators are not supported",
},
{
name: "output validator chain",
setup: func(profile *pipeline.PipelineProfile) {
profile.Output.Validators = pipeline.ValidatorOverride{Set: true}
},
want: "output validators are not supported",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile := validationProfile()
tt.setup(&profile)
cfg := Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
assertValidationContains(t, cfg, tt.want)
})
}
}
func TestValidateLaneValidatorCompatibility(t *testing.T) {
tests := []struct {
name string
lane func(*pipeline.ArtifactLaneProfile)
want string
}{
{
name: "deprecated non-empty lane validators",
lane: func(lane *pipeline.ArtifactLaneProfile) {
lane.Validators = []pipeline.ModuleBinding{{Module: "old-validator"}}
},
want: "validators are not supported at artifact lane level",
},
{
name: "stage validators omitted",
lane: func(lane *pipeline.ArtifactLaneProfile) {
},
},
{
name: "stage validators explicitly empty",
lane: func(lane *pipeline.ArtifactLaneProfile) {
lane.Extract.Validators = pipeline.ValidatorOverride{Set: true}
},
},
{
name: "stage validators configured",
lane: func(lane *pipeline.ArtifactLaneProfile) {
lane.Extract.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{Module: "validator"}},
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile := validationProfile()
lane := profile.Artifacts["lane"]
tt.lane(&lane)
profile.Artifacts["lane"] = lane
cfg := Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
err := cfg.Validate()
if tt.want == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Validate() error = %v, want context %q", err, tt.want)
}
})
}
}
func validationProfile() pipeline.PipelineProfile {
return pipeline.PipelineProfile{
ID: "main",
Input: pipeline.Binding("input"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"lane": {},
},
}
}
func assertValidationContains(t *testing.T, cfg Config, want string) {
t.Helper()
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("Validate() error = %v, want context %q", err, want)
}
}

View File

@@ -1,343 +0,0 @@
package config
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestValidateSuccessForValidConfig(t *testing.T) {
cfg := validConfig()
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
}
func TestValidateRejectsUnknownLLMProfileReferencedByBinding(t *testing.T) {
cfg := validConfig()
lane := cfg.Pipelines["example"].Artifacts["events"]
lane.Extract.LLMProfile = "missing"
cfg.Pipelines["example"].Artifacts["events"] = lane
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "unknown LLM profile") || !strings.Contains(err.Error(), "events") {
t.Fatalf("expected unknown LLM profile error with lane context, got %v", err)
}
}
func TestValidateRejectsInvalidProvider(t *testing.T) {
cfg := validConfig()
profile := cfg.LLMProfiles["default"]
profile.Provider = "unsupported"
cfg.LLMProfiles["default"] = profile
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "provider") {
t.Fatalf("expected provider error, got %v", err)
}
}
func TestValidateRejectsInvalidNumericFields(t *testing.T) {
tests := []struct {
name string
mutate func(Config) Config
want string
}{
{
name: "total concurrency",
mutate: func(cfg Config) Config {
cfg.Concurrency.TotalLLM = 0
return cfg
},
want: "total LLM concurrency",
},
{
name: "timeout",
mutate: func(cfg Config) Config {
profile := cfg.LLMProfiles["default"]
profile.TimeoutSeconds = -1
cfg.LLMProfiles["default"] = profile
return cfg
},
want: "timeout",
},
{
name: "max retries",
mutate: func(cfg Config) Config {
profile := cfg.LLMProfiles["default"]
profile.MaxRetries = -1
cfg.LLMProfiles["default"] = profile
return cfg
},
want: "max retries",
},
{
name: "max concurrency",
mutate: func(cfg Config) Config {
profile := cfg.LLMProfiles["default"]
profile.MaxConcurrency = -1
cfg.LLMProfiles["default"] = profile
return cfg
},
want: "max concurrency",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.mutate(validConfig()).Validate()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected error containing %q, got %v", tc.want, err)
}
})
}
}
func TestValidateAllowsPartialLLMProfileNumericConfig(t *testing.T) {
cfg := validConfig()
cfg.LLMProfiles["retry-only"] = LLMProfile{MaxRetries: 3}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
}
func TestValidateRejectsInvalidDiagnosticsRetention(t *testing.T) {
cfg := validConfig()
cfg.Diagnostics.Retention = diagnostics.RetentionMode("sometimes")
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "retention") {
t.Fatalf("expected retention error, got %v", err)
}
}
func TestValidateRejectsEmptyIDs(t *testing.T) {
tests := []struct {
name string
mutate func(Config) Config
want string
}{
{
name: "LLM profile",
mutate: func(cfg Config) Config {
cfg.LLMProfiles[" "] = LLMProfile{}
return cfg
},
want: "LLM profile id",
},
{
name: "pipeline",
mutate: func(cfg Config) Config {
cfg.Pipelines[" "] = pipeline.PipelineProfile{}
return cfg
},
want: "pipeline id",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.mutate(validConfig()).Validate()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected error containing %q, got %v", tc.want, err)
}
})
}
}
func TestValidateRejectsIDsDuplicatedAfterTrimming(t *testing.T) {
tests := []struct {
name string
mutate func(Config) Config
want string
}{
{
name: "LLM profile",
mutate: func(cfg Config) Config {
cfg.LLMProfiles[" default "] = cfg.LLMProfiles["default"]
return cfg
},
want: "duplicated",
},
{
name: "pipeline",
mutate: func(cfg Config) Config {
cfg.Pipelines[" example "] = cfg.Pipelines["example"]
return cfg
},
want: "duplicated",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.mutate(validConfig()).Validate()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected error containing %q, got %v", tc.want, err)
}
})
}
}
func TestValidateUsesTrimmedLLMProfileIDs(t *testing.T) {
cfg := validConfig()
cfg.LLMProfiles[" default "] = cfg.LLMProfiles["default"]
delete(cfg.LLMProfiles, "default")
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
if _, ok := cfg.LLMProfile("default"); !ok {
t.Fatalf("expected trimmed LLM profile lookup to succeed")
}
}
func validConfig() Config {
cfg := Default()
profile := cfg.LLMProfiles["default"]
profile.BaseURL = "https://example.invalid/v1"
profile.Model = "test-model"
cfg.LLMProfiles["default"] = profile
cfg.Pipelines["example"] = pipeline.PipelineProfile{
Input: pipeline.Binding("fake/input"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"events": {
Extract: pipeline.Binding("fake/extract"),
Validators: []pipeline.ModuleBinding{pipeline.Binding("fake/validator")},
},
"notes": {
Extract: pipeline.Binding("fake/extract"),
},
},
}
return cfg
}
func fakeCatalog(t *testing.T, overrides ...pipeline.ModuleSpec) pipeline.ModuleCatalog {
t.Helper()
specs := map[string]pipeline.ModuleSpec{
"fake/input": {
Key: "fake/input",
Stage: pipeline.StageInput,
Provides: []string{"source"},
},
"generic": {
Key: "generic",
Stage: pipeline.StageChunk,
Requires: []string{"source"},
Provides: []string{"chunks"},
},
"fake/extract": {
Key: "fake/extract",
Stage: pipeline.StageExtract,
Requires: []string{"chunks"},
Provides: []string{"artifact"},
},
"appendorder": {
Key: "appendorder",
Stage: pipeline.StageMerge,
Requires: []string{"artifact"},
Provides: []string{"merged"},
},
"noop": {
Key: "noop",
Stage: pipeline.StageNormalize,
Requires: []string{"merged"},
Provides: []string{"normalized"},
},
"fake/validator": {
Key: "fake/validator",
Stage: pipeline.StageValidate,
Requires: []string{"normalized"},
Provides: []string{"validated"},
},
"json": {
Key: "json",
Stage: pipeline.StageOutput,
Requires: []string{"normalized"},
},
}
for _, override := range overrides {
specs[override.Key] = override
}
inputs := pipeline.NewInputAdapterRegistry()
chunkers := pipeline.NewChunkerRegistry()
extractors := pipeline.NewExtractorRegistry()
mergers := pipeline.NewMergerRegistry()
normalizers := pipeline.NewNormalizerRegistry()
validators := pipeline.NewValidatorRegistry()
outputs := pipeline.NewOutputEncoderRegistry()
mustRegisterInput(t, inputs, specs["fake/input"])
mustRegisterChunker(t, chunkers, specs["generic"])
mustRegisterExtractor(t, extractors, specs["fake/extract"])
mustRegisterMerger(t, mergers, specs["appendorder"])
mustRegisterNormalizer(t, normalizers, specs["noop"])
mustRegisterValidator(t, validators, specs["fake/validator"])
mustRegisterOutput(t, outputs, specs["json"])
return pipeline.ModuleCatalog{
Inputs: inputs,
Chunkers: chunkers,
Extractors: extractors,
Mergers: mergers,
Normalizers: normalizers,
Validators: validators,
Outputs: outputs,
}
}
func mustRegisterInput(t *testing.T, registry *pipeline.InputAdapterRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return nil, nil }); err != nil {
t.Fatalf("register input: %v", err)
}
}
func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return nil, nil }); err != nil {
t.Fatalf("register chunker: %v", err)
}
}
func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.Extractor, error) { return nil, nil }); err != nil {
t.Fatalf("register extractor: %v", err)
}
}
func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.Merger, error) { return nil, nil }); err != nil {
t.Fatalf("register merger: %v", err)
}
}
func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.Normalizer, error) { return nil, nil }); err != nil {
t.Fatalf("register normalizer: %v", err)
}
}
func mustRegisterValidator(t *testing.T, registry *pipeline.ValidatorRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.Validator, error) { return nil, nil }); err != nil {
t.Fatalf("register validator: %v", err)
}
}
func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return nil, nil }); err != nil {
t.Fatalf("register output: %v", err)
}
}

View File

@@ -0,0 +1,91 @@
// Package debugbundle owns explicitly requested per-run debug bundles.
package debugbundle
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type Bundle struct {
path, summaryRoot, traceRoot string
createdAt time.Time
}
func Allocate(parent, runID string, startedAt time.Time) (*Bundle, error) {
parent = strings.TrimSpace(parent)
if parent == "" {
return nil, fmt.Errorf("debug parent must not be empty")
}
if err := validateRunID(runID); err != nil {
return nil, err
}
if err := os.MkdirAll(parent, 0o700); err != nil {
return nil, fmt.Errorf("create debug parent %q: %w", parent, err)
}
path := filepath.Join(parent, runID)
if err := os.Mkdir(path, 0o700); err != nil {
if os.IsExist(err) {
return nil, fmt.Errorf("debug bundle %q already exists", path)
}
return nil, fmt.Errorf("create debug bundle %q: %w", path, err)
}
summary, trace := filepath.Join(path, "summary"), filepath.Join(path, "trace")
if err := os.Mkdir(summary, 0o700); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("create debug summary %q: %w", summary, err)
}
if err := os.Mkdir(trace, 0o700); err != nil {
_ = os.RemoveAll(path)
return nil, fmt.Errorf("create debug trace %q: %w", trace, err)
}
return &Bundle{path: path, summaryRoot: summary, traceRoot: trace, createdAt: startedAt}, nil
}
func validateRunID(runID string) error {
if runID == "" {
return fmt.Errorf("debug run ID must not be empty")
}
if runID != strings.TrimSpace(runID) || strings.ContainsAny(runID, `/\\`) || filepath.IsAbs(runID) || filepath.Clean(runID) != runID || runID == "." || runID == ".." {
return fmt.Errorf("debug run ID %q must be one safe path component", runID)
}
return nil
}
func (b *Bundle) Path() string {
if b == nil {
return ""
}
return b.path
}
func (b *Bundle) SummaryRoot() string {
if b == nil {
return ""
}
return b.summaryRoot
}
func (b *Bundle) TraceRoot() string {
if b == nil {
return ""
}
return b.traceRoot
}
func (b *Bundle) RunID() string {
if b == nil {
return ""
}
return filepath.Base(b.path)
}
func (b *Bundle) CreatedAt() time.Time {
if b == nil {
return time.Time{}
}
return b.createdAt
}
func (b *Bundle) Summary() *SummaryWriter {
if b == nil {
return nil
}
return &SummaryWriter{root: b.summaryRoot, runID: b.RunID(), createdAt: b.createdAt}
}

View File

@@ -0,0 +1,163 @@
package debugbundle
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
const testBundleRunID = "run-42-00000000000000000000000000000001"
func TestAllocateCreatesRestrictiveSummaryAndTrace(t *testing.T) {
parent := t.TempDir()
fixed := time.Unix(0, 42).UTC()
bundle, err := Allocate(parent, testBundleRunID, fixed)
if err != nil {
t.Fatal(err)
}
if bundle.RunID() != testBundleRunID || bundle.CreatedAt() != fixed || bundle.SummaryRoot() != filepath.Join(bundle.Path(), "summary") || bundle.TraceRoot() != filepath.Join(bundle.Path(), "trace") {
t.Fatalf("bundle=%#v", bundle)
}
for _, path := range []string{bundle.Path(), bundle.SummaryRoot(), bundle.TraceRoot()} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o700 {
t.Fatalf("%s mode=%#o", path, info.Mode().Perm())
}
}
if err := bundle.Summary().WriteError("failed"); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(bundle.SummaryRoot(), ArtifactErrorLog))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("file mode=%#o", info.Mode().Perm())
}
}
func TestAllocateRejectsExistingBundleWithoutChangingIt(t *testing.T) {
parent := t.TempDir()
bundlePath := filepath.Join(parent, testBundleRunID)
if err := os.Mkdir(bundlePath, 0o700); err != nil {
t.Fatal(err)
}
sentinelPath := filepath.Join(bundlePath, "sentinel")
sentinel := []byte("existing bundle")
if err := os.WriteFile(sentinelPath, sentinel, 0o600); err != nil {
t.Fatal(err)
}
if _, err := Allocate(parent, testBundleRunID, time.Unix(0, 42)); err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("Allocate() error = %v, want collision", err)
}
if got, err := os.ReadFile(sentinelPath); err != nil || !bytes.Equal(got, sentinel) {
t.Fatalf("sentinel = %q, %v", got, err)
}
}
func TestAllocateRejectsUnsafeRunIDsBeforeCreatingParent(t *testing.T) {
for _, runID := range []string{"", ".", "..", "../escape", `..\\escape`, "/absolute", " trailing "} {
t.Run(runID, func(t *testing.T) {
parent := filepath.Join(t.TempDir(), "debug")
if _, err := Allocate(parent, runID, time.Time{}); err == nil {
t.Fatalf("Allocate(%q) succeeded", runID)
}
if _, err := os.Stat(parent); !os.IsNotExist(err) {
t.Fatalf("debug parent exists or stat failed: %v", err)
}
})
}
}
func TestSummaryWriterWritesEverySummaryArtifact(t *testing.T) {
bundle, err := Allocate(t.TempDir(), testBundleRunID, time.Unix(0, 42))
if err != nil {
t.Fatal(err)
}
summary := bundle.Summary()
if err := summary.WriteInvocation(Invocation{Operation: "run"}); err != nil {
t.Fatal(err)
}
if err := summary.WriteRedactedEffectiveConfig(testRedactedSummaryPayload{}); err != nil {
t.Fatal(err)
}
if err := summary.WriteResolvedPipeline(testRedactedResolvedPipelinePayload{}); err != nil {
t.Fatal(err)
}
if err := summary.WriteResolvedReferences(nil); err != nil {
t.Fatal(err)
}
if err := summary.WriteCheckpointEvents(nil); err != nil {
t.Fatal(err)
}
if err := summary.WriteRunManifest(artifacts.RunManifest{RunID: bundle.RunID()}); err != nil {
t.Fatal(err)
}
if err := summary.WriteChunkPlan(artifacts.ChunkPlanSummary{}); err != nil {
t.Fatal(err)
}
if err := summary.WriteRunReport(RunReport{RunID: bundle.RunID(), PipelineID: "test"}); err != nil {
t.Fatal(err)
}
if err := summary.WriteWarnings([]contracts.Warning{{ReasonCode: "test"}}); err != nil {
t.Fatal(err)
}
if err := summary.WriteError("failed"); err != nil {
t.Fatal(err)
}
for _, name := range []string{
ArtifactInvocationMetadata,
ArtifactEffectiveConfig,
ArtifactResolvedPipeline,
ArtifactResolvedReferences,
ArtifactCheckpointEvents,
ArtifactRunManifest,
ArtifactChunkPlan,
ArtifactRunReport,
ArtifactWarnings,
ArtifactErrorLog,
} {
info, err := os.Stat(filepath.Join(bundle.SummaryRoot(), name))
if err != nil {
t.Fatalf("summary artifact %q: %v", name, err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("summary artifact %q mode=%#o", name, info.Mode().Perm())
}
}
}
func TestSummaryWriterInternalWritesConfineArtifacts(t *testing.T) {
bundle, err := Allocate(t.TempDir(), testBundleRunID, time.Unix(0, 42))
if err != nil {
t.Fatal(err)
}
if err := bundle.Summary().writeJSON("../outside.json", map[string]any{}); err == nil {
t.Fatal("accepted traversal")
}
if err := bundle.Summary().writeBytes(`trace\\x`, []byte("x")); err == nil {
t.Fatal("accepted backslash")
}
}
type testRedactedSummaryPayload struct{}
func (testRedactedSummaryPayload) RedactedSummaryPayload() any {
return map[string]any{"redacted": true}
}
type testRedactedResolvedPipelinePayload struct{}
func (testRedactedResolvedPipelinePayload) RedactedResolvedPipelinePayload() pipeline.ResolvedPipeline {
return pipeline.ResolvedPipeline{ID: "redacted"}
}

View File

@@ -0,0 +1,128 @@
package debugbundle
import (
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
const (
ArtifactInvocationMetadata = "invocation.json"
ArtifactEffectiveConfig = "effective-config.json"
ArtifactResolvedPipeline = "resolved-pipeline.json"
ArtifactResolvedReferences = "resolved-references.json"
ArtifactCheckpointEvents = "checkpoint-events.json"
ArtifactRunManifest = "run-manifest.json"
ArtifactChunkPlan = "chunk-plan.json"
ArtifactRunReport = "run-report.json"
ArtifactWarnings = "warnings.json"
ArtifactErrorLog = "error.log"
)
type RedactedSummaryPayload interface{ RedactedSummaryPayload() any }
type RedactedResolvedPipelinePayload interface {
RedactedResolvedPipelinePayload() pipeline.ResolvedPipeline
}
type Invocation struct {
Operation string `json:"operation"`
PipelineID string `json:"pipeline_id,omitempty"`
PipelineDigest string `json:"pipeline_digest,omitempty"`
Resume bool `json:"resume,omitempty"`
RecomputeStep string `json:"recompute_step,omitempty"`
InputPath string `json:"input_path,omitempty"`
ConfigPath string `json:"config_path,omitempty"`
ConfigSource string `json:"config_source,omitempty"`
OnlyLanes []string `json:"only_lanes,omitempty"`
ChunkCacheOverride string `json:"chunk_cache_override,omitempty"`
RunID string `json:"run_id"`
StartedAt time.Time `json:"started_at"`
}
type RunReport struct {
RunID string `json:"run_id"`
PipelineID string `json:"pipeline_id"`
OutputPath string `json:"output_path,omitempty"`
DebugPath string `json:"debug_path,omitempty"`
Succeeded bool `json:"succeeded"`
OutputCount int `json:"output_count"`
RejectedCount int `json:"rejected_count"`
WarningCount int `json:"warning_count"`
ValidationStatus string `json:"validation_status,omitempty"`
}
type SummaryWriter struct {
root, runID string
createdAt time.Time
}
func (w *SummaryWriter) WriteInvocation(payload Invocation) error {
if w == nil {
return fmt.Errorf("debug summary writer must not be nil")
}
if payload.RunID == "" {
payload.RunID = w.runID
}
if payload.StartedAt.IsZero() {
payload.StartedAt = w.createdAt
}
return w.writeJSON(ArtifactInvocationMetadata, payload)
}
func (w *SummaryWriter) WriteRedactedEffectiveConfig(payload RedactedSummaryPayload) error {
if payload == nil {
return fmt.Errorf("redacted summary payload must not be nil")
}
return w.writeJSON(ArtifactEffectiveConfig, payload.RedactedSummaryPayload())
}
func (w *SummaryWriter) WriteResolvedPipeline(payload RedactedResolvedPipelinePayload) error {
if payload == nil {
return fmt.Errorf("redacted resolved pipeline payload must not be nil")
}
return w.writeJSON(ArtifactResolvedPipeline, payload.RedactedResolvedPipelinePayload())
}
func (w *SummaryWriter) WriteResolvedReferences(v []artifacts.ReferenceProvenance) error {
return w.writeJSON(ArtifactResolvedReferences, v)
}
func (w *SummaryWriter) WriteCheckpointEvents(v []pipeline.CheckpointEvent) error {
return w.writeJSON(ArtifactCheckpointEvents, v)
}
func (w *SummaryWriter) WriteRunManifest(v artifacts.RunManifest) error {
return w.writeJSON(ArtifactRunManifest, v)
}
func (w *SummaryWriter) WriteChunkPlan(v artifacts.ChunkPlanSummary) error {
return w.writeJSON(ArtifactChunkPlan, v)
}
func (w *SummaryWriter) WriteRunReport(v RunReport) error { return w.writeJSON(ArtifactRunReport, v) }
func (w *SummaryWriter) WriteWarnings(v []contracts.Warning) error {
return w.writeJSON(ArtifactWarnings, v)
}
func (w *SummaryWriter) WriteError(message string) error {
return w.writeBytes(ArtifactErrorLog, []byte(message+"\n"))
}
func (w *SummaryWriter) writeJSON(name string, v any) error {
if w == nil {
return fmt.Errorf("debug summary writer must not be nil")
}
if err := fileio.WriteJSON(w.root, summaryName(name), v, 0o700, 0o600); err != nil {
return fmt.Errorf("write debug summary artifact %q: %w", name, err)
}
return nil
}
func (w *SummaryWriter) writeBytes(name string, v []byte) error {
if w == nil {
return fmt.Errorf("debug summary writer must not be nil")
}
if err := fileio.WriteBytes(w.root, summaryName(name), v, 0o700, 0o600); err != nil {
return fmt.Errorf("write debug summary artifact %q: %w", name, err)
}
return nil
}
func summaryName(name string) string {
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, "/\\") {
return "../invalid"
}
return name
}

View File

@@ -1,12 +0,0 @@
package diagnostics
const (
ArtifactInvocationMetadata = "invocation.json"
ArtifactEffectiveConfig = "effective-config.json"
ArtifactResolvedPipeline = "resolved-pipeline.json"
ArtifactSourceDocument = "source-document.json"
ArtifactRunManifest = "run-manifest.json"
ArtifactRunReport = "run-report.json"
ArtifactWarnings = "warnings.json"
ArtifactErrorLog = "error.log"
)

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