45 Commits
v2.2.0 ... main

Author SHA1 Message Date
40cbf42438 Document Narratio v3.1.0 release
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-09-29 18:02:18 +00:00
44a318694b Forward Scriptorium credentials through the shared adapter path 2026-09-29 17:50:30 +00:00
9389f69eb0 Report missing Scriptorium credential forwarding 2026-09-29 17:40:35 +00:00
d53a64650b Document Narratio v3.0.0 migration and release
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-09-28 03:21:06 +00:00
e349bf03ca Document shared downstream development overlay workflow 2026-09-28 03:18:14 +00:00
23c4686b84 Retire completed roadmaps and retain deferred Audita work 2026-09-28 03:12:58 +00:00
4c114b6319 Adopt Notarius configuration overlays 2026-09-28 03:09:55 +00:00
3de5f9378d Update ConfigKit to v0.4.2 2026-09-28 02:42:39 +00:00
ee9a786677 Restore the verbatim BSD license reference 2026-09-28 02:27:41 +00:00
debef98536 Preserve upstream policy wording in link examples 2026-09-28 02:27:30 +00:00
e08f083ec8 Check release validation counts without requiring order 2026-09-28 02:26:45 +00:00
9bbec2fe79 Complete policy alignment roadmap audit 2026-09-28 01:54:44 +00:00
77201baddf Remove redundant CI documentation and example checks 2026-09-28 01:51:50 +00:00
ce91f04974 Align release publication policy and runbook 2026-09-28 01:50:26 +00:00
65502ce5b4 Simplify release candidate validation 2026-09-28 01:47:20 +00:00
3695303100 Complete AWS attribution and licensing navigation 2026-09-28 01:43:11 +00:00
95e865495c Attribute core modules and release Go runtime material 2026-09-28 01:38:54 +00:00
6547a8abd3 Adopt standard policy guidance and document provenance 2026-09-28 01:33:03 +00:00
b24c9a8fdf Plan policy alignment and release workflow updates 2026-09-28 01:16:40 +00:00
a5a252ab30 Expand roadmap to align the full policy set 2026-09-28 01:13:10 +00:00
2237a902b6 Adopt ConfigKit licensing update and clarify policy sources 2026-09-26 22:01:37 +00:00
f060d7eb1f Document licensing policy alignment roadmap 2026-09-26 20:45:59 +00:00
2e92f2f200 Fix license name typos in COPYRIGHT 2026-09-26 20:38:50 +00:00
7f90c70c30 Update COPYRIGHT and LICENSE contents to align with the house standard
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
2026-09-26 15:05:03 -05:00
b750c42ad6 Remove legacy manifest provenance decoding
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
2026-09-25 19:45:19 +00:00
73c4986f3d Preserve Audita module selections in YAML output 2026-09-25 19:42:22 +00:00
e5c06019b3 Record migration review findings 2026-09-25 18:28:38 +00:00
fae18bf4a7 Verify shared provider credentials across both adapters 2026-09-25 16:06:02 +00:00
e5c1029165 Document Audita v2 operator migration and examples 2026-09-25 16:00:27 +00:00
2d167d0d67 Track Audita v2 selections in polish resume evidence 2026-09-25 15:55:38 +00:00
569f467393 Archive Audita reports as polish diagnostics 2026-09-25 15:51:18 +00:00
77d9d57937 Adopt Audita v2 configuration and subprocess contract 2026-09-25 15:45:02 +00:00
7e0151ef93 Migrate Notarius to credential lists 2026-09-25 15:33:46 +00:00
49948852d6 Add shared resolved credential forwarding 2026-09-25 15:26:51 +00:00
83eaa960ec Adopt ConfigKit overlays for pipeline selection 2026-09-25 15:24:03 +00:00
e27c49e946 Track repository agent instructions 2026-09-25 15:11:56 +00:00
625e6edfd7 Align repository policies with shared standards 2026-09-25 15:11:08 +00:00
17cef0700a Add ConfigKit overlay migration to the plan 2026-09-25 15:04:55 +00:00
55c102c9ed Plan the Audita v2 migration 2026-09-25 14:50:25 +00:00
70f1dbfb59 Prepare the v2.2.1 release
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-09-22 18:18:45 +00:00
f1575effc2 Document shared checkpoint persistence 2026-09-22 15:13:46 +00:00
7e39a1d51f Delegate transcription checkpoints to shared persistence 2026-09-22 15:12:11 +00:00
3722e572df Delegate analyze checkpoints to shared persistence 2026-09-22 15:09:29 +00:00
7147e95252 Add shared stage checkpoint persistence 2026-09-22 15:05:25 +00:00
9f2cfddce6 Plan shared stage checkpoint mechanics 2026-09-22 14:54:34 +00:00
130 changed files with 6253 additions and 3767 deletions

2
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# ---> Codex # ---> Codex
.codex .codex
AGENTS.md
.DS_Store .DS_Store
@@ -52,4 +51,3 @@ pipeline.yml
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix

View File

@@ -25,16 +25,9 @@ steps:
commands: commands:
- go build ./... - go build ./...
documentation-and-examples:
image: golang:1.26
depends_on: tests
commands:
- go test ./internal/doccheck
- go test ./internal/config -run '^TestExamplesLoadAndValidate$'
cross-build: cross-build:
image: golang:1.26 image: golang:1.26
depends_on: [race-tests, static-analysis, build, documentation-and-examples] depends_on: [race-tests, static-analysis, build]
commands: commands:
- | - |
set -eu set -eu

30
AGENTS.md Normal file
View File

@@ -0,0 +1,30 @@
Start with `docs/development.md` for orientation and use its policy reading guide
to select the policies applicable to the task. Do not read the entire suite by
default.
Read and follow `docs/policy/agents.md` before recommending, planning, or
implementing repository changes.
<!-- codebase-memory-mcp:start -->
# Codebase Knowledge Graph (codebase-memory-mcp)
This project uses codebase-memory-mcp to maintain a knowledge graph of the codebase.
ALWAYS prefer MCP graph tools over grep/glob/file-search for code discovery.
## Priority Order
1. `search_graph` — find functions, classes, routes, variables by pattern
2. `trace_path` — trace who calls a function or what it calls
3. `get_code_snippet` — read specific function/class source code
4. `query_graph` — run Cypher queries for complex patterns
5. `get_architecture` — high-level project summary
## When to fall back to grep/glob
- Searching for string literals, error messages, config values
- Searching non-code files (Dockerfiles, shell scripts, configs)
- When MCP tools return insufficient results
## Examples
- Find a handler: `search_graph(name_pattern=".*OrderHandler.*")`
- Who calls it: `trace_path(function_name="OrderHandler", direction="inbound")`
- Read source: `get_code_snippet(qualified_name="pkg/orders.OrderHandler")`
<!-- codebase-memory-mcp:end -->

23
COPYRIGHT Normal file
View File

@@ -0,0 +1,23 @@
Narratio
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: GPL-3.0-or-later
Narratio is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
The GNU General Public License version 3 is provided in LICENSE.
Narratio is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
Third-party code and assets are subject to their respective license terms.
Policy documents adapted from Standard Repository Policies remain licensed
under CC-BY-SA-4.0. See docs/oss/attribution.md for the affected material,
upstream credit, and license links.

890
LICENSE
View File

@@ -1,232 +1,674 @@
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. of this license document, but changing it is not allowed.
Preamble Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works. The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
The licenses for most software and other practical works are designed
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. your programs, too.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
The precise terms and conditions for copying, distribution and modification follow.
To protect your rights, we need to prevent others from denying you
TERMS AND CONDITIONS these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
0. Definitions. you modify it: responsibilities to respect the freedom of others.
“This License” refers to version 3 of the GNU General Public License. For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. know their rights.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
A “covered work” means either the unmodified Program or a work based on the Program. giving you legal permission to copy, distribute and/or modify it.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. authors of previous versions.
1. Source Code. Some devices are designed to deny users access to install or run
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. of the GPL, as needed to protect the freedom of users.
The Corresponding Source for a work in source code form is that same work. Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
2. Basic Permissions. software on general-purpose computers, but in those that do, we wish to
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. patents cannot be used to render the program non-free.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. The precise terms and conditions for copying, distribution and
modification follow.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. TERMS AND CONDITIONS
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 0. Definitions.
4. Conveying Verbatim Copies. "This License" refers to version 3 of the GNU General Public License.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
"Copyright" also means copyright-like laws that apply to other kinds of
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. works, such as semiconductor masks.
5. Conveying Modified Source Versions. "The Program" refers to any copyrightable work licensed under this
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
To "modify" a work means to copy from or adapt all or part of the work
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. earlier work or a work "based on" the earlier work.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A "covered work" means either the unmodified Program or a work based
on the Program.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
To "propagate" a work means to do anything with it that, without
6. Conveying Non-Source Forms. permission, would make you directly or secondarily liable for
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. distribution (with or without modification), making available to the
public, and in some countries other activities as well.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
To "convey" a work means any kind of propagation that enables other
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
An interactive user interface displays "Appropriate Legal Notices"
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. menu, a prominent item in the list meets this criterion.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). 1. Source Code.
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. form of a work.
7. Additional Terms. A "Standard Interface" means an interface that either is an official
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. is widely used among developers working in that language.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or "Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or (if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
The "Corresponding Source" for a work in object code form means all
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
8. Termination. linked subprograms that the work is specifically designed to require,
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). such as by intimate data communication or control flow between those
subprograms and other parts of the work.
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
The Corresponding Source need not include anything that users
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. can regenerate automatically from other parts of the Corresponding
Source.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
The Corresponding Source for a work in source code form is that
9. Acceptance Not Required for Having Copies. same work.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
2. Basic Permissions.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
11. Patents. rights of fair use or other equivalent, as provided by copyright law.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
You may make, run and propagate covered works that you do not
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. your copyrighted material outside their relationship with you.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. makes it unnecessary.
12. No Surrender of Others' Freedom. 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
No covered work shall be deemed part of an effective technological
13. Use with the GNU Affero General Public License. measure under any applicable law fulfilling obligations under article
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
14. Revised Versions of this License. measures.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
When you convey a covered work, you waive any legal power to forbid
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. users, your or third parties' legal rights to forbid circumvention of
technological measures.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 4. Conveying Verbatim Copies.
16. Limitation of Liability. You may convey verbatim copies of the Program's source code as you
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
17. Interpretation of Sections 15 and 16. keep intact all notices stating that this License and any
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
END OF TERMS AND CONDITIONS recipients a copy of this License along with the Program.
How to Apply These Terms to Your New Programs You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
5. Conveying Modified Source Versions.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
You may convey a work based on the Program, or the modifications to
narratio produce it from the Program, in the form of source code under the
Copyright (C) 2026 eric terms of section 4, provided that you also meet all of these conditions:
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
b) The work must carry prominent notices stating that it is
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
narratio Copyright (C) 2026 eric <program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>. You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>. The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -40,3 +40,7 @@ This requires resolvable `pipeline.yml`, `campaign.yml`, and concrete
and maintenance rules. and maintenance rules.
- [Testing policy](docs/policy/testing.md) — test value, boundaries, and - [Testing policy](docs/policy/testing.md) — test value, boundaries, and
sufficiency. sufficiency.
Narratio is licensed under the [GPL v3.0 or later](COPYRIGHT).
[Open Source attribution notices](docs/oss/attribution.md) are available.

View File

@@ -44,7 +44,7 @@ Most session-aware commands accept:
- `--session <session.yml>` - `--session <session.yml>`
- `--session-id <session_id>` - `--session-id <session_id>`
- `--previous-session-id <session_id>` - `--previous-session-id <session_id>`
- `--profile <name>` - `--overlay <name>`
- `--secrets-dir <directory>` - `--secrets-dir <directory>`
Rules: Rules:
@@ -54,9 +54,9 @@ Rules:
- if both positional `<session_id>` and `--session-id` are provided, values must match. - if both positional `<session_id>` and `--session-id` are provided, values must match.
- `--previous-session-id` is a strict expectation: the selected session file - `--previous-session-id` is a strict expectation: the selected session file
must contain the same `previous_session_id`. must contain the same `previous_session_id`.
- `--profile` selects `profiles/<name>.yml` relative to the pipeline root. It - `--overlay` selects `overlays/<name>.yml` relative to the pipeline root. It
may be supplied once; omitted, empty, and the exact name `default` select the may be supplied once; omitted, empty, and the exact name `default` select the
base configuration, while an unknown named profile fails. The same selection base configuration, while an unknown named overlay fails. The same selection
applies to all common-flag commands, including `regenerate-artifacts`. applies to all common-flag commands, including `regenerate-artifacts`.
- `--secrets-dir` selects ConfigKit credential-file bootstrap explicitly. A - `--secrets-dir` selects ConfigKit credential-file bootstrap explicitly. A
relative value uses caller CWD. Without the flag, a present `secrets/` relative value uses caller CWD. Without the flag, a present `secrets/`
@@ -85,20 +85,20 @@ Commands with additional positionals keep their command-specific order:
### `config validate`, `config show`, `config sources`, `config artifacts`, and `config diff` ### `config validate`, `config show`, `config sources`, `config artifacts`, and `config diff`
```bash ```bash
narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>] narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>] narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>] narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
narratio config artifacts [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>] narratio config artifacts [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <directory>] narratio config diff <left-overlay> <right-overlay> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <directory>]
``` ```
These commands resolve the selected profile, defaults, ordinary paths, and—if These commands resolve the selected overlay, defaults, ordinary paths, and—if
a campaign is selected—the campaign-owned party. They neither discover or load a campaign is selected—the campaign-owned party. They neither discover or load
a session nor create a workspace, manifest, run, lock, adapter, or remote a session nor create a workspace, manifest, run, lock, adapter, or remote
connection. Like every pipeline-loading command, they bootstrap the selected or connection. Like every pipeline-loading command, they bootstrap the selected or
conventional secrets directory before resolving configuration; `config show` conventional secrets directory before resolving configuration; `config show`
never includes bootstrap reports or secret values in its YAML output. never includes bootstrap reports or secret values in its YAML output.
Typed credential references, including `notarius.llm_api_key`, must therefore Typed credential references, including `notarius.credentials[]`, must therefore
resolve even though these inspection commands never construct an adapter. resolve even though these inspection commands never construct an adapter.
Campaign selection is optional for a pipeline without party-driven artifact Campaign selection is optional for a pipeline without party-driven artifact
@@ -107,7 +107,7 @@ configured default campaign so Narratio can expand its concrete artifacts and
publish rules. `--campaign` and `--campaign-file` remain mutually exclusive. publish rules. `--campaign` and `--campaign-file` remain mutually exclusive.
Session, range, force, and artifact-execution flags are not accepted. Session, range, force, and artifact-execution flags are not accepted.
`config validate` writes a concise root-path, selected-profile (or `none`), and `config validate` writes a concise root-path, selected-overlay (or `none`), and
effective-digest summary after successful complete validation. `config show` effective-digest summary after successful complete validation. `config show`
writes one deterministic, secret-free YAML document containing defaulted and writes one deterministic, secret-free YAML document containing defaulted and
expanded concrete configuration. It omits ConfigKit directives, artifact expanded concrete configuration. It omits ConfigKit directives, artifact
@@ -115,9 +115,9 @@ family declarations, and runtime provenance.
`config sources` reports the same fully validated resolution without printing `config sources` reports the same fully validated resolution without printing
effective values. Its header identifies the root, ordered imports, selected effective values. Its header identifies the root, ordered imports, selected
profile and overlay, selected campaign, party source, and digest. The overlay and its file, selected campaign, party source, and digest. The
remaining tab-separated records are sorted as `path`, `role`, and `source`. remaining tab-separated records are sorted as `path`, `role`, and `source`.
Roles distinguish root, import, profile, centralized default, campaign, party, Roles distinguish root, import, overlay, centralized default, campaign, party,
and generated family ownership. A generated party member has and generated family ownership. A generated party member has
one family record and one party record at the same logical path. The output one family record and one party record at the same logical path. The output
never reads or prints secret values. never reads or prints secret values.
@@ -133,14 +133,14 @@ party-driven families retain the campaign requirement. See
[Scriptorium Artifact Entries](./config.md#scriptorium-artifact-entries) for the [Scriptorium Artifact Entries](./config.md#scriptorium-artifact-entries) for the
configuration layout. configuration layout.
`config diff` resolves both supplied profile names independently and compares `config diff` resolves both supplied overlay names independently and compares
their fully resolved, secret-free effective mappings. It does their fully resolved, secret-free effective mappings. It does
not accept `--profile`; the two positional names must be distinct, declared not accept `--overlay`; the two positional names must be distinct, valid
profiles. When party-driven families are present, both profiles must resolve to overlays. When party-driven families are present, both overlays must resolve to
the same selected campaign and party. Use `--campaign-file` if profile-specific the same selected campaign and party. Use `--campaign-file` if overlay-specific
campaign configuration would otherwise select different files. campaign configuration would otherwise select different files.
Equal profiles print `no differences`. Otherwise, sorted tab-separated records Equal overlays print `no differences`. Otherwise, sorted tab-separated records
use one of these forms, with compact JSON values: use one of these forms, with compact JSON values:
```text ```text
@@ -151,7 +151,7 @@ changed <path> <left-value> <right-value>
Mappings are flattened to their logical field paths; lists remain one atomic Mappings are flattened to their logical field paths; lists remain one atomic
value. The command compares defaulted concrete artifacts and publish rules, not value. The command compares defaulted concrete artifacts and publish rules, not
profile names, source-file layout, or formatting. It succeeds when differences overlay names, source-file layout, or formatting. It succeeds when differences
are found, making it suitable for review and migration checks. are found, making it suitable for review and migration checks.
### `version` ### `version`
@@ -178,7 +178,7 @@ Behavior:
- runs `render` before `extract`; an omitted or disabled Notarius - runs `render` before `extract`; an omitted or disabled Notarius
configuration records an explicit `notarius_disabled` self-skip; configuration records an explicit `notarius_disabled` self-skip;
- resolves every selected typed credential during the initial configuration - resolves every selected typed credential during the initial configuration
load, including a Notarius credential when extract is disabled, outside the load, including Notarius credentials when extract is disabled, outside the
selected range, or later skipped as resumable; selected range, or later skipped as resumable;
- skips already-succeeded stages unless `--force` is set or a stage-specific - skips already-succeeded stages unless `--force` is set or a stage-specific
resume check finds its durable result obsolete; resume check finds its durable result obsolete;
@@ -189,7 +189,7 @@ Behavior:
- writes the session manifest as the sole durable progress ledger. Each - writes the session manifest as the sole durable progress ledger. Each
invocation also creates its run-local directory and returns an immediate invocation also creates its run-local directory and returns an immediate
command summary; neither creates another durable progress record. command summary; neither creates another durable progress record.
- reports the resolved profile (or `none`) and effective configuration digest. - reports the resolved overlay (or `none`) and effective configuration digest.
When `--artifacts` is present, the selected range must contain `analyze` or When `--artifacts` is present, the selected range must contain `analyze` or
`publish`. Either consumer is sufficient, including a one-stage range. `publish`. Either consumer is sufficient, including a one-stage range.
@@ -304,7 +304,7 @@ Uses the same inclusive bounds, endpoint validation, force scope, and artifact
selection contract as `run`. It validates config and prints run/skip decisions selection contract as `run`. It validates config and prints run/skip decisions
for selected stages only without creating the local workdir or changing the for selected stages only without creating the local workdir or changing the
manifest. Resume-capable selected stages are checked against durable evidence. manifest. Resume-capable selected stages are checked against durable evidence.
The output includes the resolved profile (or `none`) and effective configuration The output includes the resolved overlay (or `none`) and effective configuration
digest without writing provenance or any manifest state. digest without writing provenance or any manifest state.
For `analyze`, the preview also lists explicit targets, prerequisite-only work, For `analyze`, the preview also lists explicit targets, prerequisite-only work,
execution order, and reusable current artifacts with concise reasons. These execution order, and reusable current artifacts with concise reasons. These

View File

@@ -47,24 +47,24 @@ the durable copied session input.
`narratio config validate`, `narratio config show`, `narratio config sources`, `narratio config validate`, `narratio config show`, `narratio config sources`,
and `narratio config artifacts` use the same `--config`, `--campaign`, and `narratio config artifacts` use the same `--config`, `--campaign`,
`--campaign-file`, and `--campaign-file`, and
`--profile` selection rules as pipeline commands, but do not select, discover, `--overlay` selection rules as pipeline commands, but do not select, discover,
or load a session. They resolve configured typed credential references as part or load a session. They resolve configured typed credential references as part
of the ordinary ConfigKit load but expose only their environment-variable names; of the ordinary ConfigKit load but expose only their environment-variable names;
they do not create runtime state. they do not create runtime state.
`narratio config diff <left-profile> <right-profile>` uses the same pipeline and `narratio config diff <left-overlay> <right-overlay>` uses the same pipeline and
campaign selectors, resolves each named profile with an independent ConfigKit campaign selectors, resolves each named overlay with an independent ConfigKit
load, and does not accept a separate `--profile` flag. load, and does not accept a separate `--overlay` flag.
Campaign selection is optional only when the resolved pipeline has no Campaign selection is optional only when the resolved pipeline has no
`scriptorium.artifact_families`. When families are declared, Narratio selects a `scriptorium.artifact_families`. When families are declared, Narratio selects a
campaign through an explicit flag or `pipeline.campaigns.default_campaign_id`, campaign through an explicit flag or `pipeline.campaigns.default_campaign_id`,
then parses the campaign-owned party and expands concrete artifacts and any then parses the campaign-owned party and expands concrete artifacts and any
family publish rules before validation. `config validate` prints the resulting family publish rules before validation. `config validate` prints the resulting
root, profile, and effective digest. `config show` emits the normalized root, overlay, and effective digest. `config show` emits the normalized
effective pipeline YAML, with defaults and concrete expansion included but effective pipeline YAML, with defaults and concrete expansion included but
ConfigKit directives and family declarations omitted. This review projection is ConfigKit directives and family declarations omitted. This review projection is
not promised to be a reloadable authoring document. `config sources` prints a stable not promised to be a reloadable authoring document. `config sources` prints a stable
source projection instead of effective values: root/import/profile/default source projection instead of effective values: root/import/overlay/default
ownership plus campaign/party and generated-family records. Canonical derived ownership plus campaign/party and generated-family records. Canonical derived
players trace to the party. `config artifacts` lists the concrete artifact map, players trace to the party. `config artifacts` lists the concrete artifact map,
effective dependencies, and defining sources. The [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff) effective dependencies, and defining sources. The [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
@@ -83,7 +83,7 @@ difference.
Use the [production/testing bundle](../examples/production-testing/pipeline.yml) Use the [production/testing bundle](../examples/production-testing/pipeline.yml)
as the complete copyable migration reference. Keep production settings in the as the complete copyable migration reference. Keep production settings in the
base, split them into explicit additive imports, and place testing differences base, split them into explicit additive imports, and place testing differences
in the fixed `profiles/testing.yml` overlay. Put ordinary artifacts and families in the fixed `overlays/testing.yml` overlay. Put ordinary artifacts and families
in the recommended one-definition files shown by that bundle. Convert campaign in the recommended one-definition files shown by that bundle. Convert campaign
rosters to [canonical party input](integrations/party.md), then express character rosters to [canonical party input](integrations/party.md), then express character
work as families. Inspect the result with `config validate`, `config show`, work as families. Inspect the result with `config validate`, `config show`,
@@ -108,11 +108,14 @@ remote state with an unsafe legacy identity must be migrated before use.
root-only `configkit.imports` list. Imported files contribute fields to one root-only `configkit.imports` list. Imported files contribute fields to one
logical pipeline document; they do not override fields supplied by the root logical pipeline document; they do not override fields supplied by the root
or another import. or another import.
- An omitted profile, an empty profile option, and the exact name `default` - An omitted overlay, an empty overlay option, and the exact name `default`
select the base configuration. Any other name selects select the base configuration. Any other name selects
`profiles/<name>.yml` relative to the pipeline root. `overlays/<name>.yml` relative to the pipeline root. Move previously selected
`profiles/<name>.yml` files there; the old directory is not searched, and
`--profile` is no longer accepted for Narratio pipeline selection. Explicit
imports from a directory named `profiles` remain valid.
- Campaign, session, and canonical-party documents are strict single-source - Campaign, session, and canonical-party documents are strict single-source
ConfigKit schemas. They do not enable imports, profiles, or `configkit` ConfigKit schemas. They do not enable imports, overlays, or `configkit`
directives. directives.
- Configured timeout and retry-delay durations must be positive. An omitted - Configured timeout and retry-delay durations must be positive. An omitted
artifact timeout continues to inherit its configured Scriptorium timeout. artifact timeout continues to inherit its configured Scriptorium timeout.
@@ -137,10 +140,10 @@ remote state with an unsafe legacy identity must be migrated before use.
- local (`audio_dir` or `audio_files`), or - local (`audio_dir` or `audio_files`), or
- S3 (`audio_s3.prefix`). - S3 (`audio_s3.prefix`).
### Pipeline imports and profiles ### Pipeline imports and overlays
Large pipeline configurations may be split into explicitly named fragments and Large pipeline configurations may be split into explicitly named fragments and
may use fixed-path profile overlays: may use fixed-path selected overlays:
```yaml ```yaml
configkit: configkit:
@@ -170,12 +173,12 @@ name the full field path and every source that claimed it. Narratio additionally
rejects an artifact or artifact-family key declared by more than one base file, rejects an artifact or artifact-family key declared by more than one base file,
even when those files contribute disjoint children. even when those files contribute disjoint children.
Profile names are case-sensitive and use ConfigKit's safe fixed-path naming Overlay names are case-sensitive and use ConfigKit's safe fixed-path naming
rules. Only the selected overlay is opened; a malformed unselected overlay does rules. Only the selected overlay is opened; a malformed unselected overlay does
not affect a base or different-profile load. Maps merge recursively, overlay not affect a base or different-overlay load. Maps merge recursively, overlay
scalars replace base scalars, and overlay lists replace base lists completely. scalars replace base scalars, and overlay lists replace base lists completely.
Explicit `false`, zero, empty strings, empty containers, and null values remain Explicit `false`, zero, empty strings, empty containers, and null values remain
explicit inputs and are not refilled by defaults. Profiles cannot inherit, explicit inputs and are not refilled by defaults. Overlays cannot inherit,
stack, import files, or declare another ConfigKit control section. stack, import files, or declare another ConfigKit control section.
During ConfigKit loading, Narratio applies fresh typed defaults, recorded During ConfigKit loading, Narratio applies fresh typed defaults, recorded
@@ -183,7 +186,7 @@ entry-level defaults and normalization, read-only file-local validation, and
ordinary path resolution before computing a deterministic effective ordinary path resolution before computing a deterministic effective
configuration digest. The digest represents the normalized, secret-free configuration digest. The digest represents the normalized, secret-free
runtime pipeline mapping; it excludes ConfigKit directives, source runtime pipeline mapping; it excludes ConfigKit directives, source
provenance, profile identity, and raw environment secret values. Equivalent provenance, overlay identity, and raw environment secret values. Equivalent
effective mappings therefore have the same digest regardless of how fields are effective mappings therefore have the same digest regardless of how fields are
split among the root and imports. split among the root and imports.
@@ -227,7 +230,7 @@ inputs:
- Do not place raw secrets in YAML. - Do not place raw secrets in YAML.
- Credential fields contain environment-variable names, represented internally - Credential fields contain environment-variable names, represented internally
as ConfigKit typed secret references (for example as ConfigKit typed secret references (for example
`pipeline.notarius.llm_api_key: NOTARIUS_LLM_API_KEY`). A configured reference `pipeline.notarius.credentials: [NOTARIUS_LLM_API_KEY]`). A configured reference
must resolve to a nonempty environment value during load. must resolve to a nonempty environment value during load.
- Every surviving reference is resolved during ordinary pipeline loading, - Every surviving reference is resolved during ordinary pipeline loading,
regardless of whether its integration is disabled, outside the selected regardless of whether its integration is disabled, outside the selected
@@ -331,19 +334,14 @@ Rules:
| `pipeline.seriatim.env.filler_max_duration` | float | No | unset | | `pipeline.seriatim.env.filler_max_duration` | float | No | unset |
| `pipeline.audita.binary` | string | No | `audita` | | `pipeline.audita.binary` | string | No | `audita` |
| `pipeline.audita.timeout` | duration | No | `3h` | | `pipeline.audita.timeout` | duration | No | `3h` |
| `pipeline.audita.llm_api_key` | secret reference | No | unset | | `pipeline.audita.credentials[]` | list[secret reference] | No | empty; exact environment-variable names forwarded to Audita |
| `pipeline.audita.modules[]` | list[string] | No | empty | | `pipeline.audita.modules[]` | list[string] | No | unset; omit to use Audita defaults, set `[]` for no modules |
| `pipeline.audita.base_url` | string | No | empty | | `pipeline.audita.overlay` | string | No | empty; no downstream overlay override |
| `pipeline.audita.model` | string | No | empty | | `pipeline.audita.proposal_profile` | string | No | empty; no downstream proposal-profile override |
| `pipeline.audita.total_llm_concurrency` | int | No | unset | | `pipeline.audita.validation_profile` | string | No | empty; no downstream validation-profile override |
| `pipeline.audita.proposal_llm_concurrency` | int | No | unset |
| `pipeline.audita.validation_model` | string | No | empty |
| `pipeline.audita.validation_llm_concurrency` | int | No | unset |
| `pipeline.audita.transcript_description` | string | No | empty | | `pipeline.audita.transcript_description` | string | No | empty |
| `pipeline.audita.config_path` | string | No | empty | | `pipeline.audita.config_path` | string | No | empty |
| `pipeline.audita.output_schema` | string | No | empty | | `pipeline.audita.output_schema` | string | No | empty |
| `pipeline.audita.work_dir_retention` | string | No | empty |
| `pipeline.audita.report` | bool | No | `true` |
| `pipeline.normalize.output_path` | string | No | `transcripts/final.json` | | `pipeline.normalize.output_path` | string | No | `transcripts/final.json` |
| `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` | | `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` |
| `pipeline.normalize.report` | bool | No | `true` | | `pipeline.normalize.report` | bool | No | `true` |
@@ -359,8 +357,8 @@ Rules:
| `pipeline.trim.seriatim.report` | bool | No | `false` | | `pipeline.trim.seriatim.report` | bool | No | `false` |
| `pipeline.notarius.enabled` | bool | No | `false` | | `pipeline.notarius.enabled` | bool | No | `false` |
| `pipeline.notarius.binary` | string | No | `notarius` | | `pipeline.notarius.binary` | string | No | `notarius` |
| `pipeline.notarius.llm_api_key` | secret reference | No | unset; exact environment-variable name forwarded to Notarius | | `pipeline.notarius.credentials[]` | list[secret reference] | No | empty; exact environment-variable names forwarded to Notarius |
| `pipeline.notarius.config_profile` | string | No | unset; exact Notarius ConfigKit profile selector | | `pipeline.notarius.config_overlay` | string | No | unset; exact Notarius ConfigKit overlay selector |
| `pipeline.notarius.config_path` | string | Conditional | required when enabled; relative paths resolve from the pipeline file directory | | `pipeline.notarius.config_path` | string | Conditional | required when enabled; relative paths resolve from the pipeline file directory |
| `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled | | `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled |
| `pipeline.notarius.timeout` | duration | No | `3h`; must be positive | | `pipeline.notarius.timeout` | duration | No | `3h`; must be positive |
@@ -376,6 +374,7 @@ Rules:
| `pipeline.render.include_segment_ids` | bool | No | `true` | | `pipeline.render.include_segment_ids` | bool | No | `true` |
| `pipeline.render.include_metadata` | bool | No | `false` | | `pipeline.render.include_metadata` | bool | No | `false` |
| `pipeline.scriptorium.binary` | string | No | `scriptorium` | | `pipeline.scriptorium.binary` | string | No | `scriptorium` |
| `pipeline.scriptorium.credentials[]` | list[secret reference] | No | empty; exact environment-variable names forwarded to Scriptorium |
| `pipeline.scriptorium.config_path` | string | No | empty | | `pipeline.scriptorium.config_path` | string | No | empty |
| `pipeline.scriptorium.timeout` | duration | No | `10m` | | `pipeline.scriptorium.timeout` | duration | No | `10m` |
| `pipeline.scriptorium.render_debug` | bool | No | `false` | | `pipeline.scriptorium.render_debug` | bool | No | `false` |
@@ -383,55 +382,93 @@ Rules:
| `pipeline.scriptorium.artifact_families` | map | No | empty; expands one ordinary artifact per canonical party character | | `pipeline.scriptorium.artifact_families` | map | No | empty; expands one ordinary artifact per canonical party character |
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented | | `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
### Notarius Configuration Profile ### Audita Selection and Credentials
`pipeline.notarius.config_profile` optionally selects a Notarius ConfigKit `pipeline.audita.config_path` optionally passes an Audita v2 configuration file;
configuration profile when extraction executes: when empty, Audita uses its own native config discovery. `overlay`,
`proposal_profile`, and `validation_profile` are independent, optional Audita
selectors. Empty or omitted strings add no command-line override. Narratio's
selected pipeline overlay is never inferred as an Audita overlay, and Narratio
does not create or validate Audita's backend or PromptKit profile files. Prepare
them with [Audita's v2 migration guide](https://gitea.maximumdirect.net/eric/audita/src/tag/v2.0.0/docs/migrations/v2.md).
Omit `modules` or set it to `null` to use Audita's configured default sequence.
Set `modules: []` to run no correction modules. A nonempty list is passed in
order, including repetitions. Narratio does not add a reporting or debug flag;
Audita's external configuration controls those settings.
`pipeline.audita.credentials` lists secret-reference names, not values. Narratio
resolves every listed name during pipeline loading and forwards the captured
nonempty value under that exact name to Audita. It does not inspect Audita's
configuration to discover names or map them to aliases. Audita v2 resolves
credentials for **all configured backends**, even ones not selected by the
current profiles: list every named backend credential in the selected Audita
configuration. An unauthenticated backend needs no reference. Omission, `null`,
or `[]` forwards nothing. A Narratio overlay replaces the entire list, and
`[]` clears it. See the [Audita integration contract](./integrations/audita.md)
for the subprocess and redaction boundary.
<a id="notarius-configuration-profile"></a>
### Notarius Configuration Overlay
Migration: rename `pipeline.notarius.config_profile` to `config_overlay`.
The old field is rejected; there is no compatibility alias. For Notarius v0.8+
move selected downstream files from `profiles/` to `overlays/` beside its root
configuration. This does not rename PromptKit LLM profiles.
The renamed selector also changes extraction resume evidence for named
selections: their existing extraction is regenerated on the next applicable
run, with normal dependent invalidation. Omitted/base selections do not acquire
a new invalidation solely from this rename.
`pipeline.notarius.config_overlay` optionally selects a Notarius ConfigKit
configuration overlay when extraction executes:
```yaml ```yaml
notarius: notarius:
config_profile: downstream-economy config_overlay: downstream-economy
``` ```
When the field is omitted or `null`, Narratio supplies no downstream profile When the field is omitted or `null`, Narratio supplies no downstream overlay
selector and preserves Notarius's existing configuration-selection behavior. selector and preserves Notarius's existing configuration-selection behavior.
When present, Narratio forwards the exact string to Notarius, including an When present, Narratio forwards the exact string to Notarius, including an
empty string or `default`, which explicitly select Notarius's composed base empty string or `default`, which explicitly select Notarius's composed base
configuration. Notarius owns selector interpretation and validation; Narratio configuration. Notarius owns selector interpretation and validation; Narratio
does not restrict profile names, resolve profile files, or rewrite the value. does not restrict overlay names, resolve overlay files, or rewrite the value.
This selection is independent of Narratio's own `--profile` selection. A This selection is independent of Narratio's own `--overlay` selection. A
Narratio overlay may explicitly select a differently named Notarius overlay, Narratio overlay may explicitly select a differently named Notarius overlay,
but Narratio never forwards its own profile name automatically. It is also but Narratio never forwards its own overlay name automatically. It is also
independent of `pipeline.notarius.llm_api_key`: an overlay may change either or independent of `pipeline.notarius.credentials`: an overlay may change either or
both fields according to the deployed Notarius configuration. See the both fields according to the deployed Notarius configuration. See the
[Notarius integration contract](./integrations/notarius.md) for invocation and [Notarius integration contract](./integrations/notarius.md) for invocation and
failure behavior. failure behavior.
### Notarius Credential ### Notarius Credentials
`pipeline.notarius.llm_api_key` optionally names the one environment variable `pipeline.notarius.credentials` optionally lists environment-variable names
to forward to the Notarius child process: to forward to the Notarius child process:
```yaml ```yaml
notarius: notarius:
llm_api_key: OPENROUTER_API_KEY credentials: [OPENROUTER_API_KEY, ANTHROPIC_API_KEY]
``` ```
The field contains the variable name, not a credential value. That name must The list contains variable names, not credential values. Those names must
match `api_key_env` in the Notarius/PromptKit profile selected by the deployed match the references used by the Notarius/PromptKit profiles that Notarius
Notarius pipeline; Narratio does not infer or remap provider names. Omission or loads. Notarius owns profile lookup; Narratio neither infers those names nor
`null` forwards nothing. A non-null selected reference must resolve at pipeline remaps them. Omission, `null`, or `[]` forwards nothing. Each selected reference
load time through the process environment or existing secrets-directory must resolve at pipeline load time through the process environment or existing
bootstrap, even when Notarius is disabled, extract is outside the requested secrets-directory bootstrap, even when Notarius is disabled, extract is outside
range, or a completed extraction can be reused. the requested range, or a completed extraction can be reused.
The Notarius child receives only Narratio's minimal execution environment plus The Notarius child receives only Narratio's minimal execution environment plus
this exact selected name and loaded value. Other credentials are not forwarded. these exact selected names and loaded values. Other credentials are not forwarded.
Changing the value, name, presence, or absence does not invalidate extract's Changing a value, name, presence, or absence does not invalidate extract's
semantic resume evidence. The public name remains part of safe effective semantic resume evidence. The public names remain part of safe effective
inspection, so renaming it may appear in `config diff` and change the effective inspection, so renaming them may appear in `config diff` and change the effective
configuration digest; rotating only its value does neither. configuration digest; rotating only their values does neither. An overlay
replaces the whole list, including with `[]` to clear it.
See the [Notarius integration contract](./integrations/notarius.md) for the See the [Notarius integration contract](./integrations/notarius.md) for the
delivery and redaction boundary. delivery and redaction boundary.
@@ -508,6 +545,25 @@ contract-incompatible lane fails extraction. See the
current ten-lane D&D mapping and the [Notarius contract](./integrations/notarius.md) current ten-lane D&D mapping and the [Notarius contract](./integrations/notarius.md)
for compatibility ownership. for compatibility ownership.
### Scriptorium Credentials
Set `scriptorium.credentials: [OPENROUTER_API_KEY]` in the pipeline file to
forward that provider key to Scriptorium. List additional provider names only
when the selected downstream configuration needs them. The same list applies
to trim bounds generation, analysis (including character families), and their
render-debug calls. Scriptorium retains provider/profile selection; Narratio
does not pass a model-request `--api-key-env` override.
As with Audita and Notarius, names are ConfigKit secret references resolved at
pipeline load time using the existing environment/secrets bootstrap precedence,
even when no Scriptorium stage executes. Omitted, null, or empty lists forward
nothing. An overlay replaces the entire list; `[]` clears it. Captured values
are forwarded under their exact names and registered for diagnostic redaction.
Inspection and generated metadata contain names only. Credential changes do
not invalidate completed trim or analysis work; rotating values also leaves
the effective configuration digest unchanged. Other environment variables
retain the shared minimal subprocess inheritance rules.
### Scriptorium Artifact Entries ### Scriptorium Artifact Entries
For each `pipeline.scriptorium.artifacts.<name>`: For each `pipeline.scriptorium.artifacts.<name>`:

View File

@@ -10,11 +10,37 @@ polished transcripts and generated artifacts. Start with the
[Architecture](policy/architecture.md) for normative system boundaries, and the [Architecture](policy/architecture.md) for normative system boundaries, and the
[Internal Overview](internal/overview.md) for implemented component ownership. [Internal Overview](internal/overview.md) for implemented component ownership.
Agents must read [Agent Policy](policy/agents.md) before recommending, planning,
or implementing changes. It owns implementation discipline and the default
commit-after-completed-work rule; it does not authorize pushing or releasing.
## Go Prerequisite ## Go Prerequisite
Maintained development and verification require Go 1.26. Use the official Go Maintained development and verification require Go 1.26. Use the official Go
1.26 toolchain before running repository commands. 1.26 toolchain before running repository commands.
## Policy Reading Guide
| Policy | Read before… |
| --- | --- |
| [Policy set provenance](policy/meta.md) | Adopting policies, checking upstream revisions, or updating the adopted policy set. |
| [Agents](policy/agents.md) | Recommending, planning, or implementing repository changes. |
| [Architecture](policy/architecture.md) | Planning or changing application code or its structure. |
| [Testing](policy/testing.md) | Selecting validation for code changes, or adding, changing, removing, or evaluating tests. |
| [Documentation](policy/documentation.md) | Writing, revising, or reorganizing documentation. |
| [OSS](policy/oss.md) | Adding or updating dependencies or incorporated third-party material, or changing licensing or attribution. |
| [Release](policy/release.md) | Preparing or publishing a release, changing release procedures, or making versioning and compatibility decisions. |
- Read applicable policies before making decisions they govern; multiple policies
may apply.
- Select policies using the task triggers above. Follow cross-references to
relevant sections when needed to resolve a concrete question.
- Reuse policies already available in context; reread when changed or no longer
available.
- Expand reading when the task's scope expands.
- When revising a policy itself, read that policy and any provisions elsewhere
affected by the revision.
## What To Read ## What To Read
| When working on | Read | Why | | When working on | Read | Why |
@@ -30,7 +56,7 @@ Maintained development and verification require Go 1.26. Use the official Go
| Adapters or external tool contracts | [Adapter Internals](internal/adapters.md) and [Integration Contracts](integrations/README.md) | The internal guide owns adapter composition and mechanics; integration documents own external formats and protocols. | | Adapters or external tool contracts | [Adapter Internals](internal/adapters.md) and [Integration Contracts](integrations/README.md) | The internal guide owns adapter composition and mechanics; integration documents own external formats and protocols. |
| Manifests, artifacts, workspace paths, or publish behavior | [Manifest Internals](internal/manifest.md), [Artifact Internals](internal/artifacts.md), [Workspace Internals](internal/workspace.md), [Publish Internals](internal/stage-publish.md), and [Operations](operations.md) | These separate implementation state and resolution from operator-visible layout and lifecycle. | | Manifests, artifacts, workspace paths, or publish behavior | [Manifest Internals](internal/manifest.md), [Artifact Internals](internal/artifacts.md), [Workspace Internals](internal/workspace.md), [Publish Internals](internal/stage-publish.md), and [Operations](operations.md) | These separate implementation state and resolution from operator-visible layout and lifecycle. |
| Maintained configuration or input examples | [Configuration](config.md) and [Examples](../examples/README.md) | The reference owns field meanings; the examples directory owns complete copyable files. | | Maintained configuration or input examples | [Configuration](config.md) and [Examples](../examples/README.md) | The reference owns field meanings; the examples directory owns complete copyable files. |
| Preparing, validating, or publishing a release | [Release Procedure](release.md) | The maintainer procedure owns version selection, candidate validation, guarded tag publication, and optional later CI inspection. | | Preparing, validating, or publishing a release | [Release Policy](policy/release.md) and [Release Procedure](release.md) | Policy owns requirements; the procedure owns concrete validation, guarded publication, and optional later inspection. |
| Proposed or unimplemented behavior | `docs/roadmap/` | Future work belongs only in roadmap documentation until implemented. | | Proposed or unimplemented behavior | `docs/roadmap/` | Future work belongs only in roadmap documentation until implemented. |
For an existing subsystem, also inspect its focused tests and package-level For an existing subsystem, also inspect its focused tests and package-level
@@ -38,23 +64,25 @@ contracts before changing behavior.
## Validation ## Validation
Use focused package tests while iterating. Every pull request and push runs the Use focused package tests while iterating. Push and pull-request CI runs these
following repository-wide checks before it can be accepted: repository-wide checks and cross-builds the declared targets:
```sh ```sh
go test ./... go test ./...
go test -race ./... go test -race ./...
go vet ./... go vet ./...
go build ./... go build ./...
go test ./internal/doccheck
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
``` ```
The documentation check verifies local Markdown links and the dependency graph The full suite includes documentation-link and Woodpecker dependency checks
of the Woodpecker workflows. The configuration check loads every maintained and loads maintained pipeline and session examples. For focused diagnosis,
pipeline and session example. Tag CI reuses this validation path before its run `go test ./internal/doccheck` or
asynchronous asset publication; the maintainer release boundary is documented `go test ./internal/config -run '^TestExamplesLoadAndValidate$'` directly;
in the [Release Procedure](release.md). these are available developer tools, not additional CI steps. Tag CI
independently revalidates the tagged source before asynchronous asset delivery;
it is not a second synchronous release prerequisite. The normal maintainer
entry point is the single publication command in the
[Release Procedure](release.md).
Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled
job to expose ordering and repeatability defects. Current runners cross-compile job to expose ordering and repeatability defects. Current runners cross-compile

View File

@@ -1,69 +1,54 @@
# Integration: Audita # Integration: Audita
## Purpose Narratio invokes Audita v2's `process INPUT --glossary PATH --output PATH`
Define the Audita adapter contract used by the `polish` stage. subprocess for `polish`. The configured timeout and parent cancellation bound
execution. Narratio passes explicit `--config`, `--overlay`,
`--proposal-profile`, `--validation-profile`, `--transcript-description`,
`--output-schema`, and `--modules` only when selected. An omitted module list
leaves Audita's default sequence in place; `modules: []` passes an explicit
empty argument and runs no correction modules. Order and repetitions are kept.
Narratio passes no debug, work-directory, or reporting flags, leaving those
choices to Audita's own configuration.
## External Boundary Audita owns its ConfigKit file, backend and profile definitions, native config
discovery, and their validation. Narratio does not create an Audita
configuration or infer downstream overlay/profile names from its own selected
pipeline overlay. An empty Narratio Audita selector adds no CLI override.
The [configuration reference](../config.md#audita-selection-and-credentials)
owns Narratio's fields and defaults. Audita's
[v2 migration guide](https://gitea.maximumdirect.net/eric/audita/src/tag/v2.0.0/docs/migrations/v2.md)
owns its downstream file format.
Narratio invokes `audita process` as a subprocess for each polish operation. Each name in `pipeline.audita.credentials` is a ConfigKit secret reference
The configured timeout and parent cancellation bound the invocation. Internal resolved when Narratio loads the pipeline. Narratio forwards only the selected
runner composition is documented in names and their captured, nonempty values under those exact names, in the
[the adapter implementation guide](../internal/adapters.md). minimal child environment; it never maps them to a fixed alias or rereads the
parent environment. Every name is registered for captured stdout/stderr and
failure-diagnostic redaction. Audita resolves credentials for all configured
backends, including unselected ones, so operators must list every backend
credential that its selected configuration needs. The downstream files and
provider output remain Audita's responsibility, outside Narratio's
captured-stream redaction boundary.
## Request Contract The corrected transcript is the only canonical polish output. A successful
`PolishRequest` carries: invocation must produce a valid JSON transcript with a top-level `segments`
- required transcript/glossary/output/work-dir paths; array within Narratio's 64 MiB bound. Exit zero is accepted even if Audita's
- optional report path (required when report mode is enabled); ordinary reports describe recoverable model errors or no corrections. Narratio
- generated config and stdout/stderr log paths; does not parse report status, counts, or Markdown. It discovers only
- optional per-invocation module override. `errors.yml`, `recoveries.yml`, and `report.md` next to the requested output,
each subject to a 16 MiB read bound. Missing reports are allowed, for example
when Audita reporting is disabled externally. Available reports are diagnostic
references, not canonical artifact outputs. See Audita's
[ordinary output contract](https://gitea.maximumdirect.net/eric/audita/src/tag/v2.0.0/docs/integrations/output-files.md)
for their schemas, and [operations](../operations.md) for placement and
publication.
The constructed runner owns static Audita settings: binary, timeout, On nonzero exit, Narratio preserves the original subprocess error and
credentials, default modules, model and endpoint settings, validation and output available run-local evidence; it never promotes a transcript merely because
settings, report mode, and concurrency. The `polish` stage supplies only the file exists. The adapter returns safe paths, exit details, and selection
invocation-specific paths and may override modules for that invocation. metadata on failure. Narratio's `audita.generated.v2` YAML records the
invocation, including arguments, paths, and credential names but never values;
it is a diagnostic record, not a generated Audita config file.
## Result Contract Maintained Narratio examples: [full annotated pipeline](../../examples/pipeline.full.annotated.yml)
`PolishResult` returns: and [production-shaped pipeline](../../examples/pipeline.production.yml).
- processed transcript path;
- optional report path;
- work dir and generated-config/log paths;
- exit code, duration, binary provenance;
- adapter metadata map.
## Validation and Failure Semantics
Construction fails for invalid static config values, including:
- empty binary;
- non-positive timeout;
- invalid base URL;
- invalid output schema;
- invalid work-dir retention value;
- invalid concurrency values.
Run fails for:
- missing required request paths;
- subprocess execution failure;
- invalid processed transcript JSON (`segments` array required);
- invalid report JSON when reporting is enabled.
Processed transcript JSON is limited to 64 MiB and optional report JSON to 16
MiB. Both must be regular files without symlinked path components.
Failure results still include output/log/config/exit metadata for diagnostics.
## Deterministic Behavior
- CLI args are built from runner config + request in a fixed order.
- A configured typed `llm_api_key` reference is resolved during pipeline load;
the captured value is supplied only as the child's `AUDITA_LLM_API_KEY`
override and is never re-read from the parent environment.
- Generated invocation YAML (`audita.generated.v1`) is emitted when requested.
- Manifest writes are stage-owned; adapter itself is stateless.
## Configuration
Operator-selected values are defined under `pipeline.audita.*` in the
[configuration reference](../config.md#pipeline).
Maintained example with Audita config:
- [Full annotated pipeline](../../examples/pipeline.full.annotated.yml)
- [Production-shaped pipeline](../../examples/pipeline.production.yml)

View File

@@ -7,14 +7,14 @@ lanes from the final trimmed Seriatim transcript. Narratio owns invocation,
safe bundle discovery, lane selection, and its own artifact metadata. Notarius safe bundle discovery, lane selection, and its own artifact metadata. Notarius
owns pipeline definitions, lane schemas, the receipt, and bundle formats. owns pipeline definitions, lane schemas, the receipt, and bundle formats.
Canonical Notarius v0.7.0 references: Canonical Notarius v0.8.1 references:
- [CLI reference](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/cli.md) - [CLI reference](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.8.1/docs/cli.md)
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/consumers/subprocess.md) - [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.8.1/docs/consumers/subprocess.md)
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/consumers/dnd-pipeline.md) - [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.8.1/docs/consumers/dnd-pipeline.md)
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/run-result.md) - [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.8.1/docs/integrations/run-result.md)
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/json-output.md) - [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.8.1/docs/integrations/json-output.md)
- [D&D spell-catalog overlay](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/dnd-spell-catalog-overlays.md) - [D&D spell-catalog overlay](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.8.1/docs/integrations/dnd-spell-catalog-overlays.md)
The [complete Narratio example](../../examples/pipeline.full.annotated.yml) The [complete Narratio example](../../examples/pipeline.full.annotated.yml)
records the exact current constraints for all ten D&D lanes. Treat the linked records the exact current constraints for all ten D&D lanes. Treat the linked
@@ -27,26 +27,26 @@ When `pipeline.notarius.enabled` is true, Narratio resolves the executable,
configuration path, input path, output directory, and working directory to configuration path, input path, output directory, and working directory to
absolute paths. Configured references require support for the `--reference` absolute paths. Configured references require support for the `--reference`
contract introduced in Notarius v0.6.0. Cache controls require support for the contract introduced in Notarius v0.6.0. Cache controls require support for the
selected `--resume` and `--chunk_cache` flags. Notarius v0.7.0 is the tested selected `--resume` and `--chunk_cache` flags. Notarius v0.8.1 is the tested
cache integration baseline, not an unconditional minimum version; Narratio cache integration baseline, not an unconditional minimum version; Narratio
does not impose a runtime version gate. Each reference binding is a separate does not impose a runtime version gate. Each reference binding is a separate
argument before `--json`. When `pipeline.notarius.config_profile` is present, argument before `--json`. When `pipeline.notarius.config_overlay` is present,
its exact value is another argument pair before `--json`: its exact value is another argument pair before `--json`:
```text ```text
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> [--resume] [--chunk_cache <mode>] [--reference <selector>=<verified_snapshot_path>]... [--config-profile <name>] --json notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> [--resume] [--chunk_cache <mode>] [--reference <selector>=<verified_snapshot_path>]... [--config-overlay <name>] --json
``` ```
Notarius v0.7.0 is a known supporting version for `--config-profile`; using the Notarius v0.8.1 is a known supporting version for `--config-overlay`; using the
option requires a compatible Notarius executable, but deployments that omit the option requires a compatible Notarius executable, but deployments that omit the
field acquire no additional version requirement. Omitted or null configuration field acquire no additional version requirement. Omitted or null configuration
adds no flag. Supplied empty, `default`, and named selectors are forwarded adds no flag. Supplied empty, `default`, and named selectors are forwarded
without normalization so Notarius can apply its own ConfigKit semantics. This without normalization so Notarius can apply its own ConfigKit semantics. This
option selects a Notarius configuration overlay, not a PromptKit profile through option selects a Notarius configuration overlay, not a PromptKit profile through
`--llm-profile`, and Narratio never substitutes its own selected profile name. `--llm-profile`, and Narratio never substitutes its own selected overlay name.
A missing or invalid downstream overlay is an ordinary Notarius subprocess A missing or invalid downstream overlay is an ordinary Notarius subprocess
failure: Narratio does not retry without the selector or fall back to another failure: Narratio does not retry without the selector or fall back to another
profile. overlay.
Reference paths are absolute stable snapshots streamed from manifest-verified Reference paths are absolute stable snapshots streamed from manifest-verified
canonical files prepared inside the current Narratio session workspace. They canonical files prepared inside the current Narratio session workspace. They
@@ -105,15 +105,16 @@ causes a cold miss. Chunk refresh bypasses lookup and publishes the accepted
replacement for the active source; it does not delete unrelated plans or replacement for the active source; it does not delete unrelated plans or
checkpoint state. checkpoint state.
When `pipeline.notarius.llm_api_key` is absent or null, Narratio adds no When `pipeline.notarius.credentials` is absent, null, or empty, Narratio adds no
credential to that environment. When configured, the field names an environment credentials to that environment. When configured, each list entry names an
variable whose already-loaded nonempty value is forwarded under that exact environment variable whose already-loaded nonempty value is forwarded under
name. Operators must use the same name as the `api_key_env` in the selected that exact name. Operators must use the names referenced by the selected
Notarius/PromptKit profile. Narratio does not inspect the Notarius configuration Notarius/PromptKit providers. Narratio does not inspect the Notarius configuration
to infer a provider or destination name, and it does not forward unrelated to infer providers or destination names, and it does not forward unrelated
credentials from its parent environment or secrets directory. credentials from its parent environment or secrets directory. A Narratio
overlay replaces the whole credential list; `[]` clears it.
The selected name is registered with Narratio's shared streaming redactor. Every selected name is registered with Narratio's shared streaming redactor.
Credential bytes written by the child to captured stdout, captured stderr, or Credential bytes written by the child to captured stdout, captured stderr, or
stderr-derived failure diagnostics are replaced with `<redacted>`, including stderr-derived failure diagnostics are replaced with `<redacted>`, including
custom names not known to the built-in sensitive-name list. Narratio does not custom names not known to the built-in sensitive-name list. Narratio does not
@@ -128,7 +129,7 @@ encoded credentials.
## Accepted Result ## Accepted Result
Narratio's tested invocation baseline is Notarius v0.7.0. The accepted Narratio's tested invocation baseline is Notarius v0.8.1. The accepted
receipt remains `notarius.run-result.v2`; reference flags do not change the receipt remains `notarius.run-result.v2`; reference flags do not change the
receipt or ten-lane output contract. The receipt receipt or ten-lane output contract. The receipt
must identify the configured pipeline, and its `index_file` must be exactly must identify the configured pipeline, and its `index_file` must be exactly
@@ -187,7 +188,7 @@ for failure recovery.
## Opt-in Integration Test ## Opt-in Integration Test
The production-boundary cache test requires an absolute path to a locally The production-boundary cache test requires an absolute path to a locally
built Notarius v0.7.0-compatible executable. It uses only a local built Notarius v0.8.1-compatible executable. It uses only a local
OpenAI-compatible `httptest` provider and temporary cache/config/output roots: OpenAI-compatible `httptest` provider and temporary cache/config/output roots:
```sh ```sh

View File

@@ -21,7 +21,7 @@ Both request types carry:
- output path; - output path;
- timeout; - timeout;
- generated config + stdout/stderr log paths; - generated config + stdout/stderr log paths;
- optional API-key env var name; - optional list of already-resolved credential references;
- optional working directory. - optional working directory.
## Result Contract ## Result Contract
@@ -39,7 +39,16 @@ Request validation fails for:
- non-positive timeout; - non-positive timeout;
- empty input/var names; - empty input/var names;
- empty input path values; - empty input path values;
- missing required credential env var when `APIKeyEnv` is set. - unresolved or empty selected credential references.
Both modes use the shared credential forwarding used by Audita and Notarius:
selected names and captured values augment the minimal child environment, and
all selected values are registered for captured-stream redaction. The adapter
does not reread the parent environment or override Scriptorium's provider
selection with `--api-key-env`. Generated invocation records and result metadata
contain `credential_env_names`, never credential values. This does not scan or
redact files written directly by Scriptorium. Selection semantics belong in
[Scriptorium credentials](../config.md#scriptorium-credentials).
Run behavior: Run behavior:
- subprocess errors propagate with context; - subprocess errors propagate with context;

View File

@@ -68,11 +68,15 @@ owner of serializing them as repeated `--reference` arguments before `--json`.
It also serializes an effective resume request as `--resume` and a nonempty It also serializes an effective resume request as `--resume` and a nonempty
validated chunk mode as `--chunk_cache <mode>`. The extract stage, not the validated chunk mode as `--chunk_cache <mode>`. The extract stage, not the
adapter, resolves force precedence before constructing the request. adapter, resolves force precedence before constructing the request.
Its request may also carry one already-resolved opaque ConfigKit secret Its request may also carry already-resolved ConfigKit secret references.
reference. Only the Notarius adapter reads that value, rejects an unresolved or Audita and Scriptorium requests carry the same kind of list. All three adapters use the shared
empty programmatic reference, and maps the exact configured name/value into the subprocess helper to convert their selected references into exact-name child
shared subprocess override and sensitive-name fields. It never rereads the environment overrides and sensitive-name registration. That helper rejects
parent environment. A nil reference preserves the previous invocation. unresolved or empty programmatic references, but no adapter rereads the
parent environment or inspects downstream configuration to discover names.
Empty lists add no credential overrides. See the public [Audita](../integrations/audita.md)
[Notarius](../integrations/notarius.md), and [Scriptorium](../integrations/scriptorium.md) contracts for selection and
downstream-file boundaries.
Application-owned object-store construction loads configured filesystem Application-owned object-store construction loads configured filesystem
secrets before invoking the invocation's storage factory. Production commands secrets before invoking the invocation's storage factory. Production commands

View File

@@ -9,9 +9,9 @@ pipeline-loading boundary implemented by `internal/config`.
`LoadPipeline` assembles and validates a pipeline in this order: `LoadPipeline` assembles and validates a pipeline in this order:
1. Give ConfigKit a fresh typed `PipelineConfig` default value, the root file, 1. Give ConfigKit a fresh typed `PipelineConfig` default value, the root file,
enabled imports, and the caller's profile selection. Omitted, empty, and enabled imports, and the caller's overlay selection. Omitted, empty, and
exact `default` selections use the base; named profiles use the fixed exact `default` selections use the base; named overlays use the fixed
`profiles/<name>.yml` convention. `overlays/<name>.yml` convention.
2. ConfigKit parses the root and declared imports, additively merges base 2. ConfigKit parses the root and declared imports, additively merges base
sources, applies only the selected overlay, and strictly decodes the result. sources, applies only the selected overlay, and strictly decodes the result.
3. The definition resolver applies recorded entry-level and inherited defaults, 3. The definition resolver applies recorded entry-level and inherited defaults,
@@ -45,7 +45,7 @@ a temporary downloaded session file therefore cannot invalidate the resolved
pipeline or campaign context. pipeline or campaign context.
Campaign and session files use independent ConfigKit definitions with imports Campaign and session files use independent ConfigKit definitions with imports
and profiles disabled. Campaign files normally load directly from their source; and overlays disabled. Campaign files normally load directly from their source;
session files are first read through the 8 MiB bounded-read boundary because the session files are first read through the 8 MiB bounded-read boundary because the
application must reject unresolved template placeholders before passing an exact application must reject unresolved template placeholders before passing an exact
byte snapshot to ConfigKit. Local and remotely downloaded session bytes then use byte snapshot to ConfigKit. Local and remotely downloaded session bytes then use
@@ -53,8 +53,9 @@ the same typed loader and retain the existing post-load identity expectations,
stable-input precedence, and source-relative path semantics. stable-input precedence, and source-relative path semantics.
The application also has a separate read-only inspection resolver for `config The application also has a separate read-only inspection resolver for `config
validate`, `config show`, `config sources`, and `config artifacts`. It uses the same production root/profile and validate`, `config show`, `config sources`, and `config artifacts`. It uses the
campaign selection functions, but never routes through session discovery, same production root, overlay, and campaign selection functions, but never routes
through session discovery,
remote-session download, adapter composition, workspace remote-session download, adapter composition, workspace
initialization, manifest access, or cleanup. A pipeline with retained artifact initialization, manifest access, or cleanup. A pipeline with retained artifact
family declarations must resolve its selected campaign before ordinary pipeline family declarations must resolve its selected campaign before ordinary pipeline
@@ -73,7 +74,7 @@ commands do not marshal runtime objects directly.
`EffectivePipelineSources` and `EffectiveCampaignSources` provide the separate `EffectivePipelineSources` and `EffectiveCampaignSources` provide the separate
safe provenance projection for `config sources`. Pipeline ownership begins with safe provenance projection for `config sources`. Pipeline ownership begins with
the complete logical field paths projected from the schema and classifies the complete logical field paths projected from the schema and classifies
each contributor as root, import, profile, or centralized default. The each contributor as root, import, overlay, or centralized default. The
projection replaces generated concrete member paths with paired family and projection replaces generated concrete member paths with paired family and
canonical-party records, and does the same for generated publish rules. canonical-party records, and does the same for generated publish rules.
Campaign records identify campaign-owned fields and party inputs; canonical Campaign records identify campaign-owned fields and party inputs; canonical
@@ -106,7 +107,7 @@ configured roster authority; player and session-party fields are unsupported.
## Canonical Party Domain ## Canonical Party Domain
`ParseParty` is the package-owned boundary for parsing a party source. Its `ParseParty` is the package-owned boundary for parsing a party source. Its
single-source ConfigKit definition disables imports and profiles, requires single-source ConfigKit definition disables imports and overlays, requires
`schema_version` through `VersionRule`, and strictly decodes the `schema_version` through `VersionRule`, and strictly decodes the
character-keyed typed schema before applying Narratio's party-domain checks. character-keyed typed schema before applying Narratio's party-domain checks.
Successful metadata coordinates recover authored character-map order without a Successful metadata coordinates recover authored character-map order without a
@@ -144,14 +145,14 @@ not compatibility contracts.
## Diagnostics And Runtime Metadata ## Diagnostics And Runtime Metadata
Syntax, duplicate-key, merge, profile, and schema failures include the Syntax, duplicate-key, merge, overlay, and schema failures include the
relevant source name and full field path. Additive conflicts report every relevant source name and full field path. Additive conflicts report every
claiming source so operators can repair the split without repeatedly claiming source so operators can repair the split without repeatedly
rediscovering additional conflicts. rediscovering additional conflicts.
The loaded pipeline retains private runtime metadata for the absolute root The loaded pipeline retains private runtime metadata for the absolute root
path, ordered imports, selected profile name and selection source, selected path, ordered imports, selected overlay name, source, and path, original
overlay, original ConfigKit metadata and source snapshots, effective digest, ConfigKit metadata and source snapshots, effective digest,
and leaf ownership. Base and leaf ownership. Base
leaves retain their root/import owners, replaced leaves belong to the selected leaves retain their root/import owners, replaced leaves belong to the selected
overlay, and centrally supplied values use the synthetic `default` owner. This overlay, and centrally supplied values use the synthetic `default` owner. This
@@ -163,7 +164,7 @@ the same typed safe projection used by show and diff. Runtime Notarius paths rem
execution, but the digest substitutes their normalized logical values captured execution, but the digest substitutes their normalized logical values captured
before root-relative resolution, so relocating an equivalent configuration before root-relative resolution, so relocating an equivalent configuration
bundle does not change provenance. Because ConfigKit and resolution metadata bundle does not change provenance. Because ConfigKit and resolution metadata
are private, the digest excludes source layout, profile name, and ownership. are private, the digest excludes source layout, overlay name, and ownership.
Configuration stores credentials as ConfigKit typed references. A successful Configuration stores credentials as ConfigKit typed references. A successful
load resolves their values inside the opaque typed boundary, while this safe load resolves their values inside the opaque typed boundary, while this safe
projection serializes only `EnvName()` identities. Credential-value changes are projection serializes only `EnvName()` identities. Credential-value changes are
@@ -177,7 +178,7 @@ for later runtime expansion.
## Test Surfaces ## Test Surfaces
`pipeline_configkit_test.go` exercises real imports and fixed-path profiles, `pipeline_configkit_test.go` exercises real imports and fixed-path overlays,
base selection, unselected-overlay isolation, source attribution, and Narratio's base selection, unselected-overlay isolation, source attribution, and Narratio's
unique artifact/family base-declaration rule. Existing focused configuration unique artifact/family base-declaration rule. Existing focused configuration
tests protect defaults, explicit-value semantics, strict decoding, validation, tests protect defaults, explicit-value semantics, strict decoding, validation,

View File

@@ -23,9 +23,11 @@ portable opaque segments. Unsafe legacy identities are rejected with migration
guidance rather than being normalized into a different workspace or remote guidance rather than being normalized into a different workspace or remote
namespace. namespace.
Decoding is strict at every typed manifest field. Unknown fields are rejected; Decoding rejects unknown typed fields, including informational fields inside
the schema version remains `narratio.session-manifest.v1` and there is no `effective_config` and its nested selection. The schema version remains
compatibility reader that silently drops retired state. Cleanup progress uses `narratio.session-manifest.v1`; there is no compatibility reader that silently
drops retired fields. See [overlay migration](../operations.md#migrating-manifest-overlay-provenance)
for deliberate migration of retired selection provenance. Cleanup progress uses
only the cleanup entry in `stages`. only the cleanup entry in `stages`.
Every persisted session manifest has a non-nil `stages` map. Each entry is Every persisted session manifest has a non-nil `stages` map. Each entry is
@@ -193,6 +195,25 @@ establish a current immutable snapshot.
- writes through a sibling temporary file and atomic replacement visibility; - writes through a sibling temporary file and atomic replacement visibility;
- updates `updated_at` on save. - updates `updated_at` on save.
### Checkpoint persistence boundary
Analyze and transcribe share one private checkpoint operation in
`internal/stage`. Their typed callers still decide when to checkpoint, validate
their collections, populate stage-owned fields, and serialize access. The
shared operation copies the manifest and its checkpoint-relevant mutable
collections, applies the typed update to the copied target record, and calls
the existing `manifest.Store` exactly once. Only after that save succeeds does
it promote the saved target record and manifest `updated_at` into live state;
it preserves an existing target record's identity and leaves unrelated live
stage records in place.
The copy boundary includes stage records, input and artifact slices, aggregate
output/log/configuration slices, and the typed analyze and transcribe
collections that callbacks or `LocalStore.Save` may modify. Metadata and other
pointer fields remain shared read-only because neither checkpoint callback nor
the store mutates them. This is a bounded execution helper, not a general
manifest clone or a transaction across transcript/artifact files.
Manifest replacement provides atomic visibility, not a crash- or power-loss Manifest replacement provides atomic visibility, not a crash- or power-loss
durability guarantee. Callers reload after a returned save error before retrying. durability guarantee. Callers reload after a returned save error before retrying.
A transcribe or analyze checkpoint error leaves the prior manifest collection A transcribe or analyze checkpoint error leaves the prior manifest collection
@@ -287,10 +308,10 @@ after such a private content change.
The session manifest is the authoritative stage-progress ledger across The session manifest is the authoritative stage-progress ledger across
invocations. It retains the most recently resolved invocation's bounded invocations. It retains the most recently resolved invocation's bounded
configuration provenance. It identifies the selected profile name and source configuration provenance. It identifies the selected overlay name and source
(`default` or `cli`) plus the effective configuration digest, but never a raw (`explicit` when selected) plus the effective configuration digest, but never a raw
secret or profile content. This provenance is informational: it does not secret or overlay content. This provenance is informational: it does not
participate in stage resume or cache decisions. A profile change therefore participate in stage resume or cache decisions. An overlay change therefore
invalidates only stages whose semantic configuration changed. When a private invalidates only stages whose semantic configuration changed. When a private
external-tool model, module, prompt, or profile changes behind an unchanged external-tool model, module, prompt, or profile changes behind an unchanged
configured identifier, use `--force` for the affected work. configured identifier, use `--force` for the affected work.

View File

@@ -37,7 +37,7 @@ failed stage or required manifest save prevents later stages from starting.
| --- | --- | --- | | --- | --- | --- |
| Executable | `cmd/narratio` | Process entry, standard stream wiring, argument handoff, and exit status. | | Executable | `cmd/narratio` | Process entry, standard stream wiring, argument handoff, and exit status. |
| Application orchestration | `internal/app` | Command dispatch, configuration selection, secret-file environment loading, production composition, session locking, planning, execution, restore, and user-facing reporting. | | Application orchestration | `internal/app` | Command dispatch, configuration selection, secret-file environment loading, production composition, session locking, planning, execution, restore, and user-facing reporting. |
| Configuration | [`internal/config`](configuration.md) | ConfigKit-backed pipeline imports and profiles, canonical party and family expansion, strict YAML loading, defaults, normalization, session templating, and validation. | | Configuration | [`internal/config`](configuration.md) | ConfigKit-backed pipeline imports and overlays, canonical party and family expansion, strict YAML loading, defaults, normalization, session templating, and validation. |
| Pipeline stages | `internal/stage` | Canonical stage registry, shared stage contract, execution dependencies, and implemented stage behavior. | | Pipeline stages | `internal/stage` | Canonical stage registry, shared stage contract, execution dependencies, and implemented stage behavior. |
| External boundaries | `internal/adapters`, `internal/audio` | WhisperX HTTP, downstream subprocesses, notification, object storage, and S3 audio materialization behind Narratio contracts. | | External boundaries | `internal/adapters`, `internal/audio` | WhisperX HTTP, downstream subprocesses, notification, object storage, and S3 audio materialization behind Narratio contracts. |
| Manifests | `internal/manifest` | Durable session progress, stage transitions, validation, and atomic persistence. | | Manifests | `internal/manifest` | Durable session progress, stage transitions, validation, and atomic persistence. |
@@ -106,7 +106,7 @@ so any earlier replacement can stale a prior cleanup result.
## Focused Documentation ## Focused Documentation
- [Configuration Internals](configuration.md): pipeline imports and profiles, - [Configuration Internals](configuration.md): pipeline imports and overlays,
field ownership, decoding, and root-relative path semantics. field ownership, decoding, and root-relative path semantics.
- [Adapter Internals](adapters.md): external adapter boundaries, composition, - [Adapter Internals](adapters.md): external adapter boundaries, composition,
failure behavior, and test surfaces. failure behavior, and test surfaces.

View File

@@ -115,7 +115,11 @@ Supported source families:
- before the first adapter call, checkpoints the reconciled starting collection - before the first adapter call, checkpoints the reconciled starting collection
when it removed configuration records or cleared obsolete authority. After when it removed configuration records or cleared obsolete authority. After
each successful artifact, checkpoints the complete collection before making each successful artifact, checkpoints the complete collection before making
that artifact available to the next scheduled dependent. that artifact available to the next scheduled dependent. Analyze supplies
its typed update to the shared
[checkpoint persistence boundary](manifest.md#checkpoint-persistence-boundary)
and refreshes its working and committed collections only after the save
succeeds.
- keeps adapter output in the invocation's run-local analyze directory until - keeps adapter output in the invocation's run-local analyze directory until
it is a safe, non-empty, bounded regular file with a calculated checksum and it is a safe, non-empty, bounded regular file with a calculated checksum and
complete output contract. Canonical replacement uses the shared atomic file complete output contract. Canonical replacement uses the shared atomic file

View File

@@ -24,11 +24,11 @@ procedures belong in [Operations](../operations.md).
and rejects any source change observed while copying; and rejects any source change observed while copying;
4. fingerprints the byte- and provenance-bearing Notarius invocation evidence, 4. fingerprints the byte- and provenance-bearing Notarius invocation evidence,
including sorted reference identities and the explicit downstream including sorted reference identities and the explicit downstream
configuration-profile selection; configuration-overlay selection;
5. creates a run-local staging directory and invokes the injected 5. creates a run-local staging directory and invokes the injected
`notarius.Runner`, carrying the optional already-resolved credential `notarius.Runner`, carrying the optional already-resolved credential
reference without exposing its value, the optional exact configuration reference without exposing its value, the optional exact configuration
profile, plus effective resume and chunk-cache controls after force overlay, plus effective resume and chunk-cache controls after force
precedence; precedence;
6. revalidates the reference snapshots, then validates the v2 successful 6. revalidates the reference snapshots, then validates the v2 successful
receipt, confined index, management documents, configured required lane receipt, confined index, management documents, configured required lane
@@ -73,7 +73,7 @@ Before the focused validator runs, the application compares extract's versioned
semantic fingerprint. It covers enablement, Notarius pipeline identity, sorted semantic fingerprint. It covers enablement, Notarius pipeline identity, sorted
reference selector/source mappings, sorted declared output contracts, and each reference selector/source mappings, sorted declared output contracts, and each
canonical `narratio.extraction.<key>` output identity. It also covers the canonical `narratio.extraction.<key>` output identity. It also covers the
explicit Notarius configuration-profile selection; omitted, empty, and exact explicit Notarius configuration-overlay selection; omitted, empty, and exact
`default` selectors share the base-selection identity. It excludes executable, `default` selectors share the base-selection identity. It excludes executable,
timeout, working directory, config path, and private Notarius config contents. timeout, working directory, config path, and private Notarius config contents.
Credential presence, reference name, and resolved value are likewise Credential presence, reference name, and resolved value are likewise
@@ -85,7 +85,7 @@ not rerun a valid completed extraction or stale its dependents.
stage record still matches the current byte- and provenance-bearing invocation stage record still matches the current byte- and provenance-bearing invocation
evidence. That evidence covers the current direct trimmed-transcript identity, evidence. That evidence covers the current direct trimmed-transcript identity,
sorted prepared-reference identities, pipeline identity, and configured output sorted prepared-reference identities, pipeline identity, and configured output
contracts, plus the same downstream profile-selection identity. The same contracts, plus the same downstream overlay-selection identity. The same
reference helper and transcript identity are resolved again for artifact reference helper and transcript identity are resolved again for artifact
evidence, so changing current transcript bytes, reference bytes, producer evidence, so changing current transcript bytes, reference bytes, producer
identity, or a named downstream selection makes the prior extraction obsolete. identity, or a named downstream selection makes the prior extraction obsolete.
@@ -107,7 +107,7 @@ obsolete results are non-resumable and run again; unsafe filesystem conditions
return an error rather than silently accepting or replacing data. return an error rather than silently accepting or replacing data.
This sensitivity is limited to the explicit downstream selector. Narratio's This sensitivity is limited to the explicit downstream selector. Narratio's
own profile label and whole effective-configuration digest are not blanket own overlay label and whole effective-configuration digest are not blanket
extraction invalidators. Neither contract can observe files imported by extraction invalidators. Neither contract can observe files imported by
Notarius configuration, profile contents, prompt/module definitions, or other Notarius configuration, profile contents, prompt/module definitions, or other
transitive inputs. Operators must force extraction after changing any such transitive inputs. Operators must force extraction after changing any such
@@ -144,5 +144,5 @@ recovery.
`internal/artifacts/extraction_catalog_test.go` `internal/artifacts/extraction_catalog_test.go`
- Composition and downstream behavior: `internal/app/runner_test.go`, - Composition and downstream behavior: `internal/app/runner_test.go`,
`internal/stage/analyze_test.go`, `internal/stage/publish_test.go` `internal/stage/analyze_test.go`, `internal/stage/publish_test.go`
- Opt-in Notarius v0.7.0 production-boundary coverage: - Opt-in Notarius v0.8.1 production-boundary coverage:
`internal/stage/notarius_cache_integration_test.go` `internal/stage/notarius_cache_integration_test.go`

View File

@@ -12,37 +12,41 @@ Run Audita polishing on base transcript and produce polished transcript.
## Outputs ## Outputs
- `transcripts/polished.json` - `transcripts/polished.json`
- optional `artifacts/audita.report.json`
The transcript is the only canonical output. Available `errors.yml`,
`recoveries.yml`, and `report.md` remain beside the run-local transcript and
are recorded as diagnostics, not artifact outputs.
## Key Behavior ## Key Behavior
- resolves base transcript only from verified merge manifest output evidence. - resolves base transcript only from verified merge manifest output evidence.
- invokes an Audita runner configured with static model/runtime options; the - invokes an Audita v2 runner with external configuration selectors and
invocation supplies paths and modules. explicitly forwarded provider credentials; the invocation supplies paths.
- validates processed transcript structure (`segments` array required). - validates processed transcript structure (`segments` array required).
- validates optional report JSON. - validates reported diagnostic paths against the three fixed adjacent names.
- materializes canonical outputs; records logs/generated config and adapter metadata. - materializes only the canonical transcript; records available reports with
stdout/stderr logs, generated invocation, and safe adapter metadata.
## Invariants ## Invariants
- polished transcript schema validation is mandatory. - polished transcript schema validation is mandatory.
- report output is config-gated. - reports are optional diagnostics; Audita's external configuration may disable
them. A failed invocation never promotes an incidental transcript.
## Resume Semantics ## Resume Semantics
The versioned semantic fingerprint covers the Audita service endpoint, model, Version 2 semantic evidence covers Audita's config path, downstream overlay,
validation model, module set, transcript description, output schema, selected proposal and validation profile selectors, ordered module selection (including
external configuration path, and canonical polished-transcript identity. Module the distinction between omission and an explicit empty list), transcript
ordering is normalized because the configured modules form a set. Audita's description, output schema, and canonical polished-transcript identity.
executable, timeouts, concurrency, report and debug behavior, work retention, Credentials, executable path, timeout, and diagnostics are operational and do
and resolved credential value are operational and do not invalidate a not invalidate a successful result. Public credential reference names may
successful result. The public credential reference name may participate in participate in configuration provenance but never expose resolved values.
configuration provenance but never exposes its resolved value.
Narratio can fingerprint a selected model, module, or configuration identifier, Narratio fingerprints selections, not the contents of Audita-owned config or
but it cannot inspect content that Audita privately resolves behind that stable profiles. Force `polish` after changing those files behind unchanged selections.
identifier. Force `polish` after changing such private content without changing When polish is selected, older semantic evidence is reconsidered and the stage
its identifier. reruns; a downstream-only invocation does not eagerly run polish.
## Related Contracts And Tests ## Related Contracts And Tests

View File

@@ -35,9 +35,10 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
- output path returned by adapter must match requested output path. - output path returned by adapter must match requested output path.
- an empty adapter result path means the requested path; adapters cannot select - an empty adapter result path means the requested path; adapters cannot select
an alternate destination. an alternate destination.
- candidate collection construction, manifest save, and installation into live - candidate collection construction, the shared
state are serialized. Expensive hashing and HTTP work remain outside that [checkpoint persistence boundary](manifest.md#checkpoint-persistence-boundary),
critical section. and installation into live state are serialized by transcribe's mutex.
Expensive hashing and HTTP work remain outside that critical section.
- a canonical file has no completion authority until its manifest checkpoint - a canonical file has no completion authority until its manifest checkpoint
succeeds. A save failure preserves the last successfully committed succeeds. A save failure preserves the last successfully committed
collection for ordinary retry. collection for ordinary retry.

View File

@@ -70,19 +70,19 @@ narratio run 2026-04-04
narratio session status 2026-04-04 narratio session status 2026-04-04
``` ```
Run, plan, and status output identify the resolved pipeline profile (or `none`) Run, plan, and status output identify the resolved pipeline overlay (or `none`)
and effective configuration digest. Status distinguishes the current resolved and effective configuration digest. Status distinguishes the current resolved
value from the last value persisted in the session manifest, which helps value from the last value persisted in the session manifest, which helps
diagnose profile switches without changing resume authority. diagnose overlay switches without changing resume authority.
Before switching an operational profile, compare its effective meaning with the Before switching an operational overlay, compare its effective meaning with the
production base through `narratio config diff default <profile>`. production base through `narratio config diff default <overlay>`.
The command is read-only and succeeds whether it finds differences or not. Its The command is read-only and succeeds whether it finds differences or not. Its
sorted records describe defaulted, expanded concrete configuration—not source sorted records describe defaulted, expanded concrete configuration—not source
file layout—so it can be used to review model, artifact, and publish changes file layout—so it can be used to review model, artifact, and publish changes
without creating a session or run. Use `narratio config artifacts` to review the without creating a session or run. Use `narratio config artifacts` to review the
concrete artifact identities, definitions, outputs, and dependencies. Select the concrete artifact identities, definitions, outputs, and dependencies. Select the
same campaign explicitly when profiles could resolve different campaign paths; same campaign explicitly when overlays could resolve different campaign paths;
see the [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff) see the [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
for syntax and record format. for syntax and record format.
@@ -124,10 +124,11 @@ membership, S3 object identity, or missing/tampered copies rerun prepare and
its fixed descendants without requiring `--force`. Changing prepare selection its fixed descendants without requiring `--force`. Changing prepare selection
semantics likewise reruns all fixed descendants; changing semantics likewise reruns all fixed descendants; changing
WhisperX language/service identity reuses prepare; changing a Seriatim merge WhisperX language/service identity reuses prepare; changing a Seriatim merge
transformation reuses prepare and transcribe; and changing an Audita model transformation reuses prepare and transcribe; and changing a selected Audita
reuses prepare, transcribe, and merge while rebuilding transcript refinement. overlay, profile, or module sequence reuses prepare, transcribe, and merge while
A trim change invalidates both render and extract through the fixed dependency rebuilding transcript refinement. A trim change invalidates both render and
relation, while a render-only change preserves the extract sibling. extract through the fixed dependency relation, while a render-only change
preserves the extract sibling.
Operational timeouts, retry/concurrency tuning, executable paths, Operational timeouts, retry/concurrency tuning, executable paths,
workspace/cache/spool placement, reports, diagnostics, and secret values are workspace/cache/spool placement, reports, diagnostics, and secret values are
@@ -281,29 +282,83 @@ earlier checkpoints are reused and execution resumes with failed, stale, or
missing work in dependency order. `--force` is unnecessary unless the operator missing work in dependency order. `--force` is unnecessary unless the operator
intends to replace otherwise-current artifacts. intends to replace otherwise-current artifacts.
### Developing With A Notarius Configuration Profile ### Developing With Audita And Notarius Overlays
A Narratio development overlay can explicitly select a Notarius overlay without All three applications use ConfigKit, but each owns its root configuration and
requiring the names to match. For example, `profiles/testing.yml` may contain: its own `overlays/` directory beside that file. A Narratio testing overlay can
explicitly select downstream testing overlays without requiring their names to
match. For example, Narratio's `overlays/testing.yml` may contain:
```yaml ```yaml
audita:
overlay: testing
notarius: notarius:
config_profile: downstream-economy config_overlay: downstream-economy
``` ```
Selecting Narratio's `testing` profile then supplies `downstream-economy` only Place Audita's settings in `overlays/testing.yml` beside its configuration, and
Notarius's settings in `overlays/downstream-economy.yml` beside its configuration.
Use each tool's own schema for model/profile selection and other settings;
Narratio neither copies those settings nor implicitly forwards its overlay name.
Select the Narratio file with `--overlay testing` on the desired command. Each
downstream selector is used only when its stage actually executes: `polish` for
Audita and `extract` for Notarius. Production can omit both selectors.
For Audita debug inspection, its testing overlay can include:
```yaml
diagnostics:
debug: true
work_dir: /path/to/audita-debug
```
Audita retains detailed artifacts under that directory. Inspect them there;
Narratio does not collect or manage them. Ordinary Audita reports remain a
separate existing integration. Leave debug disabled in Audita's production
configuration unless deliberately needed.
Notarius's debug controls are different: `debug.directory` selects a location
but does not enable capture. Capture requires its `--debug` flag, which Narratio
does not currently forward. Selecting a Notarius testing overlay therefore does
not by itself enable a debug bundle. See the upstream
[Audita configuration](https://gitea.maximumdirect.net/eric/audita/src/tag/v2.0.1/docs/config.md)
and [Notarius CLI](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.8.1/docs/cli.md)
for their respective controls.
Edits inside downstream configuration behind an unchanged selector are not
automatically detected by Narratio. To inspect a fresh Audita debug run, explicitly
rerun polish, for example:
```sh
narratio run SESSION --overlay testing --force --from polish --through polish
```
This replaces polish output and invalidates dependent results; it is not a
diagnostics-only operation. Selecting testing does not create an isolated
workspace or retain a separate current result.
Notarius v0.8+ defaults to two additional eligible transient provider attempts
per generation. Review downstream retry settings and Narratio's extraction
timeout together; retries can increase elapsed time and provider charges.
Notarius v0.8.1 refreshes its built-in profile checkpoint identity, so older
checkpoints may not be reused. Source-keyed chunk plans and already published
outputs are unaffected; no manual cache deletion is required.
Selecting Narratio's `testing` overlay then supplies `downstream-economy` only
when extraction executes. Switching that explicit selector makes a recorded when extraction executes. Switching that explicit selector makes a recorded
extraction non-current and reruns it when `extract` is in the selected stage extraction non-current and reruns it when `extract` is in the selected stage
range; the usual dependent invalidation then applies. Selecting a profile does range; the usual dependent invalidation then applies. Selecting an overlay does
not create a separate workspace or retain a separate current extraction result. not create a separate workspace or retain a separate current extraction result.
Narratio cannot observe edits inside Notarius configuration behind an unchanged Narratio cannot observe edits inside Notarius configuration behind an unchanged
selector. Force extraction after such an edit when a replacement is intended. selector. Force extraction after such an edit when a replacement is intended.
Use `regenerate-artifacts` when both extraction and analysis must be rebuilt; Use `regenerate-artifacts` when both extraction and analysis must be rebuilt;
use `analyze` when the existing extraction bundle should be preserved. Field use `analyze` when the existing extraction bundle should be preserved. Field
semantics belong in [Configuration](./config.md#notarius-configuration-profile), semantics belong in [Configuration](./config.md#notarius-configuration-overlay),
and the exact downstream invocation belongs in the with Audita selectors covered under
[Notarius integration contract](./integrations/notarius.md#invocation). [Audita selection and credentials](./config.md#audita-selection-and-credentials).
Exact downstream invocations belong in the [Audita](./integrations/audita.md)
and [Notarius](./integrations/notarius.md#invocation) integration contracts.
## Artifact Selection ## Artifact Selection
@@ -409,6 +464,82 @@ execute. Deleting Narratio's local reference snapshots does not invalidate a
complete durable extraction; missing snapshots are reconstructed only when a complete durable extraction; missing snapshots are reconstructed only when a
later extraction actually runs. later extraction actually runs.
## Migrating Manifest Overlay Provenance
The pipeline-selection rename is a clean cutover. Manifests containing
`effective_config.selected_profile` are rejected by strict decoding; Narratio
does not silently discard or translate that retired field.
Before using an affected local session with the new version:
1. Stop Narratio activity for the session and back up its manifest.
2. Rename only `effective_config.selected_profile` to `selected_overlay`, keeping
its `name` and `source`, or remove the retired informational member. If a
current `selected_overlay` member already exists, retain it and remove the
retired member rather than creating duplicate keys.
3. Preserve the schema version, digest, stage records, checkpoint evidence,
output references, and other session state. Retry the intended command.
This metadata is informational, not resume authority. Do not delete session
progress or force all stages merely to perform this migration. Other unknown
fields still require investigation rather than blanket removal.
Do not edit immutable remote committed snapshots. Restoring an older snapshot
with retired provenance requires a compatible previous Narratio version, followed
by migration of the restored local manifest before using the new version.
## Migrating Audita and Provider Credentials
This is a breaking clean cutover to Audita v2. Narratio does not translate an
Audita v1 configuration, retain old field aliases, or generate a downstream
configuration. Update both the deployed Audita executable/configuration and
the Narratio pipeline before running polish. Retired Narratio fields are
rejected by strict configuration loading; account for this break when choosing
a release version.
| Previous Narratio setting | New ownership and selection |
| --- | --- |
| `audita.base_url` | Audita v2 `backends.<id>.endpoint` |
| `audita.total_llm_concurrency`, `proposal_llm_concurrency`, `validation_llm_concurrency` | Audita v2 `backends.<id>.concurrency` and optional `queue_capacity`; backend capacity is shared across roles |
| `audita.model`, `validation_model` | Audita PromptKit profile files; optionally select them from Narratio with `audita.proposal_profile` and `validation_profile` |
| `audita.llm_api_key` | `audita.credentials: [PROVIDER_KEY_NAME]`, matching each Audita backend's `credential` name |
| `notarius.llm_api_key` | `notarius.credentials: [PROVIDER_KEY_NAME]`, matching references in Notarius-owned profiles |
| `audita.work_dir_retention`, `report` | Remove; Audita's external configuration owns diagnostics and reporting |
An Audita configuration can also be selected through `audita.config_path`,
and `audita.overlay` selects an Audita overlay independently of Narratio's
`--overlay`. Narratio forwards no implicit provider key: a single provider-named
secret source may be listed for both adapters, and an Audita-only backend key
may be listed only for Audita. Audita resolves credentials for all configured
backends, including those not currently selected. Notarius looks up its own
profiles; Narratio does not discover their credential names. Credential lists
may be cleared with `[]` in a Narratio overlay.
Prepare the external file from Audita's maintained
[local example](https://gitea.maximumdirect.net/eric/audita/src/tag/v2.0.0/examples/minimal-config.yml),
[service example](https://gitea.maximumdirect.net/eric/audita/src/tag/v2.0.0/examples/production-config.yml),
and [v2 migration guide](https://gitea.maximumdirect.net/eric/audita/src/tag/v2.0.0/docs/migrations/v2.md).
Before switching the executable, validate the file offline:
```bash
audita config validate --config /usr/local/etc/audita/config.yml
```
Use `audita config print-effective --config /usr/local/etc/audita/config.yml --view profiles`
to inspect downstream profile selection without contacting a provider. For a
production Audita configuration, leave ordinary reports enabled
and debug disabled unless an operator deliberately chooses otherwise; Narratio
does not override those settings. Audita writes `errors.yml`, `recoveries.yml`,
and `report.md` beside the polished output when reporting is enabled. An
exit-zero run with a valid transcript succeeds even if those reports describe
recoverable errors or no applied corrections. See the [Audita integration
contract](./integrations/audita.md) for report placement and limits.
Narratio's polish resume evidence tracks selected config path, Audita overlay,
profiles, ordered modules, description, schema, and output identity. Editing
Audita-owned files behind unchanged selectors is invisible to Narratio; force
polish with `narratio run-stage polish <session-id> --force` after such edits.
## External Command Lifecycle ## External Command Lifecycle
When an external command is cancelled or times out, Narratio terminates its When an external command is cancelled or times out, Narratio terminates its
@@ -426,6 +557,9 @@ tree and reports which stream exceeded the limit.
Run-local diagnostics are: Run-local diagnostics are:
- `runs/{run_id}/polish/outputs/transcripts/errors.yml`,
`recoveries.yml`, and `report.md` when Audita writes them beside its
run-local polished transcript
- `runs/{run_id}/extract/notarius.receipt.json` - `runs/{run_id}/extract/notarius.receipt.json`
- `runs/{run_id}/extract/notarius.stderr.log` - `runs/{run_id}/extract/notarius.stderr.log`
- `runs/{run_id}/extract/notarius-output/` before durable promotion - `runs/{run_id}/extract/notarius-output/` before durable promotion
@@ -439,6 +573,10 @@ Unlisted files and unsafe entries are never uploaded; a run with no declared
current-run diagnostics uploads no run-local files. No distinguished diagnostic current-run diagnostics uploads no run-local files. No distinguished diagnostic
file is uploaded. The durable bundle is never scanned for implicit publication; file is uploaded. The durable bundle is never scanned for implicit publication;
only lanes named by explicit `pipeline.publish.outputs` rules are uploaded. only lanes named by explicit `pipeline.publish.outputs` rules are uploaded.
To inspect a published polish report remotely, read its declared object under
the run archive's `polish/outputs/transcripts/` prefix. Session restore does
not restore run-archive diagnostics as canonical session files; it restores only
the committed manifest and declared transcript/artifact objects.
To intentionally replace the current extraction result, run: To intentionally replace the current extraction result, run:
@@ -692,9 +830,10 @@ files. Existing environment variables—including empty values—always win.
S3 `access_key_id` and `secret_access_key` are an optional pair of typed S3 `access_key_id` and `secret_access_key` are an optional pair of typed
environment references. When both are absent, the AWS SDK retains its default environment references. When both are absent, the AWS SDK retains its default
credential provider chain. When both are configured, Narratio supplies their credential provider chain. When both are configured, Narratio supplies their
load-time resolved values through an explicit static provider. Audita's optional load-time resolved values through an explicit static provider. Audita and
`llm_api_key` is likewise captured at pipeline load and supplied only through Notarius credential lists likewise resolve at pipeline load. Each adapter
the child process's `AUDITA_LLM_API_KEY` override. receives only its listed names and captured values, with no alias mapping or
ambient environment inheritance. See [configuration](./config.md#audita-selection-and-credentials).
External adapter results are individually bounded before Narratio validates or External adapter results are individually bounded before Narratio validates or
materializes them. These per-file limits do not reserve disk space: prevent hard materializes them. These per-file limits do not reserve disk space: prevent hard

213
docs/oss/attribution.md Normal file
View File

@@ -0,0 +1,213 @@
# Open Source Attribution Notices
## Standard Repository Policies
Material: `docs/policy/agents.md`, `architecture.md`, `documentation.md`,
`testing.md`, `release.md`, `meta.md`, and `oss.md`; root `AGENTS.md` and the policy reading
guide in `docs/development.md`, adapted for this repository.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: CC-BY-SA-4.0
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
## ConfigKit
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.4.2`.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL 3.0](licenses/LGPL-3.0.txt) |
[GPL 3.0](licenses/GPL-3.0.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/67c03690181c5af8dde6be325abac241dae6732f/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/67c03690181c5af8dde6be325abac241dae6732f/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/67c03690181c5af8dde6be325abac241dae6732f).
## YAML v3
Selected module: `gopkg.in/yaml.v3` at `v3.0.1`.
Copyright (c) 2006-2010 Kirill Simonov
Copyright (c) 2006-2011 Kirill Simonov
Copyright (c) 2011-2019 Canonical Ltd
Copyright 2011-2016 Canonical Ltd.
SPDX-License-Identifier: MIT AND Apache-2.0
License and notices: [MIT](licenses/MIT.txt) |
[Apache 2.0](licenses/Apache-2.0.txt) |
[LICENSE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/LICENSE) |
[NOTICE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/NOTICE).
[Source](https://github.com/go-yaml/yaml/commit/f6f7691b1fdeb513f56608cd2c32c51f8194bf51).
## YAML v4
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
Copyright 2006-2010 Kirill Simonov
Copyright 2011-2019 Canonical Ltd
Copyright 2025 The go-yaml Project Contributors
SPDX-License-Identifier: MIT AND Apache-2.0
License and notices: [MIT](licenses/MIT.txt) |
[Apache 2.0](licenses/Apache-2.0.txt) |
[LICENSE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/LICENSE) |
[NOTICE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/NOTICE).
[Source](https://github.com/yaml/go-yaml/commit/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125).
## go-cmp
Selected module: `github.com/google/go-cmp` at `v0.7.0` (test-only).
Copyright (c) 2017 The Go Authors. All rights reserved.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://github.com/google/go-cmp/blob/9b12f366a942ebc7254abc7f32ca05068b455fb7/LICENSE).
[Source](https://github.com/google/go-cmp/commit/9b12f366a942ebc7254abc7f32ca05068b455fb7).
## Go sync extension
Selected module: `golang.org/x/sync` at `v0.22.0`.
Copyright 2009 The Go Authors.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://github.com/golang/sync/blob/1eb64d4bc0cde6da1bb8ebc7f178bb577508e5d0/LICENSE) |
[PATENTS](https://github.com/golang/sync/blob/1eb64d4bc0cde6da1bb8ebc7f178bb577508e5d0/PATENTS).
[Source](https://github.com/golang/sync/commit/1eb64d4bc0cde6da1bb8ebc7f178bb577508e5d0).
## Go system extension
Selected module: `golang.org/x/sys` at `v0.47.0`.
Copyright 2009 The Go Authors.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://github.com/golang/sys/blob/9e7e939dcafac07e8ab4cffa6e5fc74908413f00/LICENSE) |
[PATENTS](https://github.com/golang/sys/blob/9e7e939dcafac07e8ab4cffa6e5fc74908413f00/PATENTS).
[Source](https://github.com/golang/sys/commit/9e7e939dcafac07e8ab4cffa6e5fc74908413f00).
## Go runtime and standard library
Material: Go `1.26.7` runtime and standard library material incorporated in
release binaries built with the declared `golang:1.26.7` CI toolchain.
Copyright 2009 The Go Authors.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://github.com/golang/go/blob/e3336a22ad3f0a90bd252c95d8b5544e02674205/LICENSE) |
[PATENTS](https://github.com/golang/go/blob/e3336a22ad3f0a90bd252c95d8b5544e02674205/PATENTS).
[Source](https://github.com/golang/go/commit/e3336a22ad3f0a90bd252c95d8b5544e02674205).
## AWS SDK for Go v2
Selected modules under `github.com/aws/aws-sdk-go-v2` at source revision
`2223642aeafb3b46ae924667ee47d31a1cf5a9d5`: root `v1.41.7`,
`aws/protocol/eventstream` `v1.7.10`, `config` `v1.32.17`, `credentials`
`v1.19.16`, `feature/ec2/imds` `v1.18.23`, `internal/configsources`
`v1.4.23`, `internal/endpoints/v2` `v2.7.23`, `internal/v4a` `v1.4.24`,
`service/internal/accept-encoding` `v1.13.9`, `service/internal/checksum`
`v1.9.15`, `service/internal/presigned-url` `v1.13.23`,
`service/internal/s3shared` `v1.19.23`, `service/signin` `v1.0.11`,
`service/sso` `v1.30.17`, `service/ssooidc` `v1.35.21`, and `service/sts`
`v1.42.1`.
Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved.
Copyright 2014-2015 Stripe, Inc.
SPDX-License-Identifier: Apache-2.0
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
[LICENSE.txt](https://github.com/aws/aws-sdk-go-v2/blob/2223642aeafb3b46ae924667ee47d31a1cf5a9d5/LICENSE.txt) |
[NOTICE.txt](https://github.com/aws/aws-sdk-go-v2/blob/2223642aeafb3b46ae924667ee47d31a1cf5a9d5/NOTICE.txt).
[Source](https://github.com/aws/aws-sdk-go-v2/commit/2223642aeafb3b46ae924667ee47d31a1cf5a9d5).
## AWS SDK for Go v2 S3
Selected module: `github.com/aws/aws-sdk-go-v2/service/s3` at `v1.101.0`.
Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved.
Copyright 2014-2015 Stripe, Inc.
SPDX-License-Identifier: Apache-2.0
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
[LICENSE.txt](https://github.com/aws/aws-sdk-go-v2/blob/84ddd08980744ac0f3bacfe7d2796c861671accc/service/s3/LICENSE.txt) |
[NOTICE.txt](https://github.com/aws/aws-sdk-go-v2/blob/84ddd08980744ac0f3bacfe7d2796c861671accc/NOTICE.txt).
[Source](https://github.com/aws/aws-sdk-go-v2/commit/84ddd08980744ac0f3bacfe7d2796c861671accc).
## AWS Smithy Go
Selected module: `github.com/aws/smithy-go` at `v1.25.1`.
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
[LICENSE](https://github.com/aws/smithy-go/blob/e094f45e716e33a1b950cf8bbe804790bf87f965/LICENSE) |
[NOTICE](https://github.com/aws/smithy-go/blob/e094f45e716e33a1b950cf8bbe804790bf87f965/NOTICE).
[Source](https://github.com/aws/smithy-go/commit/e094f45e716e33a1b950cf8bbe804790bf87f965).
## Go singleflight in AWS SDK for Go v2
Material: `internal/sync/singleflight` in the selected AWS SDK root module.
Copyright (c) 2009 The Go Authors. All rights reserved.
Copyright 2013 The Go Authors. All rights reserved.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://github.com/aws/aws-sdk-go-v2/blob/2223642aeafb3b46ae924667ee47d31a1cf5a9d5/internal/sync/singleflight/LICENSE).
[Source](https://github.com/aws/aws-sdk-go-v2/commit/2223642aeafb3b46ae924667ee47d31a1cf5a9d5).
## Go singleflight in AWS Smithy Go
Material: `internal/sync/singleflight` in the selected smithy-go module.
Copyright (c) 2009 The Go Authors. All rights reserved.
Copyright 2013 The Go Authors. All rights reserved.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://github.com/aws/smithy-go/blob/e094f45e716e33a1b950cf8bbe804790bf87f965/internal/sync/singleflight/LICENSE).
[Source](https://github.com/aws/smithy-go/commit/e094f45e716e33a1b950cf8bbe804790bf87f965).

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,11 @@
Copyright (c) <year> <owner>.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,170 @@
Creative Commons Attribution-ShareAlike 4.0 International
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors.
Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public.
Creative Commons Attribution-ShareAlike 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Section 1 – Definitions.
a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
c. BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
e. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.
h. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
i. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
k. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
l. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
m. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
Section 2 – Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part; and
B. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. Term. The term of this Public License is specified in Section 6(a).
4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
5. Downstream recipients.
A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply.
C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.
Section 3 – License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified form), You must:
A. retain the following if it is supplied by the Licensor with the Licensed Material:
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of warranties;
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
b. ShareAlike.In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
Section 4 – Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
Section 5 – Disclaimer of Warranties and Limitation of Liability.
a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
Section 6 – Term and Termination.
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
Section 7 – Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
Section 8 – Interpretation.
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
Creative Commons may be contacted at creativecommons.org.

View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

18
docs/oss/licenses/MIT.txt Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

79
docs/policy/agents.md Normal file
View File

@@ -0,0 +1,79 @@
# Agent Policy
**Base Policy Version:** 1.3
**Last Updated**: 2026-09-28
**Upstream Source**: https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/agents.md
## Purpose
Agents must read and follow these rules.
## Implementation Discipline
**KEY PRINCIPLES:** Write the simplest idiomatic code that satisfies the user's
request. Do not implicitly strengthen requirements, restrictions, or guarantees
beyond what the user actually requested.
**Keep implementation machinery proportionate.** Use straightforward,
idiomatic code and introduce only the machinery needed to support the requested
behavior and flexibility. Optimize for conceptual simplicity and
maintainability, not for the fewest lines, fewest options, or narrowest product
surface.
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
harden, or otherwise narrow behavior unless required by the user, an explicit
repository contract, or an unavoidable correctness constraint. Preserve
operator and caller choice where policy has not been established. If an
additional restriction appears advisable, present it as a decision rather than
silently implementing it.
**Do not invent guarantees.** Do not strengthen public contracts, guarantees,
or invariants beyond those expressly requested by the user.
**Use the standard library.** For example: in almost every case, the best
approach to copying files is to use the standard library. Writing custom code to
implement power-loss protection at the application level, or to guard against
bad actors with privileged and concurrent access to the host's filesystem,
is wasteful, unnecessary, and not idiomatic. Stick to the simple, standard, and
idiomatic approach unless another path is expressly requested by the user.
**An implementation detail is not repository policy.** Tests and documentation
must not elevate incidental behavior into a durable guarantee or restriction
unless that behavior was expressly selected as part of the contract.
## Git Commit Policy
By default, commit each completed, nonempty unit of work using a
concise, plain-English message describing what changed. This policy is standing
authorization to create those local commits; a separate user request is not
required. An explicit user instruction to leave work uncommitted takes precedence.
A unit of work is a focused, reviewable outcome, including its relevant tests and
documentation.
Complete the appropriate validation before committing. If work is incomplete or
validation is blocked, report that state accurately.
Before committing:
1. Run `git status --short` and review the relevant diff, including any staged
changes. Identify changes that predate the current unit of work.
2. Exclude secrets, local environment files, generated junk, and unrelated changes.
3. Stage only the files or hunks belonging to the completed unit. Use `git add .`
only when every included change belongs to that unit. Preserve unrelated
staged and unstaged work.
4. Review `git diff --staged` and run appropriate checks for the change. Reuse
successful checks for unchanged content.
5. Review `git log --oneline -n 10` and use
`git commit -m "<concise plain-English commit message>"`.
6. Report the commit identifier and any remaining uncommitted work.
If the unit cannot be safely separated from pre-existing work, leave it uncommitted
and explain the overlap rather than silently including that work. If a commit
fails, preserve the changes and report the failure; do not bypass hooks.
Default commit authorization does not authorize pushing, publishing, amending,
resetting, reverting, or rewriting history. Those actions require a separate user
instruction. Do not use `git commit -a` or `git commit --no-verify` to bypass the
staging and validation discipline above.

View File

@@ -1,10 +1,41 @@
# Architecture # Architecture Policy
This document defines Narratio's intended high-level architecture and the **Base Policy Version:** 1.3
invariants that changes must preserve. Implemented component details belong in
the [Internal Overview](../internal/overview.md) and its linked documents. **Last Updated**: 2026-09-28
Significant architectural decision history belongs under `docs/adr/` when such
records exist. **Upstream Source**: https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md
## Purpose And Ownership
The adopted policy defines the application's purpose and the architectural
constraints contributors must follow. It owns system boundaries, responsibility
and dependency rules, established invariants, and explicit non-goals.
Describe the concrete package inventory and implementation mechanics in internal
documentation. Link to canonical public contracts for their exact behavior and
to ADRs for decision rationale.
## Architectural Approach
Hexagonal architecture is strongly recommended, including encapsulating external
integrations and APIs within internal adapters. Keep domain and application
logic independent of external integration details, and make dependency direction
clear in the adopted policy.
Implement that separation with proportionate, idiomatic code. Choose interfaces
and package layout to serve the application's actual boundaries. Use adapters
for collaborators with external integration responsibilities.
## Adopted Constraints And Implemented Structure
Architectural constraints govern current and future development. Record
implemented structure accurately, proposals and implementation status in the
roadmap, and significant decisions and rationale in ADRs. Describe a target
structure as implemented when its changes land.
Constraints must reflect deliberately selected requirements and decisions.
Do not promote incidental implementation choices into architectural invariants.
## System Shape ## System Shape
@@ -148,13 +179,13 @@ Configuration is strict, explicit, centralized, and operator-oriented.
- Empty configured values do not silently replace meaningful defaults. - Empty configured values do not silently replace meaningful defaults.
- Validation rejects invalid configuration before stage execution where - Validation rejects invalid configuration before stage execution where
practical. practical.
- Root-owned imports and one selected profile resolve deterministically through - Root-owned imports and one selected overlay resolve deterministically through
the configuration owner; commands do not implement their own merge rules. the configuration owner; commands do not implement their own merge rules.
- Canonical party rosters are campaign-owned. Their derived players projection - Canonical party rosters are campaign-owned. Their derived players projection
and concrete character-family artifacts are resolved before runtime stages and concrete character-family artifacts are resolved before runtime stages
or adapters receive configuration. or adapters receive configuration.
- Resume uses stage- or artifact-owned semantic evidence for observable - Resume uses stage- or artifact-owned semantic evidence for observable
result-affecting configuration; profile identity and an effective digest are result-affecting configuration; overlay identity and an effective digest are
provenance, never blanket cache keys. provenance, never blanket cache keys.
- Session templating remains narrow and deterministic rather than becoming a - Session templating remains narrow and deterministic rather than becoming a
general configuration language. general configuration language.

View File

@@ -1,9 +1,15 @@
# Documentation Policy # Documentation Policy
**Base Policy Version:** 1.11
**Last Updated**: 2026-09-28
**Upstream Source**: https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md
## Purpose ## Purpose
This policy assigns each documentation topic to one canonical owner. Its goal is This policy assigns each documentation topic to one canonical owner. Its goal is
to keep Narratio documentation accurate, concise, discoverable, and resistant to keep Narratio's documentation accurate, concise, discoverable, and resistant
to drift for users, operators, developers, integrators, and LLM coding agents. to drift for users, operators, developers, integrators, and LLM coding agents.
## Core Rules ## Core Rules
@@ -12,36 +18,38 @@ to drift for users, operators, developers, integrators, and LLM coding agents.
Each authoritative fact belongs in one document. A non-owning document may give 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 a short, stable summary for orientation, but it must link to the canonical owner
instead of repeating volatile details. for volatile details.
Volatile details include commands, flags, configuration fields and defaults, Volatile details include commands, flags, configuration fields and defaults,
stage or integration keys, schemas, file names, paths, status codes, retry module keys, schemas, file names, paths, status codes, retry behavior, and
behavior, and runtime guarantees. If readers could reasonably treat a statement runtime guarantees. If readers could reasonably treat a statement as a
as a contract, maintain it only in the owning document. contract, maintain it only in the owning document.
### Current And Future Behavior ### Current And Future Behavior
Outside `docs/roadmap/`, documentation describes implemented behavior only. Outside `docs/roadmap/`, documentation describes implemented behavior only.
Partial features may be described only to their implemented boundary. Partial features may be described only to their implemented boundary.
ADRs are the narrow exception: an ADR may record an accepted architectural Architectural constraints may govern future changes. Describe current structure
decision before implementation, but acceptance must not be presented as proof from implemented behavior.
that the behavior exists. The roadmap owns implementation status and sequencing
until the decision is implemented. Current architecture, user, operator, ADRs may record an accepted architectural decision before implementation. The
integration, and internal documentation are updated when the behavior lands. 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.
### Audience And Detail ### Audience And Detail
Write for the document's stated audience and include only the detail needed for 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. its owned topic. Keep user and operator docs focused on using and operating the
Developer docs should link to user-facing and external contracts rather than application. Developer docs should link to user-facing and external contracts
restate them. for their definitions.
### Examples ### Examples
Complete copyable files belong in `examples/`. Documentation may use the Complete copyable files belong in `examples/`. Documentation may use the
smallest illustrative snippet needed to explain its owned topic, but should link smallest illustrative snippet needed to explain its owned topic, but should link
to maintained examples instead of embedding a second complete copy. to maintained examples for complete copyable files.
Examples must be valid, secret-free, and tested where practical. Commands and Examples must be valid, secret-free, and tested where practical. Commands and
configuration used in documentation should match the application. configuration used in documentation should match the application.
@@ -57,53 +65,69 @@ secret values.
| Topic | Canonical owner | Owned content | Content owned elsewhere | | Topic | Canonical owner | Owned content | Content owned elsewhere |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Product orientation and minimal end-to-end quickstart | `README.md` | What Narratio is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, implementation detail. | | Product orientation and minimal end-to-end quickstart | `README.md` | Purpose, value, shortest successful invocation, and navigation, including licensing and attribution. | 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. | | Contributor entry point | `docs/development.md` | Task-oriented reading guide, contributor orientation, baseline validation commands, and canonical links. | Package inventory, architecture rules, subsystem behavior, detailed change recipes. |
| Maintainer release procedure | `docs/release.md` | Version selection, candidate preparation and validation, guarded tag publication, release completion boundary, failure recovery, and optional asynchronous inspection. | Script implementation mechanics, current application contracts, and historical release summaries. | | Application architecture policy | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
| Historical release summary | `docs/releases/<tag>.md` | Immutable summary, compatibility, upgrade, and changes for one released version. | Current maintainer procedure and current application contract details. | | Policy-set provenance and upstream navigation | `docs/policy/meta.md` | Policy-set origin, local authority, adoption baselines, and upstream adoption and update links. | Task-specific reading triggers, detailed adoption procedures, and formal attribution notices. |
| 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. | | Agent policy | `docs/policy/agents.md` | Proportionate, idiomatic implementation; rules against inventing requirements, restrictions, guarantees, or policy. | Application contracts, architecture, testing policy, and contributor procedures. |
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. | | Documentation policy | `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. | | Testing policy | `docs/policy/testing.md` | Test philosophy, risk sufficiency, boundaries, doubles, coverage, regressions, and test maintenance. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
| CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, output conventions, and exit behavior. | End-to-end operating procedures, configuration field definitions, runtime filesystem layout, stage implementation details. | | Release policy | `docs/policy/release.md` | Versioning, compatibility, release integrity and immutability, release documentation, proportionate gates, publication controls, completion, and required versus optional checks. | Repository-specific commands, product installation guidance, CLI version semantics, historical release summaries, and CI implementation detail. |
| Configuration contract | `docs/config.md` | Discovery and precedence, file schemas, fields, defaults, environment overrides, validation rules, and user-selectable stage or integration settings. | Complete example files, CLI syntax, runtime state lifecycle, implementation details. | | Open source licensing and attribution policy | `docs/policy/oss.md` | License defaults and author choices, COPYRIGHT and attribution formats, license/notice links, discoverability, maintenance, and concrete exceptions. | Component entries, license texts, release commands, and dependency security review. |
| Operations | `docs/operations.md` | Runtime workflows, physical filesystem and remote-state layout, output and diagnostic handling, resume, cleanup, permissions, recovery, and operational limits. | CLI flag syntax, configuration field definitions, logical artifact schemas, implementation mechanics. | | Project copyright and license grant | `COPYRIGHT` | Project identity, copyright holders and years, SPDX expression, grant, warranty disclaimer, and third-party statement. | Complete license texts and upstream component attribution. |
| Troubleshooting | `docs/troubleshooting.md` | Symptom-driven diagnosis, likely causes, safe inspection steps and remedies, and links to relevant contracts. | CLI syntax, configuration definitions, operational procedures, integration contracts, implementation mechanics. | | Project license text | `LICENSE` | Complete GPLv3 project license text. | Companion dependency license texts and upstream component attribution. |
| 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. | | Upstream component attribution | `docs/oss/attribution.md` | House Format component entries: identity, material scope and modifications where applicable, copyright, license, required notices, and license, notice, and source links. | General attribution policy, exception reasoning, research narratives, compliance commentary, and complete standard license texts. |
| 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. | | Standard dependency license texts | `docs/oss/licenses/` | Complete, verbatim standard license texts shared by applicable entries. | Project-specific upstream notices, attribution prose, and policy interpretation. |
| External and durable integration contracts | `docs/integrations/` | External file formats and protocols, upstream and downstream contracts, logical artifact paths and schemas, media types, and compatibility behavior. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, configuration defaults. | | Repository-specific release procedure | `docs/release.md` | Maintainer prerequisites, candidate preparation, validation and publication commands and automated guards, asynchronous deliverables, delivery status, optional consumer verification, and recovery. | General release requirements, product installation guidance, historical release summaries, and current compatibility contracts. |
| 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. | | CLI contract (if applicable) | `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. |
| 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. | | Configuration contract (if applicable) | `docs/config.md` | Discovery, precedence, schema, fields, defaults, environment overrides, validation, and selectable module and validator keys. | Complete example files, CLI syntax, runtime state lifecycle, module implementation details. |
| Architectural decision history | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. | | Operations | `docs/operations.md` | Runtime workflows, physical filesystem and state layout, output, cache and debug handling, resume, cleanup, permissions, recovery, and limits. | CLI flag syntax, configuration field definitions, logical output schemas, implementation mechanics. |
| 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. | | Troubleshooting | `docs/troubleshooting.md` | Symptom-driven diagnosis, likely causes, safe inspection, and remedies. | Canonical commands, configuration definitions, operational procedures, and integration contracts. |
| Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other files intended to be copied or run. | Field-by-field reference, command reference, prose explanation. | | Integration contracts for external components (if applicable) | `docs/integrations/` | External formats and protocols, upstream and downstream contracts, logical output paths and schemas, media types, and compatibility. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, configuration defaults. |
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
| Architectural decision records | `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. |
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
| Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other copyable or runnable files. | Field-by-field reference, command reference, prose explanation. |
Documents that do not exist are required only when the corresponding interface Create supporting documents for existing interfaces and responsibilities when
or responsibility exists. Do not create placeholder API, consumer, integration, substantive content is available.
or operations documents for behavior the application does not have.
## Boundary Rules ## Boundary Rules
### Orientation ### Orientation
The README owns product orientation. The developer guide routes contributors. Link among orientation, architecture, and internal documents; maintain each
Architecture owns normative structure. Internal overview owns the current package or behavior description in its canonical owner.
concrete component map. These documents may link to one another but should not
maintain parallel package or behavior descriptions.
### Commands, Configuration, Operations, And Troubleshooting ### Commands, Configuration, Operations, And Troubleshooting
CLI documentation answers how to invoke the application. Configuration CLI documentation answers how to invoke the application. Configuration
documentation answers what settings mean. Operations answers what happens to documentation answers what settings mean. Operations answers what happens to
runtime state and how to operate or recover the application. Troubleshooting runtime state and how to operate or recover the application. When a workflow
starts from observable symptoms and links readers to the owning command, crosses these topics, choose the document that owns the task and link to the
configuration, operational, or integration contract. When a workflow crosses other contracts. Troubleshooting begins with observable symptoms and links to the owning contract.
these topics, choose the document that owns the task and link to the other
contracts. ### Releases
The release policy defines requirements and invariants that every release must
satisfy. The repository-specific release procedure defines the commands and
workflow maintainers use to satisfy that policy. Release notes record history
at immutable tags. Maintain current behavior in its canonical documentation,
update it with the corresponding behavior changes, and keep release procedures
consistent with adopted policy.
### Project Licensing And Upstream Attribution
README and release documents link to the canonical licensing and attribution
materials under the [OSS policy](oss.md). The release procedure owns URL
construction and publication mechanics.
### Contracts And Implementation ### Contracts And Implementation
Integration and API documents define externally observable shapes and Integration and API documents define externally observable shapes and
semantics. Internal documents explain how Narratio implements or consumes those semantics. Internal documents explain how this application implements or consumes those
contracts. Internal docs may name a field, file, or protocol to identify a contracts. Internal docs may name a field, file, or protocol to identify a
dependency, but must link to its canonical contract for the definition. dependency, but must link to its canonical contract for the definition.
@@ -112,8 +136,7 @@ dependency, but must link to its canonical contract for the definition.
This policy owns what documentation and examples may contain. Architecture owns This policy owns what documentation and examples may contain. Architecture owns
application security invariants. Configuration owns credential-supply application security invariants. Configuration owns credential-supply
mechanisms. Operations owns permissions and handling of sensitive runtime mechanisms. Operations owns permissions and handling of sensitive runtime
artifacts. Troubleshooting owns safe diagnostic and remediation guidance. artifacts. Internal docs own implementation mechanisms only.
Internal docs own implementation mechanisms only.
## Architecture Decision Records ## Architecture Decision Records

27
docs/policy/meta.md Normal file
View File

@@ -0,0 +1,27 @@
# Policy Set Provenance
**Base Policy Version:** 1.0
**Last Updated**: 2026-09-28
**Upstream Source**: https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/meta.md
## Origin And Local Authority
This repository's policy set is adapted from
[Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy).
The adopted local policies govern work in this repository. Upstream revisions
become local policy through deliberate adoption, preserving local decisions.
Each policy's `Upstream Source` identifies its adopted baseline.
## Adoption And Updates
For policy adoption, upstream revision reviews, and updates, consult the
[upstream adoption and update guide](https://gitea.maximumdirect.net/eric/policy/src/branch/main/docs/adoption.md).
That guide and the reference example collection are maintained upstream and
accessed through links. Keep discovery links pointed at the current upstream
project and guide; keep adopted-source and example links pinned to commits.
The local [developer guide](../development.md) routes tasks to applicable
policies. Formal credit and licensing notices for the copied policy material
belong in the local [attribution document](../oss/attribution.md).

217
docs/policy/oss.md Normal file
View File

@@ -0,0 +1,217 @@
# Open Source Licensing And Attribution Policy
**Base Policy Version:** 1.8
**Last Updated**: 2026-09-28
**Upstream Source**: https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md
## Purpose And Scope
This policy defines project licensing conventions and a repeatable House Format
for upstream attribution and license notices. It covers the project's own code
and upstream components incorporated into or distributed with the application
or library, including relevant transitive dependencies and copied code,
documentation, or assets.
Determine treatment from the component's terms and actual use.
Consulting development guidance or using tooling alone does not require an
attribution entry. Copying or adapting documentation for inclusion in the
repository is different: preserve its applicable attribution and license terms.
A material's role as development guidance does not exempt copied documents.
Apply any express permissions for attribution-free reuse of example formatting.
The baseline is a publicly available source repository with releases published
alongside it. The format is independent of language, forge, and build system.
It does not require attribution for every development tool, establish license
compatibility, or resolve every obligation arising from use or distribution.
## Canonical Locations
The [documentation policy](documentation.md) assigns ownership:
- `COPYRIGHT`: project name, copyright statements, SPDX expression, grant,
warranty disclaimer, and third-party statement.
- `LICENSE` contains the complete, unmodified project license text, including
incorporated companion text. Dependency attribution does not relicense the project.
- `docs/oss/attribution.md`: canonical component entries.
- `docs/oss/licenses/`: complete, verbatim standard and applicable companion texts.
Use recognizable license filenames, such as `Apache-2.0.txt` or `LGPL-3.0.txt`.
A shared standard text may serve multiple entries. Keep explanations outside
license texts and preserve distinct versions and exceptions.
## Narratio Licensing Scope
Narratio application code remains GPL-3.0-or-later under its existing
[COPYRIGHT](../../COPYRIGHT) and [LICENSE](../../LICENSE). Its source and release
artifacts are hosted on Gitea; separately installed subprocess applications are
not incorporated merely because Narratio invokes them. The
[release procedure](../release.md) owns publication mechanics. Policy documents
adapted from Standard Repository Policies retain CC-BY-SA-4.0 under the distinct
scope recorded in COPYRIGHT and [attribution](../oss/attribution.md).
## Upstream Reference Examples
Use the commit-pinned upstream examples to create actual project notices.
Retain their links and keep the example collection upstream; keep applicable
license texts locally as specified above.
## Project Licensing
Unless the author specifies otherwise, use **GPL version 3 or later**
(`GPL-3.0-or-later`) for applications and **LGPL version 3 or later**
(`LGPL-3.0-or-later`) for libraries. An author's explicit choice takes precedence:
for example, a one-off script may use MIT. Such a choice requires no exception
rationale, additional approval, or compensating controls under this policy.
These defaults guide new license selection. Adopting this policy does not
relicense existing code or override third-party terms. Preserve an existing
license unless the author deliberately changes it. For mixed-purpose projects,
follow the author's selected scope rather than inferring different licenses for
individual packages.
Use the upstream [application COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/a469273adda336625f96b9d769ea27b33999f146/examples/licensing/application/COPYRIGHT)
or [library COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/a469273adda336625f96b9d769ea27b33999f146/examples/licensing/library/COPYRIGHT)
to create the project’s root `COPYRIGHT` file.
Use verified project names, contribution years, and copyright holders.
For another selected license, adapt the grant, SPDX expression, disclaimer, and
text references together to reflect the author's selected grant.
For GPLv3 projects, copy the complete GPLv3 text into `LICENSE`. For LGPLv3
projects, place the complete LGPLv3 text followed by the complete GPLv3 text in
`LICENSE`; the LGPL incorporates the GPL terms. Keep the texts unmodified and
place project-specific statements in `COPYRIGHT`. For the default grants, the
grant and SPDX expression in `COPYRIGHT` must expressly identify `or-later`;
follow the author’s choice if a different grant is selected.
For other licenses, provide their complete text and any required project-specific
fields; the standard reference collection remains unchanged. Keep `COPYRIGHT`,
`LICENSE`, SPDX expressions, and README descriptions consistent with the actual
grant. This convention introduces no blanket requirement for per-file headers;
preserve notices or add them where the applicable terms require them.
Identify separately licensed documentation or assets in the project grant, with
links to their attribution and license information. An application's default
code license does not override the terms of incorporated documentation.
## Upstream Attribution House Format
Begin `docs/oss/attribution.md` with `# Open Source Attribution Notices`,
followed directly by component entries. These comprise the complete document.
Each entry presents these five fields in order, with blank lines between them:
1. **Project name** as a level-two heading. An optional identification paragraph
may follow, such as `Selected module: MODULE at VERSION.` with the module and
version in backticks. For copied documentation, a concise `Material:` sentence
may identify the affected files and whether they were adapted or copied
without modification. A factual qualifier such as `(transitive dependency)`
is permitted. Limit optional identification prose to factual component and
material scope.
2. **Upstream copyright statement or statements**, preserving their wording.
Put multiple statements in separate paragraphs. Include this field when the
selected upstream source supplies an applicable statement; otherwise, omit it.
3. **SPDX license identifier or expression**, when applicable, using
`SPDX-License-Identifier: EXPRESSION`.
4. **License and notice links**, beginning `License and notices:`, separated
by ` | `, and ending with a period. Label local standard texts by license
name, such as `MIT` or `Apache 2.0`. Label upstream files by their actual
filenames, such as `COPYRIGHT`, `LICENSE`, `NOTICE`, or `PATENTS`.
5. **Source repository link**, rendered exactly as `[Source](COMMIT_URL).`.
Use the upstream [attribution example](https://gitea.maximumdirect.net/eric/policy/src/commit/a469273adda336625f96b9d769ea27b33999f146/examples/licensing/attribution.md)
to create the project’s `docs/oss/attribution.md`.
The example illustrates omitted fields and multiple copyrights and licenses.
Replace the uppercase placeholders and illustrative URLs with actual component
information. Local license links are relative to the destination
`docs/oss/attribution.md`; copy the applicable texts into its `licenses/`
directory. Do not copy example entries for components the project does not use.
These are five fields, not a line limit. Omit the SPDX field when no applicable
identifier exists; identify nonstandard terms through the linked text. Preserve
licensing alternatives and exceptions, and record a selected option when
relevant through the factual license identification. Do not invent an identifier
or silently resolve an unsettled licensing choice.
Keep entries factual. Do not add agent-authored explanations of license
expressions, file-level license scope, research performed, retained source
notices, or compliance conclusions. Avoid filler such as "none is invented
here" and embellished link labels such as "upstream LICENSE", "additional
patent grant", or "at the imported commit". Preserve actual required upstream
content; this rule excludes editorial additions, not required notices. Policy
interpretation and exception reasoning belong in the adopted OSS policy or an
appropriate decision record, not the attribution document.
## License Texts And Upstream Links
Link standard license texts to their local copies. Link project-specific
notices to their upstream copies. Every upstream repository or notice link must
identify the exact commit corresponding to the
imported version, including the selected source revision for copied material.
Classify material according to its contents. An upstream `LICENSE`,
`COPYING`, or similar file may contain project-specific notices or exceptions
in addition to standard wording. Link both the local standard text and the
commit-specific upstream file when needed. Make companion license texts directly
reachable from the entry.
For project-specific license and attribution material, this policy accepts
direct links to the applicable upstream texts as satisfying reproduction,
inclusion, or copy requirements. This is the adopted house interpretation.
Agents must apply it without adding local-copy requirements or seeking repeated
approval solely because a license uses those terms.
This interpretation concerns notice reproduction. Concrete obligations such as
marking modified files or supplying corresponding source receive their own
applicable treatment. Preserve required notices already present in incorporated
source files; the central attribution entry does not authorize stripping them.
## Discoverability
End Narratio's README with two friendly statements linking to its actual grant
in root `COPYRIGHT` and to `docs/oss/attribution.md`, using paths relative to
the README. The statements identify Narratio's GPL v3.0-or-later grant and make
the open source attribution notices discoverable.
Each new checked-in release document and its hosted download page must end
with links whose visible text is `COPYRIGHT | LICENSE | ATTRIBUTION`. Use absolute
URLs to those files at the release's immutable tag or corresponding immutable source
revision so the links work in both locations. This release navigation is distinct
from upstream component links, which must use the imported commit.
Reuse the checked-in release document as the hosted release body where supported.
The release procedure owns concrete URLs and publication mechanics for delivering
the links through the selected channel. Do not maintain duplicate notice lists.
These conventions do not by themselves require separate downloadable license
assets, source bundles, or a different binary package format.
## Maintenance And Exceptions
Update project licensing statements together when the author changes the grant
or its scope. Update upstream notices in the change that adds, removes, upgrades,
copies, or changes the distribution of a relevant component. Check the selected source's license
and notices rather than assuming they are unchanged. Update affected entries,
commit links, and texts; retain historical versions through Git.
Check affected statements, faithful standard-text copies, and link destinations
as part of ordinary change review. For a future release tag, check intended file
paths against the candidate source; the tag need not resolve remotely before
publication. Do not add publication-time network checks, CI polling, recurring
legal sign-offs, mandatory scanners, or a full software bill of materials merely
to implement this policy.
Use the House Format routinely. Raise an exception only for a concrete obligation
or unresolved rights question it does not address, including any arising from a
particular distribution channel. Record the issue and selected treatment in the
adopted OSS policy or an appropriate decision record. Attribution entries
contain the resulting factual identification and required material, without
repeating that reasoning. Resolve the issue before the affected use or
distribution without reopening settled choices.
Common ownership of projects is neither required nor a blanket exception.
Apply the formatting convention prospectively. Do not rewrite published tags,
notes, binaries, or checksums to retrofit it. Address actual omissions deliberately
under the applicable terms and the [release correction policy](release.md#immutability-and-failure-recovery).
Ordinary attribution maintenance and release links are preparation work, not new
release approval gates.

220
docs/policy/release.md Normal file
View File

@@ -0,0 +1,220 @@
# Release Policy
**Base Policy Version:** 1.4
**Last Updated**: 2026-09-28
**Upstream Source**: https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/release.md
## Purpose
This policy defines a proportionate workflow for validating and publishing an
intended source revision under a fresh version. Releases should require little
routine intervention, avoid repeated work, and preserve published contents.
The repository-specific commands belong in `docs/release.md`. Historical release
summaries belong in `docs/releases/`. The procedure must agree with the adopted
policy and link to it rather than introduce additional release requirements.
## Release Model And Completion
A source release identifies one exact committed source revision through an
immutable version tag and a version-matched, checked-in release note. The
primary release branch is `main` unless the adopted policy specifies otherwise.
The procedure must name the authoritative publication remote.
A successful push of the intended tag, with its source commit available on the
authoritative primary branch, completes the release. The source commit may be
pushed before the tag or published together with it through an atomic push.
The procedure must choose and document the supported approach.
Release commands must finish at successful publication. They must not wait for
or poll CI, require successful CI status, inspect downloadable artifacts, or
check consumer installation or Go module availability as completion gates.
A failed or ambiguous push requires resolving the authoritative remote state
before declaring success or retrying; successful pushes do not require a
separate remote read-back gate.
Repositories may publish source only. Additional binaries, images, packages,
checksums, or other declared deliverables may be published asynchronously from
the tagged source. The procedure must identify those deliverables and the
responsibility of their automation. Published deliverables must be traceable
to that source and identify the same version.
Track source-release completion and artifact delivery separately. Report delivery
failures and optional investigations under artifact-delivery status.
## Versioning And Compatibility
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with
no leading zeroes. Prereleases require an explicit adopted policy defining
their format and use.
The selected version must be unused locally and on the authoritative remote.
Do not require queries to every configured remote or publication service.
A version known to have already been published must not be reused, even if the
normal guards do not inspect that channel. Adding a distribution channel
requires a deliberate update to the policy and procedure where needed.
For releases at or after `v1.0.0`:
- a major release may make intentional incompatible changes;
- a minor release may add backward-compatible behavior; and
- a patch release may make backward-compatible fixes.
Before `v1.0.0`, a minor release may change a documented public, operational,
configuration, data, integration, or compatibility contract when the release
note explains the impact and required consumer action. A patch release must
not intentionally break those contracts within its minor line.
Additional compatibility promises and deliberate exceptions belong in the
adopted policy or canonical contract documentation. Release notes explain
changes; they do not redefine current compatibility contracts.
## Release Documentation And Preparation
Every new release must include `docs/releases/<version>.md` in the tagged
source tree. Use the complete version tag as the filename and begin with a
title containing the product name and that exact version.
Include a short summary and any relevant compatibility impact or upgrade
instructions. Choose sections suited to the release and keep the note
proportionate to the change.
Keep exact current contracts in their canonical documents and update affected
documentation with the behavior change. Link to those documents from the note
where useful. If annotated tags are used, derive their summary from the
checked-in note.
Include the release footer defined by the [OSS policy](oss.md#discoverability)
in each new release document and preserve it on the hosted download page. The
release procedure defines the concrete URLs and publication mechanics. Prepare
these links with the release note; they do not add a network check or approval
gate.
Handle version selection, release-note content, and retirement of completed
roadmaps during preparation. Preserve planned and deferred work and repair
affected links when retiring roadmaps. Reuse ordinary change review for
unintended files, credentials, and private data. Scope release-content review
to the tagged source.
## Candidate Validation
Provide one publication command that validates the final committed candidate,
checks publication state, and publishes the intended refs. A validation-only
mode or command may be provided for diagnosis, but must not be a required step
before publication repeats the same checks.
Run the complete deterministic, offline default test suite once for the final
candidate, following the [testing policy](testing.md). For repositories that
publish builds, verify the required build targets. Select those targets for
the application's actual support commitments; native testing on each platform
and access to particular hardware are not default release prerequisites.
Additional gates must address a concrete risk that the existing checks do not
adequately cover. Do not automatically require linting, race testing, module
hygiene audits, documentation audits, credential scans, example validation, or
expanded platform matrices solely because they are possible. Retain such
checks when their distinct protection justifies their cost. A one-time migration
check must not silently become a permanent release requirement.
Release tests must not require real credentials, mutable application services,
paid APIs, provider probes, or inference calls. The procedure may describe dependency
preparation separately from offline validation.
Avoid duplicate validation across release phases. Successful checks for the
same final candidate need not be repeated solely to satisfy another phase;
any reuse must clearly apply to that exact candidate. Persistent validation
caches are not required. If the candidate changes, validate the new candidate.
Cheap publication-state checks may be repeated immediately before pushing
because local or remote state may have changed during validation.
Automate mechanical guards. Human review gates are strongly disfavored: add one
only through an explicit project-specific policy decision explaining the
concrete risk and why ordinary change review or automation is insufficient.
Do not add routine confirmations for checks the command can perform itself.
## Narratio Candidate Checks
Narratio's candidate checker adds the following checks to the one full,
race-enabled offline suite:
- `go mod tidy -diff` detects an incomplete checked-in module graph without
changing source.
- Tracked Go formatting and diff whitespace checks catch inexpensive source
hygiene defects before publication.
- Race instrumentation adds distinct coverage for concurrent I/O,
cancellation, and shared state.
- `go vet ./...` detects static issues not established by runtime tests.
- The asset builder verifies the declared Linux amd64, Linux arm64, and
best-effort macOS arm64 binaries and checks the embedded version on the host.
The checker uses `GOWORK=off` and readonly module mode for tests, vet, and
builds. Versioned module replacements are allowed; filesystem replacements
are not release inputs. Workspace and vendor files are not prohibited solely
by their presence.
## Publication Guards
Automate the checks needed to publish the intended source under a fresh version:
- the version has the adopted format and is unused locally and on the
authoritative remote;
- the worktree and index are clean on the primary release branch;
- the source being published is the exact candidate that passed validation,
including its matching release note;
- the remote primary branch already identifies that candidate, or an atomic
source/tag push will advance it to that candidate without discarding remote
history; and
- the tag identifies the validated candidate and only the intended refs are
published.
Fetch the authoritative branch and tag state as needed for these checks.
Do not force branch or tag updates to bypass a failed guard. Publish explicit
refs rather than all local tags. Do not require CI success, publication-service
availability checks, or a separate manual confirmation for each guard.
## Immutability And Failure Recovery
Published version tags and published contents are immutable. Do not move,
overwrite, recreate, or delete a published tag, or replace a published artifact,
checksum, signature, provenance statement, or release note under that version.
The checked-in note at the immutable tag is that release's historical record.
If validation fails before publication, correct and commit the candidate and
rerun the publication command. If a push fails or its outcome is ambiguous,
inspect the authoritative remote before retrying. Remove an unpublished local
tag only after confirming it was never published or used by a release channel.
An unavailable remote leaves publication status unknown; do not overwrite a tag
to resolve uncertainty.
Transient network, authentication, CI, or consumer-availability failures do not
by themselves require a new version. Asynchronous delivery may be retried from
the same immutable tag. Missing artifacts may be published later, provided
existing published contents are not replaced and the additions remain consistent
with the tagged source and any already-published checksum manifest. If that
cannot be achieved, publish a new version.
A defect requiring changes to published source or contents requires a corrected
commit, a new semantically appropriate version, a matching release note, and
the release procedure again. Leave the original release intact. A channel's
yank, deprecation, or advisory mechanism may warn consumers away from a defective
release without replacing its contents or reusing its version.
## Repository Release Procedure
Maintain `docs/release.md` as a short, executable maintainer runbook. It must
identify:
1. prerequisites, primary branch, and authoritative remote;
2. version and release-note preparation;
3. one publication command with its selected local checks and automated guards;
4. declared deliverables, required build targets, and automation responsibilities;
5. the successful-push completion point and separately reported delivery status;
and
6. failure handling, ambiguous-push recovery, and asynchronous delivery retries.
Use copyable commands where practical. Keep optional diagnostics and consumer
verification clearly optional. The procedure must not turn preparation work,
ordinary change review, or post-publication investigation into additional
release gates.

View File

@@ -1,39 +1,32 @@
# Testing Policy # Testing Policy
**Base Policy Version:** 1.3
**Last Updated**: 2026-09-28
**Upstream Source**: https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md
## Purpose ## Purpose
Our tests exist to make **incorrect changes expensive and correct changes cheap**. 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. We optimize for sufficient confidence in important behavior while minimizing friction on future development.
## Every test has a cost ## Every test has a cost
Testing is not an unqualified good. Every test imposes both an immediate cost and a continuing lifetime cost. Every test has an immediate and continuing cost: writing, review, execution,
diagnosis, fixture maintenance, and updates when legitimate behavior changes.
A test must be: Tests can also constrain refactoring, duplicate policy, and add failure noise.
- 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. A test is warranted only when the confidence it provides justifies these costs.
Evaluate that tradeoff at two levels:
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? 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? 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. Keep ordinary internal changes inexpensive.
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. Evaluate maintenance cost against the specific failure being protected. 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.
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 ## Default testing style
@@ -55,7 +48,7 @@ Control clocks, randomness, environment variables, and other process-global or m
## What deserves tests ## What deserves tests
Prioritize tests for: To the extent the following features are present in the application, prioritize tests for:
1. Public and package-level contracts. 1. Public and package-level contracts.
2. Domain rules and important invariants. 2. Domain rules and important invariants.
@@ -65,15 +58,27 @@ Prioritize tests for:
6. Previously observed or plausible regressions. 6. Previously observed or plausible regressions.
7. Representative integration and end-to-end workflows. 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. A package-level contract comprises behavior relied upon by another package or major collaborator.
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. Choose tests for requested behavior and established contracts. Tests must not
introduce new restrictions, guarantees, or threat scenarios merely because they
could be asserted. Follow the [agent policy](agents.md) when deciding whether a
behavior belongs to the contract; current implementation behavior alone is not
sufficient authority.
For established behavior involving **data integrity, destructive operations,
compatibility, security, concurrency, idempotency, or recovery**, favor durable
tests for realistic failures whose consequences warrant protection. Identify
the failure and the existing contract it would violate. These categories call
for attention, not an automatic test inventory or permission to strengthen the
application's guarantees. Account for protection already provided at other layers.
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. 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 ## Choose the right test boundary
Test through the narrowest stable boundary that expresses the behavior clearly. Choose the narrowest stable boundary that expresses the behavior clearly and
provides durable confidence with minimal incidental coupling.
This is often the package API, but it may instead be: This is often the package API, but it may instead be:
@@ -81,28 +86,15 @@ This is often the package API, but it may instead be:
- a package-level operation when several internal collaborators jointly produce the behavior; or - 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. - 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 ## Test behavior, not implementation
A test should protect a decision, contract, or invariant—not memorialize the current implementation. A test should protect a selected decision, contract, or invariant.
Before adding or retaining a test, ask: A test is suspect when it primarily detects changes to private constants or
helpers, equivalent operation ordering, incidental formatting, equivalent
algorithms, or internal structure that preserves behavior.
> What realistic defect would this test catch? Behavioral tests should normally remain stable through internal refactoring that preserves the contract.
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 ## Expected effects of different changes
@@ -119,12 +111,13 @@ A test failing is not the same as a test needing to be edited. Many tests may co
## Separate mechanism from policy ## Separate mechanism from policy
Configurable thresholds and defaults must not be duplicated throughout the test suite. Derive expectations from test-controlled configuration and relationships.
For example, do not encode an internal concurrency limit indirectly: For this example, assume the selected contract rejects a request to start more
processes than a configured limit. Do not encode its production default indirectly:
```go ```go
// Production policy: // Production default:
const maxConcurrency = 4 const maxConcurrency = 4
// Brittle test: // Brittle test:
@@ -142,25 +135,30 @@ require.NoError(t, runner.Start(limit))
require.ErrorIs(t, runner.Start(limit+1), ErrTooMuchConcurrency) require.ErrorIs(t, runner.Start(limit+1), ErrTooMuchConcurrency)
``` ```
The test should prove: For this selected contract, the test should prove:
- the configured limit is accepted; and - the configured limit is accepted; and
- one beyond the configured limit is rejected. - one beyond the configured limit is rejected.
Rejection is not a general requirement for concurrency control. If the selected
contract queues excess work or limits active workers while accepting larger
batches, test that behavior instead. Do not add rejection or a configurable
limit solely to reproduce this example.
The production default should be tested exactly only when its literal value is itself a public, operational, safety, protocol, or compatibility requirement. 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. Apply the same rule to limits, timeouts, capacities, retry counts, and ranges: test relationships and behavior.
For concurrency limits, test both kinds of behavior when relevant: For established concurrency limits, distinguish these behaviors where the contract requires them:
1. **Configuration enforcement:** invalid or excessive requested values are handled correctly. 1. **Configuration handling:** values are accepted or rejected according to the selected contract.
2. **Runtime enforcement:** observed peak concurrency never exceeds the configured limit. 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. Use a test-controlled limit and measure the behavior relative to that limit.
## Avoid semantic duplication across layers ## Assign each behavior a test owner
Each behavior should have a clear test owner. Where these layers exist, assign ownership as follows:
- Parser tests own parsing cases. - Parser tests own parsing cases.
- Validator tests own validation rules. - Validator tests own validation rules.
@@ -170,9 +168,7 @@ Each behavior should have a clear test owner.
- CLI tests own argument and configuration mapping. - CLI tests own argument and configuration mapping.
- End-to-end tests prove that representative assembled workflows work. - 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. Give each layer its distinct responsibility and keep expectation updates localized to the contract being changed.
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 ## Use test doubles deliberately
@@ -193,104 +189,76 @@ Mocks are appropriate when the contract includes facts such as:
- an expensive API is called no more than once; or - an expensive API is called no more than once; or
- a security audit event is emitted. - 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 ## Go-specific guidance
Use: Select techniques by behavior and risk:
- table-driven tests for meaningful behavioral categories and boundaries; - table-driven tests for meaningful behavioral categories and boundaries;
- `t.TempDir()` for real filesystem behavior; - `t.TempDir()` for real filesystem behavior;
- `httptest.Server` for realistic HTTP interactions; - `httptest.Server` for realistic HTTP interactions;
- fuzz tests for parsers, normalization, path handling, and broad input spaces; - fuzz tests when exploring broad input spaces, such as parsers or path handling, adds useful protection beyond representative cases;
- golden files only when the complete output is intentionally stable; - golden files only when the complete output is intentionally stable;
- integration tests where correctness depends on component interaction; and - integration tests where correctness depends on component interaction; and
- a small number of representative end-to-end tests. - a small number of representative end-to-end tests when assembled workflows expose meaningful risks not adequately covered at narrower boundaries.
Avoid exact error-string assertions unless the wording is itself contractual. Prefer `errors.Is`, `errors.As`, typed errors, or structured error fields. Prefer `errors.Is`, `errors.As`, typed errors, or structured error fields. Use exact error-wording assertions only when the wording is contractual.
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. At CLI boundaries, prefer exit classifications, structured output, and the smallest stable semantic fragment needed to identify the error. Use complete diagnostic snapshots only when the wording 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. 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. Keep tests readable and direct. Size test helpers and fixture frameworks to the maintenance benefit they provide.
## Coverage ## Coverage
Coverage is a diagnostic, not a target. Use coverage to locate untested critical branches and unexpectedly weak packages. Evaluate test quality through meaningful behavioral protection and choose coverage according to risk.
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. 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 ## Regression tests
A bug fix should normally include a regression test that fails before the fix and passes afterward. 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. 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.
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. Remove or consolidate a test when the underlying design has made recurrence impossible, its protection is redundant, or a stronger invariant test subsumes it.
## Deleting or rewriting tests ## Deleting or rewriting tests
Tests are maintained code, not permanent historical artifacts. Delete or rewrite tests when maintenance cost exceeds their continuing
protective value.
Delete or rewrite a test when its maintenance cost exceeds the confidence it provides.
Strong candidates include tests that: Strong candidates include tests that:
- require updates after harmless internal changes; - require updates after harmless internal changes;
- directly assert private constants without protecting a real contract; - assert incidental private constants or snapshot large amounts of incidental output;
- duplicate the same policy across several layers;
- verify mock choreography rather than outcomes; - verify mock choreography rather than outcomes;
- snapshot large amounts of incidental output; - duplicate policy across layers, retest trivial helpers covered by package
- test trivial private helpers already exercised through stable package behavior; behavior, or protect risks covered more effectively elsewhere;
- protect risks already covered more effectively elsewhere;
- are flaky, misleading, obsolete, or disproportionately expensive to diagnose; or - are flaky, misleading, obsolete, or disproportionately expensive to diagnose; or
- no longer correspond to a plausible failure mode. - 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. Several brittle tests may encode one genuine requirement. Consolidate them into one durable behavior-level or invariant test.
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 ## 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. Use the following questions when a proposed test needs closer evaluation. Significant test additions should be reviewable against them; written answers are optional for routine tests.
1. What realistic defect would it catch? 1. Which requested behavior or established contract does it protect, and what realistic defect would violate it?
2. How likely is that defect? 2. How likely and consequential is that defect, and is it already protected elsewhere?
3. How consequential would it be? 3. What is the narrowest stable boundary that can test it clearly?
4. Is the behavior already protected elsewhere? 4. Would a correct internal refactor or a change to an incidental default require test edits?
5. At which layer should this behavior be owned? 5. Does its confidence justify its maintenance, execution, and diagnostic cost, or would a smaller test suffice?
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 ## Definition of sufficient
A test suite is sufficient when: A test suite is sufficient when:
- important contracts and invariants are protected; - important requested behavior and established contracts are protected against realistic, consequential failures;
- meaningful boundaries and failure modes are exercised; - boundaries and component interactions have coverage where they introduce meaningful risks not protected elsewhere;
- realistic and consequential regressions are credibly protected against silent recurrence; - failures provide useful, distinct signals;
- behavior involving data integrity, destructive operations, compatibility, security, concurrency, idempotency, and recovery is credibly protected; - tests usually remain stable through legitimate internal changes; and
- 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. - 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. Reassess sufficiency 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

@@ -1,104 +1,114 @@
# Releasing Narratio # Releasing Narratio
This document is the maintainer procedure for creating a Narratio source and This is the maintainer runbook for the [Release Policy](policy/release.md).
binary release. The synchronous release boundary is a successful push of one The source release completes when the intended tag is successfully pushed to
new tag to `origin`; Woodpecker and Gitea publication happen later and do not `origin`; binary delivery is a separate asynchronous status.
change that result.
## Go prerequisite ## Prepare the candidate
Release preparation requires the maintained Go 1.26 baseline. Tag CI currently Use `main` and the authoritative Git remote `origin`. Install Git, a POSIX
uses the explicitly pinned `golang:1.26.7` image for candidate validation and shell, standard Unix build tools, and Go 1.26. Have the selected Go modules
asset construction; that asynchronous work does not change the tag-push available before offline validation. Git access must permit fetch and tag push.
completion boundary. Application-provider credentials are not required; Gitea delivery credentials
belong to CI.
## Choose a version and write its note Choose an unused stable `vMAJOR.MINOR.PATCH` under the
[compatibility rules](policy/release.md#versioning-and-compatibility). Narratio
is past v1.0.0, so intentional CLI or configuration breaks require a major
release. Update affected canonical documentation with the change. Write the
matching `docs/releases/vMAJOR.MINOR.PATCH.md` with a short summary and any
relevant compatibility impact or upgrade action; choose sections to suit the
release.
Narratio is past `v1.0.0`. Use an unused stable tag in the exact form End that checked-in note with these three links, replacing the version in
`vMAJOR.MINOR.PATCH`: each URL with the intended tag. The tag need not exist remotely yet. Check that
`COPYRIGHT`, `LICENSE`, and `docs/oss/attribution.md` are present in the
- increment `MINOR` for backward-compatible features; candidate. The tag CI reuses the checked-in note as the hosted release body.
- increment `PATCH` for backward-compatible fixes; and
- reserve a new `MAJOR` for an intentional breaking documented contract.
Before preparing the candidate, create
`docs/releases/vMAJOR.MINOR.PATCH.md` with this structure:
```markdown ```markdown
# Narratio vMAJOR.MINOR.PATCH [COPYRIGHT](https://gitea.maximumdirect.net/eric/narratio/src/tag/vMAJOR.MINOR.PATCH/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/narratio/src/tag/vMAJOR.MINOR.PATCH/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/narratio/src/tag/vMAJOR.MINOR.PATCH/docs/oss/attribution.md)
This release ...
## Summary
## Compatibility
## Upgrade
## Changes
``` ```
The compatibility section identifies relevant CLI, configuration, artifact, Retire completed roadmaps included in the release while preserving planned or
integration, or operating-contract changes. The upgrade section states the deferred work and repairing affected links. Review the change through the
required operator action, or explicitly says that no special action is ordinary development workflow for unintended files, credentials, private data,
required. Release notes are immutable historical summaries; link to the and documentation accuracy. Commit and push the candidate to `origin/main`
current canonical documentation for detailed behavior. before tagging; record its exact commit if needed:
Commit the note and all candidate changes, then use the ordinary development
workflow to push that commit to `main`. Do not create a release tag before the
candidate is committed and `origin/main` contains the exact same commit.
## Validate the candidate
Run the shared checker from any directory:
```sh ```sh
scripts/check-release-candidate.sh vMAJOR.MINOR.PATCH git rev-parse HEAD
``` ```
It validates the version and matching note, module hygiene, formatting, ## Publish
whitespace, uncached tests, race tests, static checks, documentation, examples,
and three official cross-build assets. It uses `GOWORK=off`, does not contact
application services, CI, or Gitea, and does not create tags or modify tracked
source. Fix any failure on `main`, commit it, push it normally, and rerun the
checker.
The checker builds Linux assets for `amd64` and `arm64` and a macOS asset for From a clean `main` checkout at the pushed candidate, run one command:
`arm64`. The macOS cross-build proves compilation; it is not native macOS
runtime evidence.
## Publish the tag
From a clean checkout on `main` whose `HEAD` equals `origin/main`, run:
```sh ```sh
scripts/release.sh vMAJOR.MINOR.PATCH scripts/release.sh vMAJOR.MINOR.PATCH
``` ```
The command fetches and checks `origin/main`, re-runs candidate validation, It fetches `origin/main` and tags; requires local `HEAD` to equal
then fetches and checks again before creating an explicitly unsigned lightweight `origin/main`; checks the committed note and unused version; runs the
tag for the originally recorded commit. It refuses dirty, divergent, changed, candidate checker **once**; then repeats cheap mutable-state guards before
or already-tagged candidates. It pushes only: creating an unsigned lightweight tag at the recorded commit. It verifies the
tag target and pushes only
`refs/tags/vMAJOR.MINOR.PATCH:refs/tags/vMAJOR.MINOR.PATCH`. It does not
commit, push `main`, force updates, or publish all tags.
```text The checker runs `go mod tidy -diff`, tracked Go formatting and diff whitespace
refs/tags/vMAJOR.MINOR.PATCH:refs/tags/vMAJOR.MINOR.PATCH checks, one `go test -mod=readonly -race -count=1 ./...` full suite, and
``` `go vet -mod=readonly ./...`. Its asset builder cross-builds Linux amd64,
Linux arm64, and best-effort macOS arm64, then checks the embedded version
on a host binary. The macOS cross-build proves compilation, not native
runtime behavior. Owned Go commands run with `GOWORK=off`; tests, vet, and
builds use readonly module mode. The deterministic suite includes documentation
and maintained-example checks. The command does not contact application
services or Gitea and does not wait for CI.
It never commits changes, pushes `main`, force-pushes, moves a tag, or pushes A successful tag push is the source-release completion point. The command
all tags. A successful push of that exact ref completes the release command; prints the tag and commit and exits without a separate network read-back.
the command prints the tag and commit, then returns without waiting for CI, For optional diagnosis before publication, run
querying Gitea, downloading assets, or checking checksums. `scripts/check-release-candidate.sh vMAJOR.MINOR.PATCH`; publication will
validate the candidate again, so this is not a required preparation step.
If a failure occurs before the tag is created, correct the candidate on `main` ## Artifact delivery
and repeat validation. If the push fails after local tag creation, the local tag
is intentionally retained for inspection and the command must not be retried
blindly. Once the upstream tag has been pushed, it is immutable. Correct any
defect or failed asynchronous publication with a new patch version, a new
release note, and the complete procedure again.
## Optional asynchronous inspection Tag-triggered `.woodpecker/release.yml` independently validates the tagged
source, builds the same three targets, and publishes these Gitea downloads:
After a successful tag push, a human may later inspect the tag-triggered - `narratio-vMAJOR.MINOR.PATCH-linux-amd64`
Woodpecker run and the corresponding Gitea release for binaries and checksums. - `narratio-vMAJOR.MINOR.PATCH-linux-arm64`
This is optional follow-up only. Automated releasers must not wait for, poll, - `narratio-vMAJOR.MINOR.PATCH-darwin-arm64` (best effort)
or treat CI/Gitea completion as a condition of the successful tag push. - `SHA256SUMS`
The builder embeds the tag version and checks host `version` output. CI
generates checksums and uses non-overwriting publication settings. It does
not select versions, change the source, or move tags. No image or package
registry output is declared. Tag publication is not proof that these
asynchronous downloads are available.
## Failure and recovery
If validation fails before tagging, correct and commit the candidate, push
it to `main`, and rerun the publication command. If the tag push reports
failure, its outcome may be ambiguous: keep the local tag and inspect the
exact `refs/tags/vMAJOR.MINOR.PATCH` at `origin` before declaring success
or retrying. A reachable matching remote tag at the recorded commit establishes
publication; a confirmed absent tag permits investigation of the transport
failure. If `origin` cannot be inspected, publication status is unknown.
Do not move, overwrite, or automatically retry the tag. Remove a local tag
only after confirming it was never published or used by a release channel.
A transient CI or delivery failure can be retried from the same immutable
tag. Missing assets may be added later if they match the tagged source and
any published checksum manifest; do not overwrite existing binaries,
checksums, or release-note contents. A source/content defect, or delivery
that cannot be corrected without replacement, requires a new suitable
version and matching note. Leave the original release intact.
Optional consumer inspection may compare the tag's remote commit with the
recorded candidate, check the Woodpecker delivery job and hosted Gitea note,
download the assets into an empty directory, run `sha256sum --check
SHA256SUMS` (or `shasum -a 256 --check SHA256SUMS` on macOS), and run
the matching host binary's `version` command. This is separate delivery
observation, never a source-release gate.

View File

@@ -1,27 +1,23 @@
# Release Notes # Release Notes
This directory contains immutable historical release notes for Narratio. This directory contains immutable historical release notes for Narratio.
Future notes are created with the matching stable version and use this minimum Future notes use the matching stable version and begin with
structure: `# Narratio vMAJOR.MINOR.PATCH`. Include a short summary and relevant
compatibility or upgrade guidance; choose sections suited to the release.
```markdown End each new note with the three immutable-tag notice links specified in the
# Narratio vMAJOR.MINOR.PATCH [release procedure](../release.md).
This release ...
## Summary
## Compatibility
## Upgrade
## Changes
```
See the [release procedure](../release.md) for creating a candidate and tag. See the [release procedure](../release.md) for creating a candidate and tag.
When asynchronous publication succeeds, the corresponding Gitea release is the When asynchronous publication succeeds, the corresponding Gitea release is the
canonical source for downloadable binaries and checksums. canonical source for downloadable binaries and checksums.
- [v3.1.0](v3.1.0.md)
- [v3.0.0](v3.0.0.md)
- [v2.2.1](v2.2.1.md)
- [v2.2.0](v2.2.0.md)
- [v2.1.2](v2.1.2.md)
- [v2.1.1](v2.1.1.md)
- [v2.1.0](v2.1.0.md)
- [v2.0.0](v2.0.0.md) - [v2.0.0](v2.0.0.md)
- [v1.7.0](v1.7.0.md) - [v1.7.0](v1.7.0.md)
- [v1.6.0](v1.6.0.md) - [v1.6.0](v1.6.0.md)

35
docs/releases/v2.2.1.md Normal file
View File

@@ -0,0 +1,35 @@
# Narratio v2.2.1
This maintenance release consolidates checkpoint persistence for transcription
and analysis without changing their operator-facing behavior.
## Summary
Transcribe and analyze now share one internal operation for preparing isolated
checkpoint candidates, saving them, and promoting successfully saved progress
into live state. Each stage retains its own progress model and recovery policy.
## Compatibility
CLI commands, configuration, manifest schemas, artifact formats, and external
integration contracts are unchanged. Speaker concurrency, artifact selection,
force behavior, and ordinary resume semantics are preserved. No dependency
upgrade or new external-tool version requirement is introduced.
## Upgrade
No special upgrade action or configuration migration is required when upgrading
from v2.2.0. See the [v2.2.0 release notes](v2.2.0.md) when upgrading from an
earlier version for the original speaker-checkpoint upgrade considerations.
## Changes
- Replace duplicate analyze/transcribe save-and-promote sequences with a
shared, private checkpoint helper.
- Isolate checkpoint collections from live state, including analyze records
normalized during a transcription checkpoint save.
- Preserve transcription's caller-owned locking and failure handling, and
analysis's working/committed collections and rollback behavior.
- Add focused checkpoint integrity tests while retaining stage and application
recovery coverage.
- Document the shared [checkpoint persistence boundary](../internal/manifest.md#checkpoint-persistence-boundary).

57
docs/releases/v3.0.0.md Normal file
View File

@@ -0,0 +1,57 @@
# Narratio v3.0.0
This major release moves Narratio pipeline selection to ConfigKit overlays and
updates its Audita and Notarius integration contracts. Existing deployments
must migrate configuration before running affected sessions.
## Compatibility and upgrade
- Replace Narratio's `--profile <name>` with `--overlay <name>` and move selected
`profiles/<name>.yml` files to `overlays/<name>.yml` beside the pipeline root.
The old flag and selected directory are no longer supported; explicit imports
from a directory named `profiles` remain valid. Update `config diff` positional
selections accordingly. See [configuration](../config.md#pipeline-imports-and-overlays)
and the [CLI reference](../cli.md).
- Before opening a session manifest containing
`effective_config.selected_profile`, back it up and follow the
[manifest migration procedure](../operations.md#migrating-manifest-overlay-provenance).
Strict decoding rejects the retired field. Preserve the manifest's stage and
checkpoint state; this informational rename does not itself require rerunning
every stage. Immutable remote snapshots are not edited in place.
- Deploy an Audita v2 executable and migrate its configuration before running
`polish`. Narratio no longer generates an Audita config or accepts the old
endpoint, model, concurrency, singular API-key, report, and retention fields.
Audita now owns backends, PromptKit profiles, reporting, and debug settings;
Narratio accepts optional downstream selectors and forwards only names listed
in `pipeline.audita.credentials`. Follow the
[Audita migration table](../operations.md#migrating-audita-and-provider-credentials)
and [integration contract](../integrations/audita.md).
- Replace `pipeline.notarius.llm_api_key` with the
`pipeline.notarius.credentials` list. Rename
`pipeline.notarius.config_profile` to `config_overlay` and move selected
downstream Notarius files to its `overlays/` directory. The old fields have
no compatibility aliases. A named downstream overlay requires a compatible
Notarius executable; v0.8.1 is the tested baseline, while deployments that
omit the selector gain no new version requirement. See the
[Notarius configuration reference](../config.md#notarius-configuration-overlay)
and [integration contract](../integrations/notarius.md).
## Changes
- Narratio and downstream overlay names are independent. Operators can select
separate Audita and Notarius overlays from one Narratio overlay without
implicit name forwarding. ConfigKit is updated to v0.4.2.
- Audita's corrected transcript remains the canonical polish output. Available
`errors.yml`, `recoveries.yml`, and `report.md` files are retained as bounded
diagnostics; missing reports are allowed, and an exit-zero valid transcript
succeeds even when reports describe recoverable errors or no corrections.
- Audita and Notarius credential lists resolve at pipeline load and forward
captured values under their exact names, with captured subprocess output and
failure diagnostics redacted. Narratio does not discover backend credentials
from downstream configuration; operators must list the names those tools need.
- Repository policy, license/attribution notices, release validation, and the
asynchronous artifact-delivery procedure are aligned with the current
[release runbook](../release.md). Completed implementation roadmaps have been
retired from the candidate source.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/narratio/src/tag/v3.0.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/narratio/src/tag/v3.0.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/narratio/src/tag/v3.0.0/docs/oss/attribution.md)

40
docs/releases/v3.1.0.md Normal file
View File

@@ -0,0 +1,40 @@
# Narratio v3.1.0
This minor release adds explicit provider-credential forwarding to Scriptorium
for trim bounds generation and artifact analysis.
## Compatibility and upgrade
Existing pipelines remain valid. An omitted, null, or empty
`pipeline.scriptorium.credentials` list continues to forward no provider keys.
When Scriptorium needs a credential, provision its environment variable through
Narratio's existing environment or secrets-directory mechanism and list the
exact name in the pipeline, for example:
```yaml
scriptorium:
credentials: [OPENROUTER_API_KEY]
```
Selected references must resolve to nonempty values when Narratio loads the
pipeline, even if the current command will not run Scriptorium. A Narratio
overlay replaces the complete list, and `[]` clears it. After correcting a
failed trim or analysis invocation, retry normally; successful work need not be
forced solely because a credential was added or rotated. See
[Scriptorium credentials](../config.md#scriptorium-credentials) and the
[401 troubleshooting guide](../troubleshooting.md#scriptorium-returns-http-401-during-trim-or-analyze).
## Changes
- Forward selected, already-resolved credential names and values to Scriptorium
for trim, analysis, character-family generation, and render-debug calls.
Scriptorium still owns provider and profile selection; Narratio does not add
a model-request `--api-key-env` override.
- Register selected values for captured subprocess-output and failure-diagnostic
redaction. Inspection and invocation metadata contain credential names, never
their values. This protection does not scan files Scriptorium writes directly.
- Reuse the credential-forwarding boundary shared with Audita and Notarius.
Retire the completed Scriptorium credential-forwarding bug report from the
source roadmap; its history remains in Git.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/narratio/src/tag/v3.1.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/narratio/src/tag/v3.1.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/narratio/src/tag/v3.1.0/docs/oss/attribution.md)

View File

@@ -1,102 +0,0 @@
---
title: Select a Notarius configuration profile through Narratio
status: proposed
consumer: rakestraw.dnd Ansible collection and its pipeline role
use_case: >-
The Ansible pipeline role deploys Narratio and Notarius as independently
configured applications. Operators select Narratio's testing profile to
develop prompts and other pipeline assets with inexpensive models while
retaining production settings in the base configuration. That selection
currently cannot select a Notarius configuration profile, leaving extraction
on its production settings.
desired_outcome: >-
Narratio's testing overlay can explicitly select Notarius's testing overlay.
Notarius owns the resulting model choices and any other testing-specific
settings. The operator selects testing once when invoking Narratio, without
editing production files or separately invoking Notarius.
rationale: >-
Both applications already use ConfigKit configuration overlays. Selecting
the downstream overlay preserves each application's configuration ownership
and supports future Notarius testing adjustments without adding a Narratio
configuration field for every downstream setting. This is explicit profile
composition, not a requirement for arbitrary recursive orchestration.
requested_interface:
field: pipeline.notarius.config_profile
value: Optional Notarius ConfigKit configuration profile name
supplied: >-
Invoke Notarius with --config-profile and the selected name when extraction
executes. The setting is available in Narratio's base configuration and
its ordinary configuration overlays.
omitted_or_null: >-
Do not supply a profile-selection flag. Preserve existing Notarius
configuration selection behavior.
downstream_semantics: >-
Retain Notarius's existing selector semantics, including the default
selector for its base configuration. Notarius resolves profiles relative
to its own configuration root, not Narratio's root.
explicit_selection: >-
Do not automatically forward Narratio's profile name. The selected
Notarius profile may have the same name or a different name.
distinction: >-
This selects a ConfigKit configuration overlay through --config-profile,
not a PromptKit LLM profile through --llm-profile. The Notarius overlay
can itself select the desired LLM profiles using native Notarius settings.
illustrative_consumer_configuration:
note: >-
These are separate files managed by the Ansible suite, not a proposed
combined configuration document. The Notarius pipeline and LLM profile
names are illustrative; the referenced LLM profile must be available to
Notarius.
narratio_profiles_testing_yml: |
notarius:
config_profile: testing
notarius_profiles_testing_yml: |
pipelines:
dnd-session:
llm_profile: gemini-flash-latest
operator_workflow: >-
Select --profile testing on the ordinary Narratio invocation. Narratio
selects Notarius's testing configuration overlay when it runs extraction;
other Narratio testing overrides continue to apply normally.
observable_behavior:
- >-
Notarius owns overlay discovery, composition, validation, and the effective
downstream settings. A missing or invalid selected overlay must not silently
fall back to production or be reported as successful extraction.
- >-
Changing the selected Notarius configuration profile must not silently
reuse Narratio's completed extraction from the previous selection.
Existing downstream invalidation behavior applies when extraction changes.
- >-
Editing Notarius overlay contents behind an unchanged selector retains
the existing operator responsibility to force extraction when needed.
This request does not require inspecting or tracking private Notarius files.
- >-
Existing credential forwarding remains independently configurable through
notarius.llm_api_key, including in the Narratio testing overlay when the
selected Notarius settings require a different credential reference.
consumer_follow_up: >-
After Narratio releases this interface, the standalone Narratio Ansible role
can expose an optional variable and omit the field when unset. The suite role
can deploy Notarius's testing overlay and select it from Narratio's testing
overlay, then distribute the changes through the collection. These consumer
updates are separate from this upstream feature request.
scope_boundaries:
- No Notarius change is currently required; v0.7.0 supports --config-profile.
- No separate Narratio-side Notarius LLM-profile selector is requested.
- No automatic profile-name propagation or downstream configuration rewriting.
- No new credential discovery, copying, or provider-name remapping.
- This request specifies consumer behavior, not internal Narratio code changes.
references:
narratio_configuration: ../config.md
narratio_notarius_integration: ../integrations/notarius.md
notarius_cli: https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/cli.md

View File

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

View File

@@ -1,431 +0,0 @@
# Implementation Plan: Speaker Checkpoints and Replayable Uploads
Status: Implementation complete; Stages 1-7 complete.
## Scope and Instructions
Implement [the feature roadmap](transcribe-checkpoints-and-replay.md). This plan
replaces the completed Notarius-profile plan; that work remains in Git history.
Complete the numbered stages in order, one bounded implementation prompt per
stage for gpt-5.6-terra. Record completion and actual verification results here.
Intermediate stages are not a finished feature and must not be released alone.
Before working, read `AGENTS.md`, `docs/development.md`, all three policies in
`docs/policy/`, the roadmap, and the stage-specific preparation below. Use the
maintained Go 1.26 toolchain. Preserve operator choices, speaker filenames,
bounded concurrency, streaming, and existing credentials/settings. Do not add
new commands, dependencies, service requirements, arbitrary speaker limits,
strict transcript schemas, or a generic checkpoint framework. No release or
upstream service work is part of this plan.
## Settled Design Decisions
### Progress Representation and Recovery
Add optional `transcribe_state_version` (version 1) and `transcribe_speakers`
to `manifest.StageRecord`. The latter is a map keyed by the existing derived
speaker ID, containing completed results only. Absence means that work must be
done; a separate pending/running/failed state machine is unnecessary. Aggregate
stage status/error remains authoritative for invocation failure. A versioned
empty collection is meaningful: no earlier completions are reusable.
Use a typed `TranscribeSpeakerRecord` with fingerprint version/digest,
session-relative audio path, audio SHA-256 and size, one `ArtifactRecord` output
of kind `transcript_raw`, output size, and producer run ID. The output carries
its session-relative canonical path and SHA-256. No new transcript source IDs,
synthetic schema contracts, timestamps, or historical attempt collections are
needed. Existing aggregate diagnostics can retain attempt/status/duration data
for work performed in the current invocation; do not fabricate that information
for reused outputs.
Keep the session-manifest schema identifier unchanged for this optional additive
state; validate supported per-stage versions and retain strict decoding. New
Narratio reads old manifests. Old binaries may reject the added fields under
their existing strict decoding; document this downgrade limitation.
For pre-feature manifests with no speaker evidence, regenerate transcription
once when transcribe is next selected, even if the aggregate status succeeded.
Old records do not bind individual transcript results to the audio bytes they
used. Do not guess that association from current files, old `per_file` metadata,
or abandoned run directories. This is the conservative upgrade path, not a new
general migration system. No other stage or unselected range is forcibly run.
### Reuse and Persistence
Compute a versioned per-speaker fingerprint from speaker ID, session-relative
audio identity, actual audio SHA-256/size, and the existing transcribe semantic
configuration fingerprint (URL and language). Do not include invocation ID,
absolute workspace root, concurrency, retry settings, timeout, or credentials.
Stream input hashing with cancellation; do not trust stored input checksums
alone. Recheck input bytes before accepting a new result to catch changes
observed during transcription; fail that job without checkpointing mismatched
evidence. This is not a hostile-filesystem snapshot or concurrent-editor
transaction guarantee and requires no extra audio copies or file locks.
Validate reusable output identity, canonical location, JSON, checksum, and size.
Use existing confinement/file primitives. Missing or corrupt regular outputs
become work; unsafe paths/non-regular conflicts follow existing safety errors,
not recursive deletion. Missing required audio is an input error, not permission
to reuse an unverifiable transcript. Removed inputs are removed from the current
collection and aggregate outputs without deleting arbitrary files.
One stage-owned mutex serializes candidate collection construction, manifest
save, and installation of committed state. Save a detached candidate and only
then update live authority, following analyze's pattern. Workers must not mutate
the live manifest outside that serialized section. Keep expensive HTTP work
outside the mutex. Stop dispatch/acceptance on checkpoint failure, join workers
before return, and preserve the last successfully committed collection.
Before launching requests, durably checkpoint the reconciled collection. For
force, that collection is empty: this prevents later ordinary retries from
resurrecting pre-force outputs. If this initial save fails, do not start requests
or overwrite outputs. Force again clears the current collection again; ordinary
retry retains replacements already checkpointed after the previous force.
Save each validated result at its canonical raw-transcript location before
checkpointing it. Run-local files remain staging/diagnostics, not reuse
dependencies. Bytes left by an unsuccessful checkpoint have no completion
authority. A worker may checkpoint a completed result using the parent stage
context even if a sibling canceled the work context; parent cancellation or a
checkpoint failure may prevent that save. Guarantee retention of committed
work, not all responses that happened to finish during cancellation.
### Multipart Replay Mechanism
Replace the pipe/producer upload mechanism with a small pull-based multipart
body backed by the audio reader. Use `mime/multipart.Writer` to encode a bounded
metadata prefix and suffix around the file payload: capture the prefix after
`CreateFormFile`, then encode the language field and closing boundary as the
suffix. Generate this framing once per application attempt. Each body combines
fresh readers for prefix, freshly opened audio, and suffix using `io.MultiReader`.
No full-audio buffer or temporary multipart spool is needed.
The initial body and `Request.GetBody` use the same factory/framing. Each owns
an independent audio handle. Give bodies idempotent Close and context-triggered
closure (for example `context.AfterFunc`), without holding a mutex across a
blocking Read. Read/Close coordination must release a blocked source and retain
genuine source-read/close errors while not treating deliberate early closure as
source failure. The attempt owns cleanup of all created bodies, including
replays; reject new body creation after its context expires.
Remove the old producer `Wait` dependency. Track body-local source errors with
synchronization so a swallowed upload read error cannot become a successful
transcript, and a deliberately closed original body cannot poison a successful
replay. Keep the outer retry policy and per-attempt context. Do not add replay
counters to the user retry budget or depend on Go's exact internal retry count.
Preserve ordinary HTTP redirect policy; body replay can also enable Go's
body-preserving redirects. Do not introduce a new redirect restriction.
## Stage 1: Add Durable Speaker Completion State
Status: Complete.
Completion marker (2026-09-21): Added optional versioned transcribe-owned
per-speaker completion records, strict load/save validation, isolated collection
cloning, legacy aggregate-only compatibility, and lifecycle-preservation
coverage. Verified with `go test ./internal/manifest` and `git diff --check`.
Preparation: `internal/manifest/manifest.go`, `store.go`, `analyze_state.go`,
their tests, and `docs/internal/manifest.md`.
1. Implement the additive fields, record type, version constants, validation,
and deep-copy helper described above in a focused transcribe state file.
2. Validate ownership by the transcribe stage, supported versions, required
completed-record evidence, output kind, safe relative paths, SHA-256 values,
sizes, and producer identity consistency. Use existing speaker IDs as-is;
do not apply analysis-artifact key regexes or copy unrelated analysis limits.
3. Integrate validation with both load and save. Preserve speaker state through
running/failed/stale transitions and aggregate result clearing. Generic stage
invalidation does not discard checkpoints; reconciliation checks their data.
4. Test round-trip populated and versioned-empty collections, old manifests
without fields, malformed evidence, deep-copy isolation, and lifecycle
preservation. Avoid imposing JSON formatting or map order as contracts.
Verify: `go test ./internal/manifest`.
Done when durable storage can represent partial completion independently of
aggregate status, without activating new stage behavior yet.
## Stage 2: Implement Read-Only Speaker Reconciliation
Status: Complete.
Completion marker (2026-09-21): Extracted shared deterministic transcribe job
construction and added read-only, cancellation-aware per-speaker input and
output reconciliation. Covered unchanged reuse, semantic and operational
settings, changed/added/removed inputs, legacy and forced work, missing/corrupt
outputs, unsafe evidence, and missing audio without connecting runtime skip or
checkpoint behavior. Verified with
`go test ./internal/stage ./internal/manifest` and `git diff --check`.
Preparation: `internal/stage/transcribe.go`, `semantic_contracts_initial.go`,
`analyze_reconciliation.go`, `extract_resume.go`, their tests, and the artifact
path/integrity helpers they use.
1. Extract the existing audio discovery/job construction into a shared,
transcribe-local reconciliation path. Preserve current input selection and
speaker identity behavior. Build sorted jobs and classify completed results
against the current input set and evidence defined above.
2. Add pure fingerprint construction and streamed input/output inspection.
Resolve manifest input paths with existing session-relative/absolute reading
helpers; do not change input precedence or assume the current working directory
is the session root. Newly written checkpoint paths are always relative.
3. Return current records and work-to-do without writing files or manifest state.
Force selects all work and an empty starting collection; old unversioned
transcribe state supplies no reusable records. Do not connect aggregate skip
validation until execution can produce the new evidence in Stage 3.
4. Test unchanged reuse; one changed audio among unchanged speakers; URL/language
changes; operational-setting exclusions; added/removed speakers; missing and
corrupt regular outputs; unsafe record paths; missing audio; and force.
Prefer compact tables and real temporary files over mocked hashing.
Verify: `go test ./internal/stage ./internal/manifest`.
Done when one read-only decision path describes exactly which speakers need
work, without altering existing runtime behavior prematurely.
## Stage 3: Execute and Checkpoint Speakers Incrementally
Status: Complete.
Completion marker (2026-09-21): Connected reconciliation to bounded execution,
durably checkpointed the starting and per-speaker collections through detached
manifest candidates, preserved committed work across failures and cancellation,
rebuilt deterministic aggregate outputs from reusable evidence, and added
speaker-aware resume validation. Updated production-style fixtures and covered
serialized concurrent saves, force/retry replacement, input rechecks, save
failures, partial recovery, and aggregate reconstruction. Verified with
`go test ./internal/stage ./internal/app`, `go test -race ./internal/stage`, and
`git diff --check`.
Preparation: `internal/stage/transcribe.go`, `analyze_execution_state.go`,
`run_local.go`,
`internal/app/runner.go`, and transcribe/checkpoint test fixtures.
1. Wire reconciliation into Run, require the existing ManifestStore collaborator,
and checkpoint the starting collection before launching HTTP work. Implement
detached candidate saves and serialized live-state updates. Audit Store.Save's
normalization side effects so a failed candidate save cannot mutate shared
authority; use detached records/collections wherever it may write.
2. Retain bounded errgroup execution and fail-fast cancellation. Check the work
context before beginning each queued request. After success, validate adapter
output path/JSON, recheck audio evidence, materialize canonical bytes, compute
output evidence, and checkpoint the new record. Check a checkpoint-failure
latch under the serialization lock before accepting further completions.
3. Preserve committed successes on all error/cancellation exits, including
starting-save, canonical-write, per-speaker-save, and aggregate-save failures.
Return meaningful speaker/error context and let the runner retain its existing
terminal failure behavior. Do not report partial aggregate outputs as success.
4. Assemble final outputs in deterministic speaker order from the reconciled
completed records, including reused speakers, with original producer IDs and
checksums. If all records are current but aggregate status is failed/running,
finish without WhisperX calls and rebuild the aggregate success.
5. Implement `transcribeStage.ValidateResume` using the same read-only
reconciliation, so a succeeded stage cannot skip changed audio or invalid
transcripts. Retain the existing URL/language semantic check and runner
invalidation behavior; no whole-config digest invalidation.
6. Adapt existing tests that require no canonical partial files: the new contract
permits checkpointed files but forbids aggregate success. Update fixtures to
provide real manifest stores and records rather than a production fallback
that silently disables checkpointing.
7. Test A/B completion followed by C failure and D unattempted (concurrency 1),
reload and retry only C/D; concurrently finishing speakers without lost saves;
failure and cancellation after a checkpoint; save failure retaining earlier
authority; and force failure followed by ordinary retry without old-result reuse.
Verify: `go test ./internal/stage ./internal/app` and
`go test -race ./internal/stage`.
Done when incremental progress and aggregate success work together safely under
ordinary, concurrent, canceled, and forced execution.
## Stage 4: Verify Runner, Merge, and Durable Lifecycle Integration
Status: Complete.
Completion marker (2026-09-22): Added real-persistence application coverage for
cross-invocation retry, interrupted running state, failed aggregate saves,
force/reset persistence, downstream invalidation, and one-time legacy upgrade.
Versioned merge input discovery now requires aggregate success and an exact
match between current speaker evidence and declared aggregate outputs, while
legacy/manual directory fallback remains available. Restore coverage preserves
session-relative speaker paths, leaves unpublished raw transcripts absent, and
canonical transcripts remain reusable after run-directory cleanup. Verified
with `go test ./internal/app ./internal/stage ./internal/manifest`,
`go test -race ./internal/app`, and `git diff --check`.
Preparation: `internal/app/runner.go`, `runner_runtime.go`,
`analyze_checkpoint_recovery_test.go`, `runner_terminal_failure_test.go`,
`restore_manifest_paths.go`, `internal/stage/merge.go`, and existing publish,
restore, and cleanup tests routed from `docs/internal/overview.md`.
1. Add application-level tests with real manifest persistence and fake WhisperX:
failed stage across separate invocations; all speakers committed before a
failed aggregate save; interrupted running state; force/reset persistence;
and unchanged upstream/succeeded downstream invalidation behavior. Inject
errors at durable boundaries, not every private method call.
2. Close `discoverRawTranscripts`' directory-scan fallback for versioned
transcribe state: incomplete aggregate state is not usable, and completed
state consumes only declared current aggregate outputs. Keep legacy/manual
fallback behavior for manifests without the new state rather than banning
previously allowed standalone workflows. Test that partial files and removed
speaker files cannot leak into a versioned merge, including direct stage calls.
3. Audit manifest copies/projections, remote manifest publication, restored path
handling, and cleanup. Preserve relative speaker paths on restore and verify
they resolve under the destination session, not the producer machine. Reuse
existing confinement validation, not a second path-policy layer.
4. Add focused round-trip coverage through existing publish/restore fixtures for
speaker records. Restored outputs absent from the published selection are
non-reusable locally; do not silently expand remote publication to every raw
speaker or upload audio as a new default. Removing run directories must not
remove canonical checkpointed transcripts or make reuse require run files.
5. Verify old succeeded manifests are readable and rerun once when transcribe is
selected, then reuse normally. Keep old manifests usable for other commands
and bounded ranges. Do not automatically import old unrecorded JSON files.
Verify: `go test ./internal/app ./internal/stage ./internal/manifest` and
`go test -race ./internal/app`.
Done when the new authority survives runner and storage lifecycles and cannot
expose incomplete transcripts downstream.
## Stage 5: Implement Replayable Streaming Multipart Bodies
Status: Complete.
Completion marker (2026-09-22): Replaced the producer-goroutine/pipe upload
with bounded standard-library multipart framing and independently opened,
pull-based streaming bodies shared by the initial request and `GetBody`.
Bodies are tracked, idempotently closed, canceled with their attempt context,
and retain genuine read/close failures without treating an intentionally
abandoned original as a replay failure. Added focused coverage for identical
framing, partial-read restart, simultaneous handles, source failures, and
blocked-reader release. Verified with `go test ./internal/adapters/whisperx`,
`go test -race ./internal/adapters/whisperx` (including ten repeated runs), and
`git diff --check`.
Preparation: `internal/adapters/whisperx/http.go`, `http_test.go`,
`docs/integrations/whisperx.md`, and the installed Go net/http Request/GetBody
contract. Keep this change inside the WhisperX adapter.
1. Implement the pull-based framing/body factory described above, using standard
multipart encoding rather than hand-escaped MIME headers. Add no new payload
cap or upload-size setting. Preserve file-before-language wire semantics and
the existing form names.
2. Install the initial body and GetBody with the same per-attempt boundary.
Track and close all opened bodies even if a custom transport returns early;
make cleanup/context callbacks safe against overlapping reads and closes.
A GetBody failure must be returned, not converted into an empty upload.
3. Replace pipe-specific Wait/error paths in doTranscribeAttempt with source-error
handling appropriate to pull bodies. Genuine source errors remain visible;
intentional closure during transport retry is not itself a source error.
Keep response bounds, JSON validation, output installation, retry
classification/delay, cancellation, and application attempt reporting intact.
4. Remove obsolete producer-only helpers rather than retaining two upload paths.
Adapt tests tied to producer goroutines to assert streaming and resource
release, which are the actual contracts.
5. Add focused body tests: original/replayed bytes parse identically; replay
after partial consumption starts from the beginning; simultaneous bodies use
independent handles; source failures propagate; Close/cancel releases a
blocked reader without deadlock. Use synthetic audio and gated readers.
Verify: `go test ./internal/adapters/whisperx` and
`go test -race ./internal/adapters/whisperx`.
Done when replay is available without whole-file buffering or producer-lifetime
ambiguity, and all existing HTTP behavior tests still pass.
## Stage 6: Verify Replay Through the HTTP Adapter Boundary
Status: Complete.
Completion marker (2026-09-22): Added a deterministic transport regression
that abandons a partially consumed original request, obtains and parses a fresh
multipart replay from `GetBody`, and installs the returned transcript within one
application attempt. Covered replay open/read failures, early responses,
parent cancellation during replay, and disposal of every opened source, while
proving an abandoned original does not poison a valid replay. Added a real HTTP
retry-exhaustion integration that durably retains an earlier speaker and resumes
only the uncommitted speakers. This controlled boundary verifies Narratio's
replay contract, not recovery from every real proxy protocol failure. Verified
with `go test ./internal/adapters/whisperx ./internal/app ./internal/stage`,
`go test -race ./internal/adapters/whisperx ./internal/app ./internal/stage`,
ten repeated race-enabled adapter runs, and `git diff --check`.
Preparation: Existing injectable HTTPClient/RoundTripper seam and local-server
tests in `internal/adapters/whisperx/http_test.go`; app WhisperX wiring tests.
1. Add a deterministic transport-boundary regression: consume part of the
original request, close it as a transport would, call GetBody, consume and
parse the replay using the request Content-Type, then return valid JSON.
Verify exact audio/language, installed output, and one application attempt.
This must fail before replay support; merely checking non-nil GetBody is
insufficient. Do not assert Go's internal retry count or require a flaky
handcrafted HTTP/2 peer in the default suite.
2. Cover replay-open/read failure, early response, parent cancellation during
replay, and disposal of all body handles. Reuse Stage 5 body coverage rather
than repeat every low-level case. Prove an intentionally abandoned original
body does not turn a valid replay response into failure.
3. Retain local HTTP tests for application retries, non-retryable responses,
response limits, invalid JSON, and bounded streaming; exercise that an
exhausted HTTP failure still leaves earlier speaker checkpoints reusable
using the existing stage/app seams.
4. Record the verification boundary honestly: controlled transport replay proves
Narratio's body contract, not recovery from every real proxy PROTOCOL_ERROR.
Verify: `go test ./internal/adapters/whisperx ./internal/app ./internal/stage`
and race tests for changed packages.
Done when the replay defect is protected at the adapter boundary and the
application retry/checkpoint behavior remains intact.
## Stage 7: Document and Validate the Completed Feature
Status: Complete.
Completion marker (2026-09-22): Updated the canonical transcribe, merge,
manifest, WhisperX, operations, and troubleshooting documentation for durable
speaker reconciliation, serialized checkpoints, aggregate gating, durable
force reset, legacy upgrade and downgrade limits, replayable streaming bodies,
resource ownership, and the absence of an exactly-once remote guarantee.
Reviewed the completed implementation against the feature roadmap and
implementation-discipline guidance. Verified with `go test ./...`,
`go test -race ./...`, `go vet ./...`, `go build ./...`,
`go test ./internal/doccheck`,
`go test ./internal/config -run '^TestExamplesLoadAndValidate$'`, and
`git diff --check`.
1. Update canonical transcribe and manifest internals for the collection,
reconciliation, durable-reset force behavior, serialized saves, and aggregate
gate. Remove obsolete all-speakers-before-canonical-write descriptions.
2. Update the WhisperX contract for replayable bounded streaming, application
attempts versus transport replay, resource ownership, and the lack of an
exactly-once server guarantee. Do not claim the remote protocol issue is fixed.
3. Update operations/troubleshooting with ordinary resume versus force, durable
partial success, old-manifest one-time retranscription on selecting transcribe,
downgrade limitations, and private-model-change guidance. No new CLI flags
or promises of automatic historical-file recovery. Keep detailed wire and
storage contracts in their canonical owners.
4. Review changes against the feature roadmap and the implementation-discipline
guidance. Do not elevate selected storage encoding, internal ordering, test
synchronization, or exact error wording into new product guarantees.
5. Run the full repository gates below; document results and update completion
statuses only after they pass. Leave release preparation for a separate task.
```sh
go test ./...
go test -race ./...
go vet ./...
go build ./...
go test ./internal/doccheck
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
git diff --check
```
## Open Questions
None requiring user input. The agreed roadmap supplies the product decisions;
this plan selects a completed-only manifest collection, conservative old-record
handling, and pull-based replay as implementation choices. No stronger remote
reliability guarantee, new operator restriction, or generic orchestration
surface is proposed.

View File

@@ -1,151 +0,0 @@
# Notarius Configuration Profile Selection
Status: Implemented.
## Goal and Authority
Implement the consumer behavior in the
[feature request](feature-request-notarius-config-profile.yml): let a Narratio
configuration overlay explicitly select a Notarius configuration overlay.
Operators can select testing once in Narratio while each application retains
ownership of its own model choices and other settings.
This roadmap describes the target state and required change areas, not an
implementation sequence; the [implementation plan](implementation.md) owns
the ordered work and implementation decisions. The feature request is the scope
authority; the mechanisms suggested below do not establish additional
user-facing contracts.
## Underlying Issue
Narratio can select its own testing profile, but extraction currently invokes
Notarius without a configuration-profile selector. Consequently, a Narratio
testing invocation cannot explicitly select the independently deployed Notarius
testing overlay through pipeline configuration.
Adding individual downstream model or tuning fields would duplicate Notarius's
configuration responsibilities. Passing its existing profile selector provides
the requested flexibility without that duplication.
## Targeted End State
### Explicit Configuration Composition
`pipeline.notarius.config_profile` is an optional Notarius configuration-profile
name, usable in Narratio's base configuration and ordinary overlays. For
example, Narratio's `profiles/testing.yml` may contain:
```yaml
notarius:
config_profile: testing
```
Selecting Narratio's `--profile testing` then selects that explicit downstream
profile whenever extraction executes. The two profile names need not match.
Narratio does not automatically forward its own profile name.
When supplied, the selection is passed through Notarius's `--config-profile`
argument. Omitted or null values add no profile-selection flag and preserve
existing downstream selection behavior. Notarius's selector semantics remain
authoritative, including empty and `default` selections for the composed base
configuration. Narratio does not introduce a stricter profile-name policy.
This field selects a ConfigKit configuration overlay, not a PromptKit profile
through `--llm-profile`. The Notarius overlay owns any resulting LLM-profile
selection. Notarius resolves overlays relative to its own configuration root;
Narratio neither resolves those files against its own root nor rewrites them.
### Execution, Reuse, and Failure
A changed downstream profile selection must not silently reuse Narratio's
completed extraction from the previous selection. Existing dependent-stage
invalidation applies when extraction changes; the feature does not change the
pipeline order or transcript-generation behavior.
Notarius continues to own downstream overlay discovery, composition, validation,
and cache compatibility. A missing or invalid selected overlay must fail
extraction through the existing subprocess error path, without silently falling
back to production or reporting successful extraction.
Existing resume and chunk-cache controls, including force precedence, remain
in place. Profile selection does not itself mean forced regeneration or cache
deletion. Narratio's decision to execute extraction and Notarius's decision to
reuse compatible work remain separate responsibilities.
Changes to private Notarius configuration behind an unchanged selector retain
the existing operator responsibility to force extraction when needed. There is
no new downstream file inspection or content-tracking requirement.
Credential forwarding remains independently configured through
`notarius.llm_api_key`. A Narratio testing overlay may select a different
credential reference alongside the downstream profile; no new credential
discovery or provider-name mapping is introduced.
## Required Change Areas
### Configuration and Inspection
Extend the typed Notarius configuration and its existing safe effective-YAML
projection to carry the optional field. Preserve the ordinary configuration
composition behavior for base values, overlay overrides, and null values.
Configuration inspection must reflect the selected field without exposing
credential values. No replacement configuration loader or downstream preflight
loader is needed.
### Stage and Adapter Boundary
Carry the selection from resolved configuration through the extraction request
to the subprocess adapter. Construct the optional CLI flag using the existing
argument-vector mechanism, alongside the current configuration path, references,
credential forwarding, and cache controls. Retain normal Notarius error handling.
### Extraction Reuse Evidence
Account for the downstream selection in the existing extraction reuse evidence.
The current semantic-configuration and invocation fingerprints are the relevant
change points. Keep their treatment of selection consistent so that a profile
change cannot bypass the required rerun and downstream invalidation.
This roadmap does not require a new manifest schema, separate profile-indexed
workspaces, historical per-profile result retention, or an upgrade-wide forced
rerun.
### Verification and Documentation
Use focused behavioral coverage for configuration composition and inspection,
explicit argument forwarding and omission, changed-selection reuse decisions,
and ordinary subprocess failure handling. Reuse existing test coverage where
it already protects unchanged credentials, force behavior, and invalidation;
do not duplicate Notarius's complete profile-validation or cache test suites.
When implemented, update the canonical
[configuration reference](../config.md),
[Notarius integration contract](../integrations/notarius.md),
[extract internals](../internal/stage-extract.md), and relevant maintained
examples. The development workflow in [Operations](../operations.md) should
explain explicit profile composition and the existing need to force extraction
after private downstream edits. Clarify that profile selection does not create
an isolated testing workspace or separate current extraction state.
## Compatibility and Scope Boundaries
Notarius v0.7.0 already documents `--config-profile`; no upstream change is
required. Document the capability needed when the option is used, without adding
an unconditional runtime version gate for existing unconfigured deployments.
This feature adds no Narratio CLI selector, automatic profile propagation,
Notarius LLM-profile selector, downstream configuration rewriting, new
credential mechanism, or generalized recursive orchestration. Existing output
and receipt contracts remain unchanged.
The consumer's Ansible role and suite updates are follow-up work outside this
repository. They can deploy downstream overlays and populate this field after
the interface is released.
## Scope Review
No stronger requirements or new invariants are proposed beyond the feature
request and existing repository contracts. In particular, minimizing one-time
upgrade invalidation and avoiding reruns between equivalent base selectors are
implementation considerations, not additional acceptance guarantees imposed
by this roadmap. No user decisions are currently required before planning.

View File

@@ -1,104 +0,0 @@
# Transcription Checkpoint Review Remediation
Status: Complete.
Reviewed commits `7b0bf99` through `96b4e73` against the speaker-checkpoint and
upload-replay roadmap and the original user request. No production changes were
made during review. The two failures below were reproduced with temporary tests
using the existing stage fixtures; those temporary tests were removed afterward.
## 1. Reconcile Aggregate State Even When No Speaker Needs HTTP Work
Priority: P2. Status: Complete.
Implemented read-only aggregate identity/evidence checks before resume skips.
Regression tests cover removed inputs, missing/duplicate/mismatched outputs,
repair without HTTP requests, downstream invalidation, and exclusion of removed
speakers from merge without deleting their files. Equivalent path forms and
output ordering remain reusable. Focused app/stage tests and documentation
checks pass.
Location: `internal/stage/transcribe_resume.go:21` and the reconciliation result
consumed there.
`ValidateResume` returns reusable whenever `reconciliation.work` is empty. That
only establishes that the remaining speakers need no transcription; it does
not establish that the stored speaker collection and aggregate outputs match
the current input set. Removing a speaker while the remaining inputs are
unchanged produces an empty work list, so a succeeded record can be skipped
without pruning the removed speaker. Its old aggregate output can then remain
available to merge. Likewise, missing/inconsistent aggregate output references
are not repaired when all individual checkpoints remain valid. A prepare rerun
may already stale transcribe in some workflows, but the resume validator cannot
rely on that to satisfy its own current-input/output checks.
Reproduction: create complete alice/bob checkpoint records and aggregate
outputs with the existing fixture, mark the stage succeeded, remove bob from
the manifest's audio inputs, and call `ValidateResume`. It returns reusable
despite still retaining bob in the stored collection and aggregate outputs.
Required change:
- Distinguish no HTTP work from no reconciliation work. Validate that the
committed speaker set and aggregate transcript references represent the
reconciled current set, including relevant identity/evidence, before skipping.
- When only collection/output repair is needed, return non-resumable and let
normal execution checkpoint the pruned collection and rebuild aggregate
outputs without retranscribing reusable speakers. Preserve existing dependent
invalidation; do not force otherwise-current speaker jobs.
- Keep resume inspection read-only. Do not fix this by deleting incidental
transcript files or changing stage selection rules.
Verification: add removed-only input coverage (not just removal combined with
an added speaker), plus incomplete/inconsistent aggregate output coverage.
Exercise the runner skip path and assert zero additional WhisperX requests,
correct rebuilt outputs, and no removed speaker reaching merge.
## 2. Remove the Unrequested Positive Audio-Size Requirement
Priority: P2. Status: Complete.
Audio evidence now permits zero size while still rejecting negative values.
Regression tests verify zero-size manifest round trips, successful service
results being checkpointed and reused, and service rejection being propagated
without creating a completion record. Nonempty valid-JSON output validation is
unchanged. Both new success regressions failed before the fix.
Location: `internal/manifest/transcribe_state.go:95`.
The new record validator requires `AudioSize > 0`. Previously Narratio accepted
a regular `.flac` file and delegated transcription acceptance to WhisperX;
neither the user request nor the roadmap introduced minimum audio size or a new
audio-content validation policy. Zero-byte content still has a valid checksum
and can be represented by the checkpoint mechanism. The current check runs
after a successful response and output materialization, causing the entire
stage to fail instead of retaining the completed work.
Reproduction: prepare one speaker, replace its prepared audio with an empty
regular file, and use the existing successful fake WhisperX client. Stage
execution fails at checkpoint validation with `audio_size must be positive`.
This demonstrates the extra Narratio restriction; it does not assert that a
particular production WhisperX deployment accepts empty audio.
Required change:
- Permit zero audio size and reject negative sizes. Keep byte hashing and
size equality checks. Let the downstream service decide whether the input
can be transcribed, as before.
- Do not remove the nonempty valid-JSON output requirement, which has a separate
existing justification. Do not add a new early empty-audio rejection instead.
- Update any new tests/documentation that promote strictly positive input size
to a contract.
Verification: cover a zero-size record round-trip and successful adapter result
being checkpointed/reused for that input; retain negative-size rejection and
ordinary downstream-failure tests.
## Verification After Remediation
Completed on 2026-09-22 with Go 1.26.8: focused stage/application tests,
changed-package race tests, `go test ./...`, `go test -race ./...`,
`go vet ./...`, `go build ./...`, `go test ./internal/doccheck`,
`go test ./internal/config -run '^TestExamplesLoadAndValidate$'`, and
`git diff --check` all passed. Existing replay and cancellation regression
tests remain intact; the HTTP adapter required no changes.

View File

@@ -1,183 +0,0 @@
# Resumable Speaker Transcription and Replayable Uploads
Status: Implemented.
## Goal and User Intent
Preserve completed speaker transcriptions when another speaker fails, so an
ordinary run or resume does not repeat expensive successful work. Separately,
allow Go's HTTP transport to replay WhisperX uploads when it encounters a
recoverable connection or stream failure.
The motivating incident exhausted four transcription attempts for one speaker
after three other speakers had completed. The final error reported an HTTP/2
peer `PROTOCOL_ERROR` and an inability to replay the request body without
`Request.GetBody`.
These are related reliability improvements, not a promise to eliminate all
service failures. This document owns the target state and scope; the
[implementation plan](implementation.md) owns ordered changes and concrete
storage and replay decisions.
## Pre-implementation Limitations
The [transcribe stage](../internal/stage-transcribe.md) writes successful
responses into the invocation's run directory. It installs canonical transcripts
and returns its per-file metadata only after every speaker succeeds. A failure
therefore leaves no durable per-speaker progress that ordinary resume can use,
even though some completed JSON files may remain on disk.
The [WhisperX adapter](../integrations/whisperx.md) already retries requests at
the application level, reopening audio for each attempt. Its multipart request
body is streamed through a pipe and has no replay callback. Transport-level
recovery within an attempt cannot reconstruct a consumed body. Providing that
capability addresses the replay limitation, not the underlying peer protocol
error; persistent failures can still require service or proxy investigation.
## Targeted End State
### Durable Per-Speaker Completion
Each successfully transcribed speaker file is validated, saved, and recorded
durably before that work is treated as reusable. The session manifest remains
the progress authority. Follow the existing analyze-stage checkpoint pattern
without turning speakers into pipeline stages or introducing a general task
engine.
Ordinary execution reconciles the current prepared audio set with recorded
speaker results. It reuses valid completed work and schedules failed,
unattempted, missing, or obsolete results. A result is not reusable merely
because a matching filename exists. Evidence binds it to the speaker/audio
identity, audio content, Narratio-visible recognition settings, and the saved
transcript's integrity.
The relevant recognition settings remain the WhisperX service URL and language.
Retry count/delay, concurrency, timeout, credentials, and diagnostic locations
remain operational settings rather than reasons to discard completed results.
Changes to private service models or configuration remain an operator reason
to force transcription; Narratio does not inspect the service's private state.
Changes to individual audio inputs require replacement of affected results,
not automatic retranscription of unrelated unchanged speakers. New speakers
need transcription; removed speakers do not remain in the current aggregate
output set. Records must describe the inputs actually used, not grant reuse to
old bytes under a changed identity.
### Aggregate Completion, Failure, and Force
The overall stage succeeds only when every currently required speaker has a
validated, durably recorded result. Incomplete per-speaker progress does not
make the aggregate stage successful or authorize downstream consumption of an
incomplete collection. Existing dependent-stage invalidation remains in effect.
Retain bounded concurrency and the current fail-fast behavior: a speaker
failure cancels outstanding work. Results already checkpointed remain reusable
after failure, cancellation, or process interruption. A later ordinary run or
resume retries the remaining work; it does not need a new CLI command.
Checkpoint writes must be serialized so concurrently finishing speakers cannot
overwrite each other's progress. A failed persistence operation must not
advertise an unrecorded result as complete. Previously committed progress remains
the recovery authority, and checkpoint errors must be surfaced rather than
reported as successful transcription.
`--force` on transcribe regenerates all currently required speakers instead of
reusing their prior completions. Successful replacements are checkpointed too,
so an ordinary retry after a failed forced invocation can retain the newly
completed work. Old results for speakers not successfully regenerated must not
satisfy that unfinished forced replacement on the next ordinary retry.
This deliberately relaxes the current all-speakers-before-canonical-installation
implementation: individual durable results may exist while the stage remains
failed or incomplete. The all-speakers-before-stage-success boundary remains.
### Replayable WhisperX Requests
Provide `Request.GetBody` so the HTTP transport can obtain a fresh body for the
same request. Replay reopens the audio and reproduces the multipart boundary,
filename, fields, and framing used by the original request. Independent bodies
must not share a consumed reader or mutable read position.
Preserve bounded-memory streaming rather than buffering entire audio files in
memory. Correctly close every original and replay body and its audio resources;
preserve source-read errors and cancellation, including partial uploads and
early server responses. Successful response handling must not accidentally wait
on, or report errors solely from, an abandoned body instead of the replay that
produced the response.
Keep the existing application-level retry count, delay, classification, and
per-attempt timeout semantics. Transport replays occur within an application
attempt and its context; they are not additional Narratio-configured attempts.
Do not add another custom retry loop, silently increase retries, or disable
HTTP/2 globally as a substitute for replay support.
The adapter still validates and installs only successful JSON responses.
Request replay does not provide exactly-once server execution: a lost response
can cause repeated transcription work, as can existing application retries.
No new service-side idempotency protocol is required by this feature.
## Required Change Areas
- **Manifest and persistence:** Add transcribe-owned per-speaker progress and
evidence, with safe checkpoint updates and preservation through runner failure
handling. Review manifest validation, copying, serialization, and existing
publish/restore handling wherever the new records pass through those paths.
- **Stage execution and reuse:** Reconcile speaker records before scheduling,
checkpoint validated completions, rebuild aggregate outputs from the current
set, and account for partial progress in ordinary and forced execution.
Review successful-stage reuse as well as failed-stage retries so stale audio
or missing/corrupt transcript evidence is not bypassed by an aggregate skip.
- **Artifact lifecycle:** Use existing path, installation, integrity, and manifest
mechanisms. Ensure checkpointed results do not depend on an ephemeral run
directory surviving cleanup. Do not introduce a second authoritative ledger
or infer completion by scanning abandoned runs.
- **HTTP adapter:** Make multipart bodies reproducible and correctly manage
every replay's lifetime, without changing the WhisperX HTTP schema or requiring
an upstream service change.
- **Tests:** Cover per-speaker recovery after a later failure, interruption and
checkpoint failure; changed/missing inputs or outputs; force followed by an
ordinary retry; concurrent successful completions; and aggregate downstream
gating. Extend existing fixtures rather than duplicating analyze's complete
test suite. Exercise actual body reconstruction after partial consumption,
multipart equivalence, cancellation/resource cleanup, and existing bounded
streaming and retry behavior—not just the presence of a callback. Default
tests remain offline and use local/fake HTTP boundaries, never paid services.
- **Documentation:** Update the canonical
[stage guide](../internal/stage-transcribe.md),
[manifest guide](../internal/manifest.md),
[WhisperX contract](../integrations/whisperx.md),
[operations guide](../operations.md), and
[troubleshooting guide](../troubleshooting.md) when implemented. Explain
partial completion, ordinary retry versus force, and the distinction between
request replay limitations and persistent peer protocol failures.
## Compatibility and Scope Boundaries
Existing speaker identities, raw transcript formats, and the fixed pipeline
order remain unchanged. Existing manifests remain readable, but must not be
treated as containing per-speaker evidence they never recorded. Recovery relies
on verified recorded evidence, not guessed associations between old transcripts
and current audio. No general legacy-migration framework is part of the feature.
Automatic adoption of unrecorded outputs from previously failed runs is outside
this feature. Such files may be recoverable after verifying their inputs, but
their presence alone does not prove completion. No recovery/import command,
speaker-selection CLI, historical invocation ledger, new service configuration
discovery, or broader orchestration refactor is included.
The feature does not diagnose the remote service/proxy's underlying HTTP/2
problem, guarantee recovery from persistent protocol errors, or change the
policy for continuing unrelated work after failure.
## Completion Criteria
For speakers A, B, C, and D: if A and B are checkpointed, C fails, and D has not
completed, ordinary resume retains A and B, retries C, and completes D. No
downstream stage receives an incomplete successful transcription collection.
Force replaces all four, while an ordinary retry can reuse checkpoints from
that forced replacement attempt.
The HTTP transport can reconstruct the upload after consuming the original
body, without whole-file memory buffering, lost source errors, or stranded
upload resources. A persistent peer error still fails normally after the
configured application attempts, without discarding checkpointed speakers.

View File

@@ -102,7 +102,7 @@ Safe fix:
Relevant reference: [Canonical Party Input](./integrations/party.md). Relevant reference: [Canonical Party Input](./integrations/party.md).
## Unexpected imported or profile value ## Unexpected imported or overlay value
Symptom: Symptom:
@@ -112,21 +112,21 @@ Symptom:
Diagnostics: Diagnostics:
```bash ```bash
narratio config sources --config /path/pipeline.yml --profile testing narratio config sources --config /path/pipeline.yml --overlay testing
``` ```
Add `--campaign` or `--campaign-file` when the pipeline has party-driven Add `--campaign` or `--campaign-file` when the pipeline has party-driven
artifact families. The output identifies each effective logical field's root, artifact families. The output identifies each effective logical field's root,
import, profile, default, campaign, party, or family source without printing import, overlay, default, campaign, party, or family source without printing
the field value or credential contents. the field value or credential contents.
Safe fix: Safe fix:
- move a duplicated base field so it has one owner; - move a duplicated base field so it has one owner;
- correct the selected profile or its overlay; or - correct the selected overlay; or
- correct the campaign party/family declaration that owns generated values. - correct the campaign party/family declaration that owns generated values.
To review what would actually change before switching profiles, run `config To review what would actually change before switching overlays, run `config
diff` with the same pipeline and campaign selectors. It compares normalized diff` with the same pipeline and campaign selectors. It compares normalized
effective values rather than YAML formatting or source-file layout. effective values rather than YAML formatting or source-file layout.
@@ -312,7 +312,7 @@ and [Transcribe internals](./internal/stage-transcribe.md).
Symptom: Symptom:
- configuration loading reports `secret.unset`, `secret.empty`, or an invalid - configuration loading reports `secret.unset`, `secret.empty`, or an invalid
name for `pipeline.notarius.llm_api_key`, including during inspection or a name in `pipeline.notarius.credentials`, including during inspection or a
run that would not execute extraction. run that would not execute extraction.
Likely causes: Likely causes:
@@ -328,7 +328,7 @@ Safe fix:
- set the exact nonempty environment variable or provide an equivalently named - set the exact nonempty environment variable or provide an equivalently named
file through the conventional or explicit secrets directory; file through the conventional or explicit secrets directory;
- remove or set the field to `null` when that deployment needs no credential; - remove, set to `null`, or set to `[]` when that deployment needs no credential;
and and
- use `config show`, `config sources`, and `config diff` to inspect the public - use `config show`, `config sources`, and `config diff` to inspect the public
reference name and ownership without exposing its value. reference name and ownership without exposing its value.
@@ -339,7 +339,43 @@ invalidate a completed extraction, but the current reference must still resolve
for the command to proceed. for the command to proceed.
Relevant references: [Secrets](./config.md#secrets-handling) and Relevant references: [Secrets](./config.md#secrets-handling) and
[Notarius credential](./config.md#notarius-credential). [Notarius credentials](./config.md#notarius-credentials).
## Audita v2 configuration or credential failure
Symptom:
- Narratio rejects a retired `pipeline.audita` field during loading, reports
`secret.unset` or `secret.empty` for a listed credential, or Audita rejects
its external configuration or selected profile when polish runs.
Likely causes:
- the Narratio pipeline still uses v1 endpoint, model, capacity, singular key,
report, or retention settings;
- the named key is missing or empty, including a present but empty process
environment variable that takes precedence over a secrets-directory file;
- Audita's external v2 file references a backend credential name not forwarded
by `pipeline.audita.credentials`, including a backend not selected by the
current profile; or
- an Audita overlay or profile selector names a missing downstream definition.
Safe fix:
- follow the [migration table](./operations.md#migrating-audita-and-provider-credentials)
and use a supported Audita v2 executable and configuration;
- provide each needed provider-named variable through the environment or
secrets directory, and list that same name in `pipeline.audita.credentials`;
- validate the external file offline with
`audita config validate --config <file>` before switching executables; and
- inspect Audita's effective profile selection separately from Narratio's
`config show`. Narratio does not infer downstream selectors.
An exit-zero polish with a valid transcript succeeds even when Audita reports
recoverable errors or no corrections. Missing reports are allowed when Audita
reporting is disabled externally. Edits to Audita-owned files behind unchanged
selectors require `narratio run-stage polish <session-id> --force` to replace a
previously successful result. See the [Audita integration contract](./integrations/audita.md).
## Notarius executable missing ## Notarius executable missing
@@ -703,6 +739,20 @@ manifest-owned.
Relevant references: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior) Relevant references: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior)
and [Manifest Internals](./internal/manifest.md#transcribe-owned-speaker-state). and [Manifest Internals](./internal/manifest.md#transcribe-owned-speaker-state).
## Scriptorium returns HTTP 401 during trim or analyze
Check that Narratio's pipeline selects the credential names required by
Scriptorium, for example `scriptorium.credentials: [OPENROUTER_API_KEY]`.
Provision the values through the existing environment or secrets directory;
exporting a key without selecting it does not forward it to the child process.
See [Scriptorium credentials](config.md#scriptorium-credentials).
Once corrected, retry the failed run normally; forcing previously successful
transcription or analysis is unnecessary. If authentication still fails, check
the selected Scriptorium backend, credential validity, and provider access
without printing keys. A 401 can indicate an invalid key as well as a missing
one.
## Scriptorium private input changed without a rerun ## Scriptorium private input changed without a rerun
Symptom: Symptom:

View File

@@ -14,16 +14,16 @@ in the [configuration reference](../docs/config.md).
- [Production-base/testing-overlay bundle](production-testing/pipeline.yml): - [Production-base/testing-overlay bundle](production-testing/pipeline.yml):
explicit `conf.d` imports, one ordinary artifact per `artifacts/<key>.yml`, explicit `conf.d` imports, one ordinary artifact per `artifacts/<key>.yml`,
one family per `artifact-families/<key>.yml`, and the fixed one family per `artifact-families/<key>.yml`, and the fixed
`profiles/testing.yml` overlay. Production values live in the base. The `overlays/testing.yml` overlay. Production values live in the base. The
bundle demonstrates an input-only artifact dependency, two character families bundle demonstrates an input-only artifact dependency, two character families
sharing the campaign party, and a testing-only disabled artifact. Inspect it sharing the campaign party, and a testing-only disabled artifact. Inspect it
offline with: offline with:
```sh ```sh
narratio config validate --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml narratio config validate --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
narratio config show --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing narratio config show --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --overlay testing
narratio config sources --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing narratio config sources --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --overlay testing
narratio config artifacts --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing narratio config artifacts --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --overlay testing
narratio config diff default testing --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml narratio config diff default testing --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
``` ```
@@ -70,7 +70,11 @@ The examples contain environment-variable names but no credential values. Set
`OBJECT_STORAGE_KEY_ID`, `OBJECT_STORAGE_KEY`, `AUDITA_LLM_API_KEY`, and `OBJECT_STORAGE_KEY_ID`, `OBJECT_STORAGE_KEY`, `AUDITA_LLM_API_KEY`, and
`NOTARIUS_LLM_API_KEY` to nonempty synthetic or real values before loading the `NOTARIUS_LLM_API_KEY` to nonempty synthetic or real values before loading the
examples that reference them. They use fictional campaign content and reserved examples that reference them. They use fictional campaign content and reserved
example domains. example domains. The Audita and Notarius names are example references, not
implicit aliases: external backend/profile configurations must use those exact
names, or the Narratio credential lists must be changed to match. See the
[Audita v2 migration guide](https://gitea.maximumdirect.net/eric/audita/src/tag/v2.0.0/docs/migrations/v2.md)
for maintained downstream configuration examples.
For a deployed bundle, keep credential files out of source control in a For a deployed bundle, keep credential files out of source control in a
`secrets/` directory beside the root `pipeline.yml`, one file per referenced `secrets/` directory beside the root `pipeline.yml`, one file per referenced

View File

@@ -90,22 +90,18 @@ seriatim:
filler_max_duration: 1.25 filler_max_duration: 1.25
audita: audita:
# Optional overrides; defaults shown explicitly where applicable. # Optional overrides. Audita owns its own configuration and profiles.
binary: audita binary: audita
timeout: 3h timeout: 3h
llm_api_key: AUDITA_LLM_API_KEY # Names must match credentials on every backend in the Audita v2 config.
modules: [glossary, homophones, spoken_word, grammar] credentials: [AUDITA_LLM_API_KEY]
base_url: "" # Omit modules for Audita's default sequence; modules: [] disables corrections.
model: "" overlay: ""
total_llm_concurrency: 2 proposal_profile: ""
proposal_llm_concurrency: 1 validation_profile: ""
validation_model: ""
validation_llm_concurrency: 1
transcript_description: "" transcript_description: ""
config_path: /usr/local/etc/audita/config.yml config_path: /usr/local/etc/audita/config.yml
output_schema: audita-v1 output_schema: audita-v1
work_dir_retention: auto
report: true
normalize: normalize:
# Optional; defaults shown explicitly. # Optional; defaults shown explicitly.
@@ -131,13 +127,13 @@ notarius:
# Optional structured extraction after render and before analyze. # Optional structured extraction after render and before analyze.
enabled: true enabled: true
binary: notarius binary: notarius
# Optional environment-variable reference. Match this name to api_key_env in # Optional environment-variable references. Match these names to the
# the selected Notarius/PromptKit profile; never put the credential here. # selected Notarius/PromptKit providers; never put credential values here.
llm_api_key: NOTARIUS_LLM_API_KEY credentials: [NOTARIUS_LLM_API_KEY]
# Optionally select a Notarius ConfigKit overlay when extraction runs. This # Optionally select a Notarius ConfigKit overlay when extraction runs. This
# is independent of Narratio's selected profile and may use a different name. # is independent of Narratio's selected overlay and may use a different name.
# See docs/config.md and docs/integrations/notarius.md for the contracts. # See docs/config.md and docs/integrations/notarius.md for the contracts.
# config_profile: downstream-economy # config_overlay: downstream-economy
config_path: /usr/local/etc/notarius/config.yml config_path: /usr/local/etc/notarius/config.yml
pipeline_id: dnd-session pipeline_id: dnd-session
timeout: 3h timeout: 3h
@@ -221,6 +217,9 @@ notarius:
scriptorium: scriptorium:
binary: scriptorium binary: scriptorium
# Select provider names provisioned through Narratio's environment/secrets.
# Used for trim, analysis, and render-debug calls; no provider override.
# credentials: [OPENROUTER_API_KEY, RAKESTRAWHOME_API_KEY]
config_path: /usr/local/etc/scriptorium/config.yml config_path: /usr/local/etc/scriptorium/config.yml
timeout: 10m timeout: 10m
render_debug: false render_debug: false

View File

@@ -57,14 +57,9 @@ seriatim:
audita: audita:
binary: audita binary: audita
timeout: 3h timeout: 3h
llm_api_key: AUDITA_LLM_API_KEY credentials: [AUDITA_LLM_API_KEY]
modules: [glossary, homophones, spoken_word, grammar] # Omit modules to use Audita defaults; modules: [] disables corrections.
output_schema: audita-v1 output_schema: audita-v1
work_dir_retention: auto
total_llm_concurrency: 2
proposal_llm_concurrency: 1
validation_llm_concurrency: 1
report: true
normalize: normalize:
output_path: transcripts/final.json output_path: transcripts/final.json
@@ -73,6 +68,7 @@ normalize:
scriptorium: scriptorium:
binary: scriptorium binary: scriptorium
# credentials: [OPENROUTER_API_KEY] # Enable when this provider is configured.
config_path: /usr/local/etc/scriptorium/config.yml config_path: /usr/local/etc/scriptorium/config.yml
timeout: 10m timeout: 10m
render_debug: false render_debug: false

View File

@@ -10,8 +10,8 @@ audita:
binary: audita binary: audita
modules: [glossary, grammar] modules: [glossary, grammar]
output_schema: audita-v1 output_schema: audita-v1
model: narratio-production-model-placeholder proposal_profile: narratio-production-model-placeholder
validation_model: narratio-production-validator-placeholder validation_profile: narratio-production-validator-placeholder
normalize: normalize:
output_path: transcripts/final.json output_path: transcripts/final.json

View File

@@ -1,6 +1,6 @@
audita: audita:
model: narratio-testing-model-placeholder proposal_profile: narratio-testing-model-placeholder
validation_model: narratio-testing-validator-placeholder validation_profile: narratio-testing-validator-placeholder
scriptorium: scriptorium:
artifacts: artifacts:

2
go.mod
View File

@@ -3,7 +3,7 @@ module gitea.maximumdirect.net/eric/narratio
go 1.26.0 go 1.26.0
require ( require (
gitea.maximumdirect.net/eric/ConfigKit v0.3.1 gitea.maximumdirect.net/eric/ConfigKit v0.4.2
github.com/aws/aws-sdk-go-v2/config v1.32.17 github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 github.com/aws/aws-sdk-go-v2/credentials v1.19.16
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0

4
go.sum
View File

@@ -1,5 +1,5 @@
gitea.maximumdirect.net/eric/ConfigKit v0.3.1 h1:etfTI/dxO3NnS18La0V6IeUh/Iv+3PjVfEyaN8hTuOM= gitea.maximumdirect.net/eric/ConfigKit v0.4.2 h1:ZMxmv8pOnLtAx2hFTf8Z+pHKUbR89xErkBhwjr4yK24=
gitea.maximumdirect.net/eric/ConfigKit v0.3.1/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk= gitea.maximumdirect.net/eric/ConfigKit v0.4.2/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=

View File

@@ -17,13 +17,11 @@ func (n *NoopRunner) Run(ctx context.Context, req PolishRequest) (PolishResult,
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return PolishResult{}, err return PolishResult{}, err
} }
if err := materializePlaceholders(req); err != nil { if err := materializePlaceholders(req, false); err != nil {
return PolishResult{}, err return PolishResult{}, err
} }
return PolishResult{ return PolishResult{
ProcessedTranscriptPath: req.OutputProcessedPath, ProcessedTranscriptPath: req.OutputProcessedPath,
ReportPath: req.ReportPath,
WorkDir: req.WorkDir,
StdoutLogPath: req.StdoutLogPath, StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath, StderrLogPath: req.StderrLogPath,
GeneratedConfigPath: req.GeneratedConfigPath, GeneratedConfigPath: req.GeneratedConfigPath,
@@ -47,18 +45,20 @@ func (f *FakeRunner) Run(ctx context.Context, req PolishRequest) (PolishResult,
if f.Err != nil { if f.Err != nil {
return PolishResult{}, f.Err return PolishResult{}, f.Err
} }
if err := materializePlaceholders(req); err != nil { if err := materializePlaceholders(req, true); err != nil {
return PolishResult{}, err return PolishResult{}, err
} }
res := f.Result res := f.Result
if res.ProcessedTranscriptPath == "" { if res.ProcessedTranscriptPath == "" {
res.ProcessedTranscriptPath = req.OutputProcessedPath res.ProcessedTranscriptPath = req.OutputProcessedPath
} }
if res.ReportPath == "" { if res.Reports == (ReportBundle{}) {
res.ReportPath = req.ReportPath dir := filepath.Dir(req.OutputProcessedPath)
res.Reports = ReportBundle{
ErrorsPath: filepath.Join(dir, "errors.yml"),
RecoveriesPath: filepath.Join(dir, "recoveries.yml"),
OverviewPath: filepath.Join(dir, "report.md"),
} }
if res.WorkDir == "" {
res.WorkDir = req.WorkDir
} }
if res.StdoutLogPath == "" { if res.StdoutLogPath == "" {
res.StdoutLogPath = req.StdoutLogPath res.StdoutLogPath = req.StdoutLogPath
@@ -75,10 +75,10 @@ func (f *FakeRunner) Run(ctx context.Context, req PolishRequest) (PolishResult,
return res, nil return res, nil
} }
func materializePlaceholders(req PolishRequest) error { func materializePlaceholders(req PolishRequest, reports bool) error {
if req.GeneratedConfigPath != "" { if req.GeneratedConfigPath != "" {
payload := map[string]any{ payload := map[string]any{
"schema": "audita.generated.v1", "schema": "audita.generated.v2",
"placeholder": true, "placeholder": true,
"merged_transcript_path": req.MergedTranscriptPath, "merged_transcript_path": req.MergedTranscriptPath,
"output_path": req.OutputProcessedPath, "output_path": req.OutputProcessedPath,
@@ -103,11 +103,18 @@ func materializePlaceholders(req PolishRequest) error {
}); err != nil { }); err != nil {
return err return err
} }
if err := writeJSONIfRequested(req.ReportPath, map[string]any{ if !reports {
"schema": "audita.report.v1", return nil
"steps": []any{}, }
}); err != nil { for _, report := range []struct{ name, contents string }{
return err {"errors.yml", "schema: audita-errors\nversion: 1\nstatus: completed\nerrors: []\n"},
{"recoveries.yml", "schema: audita-recoveries\nversion: 1\nstatus: completed\nrecoveries: []\n"},
{"report.md", "# Audita report\n"},
} {
path := filepath.Join(filepath.Dir(req.OutputProcessedPath), report.name)
if err := fileops.WriteFileAtomic(path, []byte(report.contents), fileops.WorkspaceFileMode); err != nil {
return fmt.Errorf("write report %q: %w", path, err)
}
} }
return nil return nil
} }

View File

@@ -11,24 +11,29 @@ type Runner interface {
Run(ctx context.Context, req PolishRequest) (PolishResult, error) Run(ctx context.Context, req PolishRequest) (PolishResult, error)
} }
// PolishRequest describes an audita invocation. // PolishRequest describes an Audita invocation. Nil Modules uses the runner's
// configured selection; a non-nil empty slice selects no modules.
type PolishRequest struct { type PolishRequest struct {
GeneratedConfigPath string GeneratedConfigPath string
MergedTranscriptPath string MergedTranscriptPath string
OutputProcessedPath string OutputProcessedPath string
GlossaryPath string GlossaryPath string
ReportPath string
WorkDir string
Modules []string Modules []string
StdoutLogPath string StdoutLogPath string
StderrLogPath string StderrLogPath string
} }
// PolishResult describes a polish output. // ReportBundle contains optional reports adjacent to the requested output.
type ReportBundle struct {
ErrorsPath string
RecoveriesPath string
OverviewPath string
}
// PolishResult describes a polish output and available diagnostics.
type PolishResult struct { type PolishResult struct {
ProcessedTranscriptPath string ProcessedTranscriptPath string
ReportPath string Reports ReportBundle
WorkDir string
StdoutLogPath string StdoutLogPath string
StderrLogPath string StderrLogPath string
GeneratedConfigPath string GeneratedConfigPath string

View File

@@ -3,12 +3,14 @@ package audita
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/url" "os"
"strconv" "path/filepath"
"strings" "strings"
"time" "time"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/fileops"
) )
@@ -16,93 +18,27 @@ import (
// MaxProcessedOutputBytes bounds Audita's processed-transcript JSON result. // MaxProcessedOutputBytes bounds Audita's processed-transcript JSON result.
const MaxProcessedOutputBytes int64 = 64 * 1024 * 1024 const MaxProcessedOutputBytes int64 = 64 * 1024 * 1024
// MaxReportOutputBytes bounds Audita's optional report JSON result. // MaxReportOutputBytes bounds each optional Audita report.
const MaxReportOutputBytes int64 = 16 * 1024 * 1024 const MaxReportOutputBytes int64 = 16 * 1024 * 1024
// SubprocessRunnerConfig defines deterministic settings for Audita CLI execution. // SubprocessRunnerConfig defines deterministic settings for Audita CLI execution.
type SubprocessRunnerConfig struct { type SubprocessRunnerConfig struct {
Binary string Binary string
Timeout time.Duration Timeout time.Duration
LLMAPIKey string Credentials []configkit.Secret
LLMAPIKeyReference string
Modules []string Modules []string
BaseURL string Overlay string
Model string ProposalProfile string
ValidationProfile string
TranscriptDescription string TranscriptDescription string
ConfigPath string ConfigPath string
OutputSchema string OutputSchema string
WorkDirRetention string
TotalLLMConcurrency *int
ProposalLLMConcurrency *int
ValidationModel string
ValidationLLMConcurrency *int
Report bool
} }
// SubprocessRunner invokes Audita via subprocess. // SubprocessRunner invokes Audita via subprocess.
type SubprocessRunner struct { type SubprocessRunner struct {
binary string cfg SubprocessRunnerConfig
timeout time.Duration run func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error)
llmAPIKey string
llmAPIKeyReference string
modules []string
baseURL string
model string
transcriptDescription string
configPath string
outputSchema string
workDirRetention string
totalLLMConcurrency *int
proposalLLMConcurrency *int
validationModel string
validationLLMConcurrency *int
report bool
}
// NewSubprocessRunnerFromConfigValues parses config-derived values once.
func NewSubprocessRunnerFromConfigValues(
binary string,
timeout string,
llmAPIKey string,
llmAPIKeyReference string,
modules []string,
baseURL string,
model string,
transcriptDescription string,
configPath string,
outputSchema string,
workDirRetention string,
totalLLMConcurrency *int,
proposalLLMConcurrency *int,
validationModel string,
validationLLMConcurrency *int,
report bool,
) (*SubprocessRunner, error) {
if strings.TrimSpace(timeout) == "" {
return nil, fmt.Errorf("audita timeout is required")
}
parsedTimeout, err := time.ParseDuration(timeout)
if err != nil {
return nil, fmt.Errorf("parse audita timeout %q: %w", timeout, err)
}
return NewSubprocessRunner(SubprocessRunnerConfig{
Binary: binary,
Timeout: parsedTimeout,
LLMAPIKey: llmAPIKey,
LLMAPIKeyReference: llmAPIKeyReference,
Modules: modules,
BaseURL: baseURL,
Model: model,
TranscriptDescription: transcriptDescription,
ConfigPath: configPath,
OutputSchema: outputSchema,
WorkDirRetention: workDirRetention,
TotalLLMConcurrency: totalLLMConcurrency,
ProposalLLMConcurrency: proposalLLMConcurrency,
ValidationModel: validationModel,
ValidationLLMConcurrency: validationLLMConcurrency,
Report: report,
})
} }
// NewSubprocessRunner constructs a validated Audita subprocess runner. // NewSubprocessRunner constructs a validated Audita subprocess runner.
@@ -113,281 +49,177 @@ func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error)
if cfg.Timeout <= 0 { if cfg.Timeout <= 0 {
return nil, fmt.Errorf("audita timeout must be > 0") return nil, fmt.Errorf("audita timeout must be > 0")
} }
if (cfg.LLMAPIKey == "") != (strings.TrimSpace(cfg.LLMAPIKeyReference) == "") {
return nil, fmt.Errorf("audita credential value and reference must be provided together")
}
for i, module := range cfg.Modules { for i, module := range cfg.Modules {
if strings.TrimSpace(module) == "" { if strings.TrimSpace(module) == "" {
return nil, fmt.Errorf("audita module at index %d is empty", i) return nil, fmt.Errorf("audita module at index %d is empty", i)
} }
} }
if strings.TrimSpace(cfg.BaseURL) != "" {
u, err := url.Parse(cfg.BaseURL)
if err != nil || u.Scheme == "" || u.Host == "" {
if err != nil {
return nil, fmt.Errorf("audita base url %q is invalid: %w", cfg.BaseURL, err)
}
return nil, fmt.Errorf("audita base url %q is invalid", cfg.BaseURL)
}
}
if cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 {
return nil, fmt.Errorf("audita total llm concurrency must be > 0 when provided")
}
if cfg.ProposalLLMConcurrency != nil && *cfg.ProposalLLMConcurrency <= 0 {
return nil, fmt.Errorf("audita proposal llm concurrency must be > 0 when provided")
}
if cfg.ValidationLLMConcurrency != nil && *cfg.ValidationLLMConcurrency <= 0 {
return nil, fmt.Errorf("audita validation llm concurrency must be > 0 when provided")
}
switch strings.TrimSpace(cfg.OutputSchema) { switch strings.TrimSpace(cfg.OutputSchema) {
case "", "bare-segments", "audita-v1": case "", "bare-segments", "audita-v1":
default: default:
return nil, fmt.Errorf("audita output schema must be one of: bare-segments, audita-v1") return nil, fmt.Errorf("audita output schema must be one of: bare-segments, audita-v1")
} }
switch strings.TrimSpace(cfg.WorkDirRetention) { cfg.Binary = strings.TrimSpace(cfg.Binary)
case "", "always", "auto", "never": cfg.Credentials = append([]configkit.Secret(nil), cfg.Credentials...)
default: cfg.Modules = cloneModules(cfg.Modules)
return nil, fmt.Errorf("audita work dir retention must be one of: always, auto, never") for i := range cfg.Modules {
cfg.Modules[i] = strings.TrimSpace(cfg.Modules[i])
} }
return &SubprocessRunner{cfg: cfg, run: subprocess.Run}, nil
modules := make([]string, len(cfg.Modules))
for i, m := range cfg.Modules {
modules[i] = strings.TrimSpace(m)
}
return &SubprocessRunner{
binary: strings.TrimSpace(cfg.Binary),
timeout: cfg.Timeout,
llmAPIKey: cfg.LLMAPIKey,
llmAPIKeyReference: strings.TrimSpace(cfg.LLMAPIKeyReference),
modules: modules,
baseURL: strings.TrimSpace(cfg.BaseURL),
model: strings.TrimSpace(cfg.Model),
transcriptDescription: strings.TrimSpace(cfg.TranscriptDescription),
configPath: strings.TrimSpace(cfg.ConfigPath),
outputSchema: strings.TrimSpace(cfg.OutputSchema),
workDirRetention: strings.TrimSpace(cfg.WorkDirRetention),
totalLLMConcurrency: cfg.TotalLLMConcurrency,
proposalLLMConcurrency: cfg.ProposalLLMConcurrency,
validationModel: strings.TrimSpace(cfg.ValidationModel),
validationLLMConcurrency: cfg.ValidationLLMConcurrency,
report: cfg.Report,
}, nil
} }
// Run executes Audita process with deterministic flags and validates output artifacts. func cloneModules(modules []string) []string {
if modules == nil {
return nil
}
return append([]string{}, modules...)
}
// Run executes Audita process and validates its corrected transcript.
func (r *SubprocessRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, error) { func (r *SubprocessRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, error) {
if r == nil { if r == nil {
return PolishResult{}, fmt.Errorf("audita subprocess runner is nil") return PolishResult{}, fmt.Errorf("audita subprocess runner is nil")
} }
if strings.TrimSpace(req.MergedTranscriptPath) == "" { for _, field := range []struct{ name, value string }{
return PolishResult{}, fmt.Errorf("audita merged transcript path is required") {"merged transcript path", req.MergedTranscriptPath},
{"glossary path", req.GlossaryPath},
{"output processed path", req.OutputProcessedPath},
} {
if strings.TrimSpace(field.value) == "" {
return PolishResult{}, fmt.Errorf("audita %s is required", field.name)
} }
if strings.TrimSpace(req.GlossaryPath) == "" {
return PolishResult{}, fmt.Errorf("audita glossary path is required")
} }
if strings.TrimSpace(req.OutputProcessedPath) == "" { modules := req.Modules
return PolishResult{}, fmt.Errorf("audita output processed path is required") if modules == nil {
modules = r.cfg.Modules
} }
if strings.TrimSpace(req.WorkDir) == "" { for i, module := range modules {
return PolishResult{}, fmt.Errorf("audita work dir is required") if strings.TrimSpace(module) == "" {
return PolishResult{}, fmt.Errorf("audita module at index %d is empty", i)
} }
if r.report && strings.TrimSpace(req.ReportPath) == "" {
return PolishResult{}, fmt.Errorf("audita report is enabled but report path is missing")
} }
modules = cloneModules(modules)
reqModules := req.Modules for i := range modules {
if reqModules == nil { modules[i] = strings.TrimSpace(modules[i])
reqModules = append([]string(nil), r.modules...)
} }
args := r.buildArgs(req, reqModules) args := r.buildArgs(req, modules)
env, names, err := subprocess.CredentialEnvironment(r.cfg.Credentials)
credentialPresent := r.llmAPIKeyReference != "" if err != nil {
env := map[string]string{} return PolishResult{}, fmt.Errorf("audita credentials: %w", err)
if credentialPresent {
env["AUDITA_LLM_API_KEY"] = r.llmAPIKey
} }
if req.GeneratedConfigPath != "" { if req.GeneratedConfigPath != "" {
if err := r.writeInvocationConfig(req, args, reqModules, credentialPresent); err != nil { if err := r.writeInvocationConfig(req, args, modules, names); err != nil {
return PolishResult{}, fmt.Errorf("write audita invocation config %q: %w", req.GeneratedConfigPath, err) return PolishResult{}, fmt.Errorf("write audita invocation config %q: %w", req.GeneratedConfigPath, err)
} }
} }
run := r.run
runRes, err := subprocess.Run(ctx, subprocess.RunRequest{ if run == nil {
Executable: r.binary, run = subprocess.Run
Args: args, }
Timeout: r.timeout, runRes, runErr := run(ctx, subprocess.RunRequest{
EnvOverrides: env, Executable: r.cfg.Binary, Args: args, Timeout: r.cfg.Timeout,
DiagnosticOwner: "audita", EnvOverrides: env, SensitiveEnvNames: names, DiagnosticOwner: "audita",
StdoutLogPath: req.StdoutLogPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath,
StderrLogPath: req.StderrLogPath,
}) })
if err != nil { result := r.result(req, modules, names, runRes)
wrappedMessage := fmt.Sprintf( if runErr != nil {
"run audita process (binary=%q, stdout_log=%q, stderr_log=%q)", result.Reports, _ = discoverReports(req.OutputProcessedPath)
r.binary, return result, fmt.Errorf("run audita process (binary=%q, output_dir=%q, stdout_log=%q, stderr_log=%q): %w",
req.StdoutLogPath, r.cfg.Binary, filepath.Dir(req.OutputProcessedPath), req.StdoutLogPath, req.StderrLogPath, runErr)
req.StderrLogPath,
)
wrappedMessage = addSubprocessStreamHint(wrappedMessage, err)
return r.failureResult(req, reqModules, runRes, credentialPresent), fmt.Errorf(
"%s: %w",
wrappedMessage,
err,
)
} }
if err := validateProcessedOutput(req.OutputProcessedPath); err != nil { if err := validateProcessedOutput(req.OutputProcessedPath); err != nil {
return r.failureResult(req, reqModules, runRes, credentialPresent), fmt.Errorf("validate audita processed output %q: %w", req.OutputProcessedPath, err) result.Reports, _ = discoverReports(req.OutputProcessedPath)
return result, fmt.Errorf("validate audita processed output %q: %w", req.OutputProcessedPath, err)
} }
if r.report { reports, err := discoverReports(req.OutputProcessedPath)
if err := validateJSONFile(req.ReportPath); err != nil { if err != nil {
return r.failureResult(req, reqModules, runRes, credentialPresent), fmt.Errorf("validate audita report output %q: %w", req.ReportPath, err) return result, err
} }
} result.Reports = reports
return result, nil
return PolishResult{
ProcessedTranscriptPath: req.OutputProcessedPath,
ReportPath: req.ReportPath,
WorkDir: req.WorkDir,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
GeneratedConfigPath: req.GeneratedConfigPath,
ExitCode: runRes.ExitCode,
Duration: runRes.Duration,
InvokedBinary: r.binary,
Metadata: map[string]any{
"adapter": "audita_subprocess",
"modules": reqModules,
"base_url": r.baseURL,
"model": r.model,
"transcript_description": r.transcriptDescription,
"config_path": r.configPath,
"output_schema": r.outputSchema,
"work_dir_retention": r.workDirRetention,
"validation_model": r.validationModel,
"total_llm_concurrency": r.totalLLMConcurrency,
"proposal_llm_concurrency": r.proposalLLMConcurrency,
"validation_llm_concurrency": r.validationLLMConcurrency,
"credential_env_var": r.llmAPIKeyReference,
"credential_present": credentialPresent,
},
}, nil
} }
func (r *SubprocessRunner) failureResult(req PolishRequest, modules []string, runRes subprocess.RunResult, credentialPresent bool) PolishResult { func (r *SubprocessRunner) result(req PolishRequest, modules, names []string, runRes subprocess.RunResult) PolishResult {
return PolishResult{ return PolishResult{
ProcessedTranscriptPath: req.OutputProcessedPath, ProcessedTranscriptPath: req.OutputProcessedPath,
ReportPath: req.ReportPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath,
WorkDir: req.WorkDir, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode,
StdoutLogPath: req.StdoutLogPath, Duration: runRes.Duration, InvokedBinary: r.cfg.Binary,
StderrLogPath: req.StderrLogPath,
GeneratedConfigPath: req.GeneratedConfigPath,
ExitCode: runRes.ExitCode,
Duration: runRes.Duration,
InvokedBinary: r.binary,
Metadata: map[string]any{ Metadata: map[string]any{
"adapter": "audita_subprocess", "adapter": "audita_subprocess", "modules": modules,
"modules": modules, "overlay": r.cfg.Overlay, "proposal_profile": r.cfg.ProposalProfile,
"base_url": r.baseURL, "validation_profile": r.cfg.ValidationProfile,
"model": r.model, "transcript_description": r.cfg.TranscriptDescription,
"transcript_description": r.transcriptDescription, "config_path": r.cfg.ConfigPath, "output_schema": r.cfg.OutputSchema,
"config_path": r.configPath, "credential_names": names,
"output_schema": r.outputSchema,
"work_dir_retention": r.workDirRetention,
"validation_model": r.validationModel,
"total_llm_concurrency": r.totalLLMConcurrency,
"proposal_llm_concurrency": r.proposalLLMConcurrency,
"validation_llm_concurrency": r.validationLLMConcurrency,
"credential_env_var": r.llmAPIKeyReference,
"credential_present": credentialPresent,
}, },
} }
} }
func (r *SubprocessRunner) buildArgs(req PolishRequest, modules []string) []string { func (r *SubprocessRunner) buildArgs(req PolishRequest, modules []string) []string {
args := []string{ args := []string{"process", req.MergedTranscriptPath, "--glossary", req.GlossaryPath, "--output", req.OutputProcessedPath}
"process", if modules != nil {
req.MergedTranscriptPath,
"--glossary", req.GlossaryPath,
"--output", req.OutputProcessedPath,
"--work-dir", req.WorkDir,
}
if r.baseURL != "" {
args = append(args, "--base-url", r.baseURL)
}
if r.model != "" {
args = append(args, "--model", r.model)
}
if len(modules) > 0 {
args = append(args, "--modules", strings.Join(modules, ",")) args = append(args, "--modules", strings.Join(modules, ","))
} }
if r.report { for _, setting := range []struct{ flag, value string }{
args = append(args, "--report-json", req.ReportPath) {"--config", r.cfg.ConfigPath}, {"--overlay", r.cfg.Overlay},
{"--proposal-profile", r.cfg.ProposalProfile}, {"--validation-profile", r.cfg.ValidationProfile},
{"--transcript-description", r.cfg.TranscriptDescription}, {"--output-schema", r.cfg.OutputSchema},
} {
if setting.value != "" {
args = append(args, setting.flag, setting.value)
} }
if r.transcriptDescription != "" {
args = append(args, "--transcript-description", r.transcriptDescription)
}
if r.configPath != "" {
args = append(args, "--config", r.configPath)
}
if r.outputSchema != "" {
args = append(args, "--output-schema", r.outputSchema)
}
if r.workDirRetention != "" {
args = append(args, "--work-dir-retention", r.workDirRetention)
}
if r.totalLLMConcurrency != nil {
args = append(args, "--total-llm-concurrency", strconv.Itoa(*r.totalLLMConcurrency))
}
if r.proposalLLMConcurrency != nil {
args = append(args, "--proposal-llm-concurrency", strconv.Itoa(*r.proposalLLMConcurrency))
}
if r.validationModel != "" {
args = append(args, "--validation-model", r.validationModel)
}
if r.validationLLMConcurrency != nil {
args = append(args, "--validation-llm-concurrency", strconv.Itoa(*r.validationLLMConcurrency))
} }
return args return args
} }
func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args []string, modules []string, credentialPresent bool) error { func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args, modules, names []string) error {
payload := map[string]any{ return writeGeneratedYAML(req.GeneratedConfigPath, map[string]any{
"schema": "audita.generated.v1", "schema": "audita.generated.v2", "binary": r.cfg.Binary, "args": args,
"binary": r.binary, "timeout": r.cfg.Timeout.String(), "modules": modules,
"args": args, "overlay": r.cfg.Overlay, "proposal_profile": r.cfg.ProposalProfile,
"timeout": r.timeout.String(), "validation_profile": r.cfg.ValidationProfile,
"modules": modules, "transcript_description": r.cfg.TranscriptDescription,
"base_url": r.baseURL, "config_path": r.cfg.ConfigPath, "output_schema": r.cfg.OutputSchema,
"model": r.model, "merged_transcript_path": req.MergedTranscriptPath, "glossary_path": req.GlossaryPath,
"transcript_description": r.transcriptDescription, "output_path": req.OutputProcessedPath, "credential_names": names,
"config_path": r.configPath, })
"output_schema": r.outputSchema, }
"work_dir_retention": r.workDirRetention,
"validation_model": r.validationModel, func discoverReports(outputPath string) (ReportBundle, error) {
"total_llm_concurrency": r.totalLLMConcurrency, var reports ReportBundle
"proposal_llm_concurrency": r.proposalLLMConcurrency, for _, report := range []struct {
"validation_llm_concurrency": r.validationLLMConcurrency, name string
"report_enabled": r.report, dest *string
"merged_transcript_path": req.MergedTranscriptPath, }{
"glossary_path": req.GlossaryPath, {"errors.yml", &reports.ErrorsPath},
"output_path": req.OutputProcessedPath, {"recoveries.yml", &reports.RecoveriesPath},
"report_path": req.ReportPath, {"report.md", &reports.OverviewPath},
"work_dir": req.WorkDir, } {
"credential_env_var": r.llmAPIKeyReference, path := filepath.Join(filepath.Dir(outputPath), report.name)
"credential_present": credentialPresent, info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
continue
} }
return writeGeneratedYAML(req.GeneratedConfigPath, payload) if err != nil {
return reports, fmt.Errorf("inspect audita report %q: %w", path, err)
}
if !info.Mode().IsRegular() {
return reports, fmt.Errorf("audita report %q is not a regular file", path)
}
if _, err := fileops.ReadFileBounded(path, MaxReportOutputBytes); err != nil {
return reports, fmt.Errorf("audita report %q exceeds or cannot be read within %d-byte limit: %w", path, MaxReportOutputBytes, err)
}
*report.dest = path
}
return reports, nil
} }
func validateProcessedOutput(path string) error { func validateProcessedOutput(path string) error {
data, err := readAuditaResult(path, MaxProcessedOutputBytes, "processed transcript") data, err := fileops.ReadFileBounded(path, MaxProcessedOutputBytes)
if err != nil { if err != nil {
return err return fmt.Errorf("audita processed transcript result exceeds or cannot be read within %d-byte limit: %w", MaxProcessedOutputBytes, err)
} }
var payload map[string]any var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil { if err := json.Unmarshal(data, &payload); err != nil {
return fmt.Errorf("parse json: %w", err) return fmt.Errorf("parse json: %w", err)
@@ -401,34 +233,3 @@ func validateProcessedOutput(path string) error {
} }
return nil return nil
} }
func addSubprocessStreamHint(message string, runErr error) string {
if runErr == nil {
return message
}
lower := strings.ToLower(runErr.Error())
if strings.Contains(lower, "bad file descriptor") || strings.Contains(lower, "exit code 120") {
return message + "; hint=audita child process may have started with invalid stderr/stdout descriptors"
}
return message
}
func validateJSONFile(path string) error {
data, err := readAuditaResult(path, MaxReportOutputBytes, "report")
if err != nil {
return err
}
var v any
if err := json.Unmarshal(data, &v); err != nil {
return fmt.Errorf("parse json: %w", err)
}
return nil
}
func readAuditaResult(path string, limit int64, category string) ([]byte, error) {
data, err := fileops.ReadFileBounded(path, limit)
if err != nil {
return nil, fmt.Errorf("audita %s result exceeds or cannot be read within %d-byte limit: %w", category, limit, err)
}
return data, nil
}

View File

@@ -2,630 +2,262 @@ package audita
import ( import (
"context" "context"
"encoding/json" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
) )
func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) { func auditaTestRequest(t *testing.T) PolishRequest {
t.Helper()
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "success")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
wrapper := writeAuditaHelperWrapper(t)
totalLLMConcurrency := 3
proposalLLMConcurrency := 2
validationLLMConcurrency := 2
runner, err := NewSubprocessRunner(SubprocessRunnerConfig{
Binary: wrapper,
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary", "homophones", "glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
TranscriptDescription: "Campaign Session 42",
ConfigPath: "/etc/audita/config.yml",
OutputSchema: "audita-v1",
WorkDirRetention: "auto",
TotalLLMConcurrency: &totalLLMConcurrency,
ProposalLLMConcurrency: &proposalLLMConcurrency,
ValidationModel: "openrouter/google/gemma-4-31b-it",
ValidationLLMConcurrency: &validationLLMConcurrency,
Report: true,
})
if err != nil {
t.Fatalf("NewSubprocessRunner() error = %v", err)
}
t.Setenv("OPENAI_KEY_SOURCE", "changed-after-resolution")
dir := t.TempDir() dir := t.TempDir()
req := PolishRequest{ return PolishRequest{
GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"), MergedTranscriptPath: filepath.Join(dir, "merged.json"),
MergedTranscriptPath: filepath.Join(dir, "base.json"),
GlossaryPath: filepath.Join(dir, "glossary.yml"), GlossaryPath: filepath.Join(dir, "glossary.yml"),
OutputProcessedPath: filepath.Join(dir, "polished.json"), OutputProcessedPath: filepath.Join(dir, "polished.json"),
ReportPath: filepath.Join(dir, "audita.report.json"),
WorkDir: filepath.Join(dir, "artifacts", "audita-work"),
StdoutLogPath: filepath.Join(dir, "audita.stdout.log"),
StderrLogPath: filepath.Join(dir, "audita.stderr.log"),
}
writeAuditaTestFile(t, req.MergedTranscriptPath, `{"segments":[]}`)
writeAuditaTestFile(t, req.GlossaryPath, "terms: []\n")
res, err := runner.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if res.ProcessedTranscriptPath != req.OutputProcessedPath {
t.Fatalf("ProcessedTranscriptPath = %q, want %q", res.ProcessedTranscriptPath, req.OutputProcessedPath)
}
if res.ReportPath != req.ReportPath {
t.Fatalf("ReportPath = %q, want %q", res.ReportPath, req.ReportPath)
}
if res.WorkDir != req.WorkDir {
t.Fatalf("WorkDir = %q, want %q", res.WorkDir, req.WorkDir)
}
if res.InvokedBinary != wrapper {
t.Fatalf("InvokedBinary = %q, want %q", res.InvokedBinary, wrapper)
}
if res.ExitCode != 0 {
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
}
if res.Duration <= 0 {
t.Fatalf("Duration = %s, want > 0", res.Duration)
}
assertJSONFileAudita(t, req.OutputProcessedPath)
assertJSONFileAudita(t, req.ReportPath)
if _, err := os.Stat(req.StdoutLogPath); err != nil {
t.Fatalf("stdout log missing: %v", err)
}
if _, err := os.Stat(req.StderrLogPath); err != nil {
t.Fatalf("stderr log missing: %v", err)
}
rec := readAuditaHelperRecord(t, recordPath)
wantArgs := []string{
"process", req.MergedTranscriptPath,
"--glossary", req.GlossaryPath,
"--output", req.OutputProcessedPath,
"--work-dir", req.WorkDir,
"--base-url", "https://openrouter.ai/api/v1",
"--model", "openrouter/google/gemma-4-31b-it",
"--modules", "glossary,homophones,glossary",
"--report-json", req.ReportPath,
"--transcript-description", "Campaign Session 42",
"--config", "/etc/audita/config.yml",
"--output-schema", "audita-v1",
"--work-dir-retention", "auto",
"--total-llm-concurrency", "3",
"--proposal-llm-concurrency", "2",
"--validation-model", "openrouter/google/gemma-4-31b-it",
"--validation-llm-concurrency", "2",
}
if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") {
t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs)
}
if rec.Env["AUDITA_LLM_API_KEY"] != "super-secret" {
t.Fatalf("AUDITA_LLM_API_KEY = %q, want propagated secret", rec.Env["AUDITA_LLM_API_KEY"])
}
if rec.Env["AUDITA_LLM_CONCURRENCY"] != "" {
t.Fatalf("AUDITA_LLM_CONCURRENCY = %q, want empty/omitted", rec.Env["AUDITA_LLM_CONCURRENCY"])
}
cfgData, err := os.ReadFile(req.GeneratedConfigPath)
if err != nil {
t.Fatalf("read generated config: %v", err)
}
if strings.Contains(string(cfgData), "super-secret") {
t.Fatalf("generated config must not contain credential value")
}
}
func TestSubprocessRunnerRejectsIncompleteResolvedCredential(t *testing.T) {
_, err := NewSubprocessRunner(SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKeyReference: "MISSING_AUDITA_KEY",
})
if err == nil || !strings.Contains(err.Error(), "credential value and reference must be provided together") {
t.Fatalf("NewSubprocessRunner() error = %v, want credential pair error", err)
}
}
func TestSubprocessRunnerUnconfiguredCredentialEnvOmitsCredential(t *testing.T) {
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "success")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
})
req := auditaReqForTest(t, false)
res, err := runner.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if res.Metadata["credential_present"] != false {
t.Fatalf("credential_present = %#v, want false", res.Metadata["credential_present"])
}
rec := readAuditaHelperRecord(t, recordPath)
if rec.Env["AUDITA_LLM_API_KEY"] != "" {
t.Fatalf("AUDITA_LLM_API_KEY = %q, want omitted/empty", rec.Env["AUDITA_LLM_API_KEY"])
}
}
func TestSubprocessRunnerOmitsUnspecifiedParentEnvironment(t *testing.T) {
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "success")
t.Setenv("AUDITA_INHERITED_MARKER", "inherited-from-parent")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
LLMAPIKey: "",
})
req := auditaReqForTest(t, false)
if _, err := runner.Run(context.Background(), req); err != nil {
t.Fatalf("Run() error = %v", err)
}
rec := readAuditaHelperRecord(t, recordPath)
if rec.Env["AUDITA_INHERITED_MARKER"] != "" {
t.Fatalf("AUDITA_INHERITED_MARKER = %q, want omitted from the child environment", rec.Env["AUDITA_INHERITED_MARKER"])
}
}
func TestSubprocessRunnerOmitsModulesFlagWhenNotConfigured(t *testing.T) {
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "success")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "",
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
})
req := auditaReqForTest(t, false)
if _, err := runner.Run(context.Background(), req); err != nil {
t.Fatalf("Run() error = %v", err)
}
rec := readAuditaHelperRecord(t, recordPath)
for i := 0; i < len(rec.Args); i++ {
if rec.Args[i] == "--modules" {
t.Fatalf("args contained --modules unexpectedly: %#v", rec.Args)
}
}
}
func TestSubprocessRunnerOmitsBaseURLAndModelFlagsWhenNotConfigured(t *testing.T) {
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "success")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "",
Report: false,
})
req := auditaReqForTest(t, false)
if _, err := runner.Run(context.Background(), req); err != nil {
t.Fatalf("Run() error = %v", err)
}
rec := readAuditaHelperRecord(t, recordPath)
for i := 0; i < len(rec.Args); i++ {
if rec.Args[i] == "--base-url" {
t.Fatalf("args contained --base-url unexpectedly: %#v", rec.Args)
}
if rec.Args[i] == "--model" {
t.Fatalf("args contained --model unexpectedly: %#v", rec.Args)
}
}
}
func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "fail")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: true,
})
req := auditaReqForTest(t, true)
_, err := runner.Run(context.Background(), req)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "run audita process") {
t.Fatalf("error = %q, want subprocess context", err.Error())
}
if !strings.Contains(err.Error(), "exit code") {
t.Fatalf("error = %q, want exit code context", err.Error())
}
if !strings.Contains(err.Error(), req.StdoutLogPath) || !strings.Contains(err.Error(), req.StderrLogPath) {
t.Fatalf("error = %q, want stdout/stderr log paths", err.Error())
}
if strings.Contains(err.Error(), "super-secret") {
t.Fatalf("failure diagnostic exposed credential value: %q", err.Error())
}
}
func TestSubprocessRunnerSubprocessFailureAddsStderrDescriptorHint(t *testing.T) {
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "fail_bad_fd")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: true,
})
req := auditaReqForTest(t, true)
_, err := runner.Run(context.Background(), req)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "hint=audita child process may have started with invalid stderr/stdout descriptors") {
t.Fatalf("error = %q, want stderr descriptor hint", err.Error())
}
}
func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "missing_output")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
})
req := auditaReqForTest(t, false)
_, err := runner.Run(context.Background(), req)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "validate audita processed output") {
t.Fatalf("error = %q, want output validation context", err.Error())
}
}
func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "invalid_output")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
})
req := auditaReqForTest(t, false)
_, err := runner.Run(context.Background(), req)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "parse json") {
t.Fatalf("error = %q, want parse json context", err.Error())
}
}
func TestSubprocessRunnerSegmentsMissingFails(t *testing.T) {
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "segments_missing")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: false,
})
req := auditaReqForTest(t, false)
_, err := runner.Run(context.Background(), req)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "segments") {
t.Fatalf("error = %q, want segments validation context", err.Error())
}
}
func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
t.Setenv("AUDITA_HELPER_MODE", "invalid_report")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
Binary: writeAuditaHelperWrapper(t),
Timeout: mustParseAuditaDuration(t, "2s"),
LLMAPIKey: "super-secret",
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
Modules: []string{"glossary"},
BaseURL: "https://openrouter.ai/api/v1",
Model: "openrouter/google/gemma-4-31b-it",
Report: true,
})
req := auditaReqForTest(t, true)
_, err := runner.Run(context.Background(), req)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "validate audita report output") {
t.Fatalf("error = %q, want report validation context", err.Error())
}
}
func TestSubprocessRunnerConstructorValidation(t *testing.T) {
_, err := NewSubprocessRunnerFromConfigValues("", "3h", "synthetic-secret", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true)
if err == nil {
t.Fatal("expected binary validation error")
}
_, err = NewSubprocessRunnerFromConfigValues("audita", "bad", "synthetic-secret", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true)
if err == nil {
t.Fatal("expected timeout parse error")
}
_, err = NewSubprocessRunner(SubprocessRunnerConfig{
Binary: "audita",
Timeout: mustParseAuditaDuration(t, "1s"),
LLMAPIKey: "synthetic-secret",
LLMAPIKeyReference: "AUDITA_LLM_API_KEY",
Modules: []string{"glossary"},
BaseURL: "://",
Model: "openrouter/google/gemma-4-31b-it",
})
if err == nil {
t.Fatal("expected base url validation error")
}
}
type auditaHelperRecord struct {
Args []string `json:"args"`
Env map[string]string `json:"env"`
}
func TestAuditaSubprocessHelper(t *testing.T) {
if os.Getenv("GO_WANT_AUDITA_HELPER") != "1" {
return
}
args := os.Args
start := -1
for i := range args {
if args[i] == "--" {
start = i + 1
break
}
}
if start < 0 || start >= len(args) {
_, _ = os.Stderr.WriteString("missing -- args separator\n")
os.Exit(2)
}
procArgs := args[start:]
outPath := auditaFlagValue(procArgs, "--output")
reportPath := auditaFlagValue(procArgs, "--report-json")
recordPath := os.Getenv("AUDITA_HELPER_RECORD_PATH")
if strings.TrimSpace(recordPath) != "" {
rec := auditaHelperRecord{
Args: procArgs,
Env: map[string]string{
"AUDITA_LLM_API_KEY": os.Getenv("AUDITA_LLM_API_KEY"),
"AUDITA_LLM_CONCURRENCY": os.Getenv("AUDITA_LLM_CONCURRENCY"),
"AUDITA_INHERITED_MARKER": os.Getenv("AUDITA_INHERITED_MARKER"),
},
}
data, _ := json.Marshal(rec)
_ = os.MkdirAll(filepath.Dir(recordPath), 0o755)
_ = os.WriteFile(recordPath, data, 0o644)
}
mode := os.Getenv("AUDITA_HELPER_MODE")
switch mode {
case "success":
writeAuditaHelperFile(outPath, `{"schema":"audita.processed.v1","segments":[]}`)
if reportPath != "" {
writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`)
}
_, _ = os.Stdout.WriteString("audita helper success stdout\n")
_, _ = os.Stderr.WriteString("audita helper success stderr\n")
os.Exit(0)
case "fail":
_, _ = os.Stderr.WriteString("audita helper failure\n")
os.Exit(8)
case "fail_bad_fd":
_, _ = os.Stderr.WriteString("OSError: [Errno 9] Bad file descriptor\n")
os.Exit(120)
case "missing_output":
if reportPath != "" {
writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`)
}
os.Exit(0)
case "invalid_output":
writeAuditaHelperFile(outPath, `not-json`)
if reportPath != "" {
writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`)
}
os.Exit(0)
case "segments_missing":
writeAuditaHelperFile(outPath, `{"schema":"audita.processed.v1"}`)
if reportPath != "" {
writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`)
}
os.Exit(0)
case "invalid_report":
writeAuditaHelperFile(outPath, `{"schema":"audita.processed.v1","segments":[]}`)
if reportPath != "" {
writeAuditaHelperFile(reportPath, `not-json`)
}
os.Exit(0)
default:
_, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode))
os.Exit(2)
}
}
func mustAuditaRunner(t *testing.T, cfg SubprocessRunnerConfig) *SubprocessRunner {
t.Helper()
r, err := NewSubprocessRunner(cfg)
if err != nil {
t.Fatalf("NewSubprocessRunner() error = %v", err)
}
return r
}
func auditaReqForTest(t *testing.T, withReport bool) PolishRequest {
t.Helper()
dir := t.TempDir()
merged := filepath.Join(dir, "base.json")
glossary := filepath.Join(dir, "glossary.yml")
writeAuditaTestFile(t, merged, `{"segments":[]}`)
writeAuditaTestFile(t, glossary, "terms: []\n")
req := PolishRequest{
GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"), GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"),
MergedTranscriptPath: merged, StdoutLogPath: filepath.Join(dir, "stdout.log"),
GlossaryPath: glossary, StderrLogPath: filepath.Join(dir, "stderr.log"),
OutputProcessedPath: filepath.Join(dir, "polished.json"),
WorkDir: filepath.Join(dir, "artifacts", "audita-work"),
StdoutLogPath: filepath.Join(dir, "audita.stdout.log"),
StderrLogPath: filepath.Join(dir, "audita.stderr.log"),
} }
if withReport {
req.ReportPath = filepath.Join(dir, "audita.report.json")
}
return req
} }
func writeAuditaHelperWrapper(t *testing.T) string { func auditaTestRunner(t *testing.T, cfg SubprocessRunnerConfig) *SubprocessRunner {
t.Helper() t.Helper()
exe, err := os.Executable() if cfg.Binary == "" {
cfg.Binary = "audita"
}
if cfg.Timeout == 0 {
cfg.Timeout = time.Second
}
runner, err := NewSubprocessRunner(cfg)
if err != nil { if err != nil {
t.Fatalf("os.Executable() error = %v", err) t.Fatal(err)
} }
path := filepath.Join(t.TempDir(), "audita-helper-wrapper.sh") return runner
content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestAuditaSubprocessHelper -- \"$@\"\n"
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
return path
} }
func writeAuditaTestFile(t *testing.T, path, contents string) { func auditaTestSecret(t *testing.T, name, value string) configkit.Secret {
t.Helper() t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Setenv(name, value)
t.Fatalf("MkdirAll(%q): %v", path, err) type loaded struct {
Credential configkit.Secret `yaml:"credential"`
} }
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { result, err := configkit.Load(configkit.Bytes("credential.yml", []byte("credential: "+name+"\n")), configkit.Options[loaded]{})
t.Fatalf("WriteFile(%q): %v", path, err)
}
}
func writeAuditaHelperFile(path, contents string) {
if strings.TrimSpace(path) == "" {
return
}
_ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, []byte(contents), 0o644)
}
func auditaFlagValue(args []string, name string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == name {
return args[i+1]
}
}
return ""
}
func readAuditaHelperRecord(t *testing.T, path string) auditaHelperRecord {
t.Helper()
data, err := os.ReadFile(path)
if err != nil { if err != nil {
t.Fatalf("ReadFile(%q): %v", path, err) t.Fatal(err)
} }
var rec auditaHelperRecord return result.Config.Credential
if err := json.Unmarshal(data, &rec); err != nil {
t.Fatalf("json unmarshal helper record: %v", err)
}
return rec
} }
func assertJSONFileAudita(t *testing.T, path string) { func writeAuditaTestFile(t *testing.T, path, value string) {
t.Helper() t.Helper()
data, err := os.ReadFile(path) if err := os.WriteFile(path, []byte(value), 0o600); err != nil {
if err != nil { t.Fatal(err)
t.Fatalf("ReadFile(%q): %v", path, err)
}
var v any
if err := json.Unmarshal(data, &v); err != nil {
t.Fatalf("json unmarshal %q: %v", path, err)
} }
} }
func mustParseAuditaDuration(t *testing.T, value string) time.Duration { func TestSubprocessRunnerUsesV2InvocationAndCapturedCredentials(t *testing.T) {
t.Helper() const first = "NARRATIO_AUDITA_CUSTOM_ONE"
d, err := time.ParseDuration(value) const second = "NARRATIO_AUDITA_CUSTOM_TWO"
if err != nil { firstSecret := auditaTestSecret(t, first, "loaded-one")
t.Fatalf("time.ParseDuration(%q) error = %v", value, err) secondSecret := auditaTestSecret(t, second, "loaded-two")
t.Setenv(first, "parent-changed")
t.Setenv(second, "parent-changed-too")
t.Setenv("NARRATIO_AUDITA_UNSELECTED", "not-forwarded")
runner := auditaTestRunner(t, SubprocessRunnerConfig{
Credentials: []configkit.Secret{firstSecret, secondSecret},
Modules: []string{"grammar", "glossary", "grammar"},
Overlay: "testing", ProposalProfile: "proposal", ValidationProfile: "validation",
TranscriptDescription: "campaign", ConfigPath: "/etc/audita/config.yml", OutputSchema: "audita-v1",
})
req := auditaTestRequest(t)
var captured subprocess.RunRequest
runner.run = func(_ context.Context, processReq subprocess.RunRequest) (subprocess.RunResult, error) {
captured = processReq
writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`)
for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} {
writeAuditaTestFile(t, filepath.Join(filepath.Dir(req.OutputProcessedPath), name), "recoverable diagnostic\n")
}
return subprocess.RunResult{}, nil
}
result, err := runner.Run(context.Background(), req)
if err != nil {
t.Fatal(err)
}
want := []string{"process", req.MergedTranscriptPath, "--glossary", req.GlossaryPath, "--output", req.OutputProcessedPath,
"--modules", "grammar,glossary,grammar", "--config", "/etc/audita/config.yml", "--overlay", "testing",
"--proposal-profile", "proposal", "--validation-profile", "validation", "--transcript-description", "campaign", "--output-schema", "audita-v1"}
if !reflect.DeepEqual(captured.Args, want) {
t.Fatalf("args = %#v, want %#v", captured.Args, want)
}
if len(captured.EnvOverrides) != 2 || captured.EnvOverrides[first] != "loaded-one" || captured.EnvOverrides[second] != "loaded-two" || captured.EnvOverrides["NARRATIO_AUDITA_UNSELECTED"] != "" {
t.Fatalf("credential forwarding = %#v", captured.EnvOverrides)
}
if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{first, second}) {
t.Fatalf("sensitive names = %#v", captured.SensitiveEnvNames)
}
if result.Reports.ErrorsPath == "" || result.Reports.RecoveriesPath == "" || result.Reports.OverviewPath == "" {
t.Fatalf("reports = %#v", result.Reports)
}
generated, err := os.ReadFile(req.GeneratedConfigPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(generated), "audita.generated.v2") || !strings.Contains(string(generated), first) || strings.Contains(string(generated), "loaded-one") {
t.Fatalf("unsafe or incomplete generated invocation: %s", generated)
}
}
func TestSubprocessRunnerModulePresenceAndReportAbsence(t *testing.T) {
for _, test := range []struct {
name string
configured []string
requested []string
wantFlag bool
wantValue string
}{
{"omitted", nil, nil, false, ""},
{"configured", []string{"grammar", "grammar"}, nil, true, "grammar,grammar"},
{"empty override", []string{"grammar"}, []string{}, true, ""},
} {
t.Run(test.name, func(t *testing.T) {
runner := auditaTestRunner(t, SubprocessRunnerConfig{Modules: test.configured})
req := auditaTestRequest(t)
req.Modules = test.requested
runner.run = func(_ context.Context, processReq subprocess.RunRequest) (subprocess.RunResult, error) {
index := -1
for i, arg := range processReq.Args {
if arg == "--modules" {
index = i
}
if strings.HasPrefix(arg, "--report") || arg == "--debug" || arg == "--work-dir" {
t.Fatalf("retired or unwanted flag %q", arg)
}
}
if (index >= 0) != test.wantFlag || (index >= 0 && processReq.Args[index+1] != test.wantValue) {
t.Fatalf("args = %#v", processReq.Args)
}
writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`)
return subprocess.RunResult{}, nil
}
result, err := runner.Run(context.Background(), req)
if err != nil || result.Reports != (ReportBundle{}) {
t.Fatalf("result = %#v, error = %v", result, err)
}
})
}
}
func TestSubprocessRunnerRejectsMissingOrMalformedTranscript(t *testing.T) {
for _, output := range []string{"", `{"segments":"wrong"}`} {
t.Run(output, func(t *testing.T) {
runner := auditaTestRunner(t, SubprocessRunnerConfig{})
req := auditaTestRequest(t)
runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) {
if output != "" {
writeAuditaTestFile(t, req.OutputProcessedPath, output)
}
return subprocess.RunResult{}, nil
}
if _, err := runner.Run(context.Background(), req); err == nil {
t.Fatal("missing or malformed transcript was accepted")
}
})
}
}
func TestSubprocessRunnerPreservesFailureAndAvailableReports(t *testing.T) {
runner := auditaTestRunner(t, SubprocessRunnerConfig{})
req := auditaTestRequest(t)
processFailure := errors.New("audita exited after writing output")
runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) {
writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`)
writeAuditaTestFile(t, filepath.Join(filepath.Dir(req.OutputProcessedPath), "errors.yml"), "status: failed\n")
return subprocess.RunResult{ExitCode: 7}, processFailure
}
result, err := runner.Run(context.Background(), req)
if !errors.Is(err, processFailure) || !strings.Contains(err.Error(), filepath.Dir(req.OutputProcessedPath)) {
t.Fatalf("error = %v", err)
}
if result.Reports.ErrorsPath == "" || result.Reports.RecoveriesPath != "" || result.Reports.OverviewPath != "" {
t.Fatalf("reports = %#v", result.Reports)
}
}
func TestSubprocessRunnerDoesNotMaskProcessFailureWithReportError(t *testing.T) {
runner := auditaTestRunner(t, SubprocessRunnerConfig{})
req := auditaTestRequest(t)
processFailure := errors.New("audita process failed")
runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) {
path := filepath.Join(filepath.Dir(req.OutputProcessedPath), "errors.yml")
if err := os.WriteFile(path, make([]byte, MaxReportOutputBytes+1), 0o600); err != nil {
t.Fatal(err)
}
return subprocess.RunResult{ExitCode: 7}, processFailure
}
if _, err := runner.Run(context.Background(), req); !errors.Is(err, processFailure) {
t.Fatalf("error = %v, want original process error", err)
}
}
func TestSubprocessRunnerBoundsAdjacentReports(t *testing.T) {
runner := auditaTestRunner(t, SubprocessRunnerConfig{})
req := auditaTestRequest(t)
runner.run = func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) {
writeAuditaTestFile(t, req.OutputProcessedPath, `{"segments":[]}`)
path := filepath.Join(filepath.Dir(req.OutputProcessedPath), "errors.yml")
if err := os.WriteFile(path, make([]byte, MaxReportOutputBytes+1), 0o600); err != nil {
t.Fatal(err)
}
return subprocess.RunResult{}, nil
}
if _, err := runner.Run(context.Background(), req); err == nil || !strings.Contains(err.Error(), "errors.yml") || !strings.Contains(err.Error(), "16777216") {
t.Fatalf("error = %v, want named report limit", err)
}
}
func TestSubprocessRunnerRedactsCustomCredentialStreams(t *testing.T) {
const first = "NARRATIO_AUDITA_STREAM_ONE"
const second = "NARRATIO_AUDITA_STREAM_TWO"
for _, fail := range []bool{false, true} {
t.Run(map[bool]string{false: "success", true: "failure"}[fail], func(t *testing.T) {
firstSecret := auditaTestSecret(t, first, "audita-first-secret")
secondSecret := auditaTestSecret(t, second, "audita-second-secret")
req := auditaTestRequest(t)
body := "#!/bin/sh\nprintf '%s\\n' \"$" + first + "\"\nprintf '%s\\n' \"$" + second + "\" >&2\n"
if fail {
body += "exit 7\n"
} else {
body += "printf '{\"segments\":[]}' > \"" + req.OutputProcessedPath + "\"\n"
}
binary := filepath.Join(t.TempDir(), "audita-helper")
writeAuditaTestFile(t, binary, body)
if err := os.Chmod(binary, 0o700); err != nil {
t.Fatal(err)
}
runner := auditaTestRunner(t, SubprocessRunnerConfig{Binary: binary, Credentials: []configkit.Secret{firstSecret, secondSecret}})
_, err := runner.Run(context.Background(), req)
if (err != nil) != fail {
t.Fatalf("error = %v, fail = %v", err, fail)
}
for _, path := range []string{req.StdoutLogPath, req.StderrLogPath} {
data, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatal(readErr)
}
if strings.Contains(string(data), "audita-first-secret") || strings.Contains(string(data), "audita-second-secret") || !strings.Contains(string(data), "<redacted>") {
t.Fatalf("unredacted stream %q: %s", path, data)
}
}
if fail && (strings.Contains(err.Error(), "audita-first-secret") || strings.Contains(err.Error(), "audita-second-secret")) {
t.Fatalf("unredacted failure: %v", err)
}
})
} }
return d
} }

View File

@@ -1,6 +1,10 @@
package notarius package notarius
import "context" import (
"context"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// FakeRunner is a configurable in-memory runner for stage tests. // FakeRunner is a configurable in-memory runner for stage tests.
type FakeRunner struct { type FakeRunner struct {
@@ -15,9 +19,10 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error)
return RunResult{}, err return RunResult{}, err
} }
copyRequest := req copyRequest := req
if req.ConfigProfile != nil { copyRequest.Credentials = append([]configkit.Secret(nil), req.Credentials...)
profile := *req.ConfigProfile if req.ConfigOverlay != nil {
copyRequest.ConfigProfile = &profile profile := *req.ConfigOverlay
copyRequest.ConfigOverlay = &profile
} }
copyRequest.References = append([]ReferenceBinding(nil), req.References...) copyRequest.References = append([]ReferenceBinding(nil), req.References...)
f.Requests = append(f.Requests, copyRequest) f.Requests = append(f.Requests, copyRequest)

View File

@@ -25,9 +25,9 @@ type ReferenceBinding struct {
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation. // RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
type RunRequest struct { type RunRequest struct {
Binary string Binary string
// LLMAPIKey is an optional already-resolved credential reference. // Credentials are already-resolved environment references selected for Notarius.
LLMAPIKey *configkit.Secret Credentials []configkit.Secret
ConfigProfile *string ConfigOverlay *string
ConfigPath string ConfigPath string
PipelineID string PipelineID string
InputPath string InputPath string

View File

@@ -52,19 +52,9 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
if err != nil { if err != nil {
return RunResult{}, err return RunResult{}, err
} }
var envOverrides map[string]string envOverrides, sensitiveEnvNames, err := subprocess.CredentialEnvironment(req.Credentials)
var sensitiveEnvNames []string if err != nil {
if req.LLMAPIKey != nil { return RunResult{}, fmt.Errorf("notarius credentials: %w", err)
if !req.LLMAPIKey.IsResolved() {
return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty")
}
name := req.LLMAPIKey.EnvName()
value := req.LLMAPIKey.Value()
if value == "" {
return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty")
}
envOverrides = map[string]string{name: value}
sensitiveEnvNames = []string{name}
} }
args := []string{ args := []string{
@@ -82,8 +72,8 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
for _, reference := range references { for _, reference := range references {
args = append(args, "--reference", reference.Selector+"="+reference.Path) args = append(args, "--reference", reference.Selector+"="+reference.Path)
} }
if req.ConfigProfile != nil { if req.ConfigOverlay != nil {
args = append(args, "--config-profile", *req.ConfigProfile) args = append(args, "--config-overlay", *req.ConfigOverlay)
} }
args = append(args, "--json") args = append(args, "--json")
processResult, err := r.run(ctx, subprocess.RunRequest{ processResult, err := r.run(ctx, subprocess.RunRequest{

View File

@@ -118,7 +118,7 @@ func TestSubprocessRunnerMapsCacheControlsToArguments(t *testing.T) {
} }
} }
func TestSubprocessRunnerMapsConfigProfileToArgumentPair(t *testing.T) { func TestSubprocessRunnerMapsConfigOverlayToArgumentPair(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
supplied bool supplied bool
@@ -133,7 +133,7 @@ func TestSubprocessRunnerMapsConfigProfileToArgumentPair(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
req := validRunRequest(t) req := validRunRequest(t)
if test.supplied { if test.supplied {
req.ConfigProfile = &test.value req.ConfigOverlay = &test.value
} }
var captured sharedsubprocess.RunRequest var captured sharedsubprocess.RunRequest
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
@@ -147,19 +147,19 @@ func TestSubprocessRunnerMapsConfigProfileToArgumentPair(t *testing.T) {
} }
flagIndex := -1 flagIndex := -1
for index, arg := range captured.Args { for index, arg := range captured.Args {
if arg == "--config-profile" { if arg == "--config-overlay" {
flagIndex = index flagIndex = index
break break
} }
} }
if !test.supplied { if !test.supplied {
if flagIndex >= 0 { if flagIndex >= 0 {
t.Fatalf("subprocess args unexpectedly contain config profile: %#v", captured.Args) t.Fatalf("subprocess args unexpectedly contain config overlay: %#v", captured.Args)
} }
return return
} }
if flagIndex < 0 || flagIndex+2 >= len(captured.Args) || captured.Args[flagIndex+1] != test.value || captured.Args[flagIndex+2] != "--json" { if flagIndex < 0 || flagIndex+2 >= len(captured.Args) || captured.Args[flagIndex+1] != test.value || captured.Args[flagIndex+2] != "--json" {
t.Fatalf("config profile argument boundary = %#v, want flag, exact value, then --json", captured.Args) t.Fatalf("config overlay argument boundary = %#v, want flag, exact value, then --json", captured.Args)
} }
}) })
} }
@@ -188,12 +188,16 @@ func TestSubprocessRunnerRejectsInvalidChunkCacheBeforeLaunch(t *testing.T) {
func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t *testing.T) { func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t *testing.T) {
const envName = "NARRATIO_NOTARIUS_CUSTOM_LLM_KEY" const envName = "NARRATIO_NOTARIUS_CUSTOM_LLM_KEY"
const otherName = "NARRATIO_NOTARIUS_CUSTOM_OTHER_KEY"
const loadedValue = "loaded-notarius-credential" const loadedValue = "loaded-notarius-credential"
const otherValue = "loaded-notarius-other-credential"
credential := loadTestCredential(t, envName, loadedValue) credential := loadTestCredential(t, envName, loadedValue)
otherCredential := loadTestCredential(t, otherName, otherValue)
t.Setenv(envName, "changed-parent-credential") t.Setenv(envName, "changed-parent-credential")
t.Setenv(otherName, "changed-parent-other-credential")
req := validRunRequest(t) req := validRunRequest(t)
req.LLMAPIKey = credential req.Credentials = []configkit.Secret{*credential, *otherCredential}
var captured sharedsubprocess.RunRequest var captured sharedsubprocess.RunRequest
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
captured = processReq captured = processReq
@@ -203,10 +207,10 @@ func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t *
if _, err := runner.Run(context.Background(), req); err != nil { if _, err := runner.Run(context.Background(), req); err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
if len(captured.EnvOverrides) != 1 || captured.EnvOverrides[envName] != loadedValue { if len(captured.EnvOverrides) != 2 || captured.EnvOverrides[envName] != loadedValue || captured.EnvOverrides[otherName] != otherValue {
t.Fatal("subprocess did not receive exactly the loaded credential override") t.Fatal("subprocess did not receive exactly the loaded credential overrides")
} }
if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{envName}) { if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{envName, otherName}) {
t.Fatalf("sensitive environment names = %#v, want configured reference name", captured.SensitiveEnvNames) t.Fatalf("sensitive environment names = %#v, want configured reference name", captured.SensitiveEnvNames)
} }
wantArgs := []string{ wantArgs := []string{
@@ -228,7 +232,7 @@ func TestSubprocessRunnerRejectsInvalidProgrammaticCredentialBeforeLaunch(t *tes
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
req := validRunRequest(t) req := validRunRequest(t)
req.LLMAPIKey = &test.reference req.Credentials = []configkit.Secret{test.reference}
started := false started := false
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
started = true started = true
@@ -342,10 +346,12 @@ func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) {
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) { func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
const envName = "NARRATIO_NOTARIUS_CUSTOM_CAPTURE_KEY" const envName = "NARRATIO_NOTARIUS_CUSTOM_CAPTURE_KEY"
const otherName = "NARRATIO_NOTARIUS_CUSTOM_CAPTURE_OTHER_KEY"
const canary = "notarius-captured-stream-canary" const canary = "notarius-captured-stream-canary"
const otherCanary = "notarius-captured-stream-other-canary"
const unrelated = "NARRATIO_NOTARIUS_UNRELATED_PARENT_KEY" const unrelated = "NARRATIO_NOTARIUS_UNRELATED_PARENT_KEY"
req := validRunRequest(t) req := validRunRequest(t)
req.LLMAPIKey = loadTestCredential(t, envName, canary) req.Credentials = []configkit.Secret{*loadTestCredential(t, envName, canary), *loadTestCredential(t, otherName, otherCanary)}
writeValidBundleAndReceipt(t, req, false) writeValidBundleAndReceipt(t, req, false)
receiptFixture := req.ReceiptPath + ".fixture" receiptFixture := req.ReceiptPath + ".fixture"
data, err := os.ReadFile(req.ReceiptPath) data, err := os.ReadFile(req.ReceiptPath)
@@ -356,7 +362,7 @@ func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T)
if err := json.Unmarshal(data, &receipt); err != nil { if err := json.Unmarshal(data, &receipt); err != nil {
t.Fatalf("decode receipt fixture: %v", err) t.Fatalf("decode receipt fixture: %v", err)
} }
receipt["credential_echo"] = canary receipt["credential_echo"] = canary + " " + otherCanary
writeJSONFile(t, receiptFixture, receipt) writeJSONFile(t, receiptFixture, receipt)
if err := os.Remove(req.ReceiptPath); err != nil { if err := os.Remove(req.ReceiptPath); err != nil {
t.Fatalf("Remove(receipt) error = %v", err) t.Fatalf("Remove(receipt) error = %v", err)
@@ -373,11 +379,16 @@ if [ "$`+envName+`" != '`+canary+`' ]; then
printf 'selected credential mismatch\n' >&2 printf 'selected credential mismatch\n' >&2
exit 8 exit 8
fi fi
if [ "$`+otherName+`" != '`+otherCanary+`' ]; then
printf 'other selected credential mismatch\n' >&2
exit 10
fi
if [ -n "$`+unrelated+`" ]; then if [ -n "$`+unrelated+`" ]; then
printf 'unrelated credential inherited\n' >&2 printf 'unrelated credential inherited\n' >&2
exit 9 exit 9
fi fi
printf '%s\n' "$`+envName+`" >&2 printf '%s\n' "$`+envName+`" >&2
printf '%s\n' "$`+otherName+`" >&2
cat "$NOTARIUS_RECEIPT_FIXTURE" cat "$NOTARIUS_RECEIPT_FIXTURE"
`) `)
req.Binary = script req.Binary = script
@@ -392,32 +403,39 @@ cat "$NOTARIUS_RECEIPT_FIXTURE"
assertTextFile(t, filepath.Join(captureDir, "working-directory"), req.WorkingDirectory+"\n") assertTextFile(t, filepath.Join(captureDir, "working-directory"), req.WorkingDirectory+"\n")
assertTextFile(t, filepath.Join(captureDir, "environment"), "") assertTextFile(t, filepath.Join(captureDir, "environment"), "")
assertRedactedCredentialFile(t, req.LogPath, canary) assertRedactedCredentialFile(t, req.LogPath, canary)
assertRedactedCredentialFile(t, req.LogPath, otherCanary)
receiptBytes, err := os.ReadFile(req.ReceiptPath) receiptBytes, err := os.ReadFile(req.ReceiptPath)
if err != nil { if err != nil {
t.Fatalf("ReadFile(receipt) error = %v", err) t.Fatalf("ReadFile(receipt) error = %v", err)
} }
assertRedactedCredentialBytes(t, receiptBytes, canary) assertRedactedCredentialBytes(t, receiptBytes, canary)
assertRedactedCredentialBytes(t, receiptBytes, otherCanary)
} }
func TestSubprocessRunnerRedactsCredentialOnProcessFailure(t *testing.T) { func TestSubprocessRunnerRedactsCredentialOnProcessFailure(t *testing.T) {
const envName = "NARRATIO_NOTARIUS_CUSTOM_FAILURE_KEY" const envName = "NARRATIO_NOTARIUS_CUSTOM_FAILURE_KEY"
const otherName = "NARRATIO_NOTARIUS_CUSTOM_FAILURE_OTHER_KEY"
const canary = "notarius-failure-stream-canary" const canary = "notarius-failure-stream-canary"
const otherCanary = "notarius-failure-stream-other-canary"
req := validRunRequest(t) req := validRunRequest(t)
req.LLMAPIKey = loadTestCredential(t, envName, canary) req.Credentials = []configkit.Secret{*loadTestCredential(t, envName, canary), *loadTestCredential(t, otherName, otherCanary)}
req.Binary = writeShellScript(t, `#!/bin/sh req.Binary = writeShellScript(t, `#!/bin/sh
printf '%s\n' "$`+envName+`" printf '%s\n' "$`+envName+`"
printf '%s\n' "$`+envName+`" >&2 printf '%s\n' "$`+envName+`" >&2
printf '%s\n' "$`+otherName+`" >&2
exit 7 exit 7
`) `)
_, err := NewSubprocessRunner().Run(context.Background(), req) _, err := NewSubprocessRunner().Run(context.Background(), req)
if err == nil { if err == nil {
t.Fatal("Run() error = nil, want process failure") t.Fatal("Run() error = nil, want process failure")
} }
if strings.Contains(err.Error(), canary) || !strings.Contains(err.Error(), "<redacted>") { if strings.Contains(err.Error(), canary) || strings.Contains(err.Error(), otherCanary) || !strings.Contains(err.Error(), "<redacted>") {
t.Fatal("process failure did not return redacted diagnostics") t.Fatal("process failure did not return redacted diagnostics")
} }
assertRedactedCredentialFile(t, req.ReceiptPath, canary) assertRedactedCredentialFile(t, req.ReceiptPath, canary)
assertRedactedCredentialFile(t, req.ReceiptPath, otherCanary)
assertRedactedCredentialFile(t, req.LogPath, canary) assertRedactedCredentialFile(t, req.LogPath, canary)
assertRedactedCredentialFile(t, req.LogPath, otherCanary)
} }
func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T) { func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T) {
@@ -437,7 +455,7 @@ func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T
req := validRunRequest(t) req := validRunRequest(t)
if test.name == "nonzero" { if test.name == "nonzero" {
profile := "missing-profile" profile := "missing-profile"
req.ConfigProfile = &profile req.ConfigOverlay = &profile
} }
req.Binary = writeShellScript(t, "#!/bin/sh\n"+test.scriptBody) req.Binary = writeShellScript(t, "#!/bin/sh\n"+test.scriptBody)
req.Timeout = test.timeout req.Timeout = test.timeout
@@ -736,7 +754,7 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) { func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) {
profile := "testing" profile := "testing"
req := RunRequest{PipelineID: "pipeline", ConfigProfile: &profile, References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}} req := RunRequest{PipelineID: "pipeline", ConfigOverlay: &profile, References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}}
want := RunResult{BundleRoot: "/bundle"} want := RunResult{BundleRoot: "/bundle"}
fake := &FakeRunner{Result: want} fake := &FakeRunner{Result: want}
got, err := fake.Run(context.Background(), req) got, err := fake.Run(context.Background(), req)
@@ -748,8 +766,8 @@ func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) {
t.Fatalf("fake retained aliased request references: %#v", fake.Requests[0].References) t.Fatalf("fake retained aliased request references: %#v", fake.Requests[0].References)
} }
profile = "changed" profile = "changed"
if *fake.Requests[0].ConfigProfile != "testing" { if *fake.Requests[0].ConfigOverlay != "testing" {
t.Fatalf("fake retained aliased config profile: %q", *fake.Requests[0].ConfigProfile) t.Fatalf("fake retained aliased config overlay: %q", *fake.Requests[0].ConfigOverlay)
} }
wantErr := errors.New("configured failure") wantErr := errors.New("configured failure")

View File

@@ -3,6 +3,7 @@ package scriptorium
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/fileops"
) )
@@ -65,6 +66,7 @@ func (f *FakeRunner) RunArtifact(ctx context.Context, req RunArtifactRequest) (A
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return ArtifactResult{}, err return ArtifactResult{}, err
} }
req.Credentials = slices.Clone(req.Credentials)
f.RunRequests = append(f.RunRequests, req) f.RunRequests = append(f.RunRequests, req)
if f.RunErr != nil { if f.RunErr != nil {
return ArtifactResult{}, f.RunErr return ArtifactResult{}, f.RunErr
@@ -105,6 +107,7 @@ func (f *FakeRunner) RenderArtifact(ctx context.Context, req RenderArtifactReque
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return ArtifactResult{}, err return ArtifactResult{}, err
} }
req.Credentials = slices.Clone(req.Credentials)
f.RenderRequests = append(f.RenderRequests, req) f.RenderRequests = append(f.RenderRequests, req)
if f.RenderErr != nil { if f.RenderErr != nil {
return ArtifactResult{}, f.RenderErr return ArtifactResult{}, f.RenderErr

View File

@@ -4,6 +4,8 @@ package scriptorium
import ( import (
"context" "context"
"time" "time"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
) )
const ( const (
@@ -32,7 +34,7 @@ type RunArtifactRequest struct {
StderrLogPath string StderrLogPath string
GeneratedConfigPath string GeneratedConfigPath string
Timeout time.Duration Timeout time.Duration
APIKeyEnv string Credentials []configkit.Secret
WorkingDir string WorkingDir string
} }
@@ -49,7 +51,7 @@ type RenderArtifactRequest struct {
StderrLogPath string StderrLogPath string
GeneratedConfigPath string GeneratedConfigPath string
Timeout time.Duration Timeout time.Duration
APIKeyEnv string Credentials []configkit.Secret
WorkingDir string WorkingDir string
} }

View File

@@ -28,7 +28,12 @@ func (r *SubprocessRunner) RunArtifact(ctx context.Context, req RunArtifactReque
if r == nil { if r == nil {
return ArtifactResult{}, fmt.Errorf("scriptorium subprocess runner is nil") return ArtifactResult{}, fmt.Errorf("scriptorium subprocess runner is nil")
} }
credentialPresent, err := validateCommonRunRequest(req.Binary, req.ConfigPath, req.PromptID, req.OutputPath, req.Timeout, req.APIKeyEnv, req.InputPaths, req.Vars) err := validateCommonRunRequest(req.Binary, req.ConfigPath, req.PromptID, req.OutputPath, req.Timeout, req.InputPaths, req.Vars)
if err != nil {
return ArtifactResult{}, err
}
envOverrides, sensitiveNames, err := subprocess.CredentialEnvironment(req.Credentials)
if err != nil { if err != nil {
return ArtifactResult{}, err return ArtifactResult{}, err
} }
@@ -46,8 +51,7 @@ func (r *SubprocessRunner) RunArtifact(ctx context.Context, req RunArtifactReque
InputPaths: cloneStringMap(req.InputPaths), InputPaths: cloneStringMap(req.InputPaths),
Vars: cloneStringMap(req.Vars), Vars: cloneStringMap(req.Vars),
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
APIKeyEnv: strings.TrimSpace(req.APIKeyEnv), CredentialNames: sensitiveNames,
APIKeyEnvPresent: credentialPresent,
WorkingDir: req.WorkingDir, WorkingDir: req.WorkingDir,
RenderFormat: "", RenderFormat: "",
RenderPromptStore: false, RenderPromptStore: false,
@@ -56,7 +60,6 @@ func (r *SubprocessRunner) RunArtifact(ctx context.Context, req RunArtifactReque
} }
} }
envOverrides, sensitiveNames := credentialEnvironment(req.APIKeyEnv)
runRes, runErr := subprocess.Run(ctx, subprocess.RunRequest{ runRes, runErr := subprocess.Run(ctx, subprocess.RunRequest{
Executable: req.Binary, Executable: req.Binary,
Args: args, Args: args,
@@ -84,8 +87,7 @@ func (r *SubprocessRunner) RunArtifact(ctx context.Context, req RunArtifactReque
"config_path": strings.TrimSpace(req.ConfigPath), "config_path": strings.TrimSpace(req.ConfigPath),
"inputs_count": len(req.InputPaths), "inputs_count": len(req.InputPaths),
"vars_count": len(req.Vars), "vars_count": len(req.Vars),
"api_key_env": strings.TrimSpace(req.APIKeyEnv), "credential_env_names": sensitiveNames,
"api_key_env_present": credentialPresent,
}, },
} }
@@ -114,7 +116,12 @@ func (r *SubprocessRunner) RenderArtifact(ctx context.Context, req RenderArtifac
if r == nil { if r == nil {
return ArtifactResult{}, fmt.Errorf("scriptorium subprocess runner is nil") return ArtifactResult{}, fmt.Errorf("scriptorium subprocess runner is nil")
} }
credentialPresent, err := validateCommonRunRequest(req.Binary, req.ConfigPath, req.PromptID, req.OutputPath, req.Timeout, req.APIKeyEnv, req.InputPaths, req.Vars) err := validateCommonRunRequest(req.Binary, req.ConfigPath, req.PromptID, req.OutputPath, req.Timeout, req.InputPaths, req.Vars)
if err != nil {
return ArtifactResult{}, err
}
envOverrides, sensitiveNames, err := subprocess.CredentialEnvironment(req.Credentials)
if err != nil { if err != nil {
return ArtifactResult{}, err return ArtifactResult{}, err
} }
@@ -132,8 +139,7 @@ func (r *SubprocessRunner) RenderArtifact(ctx context.Context, req RenderArtifac
InputPaths: cloneStringMap(req.InputPaths), InputPaths: cloneStringMap(req.InputPaths),
Vars: cloneStringMap(req.Vars), Vars: cloneStringMap(req.Vars),
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
APIKeyEnv: strings.TrimSpace(req.APIKeyEnv), CredentialNames: sensitiveNames,
APIKeyEnvPresent: credentialPresent,
WorkingDir: req.WorkingDir, WorkingDir: req.WorkingDir,
RenderFormat: "json", RenderFormat: "json",
RenderPromptStore: false, RenderPromptStore: false,
@@ -142,7 +148,6 @@ func (r *SubprocessRunner) RenderArtifact(ctx context.Context, req RenderArtifac
} }
} }
envOverrides, sensitiveNames := credentialEnvironment(req.APIKeyEnv)
runRes, runErr := subprocess.Run(ctx, subprocess.RunRequest{ runRes, runErr := subprocess.Run(ctx, subprocess.RunRequest{
Executable: req.Binary, Executable: req.Binary,
Args: args, Args: args,
@@ -170,8 +175,7 @@ func (r *SubprocessRunner) RenderArtifact(ctx context.Context, req RenderArtifac
"config_path": strings.TrimSpace(req.ConfigPath), "config_path": strings.TrimSpace(req.ConfigPath),
"inputs_count": len(req.InputPaths), "inputs_count": len(req.InputPaths),
"vars_count": len(req.Vars), "vars_count": len(req.Vars),
"api_key_env": strings.TrimSpace(req.APIKeyEnv), "credential_env_names": sensitiveNames,
"api_key_env_present": credentialPresent,
"format": "json", "format": "json",
}, },
} }
@@ -191,57 +195,39 @@ func validateCommonRunRequest(
promptID string, promptID string,
outputPath string, outputPath string,
timeout time.Duration, timeout time.Duration,
apiKeyEnv string,
inputPaths map[string]string, inputPaths map[string]string,
vars map[string]string, vars map[string]string,
) (bool, error) { ) error {
if strings.TrimSpace(binary) == "" { if strings.TrimSpace(binary) == "" {
return false, fmt.Errorf("scriptorium binary is required") return fmt.Errorf("scriptorium binary is required")
} }
if strings.TrimSpace(configPath) == "" && configPath != "" { if strings.TrimSpace(configPath) == "" && configPath != "" {
return false, fmt.Errorf("scriptorium config path must be non-empty when provided") return fmt.Errorf("scriptorium config path must be non-empty when provided")
} }
if strings.TrimSpace(promptID) == "" { if strings.TrimSpace(promptID) == "" {
return false, fmt.Errorf("scriptorium prompt id is required") return fmt.Errorf("scriptorium prompt id is required")
} }
if strings.TrimSpace(outputPath) == "" { if strings.TrimSpace(outputPath) == "" {
return false, fmt.Errorf("scriptorium output path is required") return fmt.Errorf("scriptorium output path is required")
} }
if timeout <= 0 { if timeout <= 0 {
return false, fmt.Errorf("scriptorium timeout must be > 0") return fmt.Errorf("scriptorium timeout must be > 0")
} }
for name, path := range inputPaths { for name, path := range inputPaths {
if strings.TrimSpace(name) == "" { if strings.TrimSpace(name) == "" {
return false, fmt.Errorf("scriptorium input names must be non-empty") return fmt.Errorf("scriptorium input names must be non-empty")
} }
if strings.TrimSpace(path) == "" { if strings.TrimSpace(path) == "" {
return false, fmt.Errorf("scriptorium input %q path is required", name) return fmt.Errorf("scriptorium input %q path is required", name)
} }
} }
for name := range vars { for name := range vars {
if strings.TrimSpace(name) == "" { if strings.TrimSpace(name) == "" {
return false, fmt.Errorf("scriptorium variable names must be non-empty") return fmt.Errorf("scriptorium variable names must be non-empty")
} }
} }
trimmedEnv := strings.TrimSpace(apiKeyEnv) return nil
if trimmedEnv == "" {
return false, nil
}
value, ok := os.LookupEnv(trimmedEnv)
if !ok || strings.TrimSpace(value) == "" {
return false, fmt.Errorf("scriptorium required credential environment variable %s is not set", trimmedEnv)
}
return true, nil
}
func credentialEnvironment(apiKeyEnv string) (map[string]string, []string) {
name := strings.TrimSpace(apiKeyEnv)
if name == "" {
return nil, nil
}
value, _ := os.LookupEnv(name)
return map[string]string{name: value}, []string{name}
} }
func buildRunArgs(req RunArtifactRequest) []string { func buildRunArgs(req RunArtifactRequest) []string {
@@ -258,9 +244,6 @@ func buildRunArgs(req RunArtifactRequest) []string {
for _, kv := range sortedKeyValues(req.Vars) { for _, kv := range sortedKeyValues(req.Vars) {
args = append(args, "--var", kv) args = append(args, "--var", kv)
} }
if envName := strings.TrimSpace(req.APIKeyEnv); envName != "" {
args = append(args, "--api-key-env", envName)
}
args = append(args, "--timeout", req.Timeout.String()) args = append(args, "--timeout", req.Timeout.String())
args = append(args, "--out", req.OutputPath) args = append(args, "--out", req.OutputPath)
return args return args
@@ -280,9 +263,6 @@ func buildRenderArgs(req RenderArtifactRequest) []string {
for _, kv := range sortedKeyValues(req.Vars) { for _, kv := range sortedKeyValues(req.Vars) {
args = append(args, "--var", kv) args = append(args, "--var", kv)
} }
if envName := strings.TrimSpace(req.APIKeyEnv); envName != "" {
args = append(args, "--api-key-env", envName)
}
args = append(args, "--timeout", req.Timeout.String()) args = append(args, "--timeout", req.Timeout.String())
args = append(args, "--format", "json") args = append(args, "--format", "json")
args = append(args, "--out", req.OutputPath) args = append(args, "--out", req.OutputPath)
@@ -316,8 +296,7 @@ type invocationPayload struct {
InputPaths map[string]string InputPaths map[string]string
Vars map[string]string Vars map[string]string
OutputPath string OutputPath string
APIKeyEnv string CredentialNames []string
APIKeyEnvPresent bool
WorkingDir string WorkingDir string
RenderFormat string RenderFormat string
RenderPromptStore bool RenderPromptStore bool
@@ -336,8 +315,7 @@ func writeInvocationConfig(path string, payload invocationPayload) error {
"input_paths": payload.InputPaths, "input_paths": payload.InputPaths,
"vars": payload.Vars, "vars": payload.Vars,
"output_path": payload.OutputPath, "output_path": payload.OutputPath,
"api_key_env": payload.APIKeyEnv, "credential_env_names": payload.CredentialNames,
"api_key_env_present": payload.APIKeyEnvPresent,
"working_dir": payload.WorkingDir, "working_dir": payload.WorkingDir,
"render_format": payload.RenderFormat, "render_format": payload.RenderFormat,
"render_prompt_logged": payload.RenderPromptStore, "render_prompt_logged": payload.RenderPromptStore,

View File

@@ -9,6 +9,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
) )
func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *testing.T) { func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *testing.T) {
@@ -16,6 +18,13 @@ func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *tes
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1") t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success") t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success")
t.Setenv("OPENAI_KEY_SOURCE", "super-secret") t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
loaded, err := configkit.Load[struct {
Credentials []configkit.Secret `yaml:"credentials"`
}](configkit.Bytes("test.yml", []byte("credentials: [OPENAI_KEY_SOURCE]\n")))
if err != nil {
t.Fatal(err)
}
t.Setenv("OPENAI_KEY_SOURCE", "changed-after-load")
recordPath := filepath.Join(t.TempDir(), "record.json") recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", recordPath) t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", recordPath)
@@ -34,7 +43,7 @@ func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *tes
StderrLogPath: filepath.Join(dir, "logs", "scriptorium.run.stderr.log"), StderrLogPath: filepath.Join(dir, "logs", "scriptorium.run.stderr.log"),
GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.run.generated.yml"), GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.run.generated.yml"),
Timeout: mustParseScriptoriumDuration(t, "2s"), Timeout: mustParseScriptoriumDuration(t, "2s"),
APIKeyEnv: "OPENAI_KEY_SOURCE", Credentials: loaded.Config.Credentials,
} }
writeScriptoriumFile(t, req.InputPaths["transcript"], `{"segments":[]}`) writeScriptoriumFile(t, req.InputPaths["transcript"], `{"segments":[]}`)
writeScriptoriumFile(t, req.InputPaths["other"], "notes\n") writeScriptoriumFile(t, req.InputPaths["other"], "notes\n")
@@ -70,7 +79,6 @@ func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *tes
"--input", "transcript=" + req.InputPaths["transcript"], "--input", "transcript=" + req.InputPaths["transcript"],
"--var", "campaign_name=Icewind Dale", "--var", "campaign_name=Icewind Dale",
"--var", "session_id=2026-05-03", "--var", "session_id=2026-05-03",
"--api-key-env", "OPENAI_KEY_SOURCE",
"--timeout", "2s", "--timeout", "2s",
"--out", req.OutputPath, "--out", req.OutputPath,
} }
@@ -94,13 +102,31 @@ func TestSubprocessRunnerRunExitCodeOneFails(t *testing.T) {
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1") t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit1") t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit1")
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
t.Setenv("CUSTOM_PROVIDER_KEY", "synthetic-failure-secret")
loaded, loadErr := configkit.Load[struct {
Credentials []configkit.Secret `yaml:"credentials"`
}](configkit.Bytes("test.yml", []byte("credentials: [CUSTOM_PROVIDER_KEY]\n")))
if loadErr != nil {
t.Fatal(loadErr)
}
runner := NewSubprocessRunner() runner := NewSubprocessRunner()
req := runReqForTest(t, writeScriptoriumHelperWrapper(t)) req := runReqForTest(t, writeScriptoriumHelperWrapper(t))
req.Credentials = loaded.Config.Credentials
res, err := runner.RunArtifact(context.Background(), req) res, err := runner.RunArtifact(context.Background(), req)
if err == nil { if err == nil {
t.Fatal("RunArtifact() error = nil, want non-nil") t.Fatal("RunArtifact() error = nil, want non-nil")
} }
if strings.Contains(err.Error(), "synthetic-failure-secret") {
t.Fatal("failure leaked credential")
}
data, readErr := os.ReadFile(req.StderrLogPath)
if readErr != nil {
t.Fatal(readErr)
}
if strings.Contains(string(data), "synthetic-failure-secret") || !strings.Contains(string(data), "<redacted>") {
t.Fatal("failure log did not redact selected credential")
}
if res.ExitCode != 1 { if res.ExitCode != 1 {
t.Fatalf("ExitCode = %d, want 1", res.ExitCode) t.Fatalf("ExitCode = %d, want 1", res.ExitCode)
} }
@@ -245,6 +271,7 @@ func TestScriptoriumSubprocessHelper(t *testing.T) {
os.Exit(0) os.Exit(0)
case "fail_exit1": case "fail_exit1":
_, _ = os.Stderr.WriteString("scriptorium helper failure\n") _, _ = os.Stderr.WriteString("scriptorium helper failure\n")
_, _ = os.Stderr.WriteString(os.Getenv("CUSTOM_PROVIDER_KEY"))
os.Exit(1) os.Exit(1)
case "fail_exit2_with_output": case "fail_exit2_with_output":
writeScriptoriumHelperFile(outputPath, "validation failed artifact\n") writeScriptoriumHelperFile(outputPath, "validation failed artifact\n")

View File

@@ -0,0 +1,29 @@
package subprocess
import (
"fmt"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// CredentialEnvironment returns captured secret values and names for a child
// invocation. References must already have been resolved by ConfigKit.
func CredentialEnvironment(credentials []configkit.Secret) (map[string]string, []string, error) {
if len(credentials) == 0 {
return nil, nil, nil
}
overrides := make(map[string]string, len(credentials))
names := make([]string, 0, len(credentials))
for _, credential := range credentials {
name := credential.EnvName()
value := credential.Value()
if !credential.IsResolved() || value == "" {
return nil, nil, fmt.Errorf("credential %q must be resolved and nonempty", name)
}
if _, exists := overrides[name]; !exists {
names = append(names, name)
overrides[name] = value
}
}
return overrides, names, nil
}

View File

@@ -0,0 +1,51 @@
package subprocess
import (
"reflect"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestCredentialEnvironmentUsesCapturedReferences(t *testing.T) {
t.Setenv("CUSTOM_ALPHA_KEY", "alpha-value")
t.Setenv("SECOND_PROVIDER_TOKEN", "second-value")
t.Setenv("UNSELECTED_SECRET", "unselected-value")
type configuration struct {
Credentials []configkit.Secret `yaml:"credentials"`
}
loaded, err := configkit.Load[configuration](configkit.Bytes("credentials.yml", []byte("credentials: [CUSTOM_ALPHA_KEY, SECOND_PROVIDER_TOKEN, CUSTOM_ALPHA_KEY]\n")))
if err != nil {
t.Fatal(err)
}
t.Setenv("CUSTOM_ALPHA_KEY", "later-value")
overrides, names, err := CredentialEnvironment(loaded.Config.Credentials)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(overrides, map[string]string{"CUSTOM_ALPHA_KEY": "alpha-value", "SECOND_PROVIDER_TOKEN": "second-value"}) ||
!reflect.DeepEqual(names, []string{"CUSTOM_ALPHA_KEY", "SECOND_PROVIDER_TOKEN"}) {
t.Fatalf("credential selection = %#v, %#v", overrides, names)
}
overrides["CUSTOM_ALPHA_KEY"] = "modified"
names[0] = "modified"
again, againNames, err := CredentialEnvironment(loaded.Config.Credentials)
if err != nil || again["CUSTOM_ALPHA_KEY"] != "alpha-value" || againNames[0] != "CUSTOM_ALPHA_KEY" {
t.Fatalf("credential conversion retained mutable result: %#v, %#v, %v", again, againNames, err)
}
}
func TestCredentialEnvironmentHandlesEmptyAndUnresolvedReferences(t *testing.T) {
for _, credentials := range [][]configkit.Secret{nil, {}} {
overrides, names, err := CredentialEnvironment(credentials)
if err != nil || len(overrides) != 0 || len(names) != 0 {
t.Fatalf("empty credentials = %#v, %#v, %v", overrides, names, err)
}
}
for _, credentials := range [][]configkit.Secret{{configkit.SecretEnv("UNRESOLVED_KEY")}, {{}}} {
overrides, names, err := CredentialEnvironment(credentials)
if err == nil || overrides != nil || names != nil {
t.Fatalf("unresolved credentials = %#v, %#v, %v", overrides, names, err)
}
}
}

View File

@@ -256,12 +256,12 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validated); err != nil { if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validated); err != nil {
t.Fatalf("validate production base: %v", err) t.Fatalf("validate production base: %v", err)
} }
if !strings.Contains(validated.String(), "profile=none") { if !strings.Contains(validated.String(), "overlay=none") {
t.Fatalf("validate output = %q, want unprofiled production base", validated.String()) t.Fatalf("validate output = %q, want production base", validated.String())
} }
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigShow, ConfigSources} { for _, command := range []func(context.Context, []string, io.Writer) error{ConfigShow, ConfigSources} {
if err := command(context.Background(), []string{ if err := command(context.Background(), []string{
"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing", "--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing",
}, io.Discard); err != nil { }, io.Discard); err != nil {
t.Fatalf("testing inspection command %T: %v", command, err) t.Fatalf("testing inspection command %T: %v", command, err)
} }
@@ -272,19 +272,19 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi
}, &diff); err != nil { }, &diff); err != nil {
t.Fatalf("compare profiles: %v", err) t.Fatalf("compare profiles: %v", err)
} }
if !strings.Contains(diff.String(), "audita.model") || !strings.Contains(diff.String(), "character_items_arannis.profile_id") { if !strings.Contains(diff.String(), "audita.proposal_profile") || !strings.Contains(diff.String(), "character_items_arannis.profile_id") {
t.Fatalf("profile diff = %q, want model and expanded family changes", diff.String()) t.Fatalf("profile diff = %q, want proposal profile and expanded family changes", diff.String())
} }
common := []string{ common := []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath,
"--session", sessionPath, "--profile", "testing", "--session", sessionPath, "--overlay", "testing",
} }
var planned bytes.Buffer var planned bytes.Buffer
if err := Plan(context.Background(), common, &planned); err != nil { if err := Plan(context.Background(), common, &planned); err != nil {
t.Fatalf("plan testing bundle: %v", err) t.Fatalf("plan testing bundle: %v", err)
} }
if !strings.Contains(planned.String(), "profile=testing") { if !strings.Contains(planned.String(), "overlay=testing") {
t.Fatalf("plan output = %q, want testing provenance", planned.String()) t.Fatalf("plan output = %q, want testing provenance", planned.String())
} }
@@ -305,7 +305,7 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi
if !reflect.DeepEqual(capturedPlan.Names(), []string{"analyze"}) { if !reflect.DeepEqual(capturedPlan.Names(), []string{"analyze"}) {
t.Fatalf("bounded plan = %#v, want analyze only", capturedPlan.Names()) t.Fatalf("bounded plan = %#v, want analyze only", capturedPlan.Names())
} }
if profile, ok := config.SelectedPipelineProfile(capturedCfg.Pipeline); !ok || profile.Name != "testing" { if profile, ok := config.SelectedPipelineOverlay(capturedCfg.Pipeline); !ok || profile.Name != "testing" {
t.Fatalf("captured profile = %#v, selected=%t", profile, ok) t.Fatalf("captured profile = %#v, selected=%t", profile, ok)
} }
if digest := config.EffectivePipelineDigest(capturedCfg.Pipeline); digest == "" || !strings.Contains(runOut.String(), "digest="+digest) { if digest := config.EffectivePipelineDigest(capturedCfg.Pipeline); digest == "" || !strings.Contains(runOut.String(), "digest="+digest) {
@@ -391,7 +391,7 @@ func loadAssembledSplitBundleConfig(t *testing.T, profile string) *config.Config
pipelinePath, campaignPath, sessionPath := assembledSplitBundlePaths() pipelinePath, campaignPath, sessionPath := assembledSplitBundlePaths()
options := config.SessionLoadOptions{} options := config.SessionLoadOptions{}
if profile != "" { if profile != "" {
options.Profile = &profile options.Overlay = &profile
} }
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, options) cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, options)
if err != nil { if err != nil {
@@ -415,8 +415,8 @@ func assertAssembledProvenance(t *testing.T, store *manifest.LocalStore, summary
t.Fatal(err) t.Fatal(err)
} }
value := session.EffectiveConfig value := session.EffectiveConfig
profileMatches := value != nil && ((profile == "" && value.SelectedProfile == nil) || profileMatches := value != nil && ((profile == "" && value.SelectedOverlay == nil) ||
(profile != "" && value.SelectedProfile != nil && value.SelectedProfile.Name == profile)) (profile != "" && value.SelectedOverlay != nil && value.SelectedOverlay.Name == profile))
if !profileMatches || value.EffectiveConfigDigest != digest { if !profileMatches || value.EffectiveConfigDigest != digest {
t.Fatalf("effective configuration provenance = %#v, want profile=%q digest=%q", value, profile, digest) t.Fatalf("effective configuration provenance = %#v, want profile=%q digest=%q", value, profile, digest)
} }

View File

@@ -57,7 +57,7 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
{name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"}, {name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"},
{name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"}, {name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"},
{name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"}, {name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"},
{name: "profile", args: []string{"session", "--profile", "production", "--profile=testing"}, want: "--profile may be specified only once"}, {name: "overlay", args: []string{"session", "--overlay", "production", "--overlay=testing"}, want: "--overlay may be specified only once"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -69,29 +69,29 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
} }
} }
func TestBoundedRunParsingRetainsExplicitProfilePresence(t *testing.T) { func TestBoundedRunParsingRetainsExplicitOverlayPresence(t *testing.T) {
withoutProfile, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard) withoutOverlay, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := withoutProfile.Config.sessionOptions().Profile; got != nil { if got := withoutOverlay.Config.sessionOptions().Overlay; got != nil {
t.Fatalf("omitted profile = %#v, want nil", got) t.Fatalf("omitted overlay = %#v, want nil", got)
} }
withProfile, err := parseBoundedRunRequest("run", []string{"session", "--profile", "testing"}, io.Discard) withOverlay, err := parseBoundedRunRequest("run", []string{"session", "--overlay", "testing"}, io.Discard)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := withProfile.Config.sessionOptions().Profile; got == nil || *got != "testing" { if got := withOverlay.Config.sessionOptions().Overlay; got == nil || *got != "testing" {
t.Fatalf("explicit profile = %#v, want testing", got) t.Fatalf("explicit overlay = %#v, want testing", got)
} }
explicitEmpty, err := parseBoundedRunRequest("run", []string{"session", "--profile", ""}, io.Discard) explicitEmpty, err := parseBoundedRunRequest("run", []string{"session", "--overlay", ""}, io.Discard)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := explicitEmpty.Config.sessionOptions().Profile; got == nil || *got != "" { if got := explicitEmpty.Config.sessionOptions().Overlay; got == nil || *got != "" {
t.Fatalf("explicit empty profile = %#v, want non-nil empty", got) t.Fatalf("explicit empty overlay = %#v, want non-nil empty", got)
} }
} }

View File

@@ -93,7 +93,7 @@ func cleanAllLocalWithDependencies(flags commonConfigFlags, dryRun, clearCache b
strings.TrimSpace(flags.previousSessionID) != "" { strings.TrimSpace(flags.previousSessionID) != "" {
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id") return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
} }
_, pipelineCfg, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()}, flags.secretsDir.pointer(), deps) _, pipelineCfg, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Overlay: flags.overlay.pointer()}, flags.secretsDir.pointer(), deps)
if err != nil { if err != nil {
return fmt.Errorf("clean: %w", err) return fmt.Errorf("clean: %w", err)
} }

View File

@@ -229,7 +229,7 @@ seriatim:
binary: ` + seriatimBinary + ` binary: ` + seriatimBinary + `
audita: audita:
binary: ` + auditaBinary + ` binary: ` + auditaBinary + `
llm_api_key: OPENROUTER_API_KEY credentials: [OPENROUTER_API_KEY]
notification: notification:
mode: noop mode: noop
` `
@@ -274,6 +274,28 @@ inputs:
if !strings.Contains(stdout.String(), "stage=polish executed=1 skipped=0") { if !strings.Contains(stdout.String(), "stage=polish executed=1 skipped=0") {
t.Fatalf("stdout = %q, want polish execution", stdout.String()) t.Fatalf("stdout = %q, want polish execution", stdout.String())
} }
recorded, err := (&manifest.LocalStore{}).Load(context.Background(), filepath.Join(workRoot, "manifest.json"))
if err != nil {
t.Fatalf("load polished manifest: %v", err)
}
polish := recorded.Stages["polish"]
if polish == nil || len(polish.Outputs) != 1 || polish.Outputs[0].Kind != "transcript_polished" {
t.Fatalf("polish outputs = %#v, want only canonical transcript", polish)
}
if len(polish.Logs) != 5 || len(polish.GeneratedConfigs) != 1 {
t.Fatalf("polish diagnostics = %#v, want logs, reports, and invocation", polish)
}
for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} {
found := false
for _, path := range polish.Logs {
if filepath.Base(path) == name {
found = true
}
}
if !found {
t.Fatalf("report %q absent from manifest diagnostics: %#v", name, polish.Logs)
}
}
foundReference := false foundReference := false
if err := filepath.WalkDir(workRoot, func(path string, entry os.DirEntry, walkErr error) error { if err := filepath.WalkDir(workRoot, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil || entry.IsDir() { if walkErr != nil || entry.IsDir() {
@@ -778,10 +800,18 @@ func TestAuditaAppHelper(t *testing.T) {
os.Exit(2) os.Exit(2)
} }
processArgs := args[start:] processArgs := args[start:]
if len(processArgs) < 2 || processArgs[0] != "process" {
_, _ = os.Stderr.WriteString("expected Audita v2 process invocation\n")
os.Exit(2)
}
for _, arg := range processArgs {
if arg == "--report-json" || arg == "--work-dir" || arg == "--debug" || arg == "--reporting=false" {
_, _ = os.Stderr.WriteString("retired or unwanted Audita flag\n")
os.Exit(2)
}
}
outputPath := appSeriatimFlagValue(processArgs, "--output") outputPath := appSeriatimFlagValue(processArgs, "--output")
reportPath := appSeriatimFlagValue(processArgs, "--report-json")
workDir := appSeriatimFlagValue(processArgs, "--work-dir")
if strings.TrimSpace(outputPath) == "" { if strings.TrimSpace(outputPath) == "" {
_, _ = os.Stderr.WriteString("missing --output\n") _, _ = os.Stderr.WriteString("missing --output\n")
os.Exit(2) os.Exit(2)
@@ -794,19 +824,9 @@ func TestAuditaAppHelper(t *testing.T) {
_, _ = os.Stderr.WriteString(fmt.Sprintf("write output: %v\n", err)) _, _ = os.Stderr.WriteString(fmt.Sprintf("write output: %v\n", err))
os.Exit(2) os.Exit(2)
} }
if strings.TrimSpace(reportPath) != "" { for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} {
if err := os.MkdirAll(filepath.Dir(reportPath), 0o755); err != nil { if err := os.WriteFile(filepath.Join(filepath.Dir(outputPath), name), []byte("status: completed\n"), 0o644); err != nil {
_, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir report dir: %v\n", err)) _, _ = os.Stderr.WriteString(fmt.Sprintf("write %s: %v\n", name, err))
os.Exit(2)
}
if err := os.WriteFile(reportPath, []byte(`{"report":true}`), 0o644); err != nil {
_, _ = os.Stderr.WriteString(fmt.Sprintf("write report: %v\n", err))
os.Exit(2)
}
}
if strings.TrimSpace(workDir) != "" {
if err := os.MkdirAll(workDir, 0o755); err != nil {
_, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir work dir: %v\n", err))
os.Exit(2) os.Exit(2)
} }
} }

View File

@@ -80,22 +80,22 @@ func TestConfigArtifactsReportsGeneratedFamilyProvenance(t *testing.T) {
} }
} }
func TestConfigArtifactsReportsProfiledFamilyContributors(t *testing.T) { func TestConfigArtifactsReportsOverlayFamilyContributors(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t) pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
profilePath := filepath.Join(filepath.Dir(pipelinePath), "profiles", "testing.yml") overlayPath := filepath.Join(filepath.Dir(pipelinePath), "overlays", "testing.yml")
mustWriteTestFile(t, profilePath, `scriptorium: mustWriteTestFile(t, overlayPath, `scriptorium:
artifact_families: artifact_families:
character_note: character_note:
prompt_id: dnd.character_note.testing prompt_id: dnd.character_note.testing
`) `)
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"} args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing"}
var catalog bytes.Buffer var catalog bytes.Buffer
if err := ConfigArtifacts(context.Background(), args, &catalog); err != nil { if err := ConfigArtifacts(context.Background(), args, &catalog); err != nil {
t.Fatal(err) t.Fatal(err)
} }
records := catalogRecordsByKey(t, catalog.String()) records := catalogRecordsByKey(t, catalog.String())
if got := strings.Split(records["character_note_arannis"][2], ","); !reflect.DeepEqual(got, []string{pipelinePath, profilePath}) { if got := strings.Split(records["character_note_arannis"][2], ","); !reflect.DeepEqual(got, []string{overlayPath, pipelinePath}) {
t.Fatalf("generated family definitions = %#v", got) t.Fatalf("generated family definitions = %#v", got)
} }
@@ -103,16 +103,16 @@ func TestConfigArtifactsReportsProfiledFamilyContributors(t *testing.T) {
if err := ConfigSources(context.Background(), args, &sources); err != nil { if err := ConfigSources(context.Background(), args, &sources); err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := "scriptorium.artifacts.character_note_arannis.prompt_id\tfamily\t" + profilePath want := "scriptorium.artifacts.character_note_arannis.prompt_id\tfamily\t" + overlayPath
if !strings.Contains(sources.String(), want) { if !strings.Contains(sources.String(), want) {
t.Fatalf("sources output missing profiled family contributor %q:\n%s", want, sources.String()) t.Fatalf("sources output missing overlay family contributor %q:\n%s", want, sources.String())
} }
} }
func TestConfigArtifactsReportsBaseAndProfileContributors(t *testing.T) { func TestConfigArtifactsReportsBaseAndOverlayContributors(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml") pipelinePath := filepath.Join(dir, "pipeline.yml")
profilePath := filepath.Join(dir, "profiles", "testing.yml") overlayPath := filepath.Join(dir, "overlays", "testing.yml")
mustWriteTestFile(t, pipelinePath, `workspace: mustWriteTestFile(t, pipelinePath, `workspace:
root: /srv/narratio root: /srv/narratio
whisperx: whisperx:
@@ -124,26 +124,26 @@ scriptorium:
enabled: false enabled: false
output_path: artifacts/notes.md output_path: artifacts/notes.md
`) `)
mustWriteTestFile(t, profilePath, `scriptorium: mustWriteTestFile(t, overlayPath, `scriptorium:
artifacts: artifacts:
notes: notes:
enabled: true enabled: true
prompt_id: notes prompt_id: notes
profile_only: overlay_only:
enabled: false enabled: false
output_path: artifacts/profile-only.md output_path: artifacts/overlay-only.md
`) `)
var out bytes.Buffer var out bytes.Buffer
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &out); err != nil { if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--overlay", "testing"}, &out); err != nil {
t.Fatal(err) t.Fatal(err)
} }
records := catalogRecordsByKey(t, out.String()) records := catalogRecordsByKey(t, out.String())
if got := strings.Split(records["notes"][2], ","); !reflect.DeepEqual(got, []string{pipelinePath, profilePath}) { if got := strings.Split(records["notes"][2], ","); !reflect.DeepEqual(got, []string{overlayPath, pipelinePath}) {
t.Fatalf("notes definitions = %#v", got) t.Fatalf("notes definitions = %#v", got)
} }
if got := records["profile_only"][2]; got != profilePath { if got := records["overlay_only"][2]; got != overlayPath {
t.Fatalf("profile-only definition = %q, want %q", got, profilePath) t.Fatalf("overlay-only definition = %q, want %q", got, overlayPath)
} }
} }

View File

@@ -17,7 +17,7 @@ type inspectionFlags struct {
pipelinePath string pipelinePath string
campaignPath string campaignPath string
campaignFilePath string campaignFilePath string
profile singletonStringFlag overlay singletonStringFlag
secretsDir singletonStringFlag secretsDir singletonStringFlag
} }
@@ -77,8 +77,8 @@ func configValidateWithDependencies(_ context.Context, args []string, out io.Wri
if err != nil { if err != nil {
return fmt.Errorf("config validate: %w", err) return fmt.Errorf("config validate: %w", err)
} }
_, err = fmt.Fprintf(out, "Configuration valid: root=%s; profile=%s; digest=%s\n", _, err = fmt.Fprintf(out, "Configuration valid: root=%s; overlay=%s; digest=%s\n",
inspectionRootPath(resolved), inspectionProfile(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline)) inspectionRootPath(resolved), inspectionOverlay(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline))
return err return err
} }
@@ -214,12 +214,12 @@ func parseInspectionFlags(command string, args []string, out io.Writer) (inspect
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID") fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
flags.profile.name = "profile" flags.overlay.name = "overlay"
fs.Var(&flags.profile, "profile", "named pipeline profile") fs.Var(&flags.overlay, "overlay", "named pipeline overlay")
flags.secretsDir.name = "secrets-dir" flags.secretsDir.name = "secrets-dir"
fs.Var(&flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)") fs.Var(&flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintf(out, "Usage: narratio %s [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <path>]\n\n", command) fmt.Fprintf(out, "Usage: narratio %s [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <path>]\n\n", command)
fmt.Fprintln(out, "Resolves configuration without a session, workspace, manifest, adapters, or external services.") fmt.Fprintln(out, "Resolves configuration without a session, workspace, manifest, adapters, or external services.")
fmt.Fprintln(out) fmt.Fprintln(out)
fmt.Fprintln(out, "Flags:") fmt.Fprintln(out, "Flags:")
@@ -238,7 +238,7 @@ func parseInspectionFlags(command string, args []string, out io.Writer) (inspect
} }
func resolveInspectionConfigWithDependencies(flags inspectionFlags, deps commandDependencies) (*inspectionConfig, error) { func resolveInspectionConfigWithDependencies(flags inspectionFlags, deps commandDependencies) (*inspectionConfig, error) {
pipelinePath, pipeline, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()}, flags.secretsDir.pointer(), deps) pipelinePath, pipeline, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Overlay: flags.overlay.pointer()}, flags.secretsDir.pointer(), deps)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -315,9 +315,9 @@ func inspectionRootPath(resolved *inspectionConfig) string {
return resolved.PipelinePath return resolved.PipelinePath
} }
func inspectionProfile(pipeline *config.PipelineConfig) string { func inspectionOverlay(pipeline *config.PipelineConfig) string {
if profile, ok := config.SelectedPipelineProfile(pipeline); ok { if overlay, ok := config.SelectedPipelineOverlay(pipeline); ok {
return profile.Name return overlay.Name
} }
return "none" return "none"
} }
@@ -325,7 +325,7 @@ func inspectionProfile(pipeline *config.PipelineConfig) string {
func writeConfigCommandUsage(out io.Writer) { func writeConfigCommandUsage(out io.Writer) {
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources|artifacts|diff>") fmt.Fprintln(out, "Usage: narratio config <validate|show|sources|artifacts|diff>")
fmt.Fprintln(out) fmt.Fprintln(out)
fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, config artifacts to list concrete artifacts, or config diff to compare two profiles.") fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, config artifacts to list concrete artifacts, or config diff to compare two overlays.")
} }
func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error { func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error {
@@ -343,11 +343,11 @@ func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) erro
return err return err
} }
} }
if profile, ok := config.SelectedPipelineProfile(resolved.Pipeline); ok { if overlay, ok := config.SelectedPipelineOverlay(resolved.Pipeline); ok {
if _, err := fmt.Fprintf(out, "profile: name=%s selection=%s overlay=%s\n", profile.Name, profile.Source, config.NormalizedConfigurationPath(profile.OverlayPath)); err != nil { if _, err := fmt.Fprintf(out, "overlay: name=%s selection=%s path=%s\n", overlay.Name, overlay.Source, config.NormalizedConfigurationPath(overlay.OverlayPath)); err != nil {
return err return err
} }
} else if _, err := fmt.Fprintln(out, "profile: none"); err != nil { } else if _, err := fmt.Fprintln(out, "overlay: none"); err != nil {
return err return err
} }
if resolved.Campaign == nil { if resolved.Campaign == nil {

View File

@@ -34,6 +34,7 @@ func TestConfigCommandHelpAndDispatch(t *testing.T) {
{name: "stage flag", args: []string{"config", "show", "--from", "prepare"}, want: "flag provided but not defined", code: 1}, {name: "stage flag", args: []string{"config", "show", "--from", "prepare"}, want: "flag provided but not defined", code: 1},
{name: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1}, {name: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1},
{name: "artifact flag", args: []string{"config", "show", "--artifacts", "recap"}, want: "flag provided but not defined", code: 1}, {name: "artifact flag", args: []string{"config", "show", "--artifacts", "recap"}, want: "flag provided but not defined", code: 1},
{name: "retired selector", args: []string{"config", "validate", "--profile", "testing"}, want: "flag provided but not defined", code: 1},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -102,7 +103,7 @@ whisperx:
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &validateOut); err != nil { if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &validateOut); err != nil {
t.Fatalf("ConfigValidate() error = %v", err) t.Fatalf("ConfigValidate() error = %v", err)
} }
if !strings.Contains(validateOut.String(), "Configuration valid: root=") || !strings.Contains(validateOut.String(), "; profile=none; digest=") { if !strings.Contains(validateOut.String(), "Configuration valid: root=") || !strings.Contains(validateOut.String(), "; overlay=none; digest=") {
t.Fatalf("validate output = %q", validateOut.String()) t.Fatalf("validate output = %q", validateOut.String())
} }
@@ -127,7 +128,7 @@ whisperx:
if strings.Contains(sourcesOut.String(), secret) { if strings.Contains(sourcesOut.String(), secret) {
t.Fatalf("sources output leaked a raw secret: %q", sourcesOut.String()) t.Fatalf("sources output leaked a raw secret: %q", sourcesOut.String())
} }
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n") writeInspectionOverlays(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n")
var diffOut bytes.Buffer var diffOut bytes.Buffer
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diffOut); err != nil { if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diffOut); err != nil {
t.Fatalf("ConfigDiff() error = %v", err) t.Fatalf("ConfigDiff() error = %v", err)
@@ -143,10 +144,10 @@ whisperx:
} }
} }
func TestConfigDiffReportsSemanticProfileChanges(t *testing.T) { func TestConfigDiffReportsSemanticOverlayChanges(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
writeInspectionProfiles(t, pipelinePath, `workspace: writeInspectionOverlays(t, pipelinePath, `workspace:
cleanup_after_publish: true cleanup_after_publish: true
publish: publish:
upload_run: true upload_run: true
@@ -203,7 +204,7 @@ scriptorium:
t.Fatalf("reversed ConfigDiff() error = %v", err) t.Fatalf("reversed ConfigDiff() error = %v", err)
} }
if !strings.Contains(reversed.String(), "changed\twhisperx.language\t\"fr\"\t\"en\"\n") { if !strings.Contains(reversed.String(), "changed\twhisperx.language\t\"fr\"\t\"en\"\n") {
t.Fatalf("reversed diff did not independently resolve profiles:\n%s", reversed.String()) t.Fatalf("reversed diff did not independently resolve overlays:\n%s", reversed.String())
} }
} }
@@ -219,7 +220,7 @@ audita:
scriptorium: scriptorium:
artifacts: null artifacts: null
`) `)
mustWriteTestFile(t, filepath.Join(dir, "profiles", "empty.yml"), `audita: mustWriteTestFile(t, filepath.Join(dir, "overlays", "empty.yml"), `audita:
modules: [] modules: []
scriptorium: scriptorium:
artifacts: {} artifacts: {}
@@ -229,7 +230,7 @@ scriptorium:
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &base); err != nil { if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &base); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "empty"}, &empty); err != nil { if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "empty"}, &empty); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, want := range []string{"modules: null\n", "artifacts: null\n"} { for _, want := range []string{"modules: null\n", "artifacts: null\n"} {
@@ -239,7 +240,7 @@ scriptorium:
} }
for _, want := range []string{"modules: []\n", "artifacts: {}\n"} { for _, want := range []string{"modules: []\n", "artifacts: {}\n"} {
if !strings.Contains(empty.String(), want) { if !strings.Contains(empty.String(), want) {
t.Fatalf("empty-profile show output missing %q:\n%s", want, empty.String()) t.Fatalf("empty-overlay show output missing %q:\n%s", want, empty.String())
} }
} }
@@ -259,7 +260,7 @@ scriptorium:
func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) { func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t) pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
writeInspectionProfiles(t, pipelinePath, `scriptorium: writeInspectionOverlays(t, pipelinePath, `scriptorium:
artifact_families: artifact_families:
character_note: character_note:
enabled: false enabled: false
@@ -301,11 +302,11 @@ func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) {
func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) { func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: en\n") writeInspectionOverlays(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: en\n")
var equal bytes.Buffer var equal bytes.Buffer
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &equal); err != nil { if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &equal); err != nil {
t.Fatalf("ConfigDiff() equal profiles error = %v", err) t.Fatalf("ConfigDiff() equal overlays error = %v", err)
} }
if got := equal.String(); got != "no differences\n" { if got := equal.String(); got != "no differences\n" {
t.Fatalf("equal diff output = %q", got) t.Fatalf("equal diff output = %q", got)
@@ -316,7 +317,7 @@ func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
{"production", "--config", pipelinePath}, {"production", "--config", pipelinePath},
{"production", "production", "--config", pipelinePath}, {"production", "production", "--config", pipelinePath},
{"unknown", "testing", "--config", pipelinePath}, {"unknown", "testing", "--config", pipelinePath},
{"production", "testing", "--config", pipelinePath, "--profile", "production"}, {"production", "testing", "--config", pipelinePath, "--overlay", "production"},
{"production", "testing", "--config", pipelinePath, "--config", pipelinePath}, {"production", "testing", "--config", pipelinePath, "--config", pipelinePath},
{"production", "testing", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath}, {"production", "testing", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath},
} { } {
@@ -326,10 +327,10 @@ func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
} }
} }
func TestConfigDiffBootstrapsSecretsOnceForBothProfiles(t *testing.T) { func TestConfigDiffBootstrapsSecretsOnceForBothOverlays(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n") writeInspectionOverlays(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n")
secretsDir := t.TempDir() secretsDir := t.TempDir()
const secretName = "NARRATIO_DIFF_BOOTSTRAP_REPORT" const secretName = "NARRATIO_DIFF_BOOTSTRAP_REPORT"
restoreEnvAfterTest(t, secretName) restoreEnvAfterTest(t, secretName)
@@ -388,32 +389,32 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
`, 1) `, 1)
pipelineYAML += `notarius: pipelineYAML += `notarius:
enabled: false enabled: false
llm_api_key: NARRATIO_INSPECT_NOTARIUS_ONE credentials: [NARRATIO_INSPECT_NOTARIUS_ONE]
` `
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
writeInspectionProfiles(t, pipelinePath, writeInspectionOverlays(t, pipelinePath,
"audita:\n model: production\n llm_api_key: NARRATIO_INSPECT_LLM_ONE\n", "audita:\n proposal_profile: production\n credentials: [NARRATIO_INSPECT_LLM_ONE]\n",
"audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\nnotarius:\n llm_api_key: NARRATIO_INSPECT_NOTARIUS_TWO\n", "audita:\n proposal_profile: testing\n credentials: [NARRATIO_INSPECT_LLM_TWO]\nnotarius:\n credentials: [NARRATIO_INSPECT_NOTARIUS_TWO]\n",
) )
mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "profiles", "without-notarius-key.yml"), "notarius:\n llm_api_key: null\n") mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "overlays", "without-notarius-key.yml"), "notarius:\n credentials: []\n")
var validate bytes.Buffer var validate bytes.Buffer
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &validate); err != nil { if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &validate); err != nil {
t.Fatalf("config validate: %v", err) t.Fatalf("config validate: %v", err)
} }
var show bytes.Buffer var show bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &show); err != nil { if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &show); err != nil {
t.Fatalf("config show: %v", err) t.Fatalf("config show: %v", err)
} }
var sources bytes.Buffer var sources bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &sources); err != nil { if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &sources); err != nil {
t.Fatalf("config sources: %v", err) t.Fatalf("config sources: %v", err)
} }
var catalog bytes.Buffer var catalog bytes.Buffer
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &catalog); err != nil { if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &catalog); err != nil {
t.Fatalf("config artifacts: %v", err) t.Fatalf("config artifacts: %v", err)
} }
var diff bytes.Buffer var diff bytes.Buffer
@@ -421,7 +422,7 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
t.Fatalf("config diff: %v", err) t.Fatalf("config diff: %v", err)
} }
var removed bytes.Buffer var removed bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "without-notarius-key"}, &removed); err != nil { if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "without-notarius-key"}, &removed); err != nil {
t.Fatalf("config show with removed Notarius credential: %v", err) t.Fatalf("config show with removed Notarius credential: %v", err)
} }
for label, output := range map[string]string{"show": show.String(), "sources": sources.String(), "artifacts": catalog.String(), "diff": diff.String()} { for label, output := range map[string]string{"show": show.String(), "sources": sources.String(), "artifacts": catalog.String(), "diff": diff.String()} {
@@ -438,11 +439,11 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
!strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") || !strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_TWO") { !strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") || !strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_TWO") {
t.Fatalf("diff omitted safe credential references: %q", diff.String()) t.Fatalf("diff omitted safe credential references: %q", diff.String())
} }
if !strings.Contains(sources.String(), "notarius.llm_api_key") { if !strings.Contains(sources.String(), "notarius.credentials") {
t.Fatalf("sources omitted Notarius credential ownership: %q", sources.String()) t.Fatalf("sources omitted Notarius credential ownership: %q", sources.String())
} }
if strings.Contains(removed.String(), "llm_api_key") || strings.Contains(removed.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") { if strings.Contains(removed.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") {
t.Fatalf("profile null did not remove Notarius credential: %q", removed.String()) t.Fatalf("empty overlay did not remove Notarius credential: %q", removed.String())
} }
} }
@@ -450,7 +451,7 @@ func TestConfigValidateRejectsUnavailableNotariusCredential(t *testing.T) {
const envName = "NARRATIO_INSPECT_NOTARIUS_MISSING" const envName = "NARRATIO_INSPECT_NOTARIUS_MISSING"
restoreEnvAfterTest(t, envName) restoreEnvAfterTest(t, envName)
path := filepath.Join(t.TempDir(), "pipeline.yml") path := filepath.Join(t.TempDir(), "pipeline.yml")
mustWriteTestFile(t, path, "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n") mustWriteTestFile(t, path, "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+envName+"]\n")
err := ConfigValidate(context.Background(), []string{"--config", path}, io.Discard) err := ConfigValidate(context.Background(), []string{"--config", path}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "secret.unset") { if err == nil || !strings.Contains(err.Error(), "secret.unset") {
t.Fatalf("ConfigValidate() error = %v, want unavailable credential failure", err) t.Fatalf("ConfigValidate() error = %v, want unavailable credential failure", err)
@@ -475,36 +476,36 @@ func TestConfigCommandsInspectMaintainedSplitBundleWithoutRuntimeState(t *testin
if err := ConfigDiff(context.Background(), []string{"default", "testing", "--config", pipelinePath, "--campaign-file", campaignPath}, &diff); err != nil { if err := ConfigDiff(context.Background(), []string{"default", "testing", "--config", pipelinePath, "--campaign-file", campaignPath}, &diff); err != nil {
t.Fatalf("ConfigDiff() error = %v", err) t.Fatalf("ConfigDiff() error = %v", err)
} }
if !strings.Contains(diff.String(), "audita.model") { if !strings.Contains(diff.String(), "audita.proposal_profile") {
t.Fatalf("split bundle profile diff = %q, want model change", diff.String()) t.Fatalf("split bundle overlay diff = %q, want proposal profile change", diff.String())
} }
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) { if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
t.Fatalf("inspection created example workspace: %v", err) t.Fatalf("inspection created example workspace: %v", err)
} }
} }
func writeInspectionProfiles(t *testing.T, pipelinePath, production, testing string) { func writeInspectionOverlays(t *testing.T, pipelinePath, production, testing string) {
t.Helper() t.Helper()
profilesDir := filepath.Join(filepath.Dir(pipelinePath), "profiles") overlaysDir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
mustWriteTestFile(t, filepath.Join(profilesDir, "production.yml"), production) mustWriteTestFile(t, filepath.Join(overlaysDir, "production.yml"), production)
mustWriteTestFile(t, filepath.Join(profilesDir, "testing.yml"), testing) mustWriteTestFile(t, filepath.Join(overlaysDir, "testing.yml"), testing)
} }
func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) { func TestConfigCommandsSelectOverlaysAndCampaigns(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath) enableCommandTestOverlays(t, pipelinePath)
var defaultOut bytes.Buffer var defaultOut bytes.Buffer
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &defaultOut); err != nil { if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &defaultOut); err != nil {
t.Fatalf("default ConfigValidate() error = %v", err) t.Fatalf("default ConfigValidate() error = %v", err)
} }
if !strings.Contains(defaultOut.String(), "profile=none") { if !strings.Contains(defaultOut.String(), "overlay=none") {
t.Fatalf("default output = %q", defaultOut.String()) t.Fatalf("default output = %q", defaultOut.String())
} }
var explicitOut bytes.Buffer var explicitOut bytes.Buffer
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &explicitOut); err != nil { if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "testing"}, &explicitOut); err != nil {
t.Fatalf("explicit ConfigShow() error = %v", err) t.Fatalf("explicit ConfigShow() error = %v", err)
} }
if !strings.Contains(explicitOut.String(), "language: fr") { if !strings.Contains(explicitOut.String(), "language: fr") {
@@ -589,9 +590,9 @@ whisperx:
func TestConfigSourcesReportsStableOwnership(t *testing.T) { func TestConfigSourcesReportsStableOwnership(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath) enableCommandTestOverlays(t, pipelinePath)
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"} args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing"}
var first, second bytes.Buffer var first, second bytes.Buffer
if err := ConfigSources(context.Background(), args, &first); err != nil { if err := ConfigSources(context.Background(), args, &first); err != nil {
t.Fatalf("first ConfigSources() error = %v", err) t.Fatalf("first ConfigSources() error = %v", err)
@@ -606,13 +607,13 @@ func TestConfigSourcesReportsStableOwnership(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"root: ", "root: ",
"imports: none", "imports: none",
"profile: name=testing selection=explicit overlay=", "overlay: name=testing selection=explicit path=",
"campaign: id=sample-campaign source=", "campaign: id=sample-campaign source=",
"party: source=", "party: source=",
"digest: ", "digest: ",
"path\trole\tsource\n", "path\trole\tsource\n",
"workspace.root\troot\t", "workspace.root\troot\t",
"whisperx.language\tprofile\t", "whisperx.language\toverlay\t",
"derived.players\tparty\t", "derived.players\tparty\t",
"trim.enabled\tdefault\tdefault", "trim.enabled\tdefault\tdefault",
} { } {
@@ -647,10 +648,10 @@ func TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins(t *testing.T) {
} }
} }
func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing.T) { func TestConfigSourcesPreservesOverlayOwnershipThroughFamilyExpansion(t *testing.T) {
pipelinePath, campaignPath := writeInspectionFamilyConfig(t) pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
profilePath := filepath.Join(filepath.Dir(pipelinePath), "profiles", "testing.yml") overlayPath := filepath.Join(filepath.Dir(pipelinePath), "overlays", "testing.yml")
mustWriteTestFile(t, profilePath, `scriptorium: mustWriteTestFile(t, overlayPath, `scriptorium:
artifact_families: artifact_families:
character_note: character_note:
enabled: true enabled: true
@@ -658,12 +659,12 @@ func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing
`) `)
var out bytes.Buffer var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"}, &out); err != nil { if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing"}, &out); err != nil {
t.Fatalf("ConfigSources() error = %v", err) t.Fatalf("ConfigSources() error = %v", err)
} }
want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + profilePath want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + overlayPath
if !strings.Contains(out.String(), want) { if !strings.Contains(out.String(), want) {
t.Fatalf("sources output missing profile-generated ownership %q:\n%s", want, out.String()) t.Fatalf("sources output missing overlay-generated ownership %q:\n%s", want, out.String())
} }
} }
@@ -671,7 +672,7 @@ func TestConfigSourcesAttributesInferredArtifactDependencies(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml") pipelinePath := filepath.Join(dir, "pipeline.yml")
inputPath := filepath.Join(dir, "input-only.yml") inputPath := filepath.Join(dir, "input-only.yml")
profilePath := filepath.Join(dir, "profiles", "testing.yml") overlayPath := filepath.Join(dir, "overlays", "testing.yml")
mustWriteTestFile(t, pipelinePath, `configkit: mustWriteTestFile(t, pipelinePath, `configkit:
imports: imports:
- path: input-only.yml - path: input-only.yml
@@ -701,7 +702,7 @@ scriptorium:
prior: prior:
source: narratio.artifact.first source: narratio.artifact.first
`) `)
mustWriteTestFile(t, profilePath, `scriptorium: mustWriteTestFile(t, overlayPath, `scriptorium:
artifacts: artifacts:
mixed: mixed:
inputs: inputs:
@@ -710,13 +711,13 @@ scriptorium:
`) `)
var out bytes.Buffer var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &out); err != nil { if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--overlay", "testing"}, &out); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, want := range []string{ for _, want := range []string{
"scriptorium.artifacts.input_only.depends_on\timport\t" + inputPath, "scriptorium.artifacts.input_only.depends_on\timport\t" + inputPath,
"scriptorium.artifacts.mixed.depends_on\troot\t" + pipelinePath, "scriptorium.artifacts.mixed.depends_on\troot\t" + pipelinePath,
"scriptorium.artifacts.mixed.depends_on\tprofile\t" + profilePath, "scriptorium.artifacts.mixed.depends_on\toverlay\t" + overlayPath,
} { } {
if !strings.Contains(out.String(), want) { if !strings.Contains(out.String(), want) {
t.Fatalf("sources output missing dependency contributor %q:\n%s", want, out.String()) t.Fatalf("sources output missing dependency contributor %q:\n%s", want, out.String())
@@ -727,11 +728,11 @@ scriptorium:
} }
} }
func TestConfigSourcesReportsRootImportProfileAndDefaultOwnership(t *testing.T) { func TestConfigSourcesReportsRootImportOverlayAndDefaultOwnership(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
rootPath := filepath.Join(dir, "pipeline.yml") rootPath := filepath.Join(dir, "pipeline.yml")
importPath := filepath.Join(dir, "base.yml") importPath := filepath.Join(dir, "base.yml")
profilePath := filepath.Join(dir, "profiles", "testing.yml") overlayPath := filepath.Join(dir, "overlays", "testing.yml")
mustWriteTestFile(t, rootPath, `configkit: mustWriteTestFile(t, rootPath, `configkit:
imports: imports:
- path: base.yml - path: base.yml
@@ -741,19 +742,19 @@ whisperx:
transcribe_url: https://transcription.example.com/transcribe transcribe_url: https://transcription.example.com/transcribe
`) `)
mustWriteTestFile(t, importPath, "storage:\n backend: local\n") mustWriteTestFile(t, importPath, "storage:\n backend: local\n")
mustWriteTestFile(t, profilePath, "whisperx:\n language: fr\n") mustWriteTestFile(t, overlayPath, "whisperx:\n language: fr\n")
var out bytes.Buffer var out bytes.Buffer
if err := ConfigSources(context.Background(), []string{"--config", rootPath, "--profile", "testing"}, &out); err != nil { if err := ConfigSources(context.Background(), []string{"--config", rootPath, "--overlay", "testing"}, &out); err != nil {
t.Fatalf("ConfigSources() error = %v", err) t.Fatalf("ConfigSources() error = %v", err)
} }
output := out.String() output := out.String()
for _, want := range []string{ for _, want := range []string{
"import: " + importPath, "import: " + importPath,
"profile: name=testing selection=explicit overlay=" + profilePath, "overlay: name=testing selection=explicit path=" + overlayPath,
"workspace.root\troot\t" + rootPath, "workspace.root\troot\t" + rootPath,
"storage.backend\timport\t" + importPath, "storage.backend\timport\t" + importPath,
"whisperx.language\tprofile\t" + profilePath, "whisperx.language\toverlay\t" + overlayPath,
"trim.enabled\tdefault\tdefault", "trim.enabled\tdefault\tdefault",
} { } {
if !strings.Contains(output, want) { if !strings.Contains(output, want) {

View File

@@ -20,8 +20,8 @@ type configDiffFlags struct {
} }
type configDiffRequest struct { type configDiffRequest struct {
leftProfile string leftOverlay string
rightProfile string rightOverlay string
flags configDiffFlags flags configDiffFlags
} }
@@ -37,7 +37,7 @@ type configDiffRecord struct {
Right string Right string
} }
// ConfigDiff compares two explicitly selected profiles through the same // ConfigDiff compares two explicitly selected overlays through the same
// read-only configuration resolution boundary used by config validate, show, // read-only configuration resolution boundary used by config validate, show,
// and sources. Differences describe normalized effective values rather than // and sources. Differences describe normalized effective values rather than
// the layout of root, import, or overlay files. // the layout of root, import, or overlay files.
@@ -100,7 +100,7 @@ func configDiffWithDependencies(_ context.Context, args []string, out io.Writer,
} }
func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, error) { func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, error) {
profiles, flagArgs := splitConfigDiffArguments(args) overlays, flagArgs := splitConfigDiffArguments(args)
fs := flag.NewFlagSet("config diff", flag.ContinueOnError) fs := flag.NewFlagSet("config diff", flag.ContinueOnError)
fs.SetOutput(out) fs.SetOutput(out)
var request configDiffRequest var request configDiffRequest
@@ -113,9 +113,9 @@ func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, er
fs.Var(&request.flags.campaignFilePath, "campaign-file", "path to campaign.yml") fs.Var(&request.flags.campaignFilePath, "campaign-file", "path to campaign.yml")
fs.Var(&request.flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)") fs.Var(&request.flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintln(out, "Usage: narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <path>]") fmt.Fprintln(out, "Usage: narratio config diff <left-overlay> <right-overlay> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <path>]")
fmt.Fprintln(out) fmt.Fprintln(out)
fmt.Fprintln(out, "Compares fully resolved profiles without a session, workspace, manifest, adapters, or external services.") fmt.Fprintln(out, "Compares fully resolved overlays without a session, workspace, manifest, adapters, or external services.")
fmt.Fprintln(out) fmt.Fprintln(out)
fmt.Fprintln(out, "Flags:") fmt.Fprintln(out, "Flags:")
fs.PrintDefaults() fs.PrintDefaults()
@@ -123,30 +123,30 @@ func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, er
if err := fs.Parse(flagArgs); err != nil { if err := fs.Parse(flagArgs); err != nil {
return configDiffRequest{}, fmt.Errorf("config diff: invalid flags: %w", err) return configDiffRequest{}, fmt.Errorf("config diff: invalid flags: %w", err)
} }
profiles = append(profiles, fs.Args()...) overlays = append(overlays, fs.Args()...)
if len(profiles) != 2 || strings.TrimSpace(profiles[0]) == "" || strings.TrimSpace(profiles[1]) == "" { if len(overlays) != 2 || strings.TrimSpace(overlays[0]) == "" || strings.TrimSpace(overlays[1]) == "" {
return configDiffRequest{}, fmt.Errorf("config diff: exactly two non-empty profile names are required") return configDiffRequest{}, fmt.Errorf("config diff: exactly two non-empty overlay names are required")
} }
if profiles[0] == profiles[1] { if overlays[0] == overlays[1] {
return configDiffRequest{}, fmt.Errorf("config diff: profile names must differ") return configDiffRequest{}, fmt.Errorf("config diff: overlay names must differ")
} }
if request.flags.campaignPath.set && request.flags.campaignFilePath.set { if request.flags.campaignPath.set && request.flags.campaignFilePath.set {
return configDiffRequest{}, fmt.Errorf("config diff: --campaign and --campaign-file are mutually exclusive") return configDiffRequest{}, fmt.Errorf("config diff: --campaign and --campaign-file are mutually exclusive")
} }
request.leftProfile = profiles[0] request.leftOverlay = overlays[0]
request.rightProfile = profiles[1] request.rightOverlay = overlays[1]
return request, nil return request, nil
} }
// splitConfigDiffArguments accepts the documented positional-first syntax and // splitConfigDiffArguments accepts the documented positional-first syntax and
// also permits flags before or between profile names. Values belonging to the // also permits flags before or between overlay names. Values belonging to the
// supported string flags stay with their flag so they are not mistaken for // supported string flags stay with their flag so they are not mistaken for
// profile names. // overlay names.
func splitConfigDiffArguments(args []string) (profiles, flagArgs []string) { func splitConfigDiffArguments(args []string) (overlays, flagArgs []string) {
for index := 0; index < len(args); index++ { for index := 0; index < len(args); index++ {
argument := args[index] argument := args[index]
if !strings.HasPrefix(argument, "-") || argument == "-" { if !strings.HasPrefix(argument, "-") || argument == "-" {
profiles = append(profiles, argument) overlays = append(overlays, argument)
continue continue
} }
flagArgs = append(flagArgs, argument) flagArgs = append(flagArgs, argument)
@@ -161,12 +161,12 @@ func splitConfigDiffArguments(args []string) (profiles, flagArgs []string) {
index++ index++
flagArgs = append(flagArgs, args[index]) flagArgs = append(flagArgs, args[index])
} }
return profiles, flagArgs return overlays, flagArgs
} }
func configDiffStringFlag(name string) bool { func configDiffStringFlag(name string) bool {
switch name { switch name {
case "config", "campaign", "campaign-file", "profile", "secrets-dir": case "config", "campaign", "campaign-file", "secrets-dir":
return true return true
default: default:
return false return false
@@ -175,13 +175,13 @@ func configDiffStringFlag(name string) bool {
func resolveConfigDiff(request configDiffRequest, deps commandDependencies) (*configDiffResolved, error) { func resolveConfigDiff(request configDiffRequest, deps commandDependencies) (*configDiffResolved, error) {
deps = normalizeCommandDependencies(deps) deps = normalizeCommandDependencies(deps)
pipelinePath, leftPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Profile: &request.leftProfile}, request.flags.secretsDir.pointer(), deps) pipelinePath, leftPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Overlay: &request.leftOverlay}, request.flags.secretsDir.pointer(), deps)
if err != nil { if err != nil {
return nil, fmt.Errorf("load left pipeline profile: %w", err) return nil, fmt.Errorf("load left pipeline overlay: %w", err)
} }
_, rightPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Profile: &request.rightProfile}, request.flags.secretsDir.pointer(), deps) _, rightPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Overlay: &request.rightOverlay}, request.flags.secretsDir.pointer(), deps)
if err != nil { if err != nil {
return nil, fmt.Errorf("load right pipeline profile: %w", err) return nil, fmt.Errorf("load right pipeline overlay: %w", err)
} }
needsCampaign := pipelineHasArtifactFamilies(leftPipeline) || pipelineHasArtifactFamilies(rightPipeline) needsCampaign := pipelineHasArtifactFamilies(leftPipeline) || pipelineHasArtifactFamilies(rightPipeline)
hasCampaignSelection := request.flags.campaignPath.set || request.flags.campaignFilePath.set hasCampaignSelection := request.flags.campaignPath.set || request.flags.campaignFilePath.set
@@ -259,7 +259,7 @@ func resolveSharedDiffCampaignPath(left, right *config.PipelineConfig, flags con
return "", rightErr return "", rightErr
} }
if leftPath != rightPath { if leftPath != rightPath {
return "", fmt.Errorf("profile selections resolve different campaign files; specify --campaign-file to compare one campaign") return "", fmt.Errorf("overlay selections resolve different campaign files; specify --campaign-file to compare one campaign")
} }
return leftPath, nil return leftPath, nil
} }

View File

@@ -46,7 +46,7 @@ func loadCommandConfigWithDependencies(ctx context.Context, pipelineFlag, campai
} }
}() }()
base, err := loadPipelineCampaignConfigWithDependencies(pipelineFlag, campaignFlag, campaignFileFlag, config.PipelineLoadOptions{Profile: sessionOpts.Profile}, secretsDir, deps) base, err := loadPipelineCampaignConfigWithDependencies(pipelineFlag, campaignFlag, campaignFileFlag, config.PipelineLoadOptions{Overlay: sessionOpts.Overlay}, secretsDir, deps)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -14,13 +14,13 @@ func effectiveConfigProvenance(cfg *config.Config) *manifest.EffectiveConfigProv
provenance := &manifest.EffectiveConfigProvenance{ provenance := &manifest.EffectiveConfigProvenance{
EffectiveConfigDigest: config.EffectivePipelineDigest(cfg.Pipeline), EffectiveConfigDigest: config.EffectivePipelineDigest(cfg.Pipeline),
} }
if selected, ok := config.SelectedPipelineProfile(cfg.Pipeline); ok { if selected, ok := config.SelectedPipelineOverlay(cfg.Pipeline); ok {
provenance.SelectedProfile = &manifest.SelectedProfileProvenance{ provenance.SelectedOverlay = &manifest.SelectedOverlayProvenance{
Name: selected.Name, Name: selected.Name,
Source: selected.Source, Source: selected.Source,
} }
} }
if provenance.SelectedProfile == nil && provenance.EffectiveConfigDigest == "" { if provenance.SelectedOverlay == nil && provenance.EffectiveConfigDigest == "" {
return nil return nil
} }
return provenance return provenance
@@ -35,30 +35,30 @@ func applyEffectiveConfigProvenance(m *manifest.Manifest, cfg *config.Config) {
func effectiveConfigSummary(cfg *config.Config) string { func effectiveConfigSummary(cfg *config.Config) string {
provenance := effectiveConfigProvenance(cfg) provenance := effectiveConfigProvenance(cfg)
if provenance == nil { if provenance == nil {
return "profile=none digest=unavailable" return "overlay=none digest=unavailable"
} }
profile := "none" overlay := "none"
if provenance.SelectedProfile != nil { if provenance.SelectedOverlay != nil {
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")" overlay = provenance.SelectedOverlay.Name + " (" + provenance.SelectedOverlay.Source + ")"
} }
digest := provenance.EffectiveConfigDigest digest := provenance.EffectiveConfigDigest
if digest == "" { if digest == "" {
digest = "unavailable" digest = "unavailable"
} }
return fmt.Sprintf("profile=%s digest=%s", profile, digest) return fmt.Sprintf("overlay=%s digest=%s", overlay, digest)
} }
func persistedEffectiveConfigSummary(provenance *manifest.EffectiveConfigProvenance) string { func persistedEffectiveConfigSummary(provenance *manifest.EffectiveConfigProvenance) string {
if provenance == nil { if provenance == nil {
return "profile=none digest=unavailable" return "overlay=none digest=unavailable"
} }
profile := "none" overlay := "none"
if provenance.SelectedProfile != nil { if provenance.SelectedOverlay != nil {
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")" overlay = provenance.SelectedOverlay.Name + " (" + provenance.SelectedOverlay.Source + ")"
} }
digest := provenance.EffectiveConfigDigest digest := provenance.EffectiveConfigDigest
if digest == "" { if digest == "" {
digest = "unavailable" digest = "unavailable"
} }
return fmt.Sprintf("profile=%s digest=%s", profile, digest) return fmt.Sprintf("overlay=%s digest=%s", overlay, digest)
} }

View File

@@ -665,16 +665,16 @@ func TestExtractLifecycleCredentialChangesRemainResumable(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true) cfg, env, runner := extractionLifecycleFixture(t, true)
if test.before != nil { if test.before != nil {
cfg.Pipeline.Notarius.LLMAPIKey = test.before(t) cfg.Pipeline.Notarius.Credentials = []configkit.Secret{*test.before(t)}
} }
analyzeRuns := 0 analyzeRuns := 0
plan := extractionLifecyclePlan(t, &analyzeRuns) plan := extractionLifecyclePlan(t, &analyzeRuns)
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil { if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil {
t.Fatalf("initial executeStages() error = %v", err) t.Fatalf("initial executeStages() error = %v", err)
} }
cfg.Pipeline.Notarius.LLMAPIKey = nil cfg.Pipeline.Notarius.Credentials = nil
if test.after != nil { if test.after != nil {
cfg.Pipeline.Notarius.LLMAPIKey = test.after(t) cfg.Pipeline.Notarius.Credentials = []configkit.Secret{*test.after(t)}
} }
resumed, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) resumed, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
if err != nil { if err != nil {
@@ -691,17 +691,17 @@ func TestExtractLifecycleCredentialChangesRemainResumable(t *testing.T) {
} }
} }
func TestExtractLifecycleConfigProfileChangesRerunExtractionAndDependents(t *testing.T) { func TestExtractLifecycleConfigOverlayChangesRerunExtractionAndDependents(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true) cfg, env, runner := extractionLifecycleFixture(t, true)
selected := "inexpensive" selected := "inexpensive"
cfg.Pipeline.Notarius.ConfigProfile = &selected cfg.Pipeline.Notarius.ConfigOverlay = &selected
analyzeRuns := 0 analyzeRuns := 0
plan := extractionLifecyclePlan(t, &analyzeRuns) plan := extractionLifecyclePlan(t, &analyzeRuns)
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil { if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil {
t.Fatalf("initial executeStages() error = %v", err) t.Fatalf("initial executeStages() error = %v", err)
} }
if len(runner.requests) != 1 || runner.requests[0].ConfigProfile == nil || *runner.requests[0].ConfigProfile != selected || analyzeRuns != 1 { if len(runner.requests) != 1 || runner.requests[0].ConfigOverlay == nil || *runner.requests[0].ConfigOverlay != selected || analyzeRuns != 1 {
t.Fatalf("initial requests=%#v analyze=%d, want selected extraction and analysis", runner.requests, analyzeRuns) t.Fatalf("initial requests=%#v analyze=%d, want selected extraction and analysis", runner.requests, analyzeRuns)
} }
reused, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) reused, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
@@ -721,12 +721,12 @@ func TestExtractLifecycleConfigProfileChangesRerunExtractionAndDependents(t *tes
} }
changed := "experimental" changed := "experimental"
cfg.Pipeline.Notarius.ConfigProfile = &changed cfg.Pipeline.Notarius.ConfigOverlay = &changed
rerun, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) rerun, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
if err != nil { if err != nil {
t.Fatalf("changed-profile executeStages() error = %v", err) t.Fatalf("changed-profile executeStages() error = %v", err)
} }
if len(rerun.Executed) != 2 || len(runner.requests) != 2 || runner.requests[1].ConfigProfile == nil || *runner.requests[1].ConfigProfile != changed || analyzeRuns != 2 { if len(rerun.Executed) != 2 || len(runner.requests) != 2 || runner.requests[1].ConfigOverlay == nil || *runner.requests[1].ConfigOverlay != changed || analyzeRuns != 2 {
t.Fatalf("changed-profile summary=%#v requests=%#v analyze=%d", rerun, runner.requests, analyzeRuns) t.Fatalf("changed-profile summary=%#v requests=%#v analyze=%d", rerun, runner.requests, analyzeRuns)
} }
after := loadLifecycleManifest(t, cfg) after := loadLifecycleManifest(t, cfg)
@@ -741,12 +741,12 @@ func TestExtractLifecycleConfigProfileChangesRerunExtractionAndDependents(t *tes
t.Fatalf("changed-profile lifecycle render=%#v analyze=%#v publish=%#v", after.Stages["render"], after.Stages["analyze"], after.Stages["publish"]) t.Fatalf("changed-profile lifecycle render=%#v analyze=%#v publish=%#v", after.Stages["render"], after.Stages["analyze"], after.Stages["publish"])
} }
cfg.Pipeline.Notarius.ConfigProfile = nil cfg.Pipeline.Notarius.ConfigOverlay = nil
returned, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) returned, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
if err != nil { if err != nil {
t.Fatalf("return-to-base executeStages() error = %v", err) t.Fatalf("return-to-base executeStages() error = %v", err)
} }
if len(returned.Executed) != 2 || len(runner.requests) != 3 || runner.requests[2].ConfigProfile != nil || analyzeRuns != 3 { if len(returned.Executed) != 2 || len(runner.requests) != 3 || runner.requests[2].ConfigOverlay != nil || analyzeRuns != 3 {
t.Fatalf("return-to-base summary=%#v requests=%#v analyze=%d", returned, runner.requests, analyzeRuns) t.Fatalf("return-to-base summary=%#v requests=%#v analyze=%d", returned, runner.requests, analyzeRuns)
} }
final := loadLifecycleManifest(t, cfg) final := loadLifecycleManifest(t, cfg)
@@ -755,10 +755,10 @@ func TestExtractLifecycleConfigProfileChangesRerunExtractionAndDependents(t *tes
} }
} }
func TestExtractLifecycleConfigProfileFailureClearsCurrentResultAndDependents(t *testing.T) { func TestExtractLifecycleConfigOverlayFailureClearsCurrentResultAndDependents(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true) cfg, env, runner := extractionLifecycleFixture(t, true)
selected := "inexpensive" selected := "inexpensive"
cfg.Pipeline.Notarius.ConfigProfile = &selected cfg.Pipeline.Notarius.ConfigOverlay = &selected
analyzeRuns := 0 analyzeRuns := 0
plan := extractionLifecyclePlan(t, &analyzeRuns) plan := extractionLifecyclePlan(t, &analyzeRuns)
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil { if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil {
@@ -768,7 +768,7 @@ func TestExtractLifecycleConfigProfileFailureClearsCurrentResultAndDependents(t
markLifecycleStageSucceeded(t, cfg, "publish") markLifecycleStageSucceeded(t, cfg, "publish")
changed := "missing-profile" changed := "missing-profile"
cfg.Pipeline.Notarius.ConfigProfile = &changed cfg.Pipeline.Notarius.ConfigOverlay = &changed
runner.failuresRemaining = 1 runner.failuresRemaining = 1
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err == nil { if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err == nil {
t.Fatal("changed-profile executeStages() error = nil, want downstream failure") t.Fatal("changed-profile executeStages() error = nil, want downstream failure")
@@ -783,12 +783,12 @@ func TestExtractLifecycleConfigProfileFailureClearsCurrentResultAndDependents(t
t.Fatalf("failed changed-profile lifecycle trim=%#v render=%#v analyze=%#v publish=%#v analyzeRuns=%d", t.Fatalf("failed changed-profile lifecycle trim=%#v render=%#v analyze=%#v publish=%#v analyzeRuns=%d",
failed.Stages["trim"], failed.Stages["render"], failed.Stages["analyze"], failed.Stages["publish"], analyzeRuns) failed.Stages["trim"], failed.Stages["render"], failed.Stages["analyze"], failed.Stages["publish"], analyzeRuns)
} }
if len(runner.requests) != 2 || runner.requests[1].ConfigProfile == nil || *runner.requests[1].ConfigProfile != changed { if len(runner.requests) != 2 || runner.requests[1].ConfigOverlay == nil || *runner.requests[1].ConfigOverlay != changed {
t.Fatalf("failed request = %#v, want changed selector", runner.requests) t.Fatalf("failed request = %#v, want changed selector", runner.requests)
} }
} }
func TestExtractLifecycleRealConfigProfilesSelectNotariusExplicitly(t *testing.T) { func TestExtractLifecycleRealConfigOverlaysSelectNotariusExplicitly(t *testing.T) {
seed, env, runner := extractionLifecycleFixture(t, true) seed, env, runner := extractionLifecycleFixture(t, true)
workspace := seed.Pipeline.Workspace.Root workspace := seed.Pipeline.Workspace.Root
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspace) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspace)
@@ -817,12 +817,12 @@ func TestExtractLifecycleRealConfigProfilesSelectNotariusExplicitly(t *testing.T
if err := os.WriteFile(filepath.Join(filepath.Dir(pipelinePath), "notarius.yml"), []byte("pipelines: {}\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(filepath.Dir(pipelinePath), "notarius.yml"), []byte("pipelines: {}\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
profilesDir := filepath.Join(filepath.Dir(pipelinePath), "profiles") profilesDir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
if err := os.MkdirAll(profilesDir, 0o755); err != nil { if err := os.MkdirAll(profilesDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for name, contents := range map[string]string{ for name, contents := range map[string]string{
"testing": "whisperx:\n language: fr\nnotarius:\n config_profile: downstream-economy\n", "testing": "whisperx:\n language: fr\nnotarius:\n config_overlay: downstream-economy\n",
"narratio-only": "whisperx:\n language: de\n", "narratio-only": "whisperx:\n language: de\n",
"narratio-only-two": "whisperx:\n language: es\n", "narratio-only-two": "whisperx:\n language: es\n",
} { } {
@@ -832,7 +832,7 @@ func TestExtractLifecycleRealConfigProfilesSelectNotariusExplicitly(t *testing.T
} }
load := func(profile string) *config.Config { load := func(profile string) *config.Config {
t.Helper() t.Helper()
loaded, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{Profile: &profile}) loaded, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{Overlay: &profile})
if err != nil { if err != nil {
t.Fatalf("load profile %q: %v", profile, err) t.Fatalf("load profile %q: %v", profile, err)
} }
@@ -848,7 +848,7 @@ func TestExtractLifecycleRealConfigProfilesSelectNotariusExplicitly(t *testing.T
if _, err := executeStages(context.Background(), testingConfig, plan, RunOptions{Env: env}); err != nil { if _, err := executeStages(context.Background(), testingConfig, plan, RunOptions{Env: env}); err != nil {
t.Fatalf("execute testing profile: %v", err) t.Fatalf("execute testing profile: %v", err)
} }
if len(runner.requests) != 1 || runner.requests[0].ConfigProfile == nil || *runner.requests[0].ConfigProfile != "downstream-economy" { if len(runner.requests) != 1 || runner.requests[0].ConfigOverlay == nil || *runner.requests[0].ConfigOverlay != "downstream-economy" {
t.Fatalf("testing profile request = %#v, want explicit differently named downstream selector", runner.requests) t.Fatalf("testing profile request = %#v, want explicit differently named downstream selector", runner.requests)
} }
@@ -857,7 +857,7 @@ func TestExtractLifecycleRealConfigProfilesSelectNotariusExplicitly(t *testing.T
if _, err := executeStages(context.Background(), narratioOnly, plan, RunOptions{Env: env}); err != nil { if _, err := executeStages(context.Background(), narratioOnly, plan, RunOptions{Env: env}); err != nil {
t.Fatalf("execute Narratio-only profile: %v", err) t.Fatalf("execute Narratio-only profile: %v", err)
} }
if len(runner.requests) != 2 || runner.requests[1].ConfigProfile != nil { if len(runner.requests) != 2 || runner.requests[1].ConfigOverlay != nil {
t.Fatalf("Narratio-only profile request = %#v, want no automatic downstream selector", runner.requests) t.Fatalf("Narratio-only profile request = %#v, want no automatic downstream selector", runner.requests)
} }
@@ -876,7 +876,7 @@ func TestPreparedPipelineAndManifestExcludeNotariusCredentialValue(t *testing.T)
const name = "NARRATIO_LIFECYCLE_PERSISTED_KEY" const name = "NARRATIO_LIFECYCLE_PERSISTED_KEY"
const canary = "notarius-persisted-credential-canary" const canary = "notarius-persisted-credential-canary"
cfg, env, _ := extractionLifecycleFixture(t, true) cfg, env, _ := extractionLifecycleFixture(t, true)
cfg.Pipeline.Notarius.LLMAPIKey = loadLifecycleNotariusCredential(t, name, canary) cfg.Pipeline.Notarius.Credentials = []configkit.Secret{*loadLifecycleNotariusCredential(t, name, canary)}
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil { if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
} }
@@ -1181,12 +1181,12 @@ func loadLifecycleNotariusCredential(t *testing.T, name, value string) *configki
t.Helper() t.Helper()
t.Setenv(name, value) t.Setenv(name, value)
path := filepath.Join(t.TempDir(), "pipeline.yml") path := filepath.Join(t.TempDir(), "pipeline.yml")
if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+name+"\n"), 0o644); err != nil { if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+name+"]\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
pipeline, err := config.LoadPipeline(path) pipeline, err := config.LoadPipeline(path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
return pipeline.Notarius.LLMAPIKey return &pipeline.Notarius.Credentials[0]
} }

View File

@@ -23,7 +23,7 @@ type commonConfigFlags struct {
sessionPath string sessionPath string
sessionID string sessionID string
previousSessionID string previousSessionID string
profile singletonStringFlag overlay singletonStringFlag
secretsDir singletonStringFlag secretsDir singletonStringFlag
} }
@@ -34,8 +34,8 @@ func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml") fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier") fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
flags.profile.name = "profile" flags.overlay.name = "overlay"
fs.Var(&flags.profile, "profile", "named pipeline profile") fs.Var(&flags.overlay, "overlay", "named pipeline overlay")
flags.secretsDir.name = "secrets-dir" flags.secretsDir.name = "secrets-dir"
fs.Var(&flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)") fs.Var(&flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
} }
@@ -44,7 +44,7 @@ func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
return config.SessionLoadOptions{ return config.SessionLoadOptions{
SessionID: f.sessionID, SessionID: f.sessionID,
PreviousSessionID: f.previousSessionID, PreviousSessionID: f.previousSessionID,
Profile: f.profile.pointer(), Overlay: f.overlay.pointer(),
} }
} }

View File

@@ -27,7 +27,7 @@ func sessionInitWithDependencies(ctx context.Context, args []string, out io.Writ
fs := flag.NewFlagSet("session init", flag.ContinueOnError) fs := flag.NewFlagSet("session init", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
var profile singletonStringFlag var overlay singletonStringFlag
var secretsDir singletonStringFlag var secretsDir singletonStringFlag
var remote, force bool var remote, force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
@@ -40,8 +40,8 @@ func sessionInitWithDependencies(ctx context.Context, args []string, out io.Writ
fs.StringVar(&output, "output", "", "local output session.yml path") fs.StringVar(&output, "output", "", "local output session.yml path")
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix") fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory") fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
profile.name = "profile" overlay.name = "overlay"
fs.Var(&profile, "profile", "named pipeline profile") fs.Var(&overlay, "overlay", "named pipeline overlay")
secretsDir.name = "secrets-dir" secretsDir.name = "secrets-dir"
fs.Var(&secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)") fs.Var(&secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix") fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
@@ -59,7 +59,7 @@ func sessionInitWithDependencies(ctx context.Context, args []string, out io.Writ
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive") return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
} }
base, err := loadPipelineCampaignConfigWithDependencies(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Profile: profile.pointer()}, secretsDir.pointer(), deps) base, err := loadPipelineCampaignConfigWithDependencies(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Overlay: overlay.pointer()}, secretsDir.pointer(), deps)
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }

View File

@@ -11,10 +11,10 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
) )
func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) { func TestRunOverlaySelectionFlowsThroughSharedLoader(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath) enableCommandTestOverlays(t, pipelinePath)
var language string var language string
executor := func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) { executor := func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
@@ -23,12 +23,17 @@ func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
} }
if err := runWithPipelineExecutor(context.Background(), []string{ if err := runWithPipelineExecutor(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "testing", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--overlay", "testing",
}, io.Discard, executor); err != nil { }, io.Discard, executor); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if language != "fr" { if language != "fr" {
t.Fatalf("explicit profile language = %q, want fr", language) t.Fatalf("explicit overlay language = %q, want fr", language)
}
if err := runWithPipelineExecutor(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "testing",
}, io.Discard, executor); err == nil {
t.Fatal("retired pipeline selector was accepted")
} }
if err := runWithPipelineExecutor(context.Background(), []string{ if err := runWithPipelineExecutor(context.Background(), []string{
@@ -40,25 +45,25 @@ func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
t.Fatalf("base configuration language = %q, want en", language) t.Fatalf("base configuration language = %q, want en", language)
} }
for _, profile := range []string{"unknown"} { for _, overlay := range []string{"unknown"} {
err := runWithPipelineExecutor(context.Background(), []string{ err := runWithPipelineExecutor(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", profile, "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--overlay", overlay,
}, io.Discard, executor) }, io.Discard, executor)
if err == nil || !strings.Contains(err.Error(), "profile") { if err == nil || !strings.Contains(err.Error(), "overlay") {
t.Fatalf("profile %q error = %v, want selection rejection", profile, err) t.Fatalf("overlay %q error = %v, want selection rejection", overlay, err)
} }
} }
if err := runWithPipelineExecutor(context.Background(), []string{ if err := runWithPipelineExecutor(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--overlay", "",
}, io.Discard, executor); err != nil { }, io.Discard, executor); err != nil {
t.Fatalf("explicit empty profile should select base: %v", err) t.Fatalf("explicit empty overlay should select base: %v", err)
} }
} }
func enableCommandTestProfiles(t *testing.T, pipelinePath string) { func enableCommandTestOverlays(t *testing.T, pipelinePath string) {
t.Helper() t.Helper()
dir := filepath.Join(filepath.Dir(pipelinePath), "profiles") dir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -0,0 +1,280 @@
package app
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
const (
sharedProviderName = "NARRATIO_SHARED_PROVIDER_KEY"
auditaOnlyName = "NARRATIO_AUDITA_ONLY_KEY"
sharedProviderKey = "synthetic-shared-provider-value"
auditaOnlyKey = "synthetic-audita-only-value"
)
func TestCommandsShareProviderCredentialAcrossAdapters(t *testing.T) {
configDir := t.TempDir()
workspace := t.TempDir()
secretsDir := filepath.Join(configDir, "secrets")
for name, value := range map[string]string{
sharedProviderName: sharedProviderKey,
auditaOnlyName: auditaOnlyKey,
} {
restoreEnvAfterTest(t, name)
if err := os.Unsetenv(name); err != nil {
t.Fatal(err)
}
mustWriteSecretFile(t, filepath.Join(secretsDir, name), value+"\n")
}
fixture := writeProviderProcessWrapper(t)
seriatim := writeSeriatimAppTestWrapper(t)
configPath := filepath.Join(configDir, "pipeline.yml")
campaignPath := writeAppTestCampaignConfig(t, configDir)
sessionPath := filepath.Join(configDir, "session.yml")
notariusConfig := filepath.Join(configDir, "notarius.yml")
mustWriteTestFile(t, notariusConfig, "pipelines: {}\n")
mustWriteTestFile(t, configPath, `workspace:
root: `+workspace+`
storage:
backend: local
whisperx:
transcribe_url: https://example.com/transcribe
seriatim:
binary: `+seriatim+`
audita:
binary: `+fixture+`
credentials: [`+sharedProviderName+`, `+auditaOnlyName+`]
scriptorium:
binary: `+fixture+`
credentials: [`+sharedProviderName+`]
render_debug: true
artifacts:
recap:
enabled: true
prompt_id: recap
output_path: artifacts/recap.md
inputs:
transcript:
source: narratio.transcript.final_trimmed
notarius:
enabled: true
binary: `+fixture+`
config_path: `+notariusConfig+`
pipeline_id: dnd-session
credentials: [`+sharedProviderName+`]
outputs:
npc_registry:
lane_id: npc-registry
media_type: application/json
schema_id: notarius.dnd.npc_registry
schema_version: v1
module_key: dnd/npc-registry
notification:
mode: noop
`)
mustWriteTestFile(t, sessionPath, `session_id: 2026-05-03
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`)
var stdout, stderr bytes.Buffer
if code := Execute([]string{"config", "show", "--config", configPath}, &stdout, &stderr); code != 0 {
t.Fatalf("config show exit = %d; stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), sharedProviderName) || !strings.Contains(stdout.String(), auditaOnlyName) {
t.Fatalf("config show omitted reference names: %q", stdout.String())
}
assertNoProviderValues(t, stdout.Bytes(), stderr.Bytes())
workRoot := filepath.Join(workspace, "work", "sample-campaign", "2026-05-03")
manifestPath := filepath.Join(workRoot, "manifest.json")
basePath := filepath.Join(workRoot, "transcripts", "base.json")
mustWriteTestFile(t, basePath, `{"schema":"seriatim-intermediate","segments":[]}`)
seedCurrentBuiltInEvidence(t, manifestPath, "2026-05-03", "sample-campaign", "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath)
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "[]\n")
args := []string{"--config", configPath, "--campaign-file", campaignPath, "--session", sessionPath}
stdout.Reset()
stderr.Reset()
if code := Execute(append([]string{"run-stage", "polish", "2026-05-03", "--force"}, args...), &stdout, &stderr); code != 0 {
t.Fatalf("polish exit = %d; stderr=%q", code, stderr.String())
}
assertNoProviderValues(t, stdout.Bytes(), stderr.Bytes())
m, err := (&manifest.LocalStore{}).Load(context.Background(), manifestPath)
if err != nil {
t.Fatal(err)
}
if polish := m.Stages["polish"]; polish == nil || len(polish.Outputs) != 1 || len(polish.Logs) != 5 {
t.Fatalf("polish did not retain canonical transcript and ordinary reports: %#v", polish)
}
for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} {
found := false
for _, path := range m.Stages["polish"].Logs {
found = found || filepath.Base(path) == name
}
if !found {
t.Fatalf("missing polish report %q", name)
}
}
trimmedPath := filepath.Join(workRoot, "transcripts", "final.trimmed.json")
mustWriteTestFile(t, trimmedPath, `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, trimmedPath)
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPath, m); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
if code := Execute(append([]string{"run-stage", "extract", "2026-05-03", "--force"}, args...), &stdout, &stderr); code != 0 {
t.Fatalf("extract exit = %d; stderr=%q", code, stderr.String())
}
assertNoProviderValues(t, stdout.Bytes(), stderr.Bytes())
if !strings.Contains(stdout.String(), "stage=extract executed=1") {
t.Fatalf("extract did not execute: %q", stdout.String())
}
redactedLogs := 0
stdout.Reset()
stderr.Reset()
if code := Execute(append([]string{"analyze", "2026-05-03"}, args...), &stdout, &stderr); code != 0 {
t.Fatalf("analyze exit = %d; stderr=%q", code, stderr.String())
}
assertNoProviderValues(t, stdout.Bytes(), stderr.Bytes())
if err := filepath.WalkDir(workRoot, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil || entry.IsDir() {
return walkErr
}
data, err := os.ReadFile(path)
if err == nil {
assertNoProviderValues(t, data)
if strings.HasSuffix(path, ".stderr.log") && bytes.Contains(data, []byte("<redacted>")) {
redactedLogs++
}
}
return err
}); err != nil {
t.Fatal(err)
}
if redactedLogs < 4 {
t.Fatalf("redacted subprocess logs = %d, want Audita, Notarius and Scriptorium run/render", redactedLogs)
}
}
func assertNoProviderValues(t *testing.T, content ...[]byte) {
t.Helper()
for _, data := range content {
if bytes.Contains(data, []byte(sharedProviderKey)) || bytes.Contains(data, []byte(auditaOnlyKey)) {
t.Fatal("credential value appeared in inspection or workspace output")
}
}
}
func writeProviderProcessWrapper(t *testing.T) string {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "provider-process.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\nexec \""+exe+"\" -test.run=TestProviderCredentialProcessFixture -- \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
return path
}
func TestProviderCredentialProcessFixture(t *testing.T) {
if !appHelperInvocation() {
return
}
start := -1
for i, arg := range os.Args {
if arg == "--" {
start = i + 1
break
}
}
if start < 0 || start >= len(os.Args) {
os.Exit(2)
}
args := os.Args[start:]
if os.Getenv(sharedProviderName) != sharedProviderKey {
_, _ = os.Stderr.WriteString("shared credential not forwarded\n")
os.Exit(2)
}
if appSeriatimFlagValue(args, "--prompt") != "" {
if _, present := os.LookupEnv(auditaOnlyName); present {
os.Exit(2)
}
if appSeriatimFlagValue(args, "--api-key-env") != "" {
os.Exit(2)
}
output := appSeriatimFlagValue(args, "--out")
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
os.Exit(2)
}
if err := os.WriteFile(output, []byte("{}\n"), 0o644); err != nil {
os.Exit(2)
}
_, _ = os.Stdout.WriteString(sharedProviderKey + "\n")
_, _ = os.Stderr.WriteString(sharedProviderKey + "\n")
os.Exit(0)
}
switch args[0] {
case "process":
if os.Getenv(auditaOnlyName) != auditaOnlyKey {
_, _ = os.Stderr.WriteString("Audita-only credential not forwarded\n")
os.Exit(2)
}
output := appSeriatimFlagValue(args, "--output")
if output == "" {
os.Exit(2)
}
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
os.Exit(2)
}
if err := os.WriteFile(output, []byte(`{"schema":"audita.processed.v1","segments":[]}`), 0o644); err != nil {
os.Exit(2)
}
for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} {
if err := os.WriteFile(filepath.Join(filepath.Dir(output), name), []byte("status: completed\n"), 0o644); err != nil {
os.Exit(2)
}
}
case "run":
if _, present := os.LookupEnv(auditaOnlyName); present {
_, _ = os.Stderr.WriteString("Audita-only credential reached Notarius\n")
os.Exit(2)
}
root := appSeriatimFlagValue(args, "--output-dir")
bundle := filepath.Join(root, "fixture-run")
if err := os.MkdirAll(filepath.Join(bundle, "lanes"), 0o755); err != nil {
os.Exit(2)
}
for name, value := range map[string]string{
"manifest.json": "{}", "rejected.json": `{"rejected":[]}`,
"warnings.json": `{"schema_version":"notarius.warnings.v2","group_count":0,"occurrence_count":0,"groups":[]}`,
"diagnostics.json": `{"schema_version":"notarius.diagnostics.v1","group_count":0,"occurrence_count":0,"truncated":false,"unrepresented_occurrence_count":0,"groups":[]}`,
"lanes/npc.json": `{"records":[]}`,
"index.json": `{"manifest_file":"manifest.json","output_files":[{"lane_id":"npc-registry","file":"lanes/npc.json","media_type":"application/json","module_key":"dnd/npc-registry","schema_id":"notarius.dnd.npc_registry","schema_version":"v1"}],"rejected_file":"rejected.json","warnings_file":"warnings.json","diagnostics_file":"diagnostics.json"}`,
} {
if err := os.WriteFile(filepath.Join(bundle, filepath.FromSlash(name)), []byte(value), 0o644); err != nil {
os.Exit(2)
}
}
_, _ = os.Stdout.WriteString(fmt.Sprintf(`{"schema_version":"notarius.run-result.v2","run_id":"fixture-run","pipeline_id":"dnd-session","output_directory":%q,"index_file":"index.json","normalized_output_count":1,"rejected_output_count":0,"warning_group_count":0,"warning_occurrence_count":0,"diagnostic_group_count":0,"diagnostic_occurrence_count":0,"diagnostics_truncated":false,"validation_status":"approved"}`, bundle))
default:
os.Exit(2)
}
_, _ = os.Stderr.WriteString(sharedProviderKey + "\n")
os.Exit(0)
}

View File

@@ -14,7 +14,7 @@ import (
func TestRegenerateArtifactsForwardsCanonicalPipelineRequest(t *testing.T) { func TestRegenerateArtifactsForwardsCanonicalPipelineRequest(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath) enableCommandTestOverlays(t, pipelinePath)
var capturedPlan BoundedPlan var capturedPlan BoundedPlan
var capturedOptions RunOptions var capturedOptions RunOptions
executor := func(_ context.Context, cfg *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) { executor := func(_ context.Context, cfg *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
@@ -30,7 +30,7 @@ func TestRegenerateArtifactsForwardsCanonicalPipelineRequest(t *testing.T) {
"--config", pipelinePath, "--config", pipelinePath,
"--campaign-file", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--profile", "testing", "--overlay", "testing",
}, io.Discard, io.Discard, executor) }, io.Discard, io.Discard, executor)
if code != 0 { if code != 0 {
t.Fatalf("executeWithPipelineExecutor() code = %d, want 0", code) t.Fatalf("executeWithPipelineExecutor() code = %d, want 0", code)

View File

@@ -44,6 +44,28 @@ func TestCommittedRestorePlanUsesOnlyDeclaredObjects(t *testing.T) {
} }
} }
func TestCommittedRestoreDoesNotRequirePolishRunDiagnostics(t *testing.T) {
cfg := restorePlanConfig(t)
fake := &storage.FakeBackend{}
current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{
"transcripts/full.json": []byte(`{"segments":[]}`),
})
diagnostic := filepath.Join(cfg.Pipeline.Workspace.Root, "runs", current.RunID, "polish", "outputs", "transcripts", "errors.yml")
current.Manifest.Stages["polish"] = &manifest.StageRecord{
Name: "polish", Status: manifest.StatusSucceeded,
Logs: []string{diagnostic},
}
plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{})
if err != nil {
t.Fatalf("buildRestorePlan() error = %v", err)
}
for _, action := range plan.Actions {
if strings.Contains(action.LocalRelativePath, "errors.yml") {
t.Fatalf("diagnostic became a required restore target: %#v", action)
}
}
}
func TestCommittedRestoreDownloadsCarriedOutputFromDeclaredRun(t *testing.T) { func TestCommittedRestoreDownloadsCarriedOutputFromDeclaredRun(t *testing.T) {
cfg := restorePlanConfig(t) cfg := restorePlanConfig(t)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}

View File

@@ -80,7 +80,7 @@ func runStageWithDependencies(ctx context.Context, args []string, out io.Writer,
SessionPath: flags.sessionPath, SessionPath: flags.sessionPath,
SessionID: flags.sessionID, SessionID: flags.sessionID,
PreviousSessionID: flags.previousSessionID, PreviousSessionID: flags.previousSessionID,
Profile: flags.profile.pointer(), Overlay: flags.overlay.pointer(),
SecretsDir: flags.secretsDir.pointer(), SecretsDir: flags.secretsDir.pointer(),
Force: force, Force: force,
SelectedArtifacts: normalizedArtifacts, SelectedArtifacts: normalizedArtifacts,
@@ -139,7 +139,7 @@ func analyzeWithDependencies(ctx context.Context, args []string, out io.Writer,
SessionPath: flags.sessionPath, SessionPath: flags.sessionPath,
SessionID: flags.sessionID, SessionID: flags.sessionID,
PreviousSessionID: flags.previousSessionID, PreviousSessionID: flags.previousSessionID,
Profile: flags.profile.pointer(), Overlay: flags.overlay.pointer(),
SecretsDir: flags.secretsDir.pointer(), SecretsDir: flags.secretsDir.pointer(),
Force: true, Force: true,
SelectedArtifacts: normalizedArtifacts, SelectedArtifacts: normalizedArtifacts,
@@ -196,7 +196,7 @@ func publishWithDependencies(ctx context.Context, args []string, out io.Writer,
SessionPath: flags.sessionPath, SessionPath: flags.sessionPath,
SessionID: flags.sessionID, SessionID: flags.sessionID,
PreviousSessionID: flags.previousSessionID, PreviousSessionID: flags.previousSessionID,
Profile: flags.profile.pointer(), Overlay: flags.overlay.pointer(),
SecretsDir: flags.secretsDir.pointer(), SecretsDir: flags.secretsDir.pointer(),
Force: true, Force: true,
SelectedArtifacts: normalizedArtifacts, SelectedArtifacts: normalizedArtifacts,
@@ -224,7 +224,7 @@ type singleStageCommand struct {
SessionPath string SessionPath string
SessionID string SessionID string
PreviousSessionID string PreviousSessionID string
Profile *string Overlay *string
SecretsDir *string SecretsDir *string
Force bool Force bool
SelectedArtifacts []string SelectedArtifacts []string
@@ -240,7 +240,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand, executor
loaded, err := loadCommandConfigWithDependencies(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{ loaded, err := loadCommandConfigWithDependencies(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{
SessionID: req.SessionID, SessionID: req.SessionID,
PreviousSessionID: req.PreviousSessionID, PreviousSessionID: req.PreviousSessionID,
Profile: req.Profile, Overlay: req.Overlay,
}, req.SecretsDir, deps) }, req.SecretsDir, deps)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", req.CommandName, err) return nil, fmt.Errorf("%s: %w", req.CommandName, err)

View File

@@ -207,35 +207,18 @@ func buildDefaultAuditaRunner(cfg *config.Config) (audita.Runner, error) {
return &audita.NoopRunner{}, nil return &audita.NoopRunner{}, nil
} }
report := false timeout, err := time.ParseDuration(a.Timeout)
if a.Report != nil { if err != nil {
report = *a.Report return nil, fmt.Errorf("parse pipeline.audita.timeout %q: %w", a.Timeout, err)
} }
llmAPIKey := "" runner, err := audita.NewSubprocessRunner(audita.SubprocessRunnerConfig{
llmAPIKeyReference := "" Binary: a.Binary, Timeout: timeout, Credentials: a.Credentials,
if a.LLMAPIKey != nil { Modules: append(a.Modules[:0:0], a.Modules...),
llmAPIKey = a.LLMAPIKey.Value() Overlay: a.Overlay, ProposalProfile: a.ProposalProfile,
llmAPIKeyReference = a.LLMAPIKey.EnvName() ValidationProfile: a.ValidationProfile,
} TranscriptDescription: a.TranscriptDescription, ConfigPath: a.ConfigPath,
OutputSchema: a.OutputSchema,
runner, err := audita.NewSubprocessRunnerFromConfigValues( })
a.Binary,
a.Timeout,
llmAPIKey,
llmAPIKeyReference,
append([]string(nil), a.Modules...),
a.BaseURL,
a.Model,
a.TranscriptDescription,
a.ConfigPath,
a.OutputSchema,
a.WorkDirRetention,
a.TotalLLMConcurrency,
a.ProposalLLMConcurrency,
a.ValidationModel,
a.ValidationLLMConcurrency,
report,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("from pipeline.audita: %w", err) return nil, fmt.Errorf("from pipeline.audita: %w", err)
} }

View File

@@ -138,7 +138,7 @@ func TestNotariusCredentialLoadsFromCommandSecretBootstrap(t *testing.T) {
restoreEnvAfterTest(t, envName) restoreEnvAfterTest(t, envName)
root := t.TempDir() root := t.TempDir()
pipelinePath := filepath.Join(root, "pipeline.yml") pipelinePath := filepath.Join(root, "pipeline.yml")
if err := os.WriteFile(pipelinePath, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n"), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n credentials: ["+envName+"]\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
secretsDir := filepath.Join(root, "secrets") secretsDir := filepath.Join(root, "secrets")
@@ -158,8 +158,8 @@ func TestNotariusCredentialLoadsFromCommandSecretBootstrap(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("load pipeline: %v", err) t.Fatalf("load pipeline: %v", err)
} }
if pipeline.Notarius == nil || pipeline.Notarius.LLMAPIKey == nil || !pipeline.Notarius.LLMAPIKey.IsResolved() || if pipeline.Notarius == nil || len(pipeline.Notarius.Credentials) != 1 || !pipeline.Notarius.Credentials[0].IsResolved() ||
pipeline.Notarius.LLMAPIKey.EnvName() != envName || pipeline.Notarius.LLMAPIKey.Value() != test.wantValue { pipeline.Notarius.Credentials[0].EnvName() != envName || pipeline.Notarius.Credentials[0].Value() != test.wantValue {
t.Fatal("Notarius credential did not resolve from the selected command bootstrap source") t.Fatal("Notarius credential did not resolve from the selected command bootstrap source")
} }
}) })
@@ -220,7 +220,7 @@ func TestExecuteConfigValidateReportsSkippedEmptyBeforeTypedLoadFailure(t *testi
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
pipelineYAML = append(pipelineYAML, []byte("notarius:\n enabled: false\n llm_api_key: "+name+"\n")...) pipelineYAML = append(pipelineYAML, []byte("notarius:\n enabled: false\n credentials: ["+name+"]\n")...)
if err := os.WriteFile(pipelinePath, pipelineYAML, 0o644); err != nil { if err := os.WriteFile(pipelinePath, pipelineYAML, 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -364,8 +365,8 @@ func TestRefinementSemanticChangesRespectDependencyBranches(t *testing.T) {
wantRuns [7]int wantRuns [7]int
wantExtractStatus manifest.StageStatus wantExtractStatus manifest.StageStatus
}{ }{
{name: "polish model", mutate: func(cfg *config.Config) { {name: "polish description", mutate: func(cfg *config.Config) {
cfg.Pipeline.Audita.Model = "production" cfg.Pipeline.Audita.TranscriptDescription = "production"
}, wantRuns: [7]int{0, 0, 0, 1, 1, 1, 1}, wantExtractStatus: manifest.StatusStale}, }, wantRuns: [7]int{0, 0, 0, 1, 1, 1, 1}, wantExtractStatus: manifest.StatusStale},
{name: "normalize schema", mutate: func(cfg *config.Config) { {name: "normalize schema", mutate: func(cfg *config.Config) {
cfg.Pipeline.Normalize = &config.NormalizeConfig{OutputSchema: "seriatim.transcript.v2"} cfg.Pipeline.Normalize = &config.NormalizeConfig{OutputSchema: "seriatim.transcript.v2"}
@@ -424,6 +425,69 @@ func TestRefinementSemanticChangesRespectDependencyBranches(t *testing.T) {
} }
} }
func TestOldPolishSemanticEvidenceIsReconsideredOnlyWhenSelected(t *testing.T) {
cfg := testConfig(t)
names := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
for _, candidate := range stage.All() {
seed.MarkStageSucceeded(candidate.Name(), time.Now().UTC(), nil)
}
providers := make([]stage.SemanticConfigFingerprinter, len(names))
runs := make([]int, len(names))
selected := make([]stage.Stage, 0, len(names))
for index, name := range names {
providers[index] = canonicalSemanticProvider(t, name)
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if name == "polish" {
fingerprint.Version--
}
seed.Stages[name].SemanticConfig = &fingerprint
selected = append(selected, semanticContractRunStub{name: name, provider: providers[index], runs: &runs[index]})
}
store := &manifest.LocalStore{}
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatal(err)
}
if _, err := executeStages(context.Background(), cfg, selected[len(selected)-1:], RunOptions{}); err != nil {
t.Fatal(err)
}
if runs[3] != 0 {
t.Fatal("downstream-only execution invoked polish")
}
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatal(err)
}
if loaded.Stages["polish"].SemanticConfig.Version != seed.Stages["polish"].SemanticConfig.Version {
t.Fatal("downstream-only execution rewrote unselected polish evidence")
}
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
t.Fatal(err)
}
if want := []int{0, 0, 0, 1, 1, 1, 1}; !reflect.DeepEqual(runs, want) {
t.Fatalf("runs = %v, want %v", runs, want)
}
loaded, err = store.Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatal(err)
}
if loaded.Stages["polish"].SemanticConfig.Version != 2 || loaded.Stages["extract"].Status != manifest.StatusStale {
t.Fatalf("old evidence was not replaced with normal dependent invalidation: polish=%#v extract=%#v", loaded.Stages["polish"], loaded.Stages["extract"])
}
for index := range runs {
runs[index] = 0
}
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(runs, make([]int, len(names))) {
t.Fatalf("unchanged new evidence reran stages: %v", runs)
}
}
func TestDeliverySemanticChangesInvalidateOnlyTheirFixedDependents(t *testing.T) { func TestDeliverySemanticChangesInvalidateOnlyTheirFixedDependents(t *testing.T) {
t.Run("extract contract", func(t *testing.T) { t.Run("extract contract", func(t *testing.T) {
cfg := testConfig(t) cfg := testConfig(t)

View File

@@ -93,7 +93,7 @@ func declarationDefinitionSources(cfg *PipelineConfig, declaration configkit.Pat
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, change := range cfg.resolution.configkitMetadata.History(declaration) { for _, change := range cfg.resolution.configkitMetadata.History(declaration) {
if change.Origin.Layer != configkit.LayerBase && change.Origin.Layer != configkit.LayerProfile { if change.Origin.Layer != configkit.LayerBase && change.Origin.Layer != configkit.LayerOverlay {
continue continue
} }
for _, location := range change.Origin.Locations { for _, location := range change.Origin.Locations {

View File

@@ -167,19 +167,14 @@ type SeriatimEnvConfig struct {
type AuditaConfig struct { type AuditaConfig struct {
Binary string `yaml:"binary" json:"binary"` Binary string `yaml:"binary" json:"binary"`
Timeout string `yaml:"timeout" json:"timeout"` Timeout string `yaml:"timeout" json:"timeout"`
LLMAPIKey *configkit.Secret `yaml:"llm_api_key,omitempty" json:"llm_api_key,omitempty"` Credentials []configkit.Secret `yaml:"credentials,omitempty" json:"credentials,omitempty"`
Modules []string `yaml:"modules" json:"modules"` Modules []string `yaml:"modules" json:"modules"`
BaseURL string `yaml:"base_url" json:"base_url"` Overlay string `yaml:"overlay" json:"overlay"`
Model string `yaml:"model" json:"model"` ProposalProfile string `yaml:"proposal_profile" json:"proposal_profile"`
TotalLLMConcurrency *int `yaml:"total_llm_concurrency" json:"total_llm_concurrency"` ValidationProfile string `yaml:"validation_profile" json:"validation_profile"`
ProposalLLMConcurrency *int `yaml:"proposal_llm_concurrency" json:"proposal_llm_concurrency"`
ValidationModel string `yaml:"validation_model" json:"validation_model"`
ValidationLLMConcurrency *int `yaml:"validation_llm_concurrency" json:"validation_llm_concurrency"`
TranscriptDescription string `yaml:"transcript_description" json:"transcript_description"` TranscriptDescription string `yaml:"transcript_description" json:"transcript_description"`
ConfigPath string `yaml:"config_path" json:"config_path"` ConfigPath string `yaml:"config_path" json:"config_path"`
OutputSchema string `yaml:"output_schema" json:"output_schema"` OutputSchema string `yaml:"output_schema" json:"output_schema"`
WorkDirRetention string `yaml:"work_dir_retention" json:"work_dir_retention"`
Report *bool `yaml:"report" json:"report"`
} }
// NormalizeConfig configures normalize-stage transcript schema/output behavior. // NormalizeConfig configures normalize-stage transcript schema/output behavior.
@@ -225,6 +220,7 @@ type RenderConfig struct {
// ScriptoriumConfig configures Scriptorium-backed artifact generation. // ScriptoriumConfig configures Scriptorium-backed artifact generation.
type ScriptoriumConfig struct { type ScriptoriumConfig struct {
Credentials []configkit.Secret `yaml:"credentials,omitempty" json:"credentials,omitempty"`
Binary string `yaml:"binary" json:"binary"` Binary string `yaml:"binary" json:"binary"`
ConfigPath string `yaml:"config_path" json:"config_path"` ConfigPath string `yaml:"config_path" json:"config_path"`
Timeout string `yaml:"timeout" json:"timeout"` Timeout string `yaml:"timeout" json:"timeout"`
@@ -309,8 +305,8 @@ type ScriptoriumInputConfig struct {
type NotariusConfig struct { type NotariusConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `yaml:"enabled" json:"enabled"`
Binary string `yaml:"binary" json:"binary"` Binary string `yaml:"binary" json:"binary"`
LLMAPIKey *configkit.Secret `yaml:"llm_api_key,omitempty" json:"llm_api_key,omitempty"` Credentials []configkit.Secret `yaml:"credentials,omitempty" json:"credentials,omitempty"`
ConfigProfile *string `yaml:"config_profile,omitempty" json:"config_profile,omitempty"` ConfigOverlay *string `yaml:"config_overlay,omitempty" json:"config_overlay,omitempty"`
Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"` Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"`
ChunkCache string `yaml:"chunk_cache,omitempty" json:"chunk_cache,omitempty"` ChunkCache string `yaml:"chunk_cache,omitempty" json:"chunk_cache,omitempty"`
ConfigPath string `yaml:"config_path" json:"config_path"` ConfigPath string `yaml:"config_path" json:"config_path"`

View File

@@ -21,42 +21,40 @@ func (cfg StorageS3Config) MarshalYAML() (any, error) {
}, nil }, nil
} }
// MarshalYAML exposes only the public Audita credential reference name. // MarshalYAML exposes only public Audita credential reference names.
func (cfg AuditaConfig) MarshalYAML() (any, error) { func (cfg AuditaConfig) MarshalYAML() (any, error) {
type safeAudita struct { type safeAudita struct {
Binary string `yaml:"binary"` Binary string `yaml:"binary"`
Timeout string `yaml:"timeout"` Timeout string `yaml:"timeout"`
LLMAPIKey *string `yaml:"llm_api_key,omitempty"` Credentials []string `yaml:"credentials,omitempty"`
Modules []string `yaml:"modules"` Modules *[]string `yaml:"modules,omitempty"`
BaseURL string `yaml:"base_url"` Overlay string `yaml:"overlay"`
Model string `yaml:"model"` ProposalProfile string `yaml:"proposal_profile"`
TotalLLMConcurrency *int `yaml:"total_llm_concurrency"` ValidationProfile string `yaml:"validation_profile"`
ProposalLLMConcurrency *int `yaml:"proposal_llm_concurrency"`
ValidationModel string `yaml:"validation_model"`
ValidationLLMConcurrency *int `yaml:"validation_llm_concurrency"`
TranscriptDescription string `yaml:"transcript_description"` TranscriptDescription string `yaml:"transcript_description"`
ConfigPath string `yaml:"config_path"` ConfigPath string `yaml:"config_path"`
OutputSchema string `yaml:"output_schema"` OutputSchema string `yaml:"output_schema"`
WorkDirRetention string `yaml:"work_dir_retention"` }
Report *bool `yaml:"report"` // A nil pointer omits the override; a pointer to an empty slice disables modules.
var modules *[]string
if cfg.Modules != nil {
modules = &cfg.Modules
} }
return safeAudita{ return safeAudita{
Binary: cfg.Binary, Timeout: cfg.Timeout, LLMAPIKey: secretReferenceName(cfg.LLMAPIKey), Binary: cfg.Binary, Timeout: cfg.Timeout, Credentials: secretReferenceNames(cfg.Credentials),
Modules: cfg.Modules, BaseURL: cfg.BaseURL, Model: cfg.Model, Modules: modules, Overlay: cfg.Overlay, ProposalProfile: cfg.ProposalProfile,
TotalLLMConcurrency: cfg.TotalLLMConcurrency, ProposalLLMConcurrency: cfg.ProposalLLMConcurrency, ValidationProfile: cfg.ValidationProfile, TranscriptDescription: cfg.TranscriptDescription,
ValidationModel: cfg.ValidationModel, ValidationLLMConcurrency: cfg.ValidationLLMConcurrency, ConfigPath: cfg.ConfigPath, OutputSchema: cfg.OutputSchema,
TranscriptDescription: cfg.TranscriptDescription, ConfigPath: cfg.ConfigPath,
OutputSchema: cfg.OutputSchema, WorkDirRetention: cfg.WorkDirRetention, Report: cfg.Report,
}, nil }, nil
} }
// MarshalYAML exposes only the public Notarius credential reference name. // MarshalYAML exposes only public Notarius credential reference names.
func (cfg NotariusConfig) MarshalYAML() (any, error) { func (cfg NotariusConfig) MarshalYAML() (any, error) {
type safeNotarius struct { type safeNotarius struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Binary string `yaml:"binary"` Binary string `yaml:"binary"`
LLMAPIKey *string `yaml:"llm_api_key,omitempty"` Credentials []string `yaml:"credentials,omitempty"`
ConfigProfile *string `yaml:"config_profile,omitempty"` ConfigOverlay *string `yaml:"config_overlay,omitempty"`
Resume bool `yaml:"resume,omitempty"` Resume bool `yaml:"resume,omitempty"`
ChunkCache string `yaml:"chunk_cache,omitempty"` ChunkCache string `yaml:"chunk_cache,omitempty"`
ConfigPath string `yaml:"config_path"` ConfigPath string `yaml:"config_path"`
@@ -67,14 +65,43 @@ func (cfg NotariusConfig) MarshalYAML() (any, error) {
Outputs map[string]NotariusOutputConfig `yaml:"outputs"` Outputs map[string]NotariusOutputConfig `yaml:"outputs"`
} }
return safeNotarius{ return safeNotarius{
Enabled: cfg.Enabled, Binary: cfg.Binary, LLMAPIKey: secretReferenceName(cfg.LLMAPIKey), Enabled: cfg.Enabled, Binary: cfg.Binary, Credentials: secretReferenceNames(cfg.Credentials),
ConfigProfile: cfg.ConfigProfile, ConfigOverlay: cfg.ConfigOverlay,
Resume: cfg.Resume, ChunkCache: cfg.ChunkCache, Resume: cfg.Resume, ChunkCache: cfg.ChunkCache,
ConfigPath: cfg.ConfigPath, PipelineID: cfg.PipelineID, Timeout: cfg.Timeout, ConfigPath: cfg.ConfigPath, PipelineID: cfg.PipelineID, Timeout: cfg.Timeout,
WorkingDirectory: cfg.WorkingDirectory, References: cfg.References, Outputs: cfg.Outputs, WorkingDirectory: cfg.WorkingDirectory, References: cfg.References, Outputs: cfg.Outputs,
}, nil }, nil
} }
// MarshalYAML exposes only public Scriptorium credential reference names.
func (cfg ScriptoriumConfig) MarshalYAML() (any, error) {
type safeScriptorium struct {
Binary string `yaml:"binary"`
ConfigPath string `yaml:"config_path"`
Timeout string `yaml:"timeout"`
RenderDebug bool `yaml:"render_debug"`
Credentials []string `yaml:"credentials,omitempty"`
Artifacts map[string]ScriptoriumArtifactConfig `yaml:"artifacts"`
ArtifactFamilies map[string]ScriptoriumArtifactFamilyConfig `yaml:"artifact_families"`
}
return safeScriptorium{
Binary: cfg.Binary, ConfigPath: cfg.ConfigPath, Timeout: cfg.Timeout,
RenderDebug: cfg.RenderDebug, Credentials: secretReferenceNames(cfg.Credentials),
Artifacts: cfg.Artifacts, ArtifactFamilies: cfg.ArtifactFamilies,
}, nil
}
func secretReferenceNames(secrets []configkit.Secret) []string {
if secrets == nil {
return nil
}
names := make([]string, len(secrets))
for index, secret := range secrets {
names[index] = secret.EnvName()
}
return names
}
func secretReferenceName(secret *configkit.Secret) *string { func secretReferenceName(secret *configkit.Secret) *string {
if secret == nil { if secret == nil {
return nil return nil

View File

@@ -0,0 +1,33 @@
package config
import (
"reflect"
"testing"
"gopkg.in/yaml.v3"
)
func TestAuditaYAMLPreservesModuleSelection(t *testing.T) {
for _, tc := range []struct {
name string
modules []string
}{
{name: "omitted"},
{name: "explicit empty", modules: []string{}},
{name: "ordered repetitions", modules: []string{"grammar", "names", "grammar"}},
} {
t.Run(tc.name, func(t *testing.T) {
data, err := yaml.Marshal(AuditaConfig{Modules: tc.modules})
if err != nil {
t.Fatal(err)
}
var decoded AuditaConfig
if err := yaml.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(decoded.Modules, tc.modules) {
t.Fatalf("modules = %#v, want %#v; YAML:\n%s", decoded.Modules, tc.modules, data)
}
})
}
}

View File

@@ -30,7 +30,6 @@ const (
DefaultAuditaBinary = "audita" DefaultAuditaBinary = "audita"
DefaultAuditaTimeout = "3h" DefaultAuditaTimeout = "3h"
DefaultAuditaReport = true
DefaultNotariusBinary = "notarius" DefaultNotariusBinary = "notarius"
DefaultNotariusTimeout = "3h" DefaultNotariusTimeout = "3h"
// MaxNotariusReferenceBindings is the maximum number of CLI reference // MaxNotariusReferenceBindings is the maximum number of CLI reference

View File

@@ -14,21 +14,21 @@ const (
effectivePipelineDigestVersion = "narratio-effective-pipeline-v3\n" effectivePipelineDigestVersion = "narratio-effective-pipeline-v3\n"
) )
// PipelineProfileProvenance identifies the profile selected while resolving a // PipelineOverlayProvenance identifies the overlay selected while resolving a
// pipeline. It contains only non-secret selection metadata. // pipeline. It contains only non-secret selection metadata.
type PipelineProfileProvenance struct { type PipelineOverlayProvenance struct {
Name string Name string
Source string Source string
OverlayPath string OverlayPath string
} }
// SelectedPipelineProfile reports the profile used to resolve cfg, if any. // SelectedPipelineOverlay reports the overlay used to resolve cfg, if any.
func SelectedPipelineProfile(cfg *PipelineConfig) (*PipelineProfileProvenance, bool) { func SelectedPipelineOverlay(cfg *PipelineConfig) (*PipelineOverlayProvenance, bool) {
if cfg == nil || cfg.resolution == nil || cfg.resolution.selectedProfile == nil { if cfg == nil || cfg.resolution == nil || cfg.resolution.selectedOverlay == nil {
return nil, false return nil, false
} }
selection := cfg.resolution.selectedProfile selection := cfg.resolution.selectedOverlay
return &PipelineProfileProvenance{Name: selection.name, Source: selection.source, OverlayPath: selection.overlayPath}, true return &PipelineOverlayProvenance{Name: selection.name, Source: selection.source, OverlayPath: selection.overlayPath}, true
} }
// EffectivePipelineDigest reports the deterministic secret-free digest for a // EffectivePipelineDigest reports the deterministic secret-free digest for a

View File

@@ -401,8 +401,8 @@ func pipelineSourceRole(cfg *PipelineConfig, source string) string {
if source == cfg.resolution.rootPath { if source == cfg.resolution.rootPath {
return "root" return "root"
} }
if cfg.resolution.selectedProfile != nil && source == cfg.resolution.selectedProfile.overlayPath { if cfg.resolution.selectedOverlay != nil && source == cfg.resolution.selectedOverlay.overlayPath {
return "profile" return "overlay"
} }
for _, imported := range cfg.resolution.imports { for _, imported := range cfg.resolution.imports {
if source == imported { if source == imported {

View File

@@ -30,6 +30,7 @@ type effectivePipelineProjection struct {
} }
type effectiveScriptoriumConfig struct { type effectiveScriptoriumConfig struct {
Credentials []string `yaml:"credentials,omitempty" json:"credentials,omitempty"`
Binary string `yaml:"binary" json:"binary"` Binary string `yaml:"binary" json:"binary"`
ConfigPath string `yaml:"config_path" json:"config_path"` ConfigPath string `yaml:"config_path" json:"config_path"`
Timeout string `yaml:"timeout" json:"timeout"` Timeout string `yaml:"timeout" json:"timeout"`
@@ -52,6 +53,7 @@ func projectEffectivePipeline(cfg *PipelineConfig, logicalNotarius bool) effecti
} }
if cfg.Scriptorium != nil { if cfg.Scriptorium != nil {
projection.Scriptorium = &effectiveScriptoriumConfig{ projection.Scriptorium = &effectiveScriptoriumConfig{
Credentials: secretReferenceNames(cfg.Scriptorium.Credentials),
Binary: cfg.Scriptorium.Binary, ConfigPath: cfg.Scriptorium.ConfigPath, Binary: cfg.Scriptorium.Binary, ConfigPath: cfg.Scriptorium.ConfigPath,
Timeout: cfg.Scriptorium.Timeout, RenderDebug: cfg.Scriptorium.RenderDebug, Timeout: cfg.Scriptorium.Timeout, RenderDebug: cfg.Scriptorium.RenderDebug,
Artifacts: cfg.Scriptorium.Artifacts, Artifacts: cfg.Scriptorium.Artifacts,

View File

@@ -15,14 +15,14 @@ func LoadPipeline(path string) (*PipelineConfig, error) {
return LoadPipelineWithOptions(path, PipelineLoadOptions{}) return LoadPipelineWithOptions(path, PipelineLoadOptions{})
} }
// PipelineLoadOptions carries an optional explicit profile selection. Nil, // PipelineLoadOptions carries an optional explicit overlay selection. Nil,
// empty, and the exact name "default" select the base configuration. // empty, and the exact name "default" select the base configuration.
type PipelineLoadOptions struct { type PipelineLoadOptions struct {
Profile *string Overlay *string
} }
// LoadPipelineWithOptions loads pipeline configuration with strict field // LoadPipelineWithOptions loads pipeline configuration with strict field
// checking and optional named-profile selection. // checking and optional named-overlay selection.
func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineConfig, error) { func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineConfig, error) {
cfg, err := loadPipelineWithConfigKit(path, opts) cfg, err := loadPipelineWithConfigKit(path, opts)
if err != nil { if err != nil {
@@ -31,20 +31,20 @@ func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineCo
return finalizeLoadedPipeline(path, cfg) return finalizeLoadedPipeline(path, cfg)
} }
// LoadPipelineProfilePair resolves two profiles as independent ConfigKit loads. // LoadPipelineOverlayPair resolves two overlays as independent ConfigKit loads.
func LoadPipelineProfilePair(path, leftProfile, rightProfile string) (*PipelineConfig, *PipelineConfig, error) { func LoadPipelineOverlayPair(path, leftOverlay, rightOverlay string) (*PipelineConfig, *PipelineConfig, error) {
if leftProfile == rightProfile { if leftOverlay == rightOverlay {
return nil, nil, fmt.Errorf("load pipeline profiles: left and right profile selections must differ") return nil, nil, fmt.Errorf("load pipeline overlays: left and right overlay selections must differ")
} }
leftOptions := PipelineLoadOptions{Profile: &leftProfile} leftOptions := PipelineLoadOptions{Overlay: &leftOverlay}
rightOptions := PipelineLoadOptions{Profile: &rightProfile} rightOptions := PipelineLoadOptions{Overlay: &rightOverlay}
left, err := LoadPipelineWithOptions(path, leftOptions) left, err := LoadPipelineWithOptions(path, leftOptions)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("load left pipeline profile: %w", err) return nil, nil, fmt.Errorf("load left pipeline overlay: %w", err)
} }
right, err := LoadPipelineWithOptions(path, rightOptions) right, err := LoadPipelineWithOptions(path, rightOptions)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("load right pipeline profile: %w", err) return nil, nil, fmt.Errorf("load right pipeline overlay: %w", err)
} }
return left, right, nil return left, right, nil
} }
@@ -76,7 +76,7 @@ func LoadSession(path string) (*SessionConfig, error) {
type SessionLoadOptions struct { type SessionLoadOptions struct {
SessionID string SessionID string
PreviousSessionID string PreviousSessionID string
Profile *string Overlay *string
} }
// LoadSessionWithOptions loads session configuration from a YAML file with // LoadSessionWithOptions loads session configuration from a YAML file with
@@ -143,7 +143,7 @@ func Load(pipelinePath string, paths ...string) (*Config, error) {
// LoadWithSessionOptions loads and resolves combined pipeline, campaign, and // LoadWithSessionOptions loads and resolves combined pipeline, campaign, and
// session configuration with expected session identity checks. // session configuration with expected session identity checks.
func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) { func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
pipelineCfg, err := LoadPipelineWithOptions(pipelinePath, PipelineLoadOptions{Profile: sessionOpts.Profile}) pipelineCfg, err := LoadPipelineWithOptions(pipelinePath, PipelineLoadOptions{Overlay: sessionOpts.Overlay})
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -93,7 +93,7 @@ func TestCredentialValuesNeverEnterEffectiveProjectionOrDigest(t *testing.T) {
t.Setenv("NARRATIO_TEST_SECRET_KEY", firstCanary) t.Setenv("NARRATIO_TEST_SECRET_KEY", firstCanary)
t.Setenv("NARRATIO_TEST_AUDITA_KEY", firstCanary) t.Setenv("NARRATIO_TEST_AUDITA_KEY", firstCanary)
t.Setenv("NARRATIO_TEST_NOTARIUS_KEY", firstCanary) t.Setenv("NARRATIO_TEST_NOTARIUS_KEY", firstCanary)
pipelineYAML := strings.Replace(testPipelineBaseYAML, "audita:\n binary: audita\n", "audita:\n binary: audita\n llm_api_key: NARRATIO_TEST_AUDITA_KEY\n", 1) + ` pipelineYAML := strings.Replace(testPipelineBaseYAML, "audita:\n binary: audita\n", "audita:\n binary: audita\n credentials: [NARRATIO_TEST_AUDITA_KEY]\n", 1) + `
storage: storage:
backend: s3 backend: s3
s3: s3:
@@ -102,8 +102,8 @@ storage:
secret_access_key: NARRATIO_TEST_SECRET_KEY secret_access_key: NARRATIO_TEST_SECRET_KEY
notarius: notarius:
enabled: false enabled: false
llm_api_key: NARRATIO_TEST_NOTARIUS_KEY credentials: [NARRATIO_TEST_NOTARIUS_KEY]
config_profile: "" config_overlay: ""
` `
pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML) pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
@@ -141,23 +141,23 @@ notarius:
} }
} }
} }
if !strings.Contains(string(firstYAML), "config_profile: \"\"\n") { if !strings.Contains(string(firstYAML), "config_overlay: \"\"\n") {
t.Fatalf("effective YAML omitted explicit empty Notarius config profile: %q", firstYAML) t.Fatalf("effective YAML omitted explicit empty Notarius config overlay: %q", firstYAML)
} }
var roundTrip struct { var roundTrip struct {
Notarius *struct { Notarius *struct {
LLMAPIKey *string `yaml:"llm_api_key"` Credentials []string `yaml:"credentials"`
ConfigProfile *string `yaml:"config_profile"` ConfigOverlay *string `yaml:"config_overlay"`
} `yaml:"notarius"` } `yaml:"notarius"`
} }
if err := yaml.Unmarshal(firstYAML, &roundTrip); err != nil { if err := yaml.Unmarshal(firstYAML, &roundTrip); err != nil {
t.Fatalf("round-trip effective YAML: %v", err) t.Fatalf("round-trip effective YAML: %v", err)
} }
if roundTrip.Notarius == nil || roundTrip.Notarius.ConfigProfile == nil || *roundTrip.Notarius.ConfigProfile != "" { if roundTrip.Notarius == nil || roundTrip.Notarius.ConfigOverlay == nil || *roundTrip.Notarius.ConfigOverlay != "" {
t.Fatalf("round-trip config profile = %#v, want explicit empty", roundTrip.Notarius) t.Fatalf("round-trip config overlay = %#v, want explicit empty", roundTrip.Notarius)
} }
if roundTrip.Notarius.LLMAPIKey == nil || *roundTrip.Notarius.LLMAPIKey != "NARRATIO_TEST_NOTARIUS_KEY" { if len(roundTrip.Notarius.Credentials) != 1 || roundTrip.Notarius.Credentials[0] != "NARRATIO_TEST_NOTARIUS_KEY" {
t.Fatalf("round-trip credential reference = %#v, want safe public name", roundTrip.Notarius.LLMAPIKey) t.Fatalf("round-trip credential reference = %#v, want safe public name", roundTrip.Notarius.Credentials)
} }
t.Setenv("NARRATIO_TEST_ACCESS_KEY", secondCanary) t.Setenv("NARRATIO_TEST_ACCESS_KEY", secondCanary)
@@ -358,7 +358,7 @@ func TestExamplesLoadAndValidate(t *testing.T) {
t.Run(profile.name, func(t *testing.T) { t.Run(profile.name, func(t *testing.T) {
options := PipelineLoadOptions{} options := PipelineLoadOptions{}
if profile.selection != "" { if profile.selection != "" {
options.Profile = &profile.selection options.Overlay = &profile.selection
} }
pipeline, err := LoadPipelineWithOptions(pipelinePath, options) pipeline, err := LoadPipelineWithOptions(pipelinePath, options)
if err != nil { if err != nil {
@@ -375,18 +375,18 @@ func TestExamplesLoadAndValidate(t *testing.T) {
if err := Validate(cfg); err != nil { if err := Validate(cfg); err != nil {
t.Fatalf("validate split bundle: %v", err) t.Fatalf("validate split bundle: %v", err)
} }
if cfg.Pipeline.Audita.Model != profile.model { if cfg.Pipeline.Audita.ProposalProfile != profile.model {
t.Fatalf("audita model = %q, want %q", cfg.Pipeline.Audita.Model, profile.model) t.Fatalf("audita proposal profile = %q, want %q", cfg.Pipeline.Audita.ProposalProfile, profile.model)
} }
if cfg.Pipeline.Audita.LLMAPIKey != nil || cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil { if len(cfg.Pipeline.Audita.Credentials) != 0 || cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil {
t.Fatalf("split bundle must remain offline and secret-free: audita=%#v storage=%#v", cfg.Pipeline.Audita, cfg.Pipeline.Storage) t.Fatalf("split bundle must remain offline and secret-free: audita=%#v storage=%#v", cfg.Pipeline.Audita, cfg.Pipeline.Storage)
} }
selectedProfile, selected := SelectedPipelineProfile(cfg.Pipeline) selectedOverlay, selected := SelectedPipelineOverlay(cfg.Pipeline)
if profile.selection == "" && selected { if profile.selection == "" && selected {
t.Fatalf("production base unexpectedly selected profile %#v", selectedProfile) t.Fatalf("production base unexpectedly selected overlay %#v", selectedOverlay)
} }
if profile.selection != "" && (!selected || selectedProfile.Name != profile.selection) { if profile.selection != "" && (!selected || selectedOverlay.Name != profile.selection) {
t.Fatalf("selected profile = %#v, want %q", selectedProfile, profile.selection) t.Fatalf("selected overlay = %#v, want %q", selectedOverlay, profile.selection)
} }
for _, key := range []string{ for _, key := range []string{
"session_recap", "session_recap",

View File

@@ -58,8 +58,10 @@ notarius:
func TestNotariusCredentialReferenceLoading(t *testing.T) { func TestNotariusCredentialReferenceLoading(t *testing.T) {
const envName = "NARRATIO_TEST_NOTARIUS_KEY" const envName = "NARRATIO_TEST_NOTARIUS_KEY"
const secondName = "NARRATIO_TEST_NOTARIUS_SECOND_KEY"
const canary = "notarius-credential-canary" const canary = "notarius-credential-canary"
t.Setenv(envName, canary) t.Setenv(envName, canary)
t.Setenv(secondName, "second-canary")
tests := []struct { tests := []struct {
name string name string
@@ -67,8 +69,9 @@ func TestNotariusCredentialReferenceLoading(t *testing.T) {
wantNil bool wantNil bool
}{ }{
{name: "omitted", wantNil: true}, {name: "omitted", wantNil: true},
{name: "null", credential: " llm_api_key: null\n", wantNil: true}, {name: "null", credential: " credentials: null\n", wantNil: true},
{name: "custom reference", credential: " llm_api_key: " + envName + "\n"}, {name: "empty", credential: " credentials: []\n"},
{name: "custom references", credential: " credentials: [" + envName + ", " + secondName + "]\n"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -81,15 +84,19 @@ func TestNotariusCredentialReferenceLoading(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if test.wantNil { if test.wantNil && cfg.Notarius.Credentials != nil {
if cfg.Notarius.LLMAPIKey != nil { t.Fatal("credential list should be nil")
t.Fatal("credential reference should be nil")
} }
if test.name == "empty" && len(cfg.Notarius.Credentials) == 0 {
return return
} }
if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != envName || if test.wantNil {
!cfg.Notarius.LLMAPIKey.IsResolved() || cfg.Notarius.LLMAPIKey.Value() != canary { return
t.Fatal("credential reference was not loaded and resolved") }
if len(cfg.Notarius.Credentials) != 2 ||
cfg.Notarius.Credentials[0].EnvName() != envName || cfg.Notarius.Credentials[0].Value() != canary ||
cfg.Notarius.Credentials[1].EnvName() != secondName || cfg.Notarius.Credentials[1].Value() != "second-canary" {
t.Fatal("credential references were not loaded and resolved")
} }
}) })
} }
@@ -110,7 +117,7 @@ func TestNotariusCredentialReferenceFailures(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "pipeline.yml") path := filepath.Join(t.TempDir(), "pipeline.yml")
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n llm_api_key: " + test.reference + "\n" pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n credentials: [" + test.reference + "]\n"
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -122,6 +129,17 @@ func TestNotariusCredentialReferenceFailures(t *testing.T) {
} }
} }
func TestNotariusRejectsRetiredCredentialField(t *testing.T) {
path := filepath.Join(t.TempDir(), "pipeline.yml")
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n llm_api_key: NARRATIO_TEST_NOTARIUS_KEY\n"
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadPipeline(path); err == nil || !strings.Contains(err.Error(), "decode.unknown_field") {
t.Fatalf("LoadPipeline() error = %v, want unknown field", err)
}
}
func TestNotariusEnabledDefaultsAndPathResolution(t *testing.T) { func TestNotariusEnabledDefaultsAndPathResolution(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
pipelinePath := filepath.Join(dir, "deployment", "pipeline.yml") pipelinePath := filepath.Join(dir, "deployment", "pipeline.yml")

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