46 Commits

Author SHA1 Message Date
fc752b5860 Update COPYRIGHT and LICENSE contents to align with the house standard 2026-09-26 15:02:11 -05:00
44eba766d6 Prepare version 0.8.0 release 2026-09-26 01:36:32 +00:00
f1566d557c Verify retry accounting and execution summary propagation 2026-09-26 01:07:31 +00:00
1d29b321dd Record remaining kit upgrade verification work 2026-09-25 23:59:43 +00:00
7ac9d94f10 Verify ConfigKit and PromptKit upgrades 2026-09-25 22:04:55 +00:00
227c05b154 Document retry composition and preserve error summaries 2026-09-25 22:03:50 +00:00
b09ce47d4a Record execution summaries in debug artifacts 2026-09-25 22:02:56 +00:00
6b8a4408c1 Add LLM execution summaries 2026-09-25 22:01:16 +00:00
bd83255a8e Preserve LLM generation failure metadata 2026-09-25 21:59:15 +00:00
10ac29d149 Configure PromptKit transient retries 2026-09-25 21:56:29 +00:00
d29a5cecd3 Upgrade PromptKit runtime baseline 2026-09-25 21:49:44 +00:00
31b48d2d5e Rename configuration selection to overlays 2026-09-25 21:46:35 +00:00
8c0e93143f Plan the ConfigKit and PromptKit upgrade implementation 2026-09-25 21:05:02 +00:00
f0abc5d220 Plan ConfigKit and PromptKit upgrades 2026-09-25 20:56:46 +00:00
acec6dc15e Simplify source releases with one guarded publication command 2026-09-25 20:33:19 +00:00
cee254651f Align architecture documentation and testing policies with current standards 2026-09-25 20:26:34 +00:00
cc51b8470a Establish a dedicated agent policy 2026-09-25 20:25:36 +00:00
53d9284f0b Upgrade PromptKit and prepare version 0.7.1 2026-09-22 14:31:55 +00:00
4e77725b24 Prepare version 0.7.0 and retire completed configuration plans 2026-09-14 00:54:22 +00:00
c02eec74dd Update the validator example for version 5 configuration 2026-09-14 00:42:35 +00:00
6107adb140 Identify configuration source files in load errors 2026-09-14 00:41:41 +00:00
9c5e19c225 Preserve explicit worker counts when normalizing keys 2026-09-14 00:40:40 +00:00
04da64fe32 Validate explicit configuration branches before conversion 2026-09-14 00:39:47 +00:00
5f7923c758 Record configuration normalization at sequence boundaries 2026-09-14 00:38:50 +00:00
3010a918dc Finalize ConfigKit architecture documentation 2026-09-13 17:26:57 +00:00
19f1979bac Add composed development configuration example 2026-09-13 17:24:55 +00:00
339908c1e4 Remove legacy configuration machinery 2026-09-13 17:23:00 +00:00
467bbba3e1 Add configuration inspection commands 2026-09-13 17:16:06 +00:00
92a6a4985a Switch commands to ConfigKit configuration 2026-09-13 17:12:56 +00:00
d809085406 Prepare ConfigKit validation and listing commands 2026-09-13 16:51:46 +00:00
bb922f19d9 Prepare CLI loading for ConfigKit configuration 2026-09-13 16:50:40 +00:00
320de32a61 Resolve ConfigKit paths from declaration origins 2026-09-13 16:48:44 +00:00
69295d72c9 Publish redacted ConfigKit effective configuration 2026-09-13 16:43:32 +00:00
d770e19c17 Accept ConfigKit integers in module options 2026-09-13 16:41:14 +00:00
cf17dd7bd0 Convert ConfigKit documents to runtime configuration 2026-09-13 16:39:33 +00:00
9c247cf3a2 Validate ConfigKit pipeline documents 2026-09-13 16:35:46 +00:00
b1cd919605 Apply ConfigKit operational configuration defaults 2026-09-13 16:26:18 +00:00
b822e7f510 Add ConfigKit version 5 document boundary 2026-09-13 16:18:30 +00:00
c403a38b50 Update ConfigKit adoption plan for version 0.3.0 2026-09-13 15:49:26 +00:00
00972e8d48 Plan ConfigKit adoption and clarify implementation policy 2026-09-12 02:39:03 +00:00
946fa10764 Verify deployment-managed cache permissions 2026-09-11 15:06:29 +00:00
d2afa9dbd2 Document deployment-managed cache permissions 2026-09-11 15:04:54 +00:00
7498fda030 Apply deployment permissions to checkpoints 2026-09-11 15:03:39 +00:00
ea503acd42 Respect deployment permissions for chunk plans 2026-09-11 15:01:01 +00:00
153b1e4689 Add deployment-managed cache writer 2026-09-11 14:59:27 +00:00
7d9adc77ee Plan deployment-managed cache permissions 2026-09-11 14:24:51 +00:00
120 changed files with 8054 additions and 3898 deletions

View File

@@ -1,3 +1,4 @@
# Advisory validation after source publication, never a release-completion gate.
when:
- event: tag
@@ -5,29 +6,4 @@ steps:
- name: validate-release
image: golang:1.25.5
commands:
- |
set -eu
version="$CI_COMMIT_TAG"
release_note="docs/releases/$version.md"
if ! printf '%s\n' "$version" | grep -E -x 'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' >/dev/null; then
printf '%s\n' "invalid release tag: $version" >&2
exit 1
fi
if [ ! -s "$release_note" ]; then
printf '%s\n' "missing release note: $release_note" >&2
exit 1
fi
if ! grep -F -x "# Notarius $version" "$release_note" >/dev/null; then
printf '%s\n' "release note heading does not match $version" >&2
exit 1
fi
for heading in '## Summary' '## Compatibility' '## Upgrade' '## Changes'; do
if ! grep -F -x "$heading" "$release_note" >/dev/null; then
printf '%s\n' "release note is missing heading: $heading" >&2
exit 1
fi
done
./scripts/check-release-source.sh "$version"
- ./scripts/check-release-source.sh "$CI_COMMIT_TAG"

View File

@@ -1,2 +1,5 @@
Please review `docs/development.md` for initial orientation in this repository
and follow its task-specific reading guide.
Review and follow `docs/policy/agents.md` before recommending, planning, or
implementing any changes to this repository.

19
COPYRIGHT Normal file
View File

@@ -0,0 +1,19 @@
Notarius
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: GPL-3.0-or-later
Notarius is free software: you can redistribute it and/or modify it
under the terms of the GNU Lesser 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.
Notarius 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 Lesser General Public License for more details.
Third-party code and assets are subject to their respective license terms.

892
LICENSE
View File

@@ -1,232 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 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.
go-application-template
Copyright (C) 2026 eric
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/>.
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:
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
go-application-template Copyright (C) 2026 eric
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.
<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”.
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

@@ -0,0 +1,59 @@
# ADR-0017: Defer cache access policy to the deployment
**Status:** Accepted
**Date:** 2026-09-11
## Context
Canonical chunk plans and invocation checkpoints are reconstructible cache
state. They may include source-derived content, so access and retention matter,
but Notarius cannot determine whether a deployment needs a private cache or a
cache shared by a trusted group. The current application-enforced `0700` and
`0600` modes prevent ordinary Unix provisioning from making that choice.
[ADR-0005](0005-cache-canonical-chunk-plans-by-source.md) governs canonical
chunk-plan reuse, while [ADR-0006](0006-separate-output-cache-and-debug-state.md)
established the output, cache, and debug separation refined by
[ADR-0007](0007-separate-checkpoint-recording-from-reuse.md). None of those
decisions requires Notarius to own exact cache access modes.
## Decision
Deployments own cache access policy through provisioned directories, process
umask, setgid inheritance, and default ACLs. Notarius requests conventional
maximum creation modes for cache entries and leaves the kernel to select their
effective permissions.
Notarius continues to own confined paths, symlink rejection, validation,
atomic publication, cleanup, and recovery. Those correctness boundaries are
independent from deciding which accounts may access cache state.
Source-derived cache contents can be sensitive. Operators own their access and
retention policy, while API credentials remain outside cache payloads under the
existing secret boundary. Existing cache inode modes are not migrated
automatically.
This decision does not supersede ADR-0005, ADR-0006, or ADR-0007.
## Alternatives considered
- Continue enforcing private `0700` directory and `0600` file modes. This
makes one deployment choice for every operator and prevents trusted-group
provisioning.
- Add configurable Notarius permission modes or a private/group cache setting.
This preserves application ownership of a deployment concern and expands the
public configuration surface without a durable policy reason.
- Recursively converge existing cache ownership, modes, or ACLs. This would
make upgrades mutate operator-managed state and cannot safely infer an
intended policy.
## Consequences
Operators can use a restrictive umask for private cache state or provision a
setgid/default-ACL cache root for trusted-group access. They must deliberately
recreate or administratively migrate pre-existing cache trees when their
existing modes do not match the desired deployment policy.
Notarius does not add user or group management, ACL mutation, permission
configuration, or recursive cache migration. Output and debug permission
contracts remain separate from this decision.

View File

@@ -0,0 +1,53 @@
# ADR-0018: Adopt ConfigKit for configuration loading
**Status:** Accepted
**Date:** 2026-09-13
## Context
Notarius's former configuration boundary owned strict YAML parsing, input
presence bookkeeping, defaults, normalization, environment overlays, and
configuration inspection. Those general-purpose responsibilities had grown
alongside application-specific pipeline and module semantics.
ConfigKit v0.3.0 provides strict typed YAML loading, imports, configuration
profiles, explicit CLI overrides, recorded defaults and normalization, safe
inspection rendering, and released source-origin APIs. The application needs
those source records to resolve filesystem paths according to the file that
supplied a surviving declaration.
## Decision
Notarius will adopt the root public `gitea.maximumdirect.net/eric/ConfigKit`
package at v0.3.0 for its version 5 configuration contract. ConfigKit owns
source composition, strict decoding, input presence, metadata, and safe
effective/explanation rendering. Notarius retains its typed document schema,
field semantics, runtime conversion, catalog-dependent pipeline resolution,
PromptKit profile handling, and physical run preparation.
Version 5 is a deliberate breaking file-contract change. It uses canonical
object module bindings and reference sources, and enables ConfigKit imports
and configuration profiles. The selected configuration file's canonical
directory is the base for its declared application paths; ConfigKit's released
`Resolution.InputInfo` and `Resolution.Source` APIs provide their origins.
`NOTARIUS_CONFIG` remains file discovery, while field-level operational
environment overlays are retired. Explicit configuration-valued CLI flags are
represented as ConfigKit overrides; invocation actions remain outside that
boundary.
## Alternatives considered
- Continue extending the custom YAML loader. This preserves duplicated
general-purpose parsing, presence, composition, and inspection machinery.
- Retain version 4 alongside version 5. This would leave a supported dual
configuration boundary and delay removal of the machinery being replaced.
- Wrap ConfigKit in a compatibility pre-parser. This weakens ConfigKit's
strict contract and recreates the application-owned parser complexity.
## Consequences
The released application has one ConfigKit-backed loader and one version 5
contract. Operator documentation and maintained examples move with the cutover.
ConfigKit metadata is retained only at the configuration publication boundary;
its types do not become module or runtime pipeline interfaces.

View File

@@ -12,8 +12,8 @@ defined in [Operations](operations.md).
notarius help
notarius --version
notarius run <pipeline-id> --input path/to/source.json [--json] [flags]
notarius config validate [--config path/to/config.yml] [--pipeline pipeline-id] [--only lane-a,lane-b]
notarius pipelines list [--config path/to/config.yml] [--json]
notarius config validate [--config path/to/config.yml] [--config-overlay name] [--pipeline pipeline-id] [--only lane-a,lane-b]
notarius pipelines list [--config path/to/config.yml] [--config-overlay name] [--json]
~~~
Running Notarius without arguments, or with **help**, **--help**, or **-h**,
@@ -42,6 +42,7 @@ pipeline ID and **--input** are required.
| Flag | Meaning |
| --- | --- |
| **--config path** | Use this configuration file. When omitted, configuration discovery applies; see [Configuration](config.md). |
| **--config-overlay name** | Select a ConfigKit configuration overlay. Empty and **default** select the composed base configuration. |
| **--input path** | Source input file to process. Required. |
| **--output-dir path** | Override the configured output root for this run. |
| **--json** | Write the successful run-result receipt as JSON to standard output. |
@@ -154,10 +155,10 @@ OPENROUTER_API_KEY=your-api-key \
## config validate
~~~
notarius config validate [--config path/to/config.yml] [--pipeline pipeline-id] [--only lane-a,lane-b]
notarius config validate [--config path/to/config.yml] [--config-overlay name] [--pipeline pipeline-id] [--only lane-a,lane-b]
~~~
This command loads and validates a configuration. With **--pipeline**, it also
This command loads and validates the selected composed configuration. With **--pipeline**, it also
resolves that pipeline against the production module catalog. **--only** selects
lanes during that resolution and requires **--pipeline**.
@@ -180,10 +181,32 @@ OPENROUTER_API_KEY=validation-placeholder \
The placeholder in the second command is sufficient only for offline
validation; it cannot run a provider-backed pipeline.
## config effective
~~~
notarius config effective [--config path/to/config.yml] [--config-overlay name]
~~~
This command writes the selected composed configuration as deterministic,
redacted YAML. Standard output contains only that YAML: it does not validate a
pipeline, prepare modules, materialize references, or contact a provider.
## config explain
~~~
notarius config explain [--config path/to/config.yml] [--config-overlay name] <json-pointer>
~~~
This command writes ConfigKit's redacted, human-readable explanation for one
canonical JSON Pointer. Supply `""` explicitly to explain the root. A malformed
pointer is a syntax error; a well-formed unknown path is a command failure.
The explanation can describe a value displaced by a selected overlay. Standard
output contains only the explanation.
## pipelines list
~~~
notarius pipelines list [--config path/to/config.yml] [--json]
notarius pipelines list [--config path/to/config.yml] [--config-overlay name] [--json]
~~~
This command lists configured pipeline IDs in sorted order. By default, it
@@ -200,6 +223,10 @@ go run ./cmd/notarius pipelines list \
Successful commands write their primary result to standard output. Warnings and
errors are written to standard error.
Configuration load errors include ConfigKit's field diagnostics and source
coordinates, followed by a source-ID legend identifying the acquired files or
source labels. This includes imported files and the selected configuration overlay.
For **run --json**, actionable process warnings remain on standard error and standard output is a
machine-readable success result only. Syntax and runtime diagnostics remain on
standard error. Parse the result only after the process exits with status 0.
@@ -211,6 +238,6 @@ standard error. Parse the result only after the process exits with status 0.
| 2 | The command or flag syntax was invalid, including unknown commands, missing required arguments, invalid flag values, or invalid flag combinations. |
The root help spellings are the supported help path. Invoking **--help** on
**run**, **config validate**, or **pipelines list** is handled by the flag
**run**, a **config** subcommand, or **pipelines list** is handled by the flag
parser as a usage error: it writes an error to standard error and exits with
status 2.

View File

@@ -1,7 +1,7 @@
# Configuration
This is the canonical reference for Notarius configuration. Configuration files
are YAML and must declare version 4. They select pipelines and their modules;
are YAML and must declare version 5. They select pipelines and their modules;
the [CLI reference](cli.md) owns invocation syntax, and
[Operations](operations.md) owns run-state procedures.
@@ -19,12 +19,16 @@ The command fails if none of these paths provides a configuration file.
For configuration values, precedence is:
1. built-in defaults;
2. the selected YAML file;
3. supported operational environment variables; and
4. the CLI run overrides that apply to a command.
2. composed YAML sources, including imports;
3. the selected ConfigKit overlay; and
4. explicitly supplied configuration-valued run flags.
Environment variables do not provide a second configuration schema. They only
override the fields listed below.
`--config-overlay` selects a ConfigKit overlay from `overlays/<name>.yml`
beneath the selected configuration root. Empty and `default` select no overlay.
`profiles/<name>.yml` is not a fallback location, and `--config-profile` is no
longer accepted. Configuration overlays are distinct from PromptKit LLM profiles selected by
`llm_profile` or `--llm-profile`. The retired operational environment variables
have no effect; `NOTARIUS_CONFIG` remains a source-discovery input.
## Maintained Examples
@@ -33,7 +37,8 @@ override the fields listed below.
- [Complete D&D configuration](../examples/dnd-complete.config.yml) uses
ordered steps, all implemented D&D lanes, generated references, state
settings, bounded LLM concurrency, and the maintained
[operator profile](../examples/profiles/dnd-extraction.yml).
[LLM profile](../examples/llm-profiles/dnd-extraction.yml) and imports its
pipeline from [a fragment](../examples/pipelines/dnd-session.yml).
Use these complete files as starting points rather than combining the
illustrative fragments in this reference.
@@ -46,7 +51,7 @@ other than **version** is optional.
| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| **version** | integer | none | Required; must be 4. |
| **version** | integer or quoted string | none | Required; must be 5. Maintained files use `version: 5`. |
| **promptkit** | object | none | Profile source and optional local-backend configuration. |
| **pipelines** | map | empty | Maps pipeline IDs to pipeline definitions. |
| **concurrency** | object | see below | Global LLM and extraction limits. |
@@ -69,6 +74,22 @@ Built-in defaults are:
An empty cache directory in YAML deliberately selects the corresponding
per-user root. An explicit empty output or debug directory is invalid.
Selecting a chunk-plan or checkpoint directory does not configure filesystem
access policy. Cache permissions come from deployment provisioning, process
umask, setgid inheritance, and default ACLs; see [Operations](operations.md#cache-provisioning).
Every nonempty relative configured destination or reference path resolves from
the canonical directory of the file that supplies its surviving declaration.
This includes imported fragments and selected configuration overlays. Built-in
relative destinations use the selected main configuration file's directory;
explicit `--output-dir` and `--debug-dir` values use the invocation working
directory. Absolute values remain unchanged.
The root file can use ConfigKit imports to compose disjoint definitions. Imports
and selected overlays remain under the configuration root; they do not merge or
append a pipeline's `steps` sequence. An overlay replaces sequences as defined
by ConfigKit, so changing an individual ordered binding requires supplying the
whole replacement sequence.
## PromptKit Profiles
@@ -76,11 +97,12 @@ The optional **promptkit** object selects one source of profile definitions and
may register one conventional local OpenAI-compatible backend:
~~~yaml
version: 4
version: 5
promptkit:
profile_dir: ./profiles
# profile_file: ./profiles.yml
transient_retries: 2
local_backend:
endpoint: http://localhost:8000/v1
concurrency_limit: 2
@@ -90,21 +112,23 @@ promptkit:
| --- | --- | --- |
| **profile_dir** | string | Non-empty directory containing profile files. |
| **profile_file** | string | Non-empty profile file. |
| **transient_retries** | integer | Non-negative additional built-in HTTP attempts for eligible transient provider failures; defaults to 2. Set 0 to disable these retries. |
| **local_backend** | object | Optional registration for the conventional PromptKit backend ID **local**. |
| **local_backend.endpoint** | string | Required when **local_backend** is present; absolute HTTP or HTTPS URL with a host. |
| **local_backend.concurrency_limit** | integer | Optional non-negative limit; defaults to 0. |
Set at most one of **profile_dir** and **profile_file**. Relative values use
the process working directory, not the configuration file's directory. The
complete example's `./examples/profiles/dnd-extraction.yml` value is therefore
valid when Notarius is launched from the repository root; use an absolute path
for services and containers.
Set at most one of **profile_dir** and **profile_file**. Every nonempty relative
configured filesystem path is resolved from the canonical directory of the
file that supplies it. The complete example's
`./llm-profiles/dnd-extraction.yml` value therefore resolves beside that example.
Use absolute paths for services and containers when their deployment layout is
not configuration-relative.
An operator source is optional. For a requested ID, PromptKit checks the
configured operator source first, then Notarius's embedded fallback profiles,
then its own built-in catalog. A matching profile is complete: it replaces a
lower-precedence definition rather than merging with it. The maintained
[`dnd-extraction` operator profile](../examples/profiles/dnd-extraction.yml)
[`dnd-extraction` LLM profile](../examples/llm-profiles/dnd-extraction.yml)
is a secret-free deployment artifact; production, development, and local
deployments can each provide a complete definition with that same workload ID.
Use workload-oriented IDs for new profiles instead of model names.
@@ -147,30 +171,15 @@ requiring credential values. It rejects absent, malformed, or incompatible
profiles before a run prepares modules. Credential availability is checked only
when a generation is prepared.
## Migrating Version 3 Configuration
## Version 5 Migration
Version 3 files are not decoded or rewritten. Change **version: 3** to
**version: 4** and rename the top-level **scriptorium:** section to
**promptkit:**. Version 4 decoding is strict, so a remaining **scriptorium**
field is rejected as unknown.
## Operational Environment Variables
These variables are applied after YAML values:
| Variable | Overrides | Rules |
| --- | --- | --- |
| **NOTARIUS_TOTAL_LLM_CONCURRENCY** | **concurrency.total_llm** | Integer. |
| **NOTARIUS_STAGE_WORKERS_EXTRACT** | **concurrency.stage_workers.extract** | Integer. |
| **NOTARIUS_OUTPUT_DIR** | **output.directory** | Non-empty path. |
| **NOTARIUS_CACHE_CHUNK_PLANS_MODE** | **cache.chunk_plans.mode** | **auto**, **bypass**, or **refresh**. |
| **NOTARIUS_CACHE_CHUNK_PLANS_DIR** | **cache.chunk_plans.directory** | Non-empty path. |
| **NOTARIUS_CACHE_CHECKPOINTS_DIR** | **cache.checkpoints.directory** | Non-empty path. |
| **NOTARIUS_DEBUG_DIR** | **debug.directory** | Non-empty path. |
Integer values are trimmed then parsed as base-10 integers. Directory and
output values reject NUL characters. **NOTARIUS_CONFIG** participates only in
configuration discovery.
Version 3 and version 4 files are rejected and are not rewritten at load time.
Use `version: 5`, rename the former `scriptorium` section to `promptkit`, and
convert every module binding to an object containing `module`. Convert every
external reference to `{path: ...}` and move any lane-level references to the
corresponding extraction binding. Put durable operational values in YAML or
use the documented explicit run flags; the former field-level environment
variables are not read.
## Concurrency, Output, Cache, And Debug
@@ -195,11 +204,14 @@ debug:
**concurrency.total_llm** must be greater than zero. The only supported
**concurrency.stage_workers** key is **extract**; its value must be from 1
through **total_llm**. When omitted, it is recalculated from the effective
**total_llm** after YAML and environment precedence.
**total_llm** after imports, overlay selection, and explicit ConfigKit
overrides.
**cache.chunk_plans.mode** accepts **auto**, **bypass**, or **refresh**.
**cache.checkpoints.enabled** is a boolean. The CLI can override the output
directory and chunk-plan mode for one run; see [CLI reference](cli.md#run).
Cache directory fields select only locations; Notarius has no cache permission
field or operational environment override.
## Pipelines
@@ -209,14 +221,20 @@ Each **pipelines** entry has a unique, non-empty ID and the following shape:
pipelines:
dnd-session:
llm_profile: dnd-extraction
input: seriatim
chunk: generic
output: json
input:
module: seriatim
chunk:
module: generic
output:
module: json
artifacts:
spells:
extract: dnd/spells
merge: appendorder
normalize: dnd/spells
extract:
module: dnd/spells
merge:
module: appendorder
normalize:
module: dnd/spells
~~~
| Field | Type | Default | Rules |
@@ -286,19 +304,20 @@ A lane has these fields:
| **extract** | module binding | none | Required. |
| **merge** | module binding | **appendorder** | Optional. |
| **normalize** | module binding | **noop** | Optional. |
| **references** | map | none | Supported compatibility alias for **extract.references**. |
| **references** | map | none | Not supported at lane scope; place references on the eligible binding. |
| **validators** | list | none | Non-empty lane-level lists are rejected. Set validator overrides on a binding instead. |
The lane-level **references** alias remains accepted. When the alias and
**extract.references** bind the same slot, **extract.references** wins. Use
the binding-local form in new configurations.
Lane-level **references** are not supported. Put a lane extraction reference
under **extract.references**.
## Module Bindings And Validators
Use a module key directly when no other binding fields are needed:
Every module and validator binding uses an object, even when it has no fields
beyond its module key:
~~~yaml
input: seriatim
input:
module: seriatim
~~~
Use an object for fields:
@@ -309,7 +328,8 @@ extract:
llm_profile: dnd-extraction
retries: 2
references:
spell_catalog: ./dnd-spell-catalog.json
spell_catalog:
path: ./dnd-spell-catalog.json
~~~
| Binding field | Type | Default | Rules |
@@ -381,18 +401,20 @@ When enabled, it publishes the selected source-unit excerpt defined by the
## References And Ordered Handoffs
Reference maps bind named slots that the selected target declares. A scalar is
an external path. Pipeline-level maps accept only external paths; step-local
and binding-local maps may also select a normalized artifact from an earlier
step:
Reference maps bind named slots that the selected target declares. An external
reference is an object with `path`. Pipeline-level maps accept only external
paths; step-local and binding-local maps may also select a normalized artifact
from an earlier step:
~~~yaml
steps:
- id: describe-session
artifacts:
npc-registry:
extract: dnd/npc-registry
normalize: dnd/npc-registry
extract:
module: dnd/npc-registry
normalize:
module: dnd/npc-registry
- id: extract-events
references:
npc_registry:
@@ -401,8 +423,10 @@ steps:
lane: npc-registry
artifacts:
spells:
extract: dnd/spells
normalize: dnd/spells
extract:
module: dnd/spells
normalize:
module: dnd/spells
~~~
An artifact selector contains only **step** and **lane**. The producer must be
@@ -417,9 +441,10 @@ bindings are then operational overrides of configured external paths; their
pipeline, lane, and binding scopes and precedence are defined by the
[CLI reference](cli.md#reference-selectors). A CLI file reference cannot
replace a configured generated artifact handoff. Required slots must be bound
after configuration and CLI reference actions are applied. Reference paths in
YAML are resolved relative to the configuration file; CLI reference paths are
resolved relative to the process working directory.
after configuration and CLI reference actions are applied. A YAML reference
path resolves from the file that supplies its surviving declaration, including
an imported fragment or selected overlay. CLI reference paths resolve relative
to the process working directory.
### D&D Reference Slots
@@ -521,11 +546,11 @@ extract:
module: dnd/scene-descriptions
retries: 1
validators:
- generic/valid_json
- extract/dnd/scene-descriptions/shape
- extract/dnd/scene-descriptions/source_refs
- generic/valid_json_schema
- extract/dnd/scene-descriptions/source_relatedness
- module: generic/valid_json
- module: extract/dnd/scene-descriptions/shape
- module: extract/dnd/scene-descriptions/source_refs
- module: generic/valid_json_schema
- module: extract/dnd/scene-descriptions/source_relatedness
- module: extract/dnd/scene-descriptions/combat_semantics
retries: 1
~~~
@@ -568,7 +593,16 @@ go run ./cmd/notarius config validate \
~~~
Configuration validation rejects invalid YAML, unsupported fields, invalid
defaults or environment overrides, incompatible module keys, unknown options,
defaults or explicit CLI overrides, incompatible module keys, unknown options,
invalid reference bindings, missing required reference slots, invalid validator
overrides, and incompatible generated artifact handoffs. Use
[pipelines list](cli.md#pipelines-list) to inspect configured IDs.
## Inspection
Use [config effective](cli.md#config-effective) to write the selected composed
configuration as deterministic, redacted YAML. Use
[config explain](cli.md#config-explain) with a canonical JSON Pointer to inspect
the effective or historical provenance for one path, including a value displaced
by a selected overlay. Neither command resolves a pipeline, prepares modules,
materializes references, or requires provider credentials.

View File

@@ -23,16 +23,14 @@ A deployment must provide its own PromptKit profile and campaign reference
files. Use absolute paths for service and subprocess deployments. In
particular, observe these different resolution rules:
- reference paths in YAML are resolved relative to the Notarius configuration
file;
- reference and PromptKit profile paths in YAML are resolved relative to the
file that supplies their surviving declaration;
- reference paths passed with `--reference` are resolved relative to the
Notarius process working directory; and
- `promptkit.profile_file` is resolved relative to the Notarius process working
directory.
Notarius process working directory.
Do not copy the repository example's relative profile path into a deployment
without also controlling that working directory. The complete path and profile
rules are defined in [Configuration](../config.md).
without preserving its configuration-relative layout. The complete path and
profile rules are defined in [Configuration](../config.md).
Preflight the deployed configuration before processing sessions and whenever
it changes:

View File

@@ -15,9 +15,12 @@ configured lane inventory, and downstream acceptance checklist, see
Optionally preflight a selected configuration and pipeline before work starts:
```sh
notarius config validate --config /path/to/notarius.yml --pipeline pipeline-id
notarius config validate --config /path/to/notarius.yml --config-overlay production --pipeline pipeline-id
```
Omit `--config-overlay` when the composed base configuration is the intended
selection; it is independent from any `--llm-profile` run override.
Invoke the run with explicit paths and machine-readable output. Capture
standard output and standard error separately; do not combine them before
processing the result.
@@ -25,6 +28,7 @@ processing the result.
```sh
notarius run pipeline-id \
--config /path/to/notarius.yml \
--config-overlay production \
--input /path/to/source.json \
--output-dir /path/to/output-root \
--json

View File

@@ -9,6 +9,9 @@ the [README](../README.md) for product context, [Architecture](policy/architectu
for system boundaries, and [Internal Overview](internal/overview.md) for the
implemented component map.
Read [Agent Policy](policy/agents.md) before recommending, planning, or
implementing changes. It owns implementation discipline and local commit rules.
## What To Read
| When working on | Read | Why |
@@ -25,7 +28,7 @@ implemented component map.
| LLM clients, prompts, schemas, profiles, or scheduling | [LLM Runtime](internal/llm.md) | It documents the transport boundary and PromptKit integration. |
| Output, cache, resume, or debug artifacts | [Run State Internals](internal/state.md), [Operations](operations.md), and [Configuration](config.md) | These separate implementation details, operator behavior, and configuration contracts. |
| External input formats, artifact schemas, or durable output files | [Integration Contracts](integrations/) | Integration documents define external and durable data contracts. |
| Release preparation, tagging, publication, or verification | [Source Releases](release.md) and [Documentation Policy](policy/documentation.md) | The release procedure owns maintainer guards and immutable-tag recovery; the policy assigns release-note ownership. |
| Release preparation, tagging, publication, or optional diagnostics | [Release Policy](policy/release.md) and [Source Releases](release.md) | Policy owns release requirements and completion; the runbook supplies the publication command and recovery procedure. |
| Proposed or unimplemented behavior | [Roadmap](roadmap/) | Future work belongs only in roadmap documentation until implemented. |
For an existing subsystem, also inspect its focused tests and the package-local

View File

@@ -0,0 +1,13 @@
# ConfigKit Integration
Notarius uses the public root package
[`gitea.maximumdirect.net/eric/ConfigKit`](https://gitea.maximumdirect.net/eric/ConfigKit)
at v0.4.0 for version 5 configuration acquisition, imports, overlays, explicit
overrides, diagnostics, and safe inspection rendering.
Notarius owns the typed document, domain defaults and validation, runtime
conversion, catalog-dependent pipeline resolution, source-aware configured
paths, and command discovery. It uses only ConfigKit's public path, result,
diagnostic, and resolution-source APIs. Upstream documentation owns general
ConfigKit semantics; [Configuration](../config.md) owns Notarius's file and
operator contract.

View File

@@ -1,11 +1,11 @@
# PromptKit Integration
Notarius pins
[`gitea.maximumdirect.net/eric/promptkit` v0.9.0](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.9.0)
[`gitea.maximumdirect.net/eric/promptkit` v0.11.0](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0)
as its in-process prompt engine. The upstream
[Go package consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.9.0/docs/consumers/pkg-promptkit.md)
[Go package consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/consumers/pkg-promptkit.md)
owns the public engine API, and the upstream
[format reference](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.9.0/docs/formats.md)
[format reference](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/formats.md)
owns prompt, profile, and schema file contracts.
## Supported Boundary
@@ -27,7 +27,7 @@ Notarius relies on the root `promptkit` package to:
admission exhaustion through `ErrCapacityExceeded`.
The pinned
[`BackendLocal`, `LocalBackend`, and `WithBackend` API](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.9.0/backends.go)
[`BackendLocal`, `LocalBackend`, and `WithBackend` API](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/backends.go)
owns the registration and backend-capacity contract.
For one completion, the adapter calls `PrepareExecution`, takes a
@@ -53,7 +53,7 @@ Notarius sends one stable effective session through PromptKit's direct session
field, which is authoritative for provider session behavior. It also retains
the same value as the `session_id` prompt variable for maintained prompt
compatibility. The generated identifier is 76 ASCII characters, within
PromptKit v0.9.0's 256-code-point session limit. Session IDs are non-secret
PromptKit's 256-code-point session limit. Session IDs are non-secret
correlation identifiers and may be exposed to providers and provider
observability. The CLI contract owns generation and override behavior.
@@ -71,7 +71,15 @@ endpoint-only profiles have no such backend limit. The adapter translates
PromptKit capacity rejection into the provider-neutral Notarius
`ErrLLMCapacityExceeded` contract. It may include the normalized selected
backend ID in safe diagnostic context, without exposing PromptKit's capacity
error type, and leaves retries to the calling pipeline stage.
error type. PromptKit's built-in client retries eligible transient HTTP failures
with its v0.11.0 default of two additional attempts per generation; Notarius
keeps pipeline-stage retries outside that completion boundary.
The adapter projects built-in generation failures as the application-owned
`LLMGenerationError`, retaining a safe failure kind, HTTP status when present,
and retry-exhaustion attempt count. Transport, malformed successful envelopes,
provider statuses, and generic generation failures remain distinct without
exposing PromptKit error values to Notarius callers.
## Profile Sources And Compatibility
@@ -86,16 +94,16 @@ configuration and deployment workflow are defined in
[Operations](../operations.md#promptkit-profile-deployment).
PromptKit owns `base_profile` resolution under its
[pinned format rules](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.9.0/docs/formats.md).
[pinned format rules](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/formats.md).
Notarius records the selected leaf identity and resolved target without parsing
or merging inheritance. An unset filesystem `api_key_env` is optional and may
reach the provider without authorization, which can result in a 401 or 403.
PromptKit v0.9.0 accepts only the `developer`, `system`, `user`, and
PromptKit accepts only the `developer`, `system`, `user`, and
`assistant` text-chat roles after normalizing case and surrounding whitespace.
Maintained Notarius prompt definitions use only `system` and `user`.
For application-owned semantic correction, Notarius uses PromptKit v0.9.0's
For application-owned semantic correction, Notarius uses PromptKit's
`RunRequest.AppendedMessages` after the ordinary rendered prompt. It supplies
exactly two messages in order: the latest validated producer response with
role `assistant`, then deterministic validation guidance with role `user`.
@@ -112,12 +120,28 @@ and terminal summaries retain only safe counts, digests, identities, and usage;
complete appended messages remain limited to the explicitly requested detailed
debug trace.
PromptKit now obtains its maintained OpenRouter and Rakestrawhome backend and
PromptKit obtains its maintained OpenRouter and Rakestrawhome backend and
profile catalogs from independently versioned transitive modules. Notarius
does not import or register either catalog; PromptKit retains catalog source,
identity, precedence, credential, and capacity ownership.
Notarius supports this boundary against PromptKit v0.9.0. Its fallback source,
External catalog profile names and settings can change between dependency
releases, including removal without aliases. Review directly selected IDs and
inherited `base_profile` values when upgrading. An application-owned alias that
inherits an external profile does not freeze that profile's settings.
PromptKit's opt-in `promptkit-openrouter-standard` and
`promptkit-openrouter-auto` names provide name continuity within its
selected-and-tested dependency scope, not a fixed model or provider availability
guarantee. See the upstream
[catalog compatibility policy](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/policy/release.md#catalog-profile-compatibility).
Use a complete operator-owned profile with explicit backend or endpoint, model,
and settings when those choices must remain operator-controlled. The embedded
`dnd-extraction` fallback already follows this model and does not inherit an
external catalog profile. An upgrade updates Notarius's compiled-in catalog
checkpoint identity so prior profile-source checkpoints are not reused.
Notarius supports this boundary against PromptKit v0.11.0. Its fallback source,
prepared-execution, inspection, and typed capacity APIs are used as public
upstream contracts; other PromptKit APIs or file-format behavior are not
implicitly supported. A dependency upgrade requires reviewing the adapter,
@@ -146,3 +170,9 @@ profile/configuration contracts against the upstream documentation. Maintained
production prompts use PromptKit's bounded structural-repair contract; their
current declaration is one additional repair attempt. Notarius retains the
transport-neutral boundary and does not expose PromptKit types to modules.
PromptKit v0.11.0 is licensed under
[LGPL-3.0-or-later](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/LICENSE).
Its accompanying [GPLv3 notice](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/COPYING)
is incorporated by that license. This dependency change does not alter
Notarius's source license or distribution workflow.

View File

@@ -25,7 +25,8 @@ preparation, and runner mechanics after their inputs are supplied.
## Dispatch And Configuration Handoff
The root dispatcher handles help, version reporting, configuration validation,
pipeline listing, and a pipeline run. Version reporting resolves build
effective-configuration inspection, explanation inspection, pipeline listing,
and a pipeline run. Version reporting resolves build
information through `internal/buildinfo` before production composition, so the
diagnostic remains available without configuration or runtime collaborators.
The public syntax, streams, exit classes, and version semantics are defined by
@@ -33,14 +34,15 @@ the [CLI reference](../cli.md). Other root commands normalize injectable
options before dispatch so that a missing production dependency fails as a
command error rather than reaching execution.
Commands that need configuration use one shared loader. The CLI discovers the
file, parses it through **internal/core/config**, starts from defaults, applies
the file and supported environment overrides, and then validates it for the
command. The configured discovery and precedence contract is in
Commands that need configuration use one shared ConfigKit-backed loader. The
CLI discovers the file, passes the requested overlay and only explicit
configuration-valued run overrides, and receives a **LoadedConfig**. The
configured discovery and precedence contract is in
[Configuration](../config.md), while the loading and resolution mechanics are
in [Configuration Internals](configuration.md).
Configuration validation without a selected pipeline checks structural
`config effective` and `config explain` only render the loaded ConfigKit view;
they do not resolve pipelines or construct providers. Configuration validation without a selected pipeline checks structural
configuration only. Validation with a selected pipeline also builds the
effective catalog, resolves the pipeline, and verifies every explicit effective
PromptKit profile. Selected LLM-backed input, chunk, lane, output, and validator
@@ -84,8 +86,7 @@ field.
After parsing and validating a run invocation, the CLI performs this ordered
handoff:
1. load and validate configuration, then apply command-level operational
overrides;
1. load and validate configuration with ConfigKit command overrides;
2. create and validate a safe run identity, then allocate a debug bundle only
when requested;
3. build the effective catalog, resolve requested reference changes, resolve

View File

@@ -8,17 +8,17 @@ either interface.
## Boundary
The configuration package turns a selected YAML file and supported environment
values into a validated, independently owned configuration. It then resolves a
The configuration package turns a selected ConfigKit-composed YAML document
into a validated, independently owned configuration. It then resolves a
requested pipeline against a module catalog before the framework prepares or
runs anything.
| Boundary | Inputs | Outputs | Does not own |
| --- | --- | --- | --- |
| Loading | Selected file path and environment lookup | Parsed file model and a populated **Config** | Choosing the file path or reporting a command result. |
| Loading | Selected file path, profile, overrides, and invocation directory | **LoadedConfig** with ConfigKit inspection metadata and a populated **Config** | Choosing the file path or reporting a command result. |
| Validation | **Config** | Structural configuration errors with pipeline, lane, or binding context | Module availability, capabilities, or construction. |
| Resolution | Valid **Config**, selected pipeline and lanes, runtime reference changes, LLM override, and module catalog | **EffectiveConfig** with a **ResolvedPipeline** | Materializing reference bytes, preparing modules, execution, or filesystem state. |
| Summary | **Config** or **EffectiveConfig** | Detached redacted payload suitable for debug summaries | Redacting arbitrary process state or provider traffic. |
| Inspection | **LoadedConfig** and an optional JSON Pointer | Redacted effective YAML or explanation text | Pipeline selection, module preparation, or provider work. |
The CLI discovers a configuration file, invokes this package, and supplies the
result to the framework. Configuration never reads an input file, constructs a
@@ -28,23 +28,15 @@ at their respective [CLI](cli.md), [pipeline](pipeline.md), and
## Loading And Validation
The CLI loads configuration in this order:
1. parse the selected YAML file strictly into the file model;
2. start from **Default**;
3. apply the file model; and
4. apply the supported environment overrides.
This establishes the public precedence order without giving environment input a
second file schema. Loading and application reject malformed YAML, unsupported
file versions, unknown fields, invalid values, and identifiers that are empty
or collide after whitespace normalization. The file application also makes the
effective extraction-worker default follow the effective LLM limit. A present
PromptKit local-backend object requires and trims its endpoint, defaults its
omitted concurrency limit to zero, and is copied so the parsed file model
cannot alias the populated **Config**. A pipeline `llm_profile` is
presence-aware: omission remains empty, while a present blank value is
rejected and a non-empty file value is trimmed before it reaches **Config**.
ConfigKit acquires the selected root, imports, optional selected overlay, and
explicit CLI overrides in precedence order. Its document definition supplies
typed defaults, source-aware path normalization, and structural validation
before conversion to the independently owned runtime **Config**. Only
`NOTARIUS_CONFIG` participates in discovery; retired field-level environment
variables do not participate in loading. Version 5 decoding rejects scalar
module bindings and scalar external references. ConfigKit retains redacted,
historical inspection metadata while **LoadedConfig** keeps that implementation
detail private behind effective and explanation writers.
**Config.Validate** checks configuration-only invariants before resolution. It
rejects incompatible profile sources, invalid state-surface values, unsupported
@@ -52,18 +44,21 @@ concurrency settings, malformed bindings and references, invalid retries, and
invalid pipeline, step, or lane structure. PromptKit local-backend validation
accepts only an absolute HTTP or HTTPS endpoint with a host and no user
information, query, or fragment, and rejects a negative local concurrency
limit. Its errors retain the closest known pipeline, lane, and binding context.
limit. It also rejects a negative `promptkit.transient_retries` value; decoded
omission resolves to two, while explicit zero remains a distinct opt-out. Its
errors retain the closest known pipeline, lane, and binding context.
It deliberately does not require modules to be registered: that requires a
catalog and belongs to resolution.
The exact user-selectable values and validation rules are defined in
[Configuration](../config.md). Keep additions to the file model, an
environment override, its validation, and that reference in the same change.
[Configuration](../config.md). Keep document schema, source-aware normalization,
runtime conversion, and that reference aligned in the same change.
## Effective Resolution
**Config.Resolve** first recomputes derived concurrency defaults and validates
the configuration. It normalizes the requested pipeline ID, copies the selected
**Config.Resolve** derives an absent extraction-worker value in an owned copy
and validates the configuration. A programmatically supplied worker value is
always explicit. It normalizes the requested pipeline ID, copies the selected
profile, and passes the non-empty command-level LLM profile override, requested
lane selection, and reference changes to the framework resolver.
@@ -110,43 +105,39 @@ bytes, a debug payload, or all runtime state. The CLI records it as invocation
provenance before execution; cache and checkpoint identity have additional
owners in [Run State Internals](state.md).
Configuration summaries must use **Redacted**, **RedactedSummaryPayload**, or
**RedactedResolvedPipelinePayload**, never a direct configuration marshal.
Those methods copy every binding and nested option container, replace values
whose key is credential-shaped with **[REDACTED]**, and omit materialized
reference content while retaining safe binding and reference provenance. The
payload must not alias the source configuration or resolved pipeline.
PromptKit's local endpoint and concurrency limit are preserved as non-secret
configuration metadata in the independently owned summary; the object contains
no credential value. This redaction is deliberately narrow: it protects
configuration summaries and does not authorize recording arbitrary environment
values or provider requests.
Configuration publication uses ConfigKit's redacted effective YAML. Resolved
pipeline publication uses **RedactedResolvedPipelinePayload**, which copies
bindings and nested option containers, replaces credential-shaped values with
**[REDACTED]**, and omits materialized reference content while retaining safe
provenance. This redaction is deliberately narrow and does not authorize
recording arbitrary environment values or provider requests.
## Invariants To Preserve
- Defaults, YAML values, and environment values are applied in one direction;
later sources may override only their supported operational settings.
- ConfigKit composition applies defaults, imports, an optional overlay, and
explicit overrides in one direction with retained origin metadata.
- A configuration is structurally valid before it is resolved, and a resolved
pipeline is compatible with the supplied catalog before preparation begins.
- Whitespace-normalized identifiers are unique wherever they identify a
pipeline, step, lane, worker, or reference slot.
- Resolution and summary generation return detached data. Redaction must cover
every configured and resolved binding, including nested validator bindings.
- Runtime conversion and resolved-pipeline publication return detached data.
- The resolved PromptKit transient-retry budget is always passed to the shared
built-in client; document presence preserves an explicit zero.
- The resolved digest changes when resolved composition changes and never
includes itself.
## Focused Tests
- **internal/core/config/file_config_contract_test.go** covers strict file
parsing, normalization, file application, and structural rejection.
- **internal/core/config/env_contract_test.go** covers supported operational
overrides and their precedence.
- **internal/core/config/document_test.go** and
**operational_document_test.go** cover ConfigKit document loading,
normalization, imports, overlays, and overrides.
- **internal/core/config/validation_contract_test.go** covers configuration
invariants and contextual failures.
- **internal/core/config/effective_config_contract_test.go** covers defaults,
selections, overrides, resolution context, digest changes, and ownership.
- **internal/core/config/redaction_test.go** covers recursive credential
redaction, reference-content exclusion, and non-aliasing payloads.
- **internal/core/config/loaded_config_test.go** covers safe effective and
explanation rendering; **redaction_test.go** covers resolved-pipeline
redaction and reference-content exclusion.
Run **go test ./internal/core/config** after changing this boundary. Changes to
the handoff or resolved-composition semantics also need the focused framework

View File

@@ -216,6 +216,19 @@ than output-validation failures. Apart from documented context, capacity, and
invalid-output categories, provider error values and types do not cross the
adapter error chain; callers receive only a credential-redacted diagnostic.
`LLMGenerationError` preserves the provider-neutral failure kind (`transport`,
`provider_status`, `malformed_response`, or generic `generation`) and any HTTP
status. When PromptKit exhausts an eligible transient-status retry budget, it
also records that exhaustion and the attempts in that generation. Those fields
do not include repair generations, and zero attempts means unavailable rather
than proof that no request was sent.
Every execution that reaches PromptKit returns an optional request-local
`execution` summary. It contains only terminal aggregate attempts, retry and
repair counts, durations, outcome and failure classification, status, and
presence-aware token usage. Preparation failures have no summary; later local
decode failures retain the upstream terminal summary without changing it.
When PromptKit rejects backend admission before generation, the adapter maps
`promptkit.ErrCapacityExceeded` to
`contracts.ErrLLMCapacityExceeded`, retaining prompt context and a redacted
@@ -258,14 +271,18 @@ diagnostics; see
## Timeout Ownership
The caller context remains the outer cancellation authority. PromptKit applies
a positive effective generation timeout as an inner request deadline; an
explicit zero disables only that generation deadline. The HTTP client timeout
is a separate transport-wide cap. Notarius forwards the caller context and
does not install another timeout wrapper around PromptKit.
a positive effective generation timeout as an inner request deadline for each
built-in generation attempt; an explicit zero disables only that generation
deadline. The HTTP client timeout is a separate transport-wide cap. Notarius
forwards the caller context and does not install another timeout wrapper around
PromptKit.
The selected PromptKit profile owns generation settings. Notarius binding
retries remain outside the adapter and repeat the complete module operation
and validation chain. PromptKit does not add a provider retry loop.
The selected PromptKit profile owns generation settings. The resolved global
`promptkit.transient_retries` configuration is passed to the shared PromptKit
engine and controls additional built-in attempts for eligible transient HTTP
failures in one generation. It defaults to two; explicit zero disables those
attempts. Notarius binding retries remain outside the adapter and repeat the
complete module operation and validation chain.
Operator-facing behavior is summarized in
[Operations](../operations.md#operational-limits), and the pinned upstream
contract is identified in

View File

@@ -26,7 +26,7 @@ physical state roots.
| --- | --- | --- |
| Executable and command boundary | **cmd/notarius**, **internal/cli** | Process entry, command dispatch, configuration discovery, production composition, runtime collaborator setup, durable file placement, and user-facing reporting. |
| Build information | **internal/buildinfo** | Resolves a stable linked release tag or build metadata for the diagnostic root version command. |
| Configuration | **internal/core/config** | Defaults, strict YAML parsing, environment overrides, structural validation, effective resolution, redaction, and resolved-composition summaries. |
| Configuration | **internal/core/config** | ConfigKit document definition, source-aware normalization, runtime conversion, structural validation, safe inspection, effective resolution, and resolved-composition redaction. |
| Generic models | **internal/core/source**, **internal/core/artifacts**, **internal/framework/contracts** | Source documents and chunks, manifests and provenance, plus typed artifact, reference, validation, output, and structured-completion contracts. |
| Pipeline framework | **internal/framework/pipeline** | Registries, profile and reference resolution, typed preparation, validation, retry coordination, ordered execution, handoff, and result assembly. |
| LLM and prompt runtime | **internal/framework/llm**, **internal/framework/promptfs** | Provider-neutral structured completions, scheduling, profile recording, prompt assets, schema registration, and credential-shaped-value redaction. |

View File

@@ -52,7 +52,7 @@ consumers do not repeat profile inheritance or configuration resolution.
Each LLM request receives its own copy of that resolved value. PromptKit spends
it only for structural correction inside one completion; the runner's binding
retry policy remains the separate outer budget for complete stage attempts.
Configuration resolution supplies the selected profile and catalog; see
Configuration resolution supplies the selected overlay and catalog; see
[Configuration Internals](configuration.md).
External reference materialization happens before preparation. The materializer

View File

@@ -1,5 +1,9 @@
# Run State Internals
LLM debug response artifacts and their safe per-call references retain an
optional terminal execution summary when one is available. This metadata is
diagnostic only and does not change receipts, checkpoints, or aggregate usage.
This document describes the implementation collaborators behind output, cache,
and debug state. User-visible fields belong in [Configuration](../config.md),
and physical layout, retention, recovery, reason codes, and cleanup belong in
@@ -32,11 +36,18 @@ owned by the [Accepted Chunk Map contract](../integrations/chunk-map.md).
`internal/framework/chunkplan` owns source-addressed plan storage, validation,
and atomic publication. Its store is constructed only when the selected mode is
not `bypass`.
not `bypass`. It preserves rooted entry checks while creating new cache roots
and source-digest directories with requested mode `0777`, then creates an
atomic temporary file through rooted `O_CREATE|O_EXCL` with requested mode
`0666` before sync and rename.
`internal/framework/checkpoint` owns checkpoint identity, manifests, payload
codecs, loader, and recorder. The CLI constructs a recorder whenever checkpoint
recording is enabled and constructs a loader only for a `--resume` invocation.
The recorder writes manifest and payload JSON through
`fileio.WriteJSONWithProcessPermissions`, whose confined atomic writer creates
new directories with requested mode `0777` and temporary files with requested
mode `0666` before publication.
Identity incorporates explicit stable semantic fingerprints collected from
prepared modules and validators in addition to configuration, input,
references, runtime overrides, observed LLM profiles, and the LLM runtime's
@@ -86,7 +97,10 @@ and debug-summary JSON.
canonical operator-facing reason-code reference.
`internal/core/fileio` provides confined atomic file writes used by state
collaborators. The chunk-plan store retains its stronger entry validation.
collaborators. Its exact-mode writer remains available to output and debug
callers; its process-permission writer is used by checkpoints. The chunk-plan
store retains its stronger rooted entry validation. [Operations](../operations.md#cache-provisioning)
owns cache mode and provisioning guidance.
The CLI constructs selective-recomputation policy from resolved generated
artifact dependencies. It forces the selected step and transitive consumers,

View File

@@ -50,11 +50,45 @@ directory selects a per-user root:
The field definitions and configuration examples are in [Configuration](config.md).
On supported Unix systems, output directories and files are created with
requested modes **0755** and **0644**. Chunk-plan, checkpoint, and debug
directories and files use **0700** and **0600**. The operating system's umask
may impose stricter output modes. Cache and debug roots may contain sensitive
source-derived data, so provision them for one trusted account or service. An
output bundle can also contain source content when its JSON output enables
requested modes **0755** and **0644**; debug directories and files use
**0700** and **0600**. The operating system's umask may impose stricter output
or debug modes. Chunk-plan and checkpoint caches instead request maximum modes
of **0777** for new directories and **0666** for new files, including atomic
temporary files. The kernel applies the service's umask and applicable default
ACLs when those entries are created.
## Cache Provisioning
Cache locations select where Notarius writes, not who can access them. A
private service can use a restrictive umask:
~~~sh
umask 0077
notarius run example --config /etc/notarius/config.yml --input /srv/input.txt
~~~
A trusted-group deployment can provision a setgid cache root and use a
group-oriented umask (or an equivalent default ACL):
~~~sh
install -d -m 2770 -g notarius-operators /srv/notarius/chunk-plans /srv/notarius/checkpoints
umask 0007
notarius run example --config /etc/notarius/config.yml --input /srv/input.txt
~~~
Setgid and default ACLs control inherited group ownership and access policy;
Notarius does not copy a parent directory's literal mode bits. Cache state is
reconstructible but can contain source-derived data, so operators own its
access and retention policy. Existing cache inodes retain their prior modes
after an upgrade. Recreate a cache beneath correctly provisioned parents, or
stop writers and perform a deliberate administrative migration when a
whole-tree change is required.
Cache publication remains atomic and stored records remain validated. Those
guarantees do not add cross-process locking or transactional coordination for
simultaneous writers to one checkpoint identity.
Output bundles can also contain source content when JSON output enables
evidence publication. Apply an appropriate umask and output-root access policy
before enabling that option; the requested output modes alone may not be
suitable for transcript-bearing bundles.
@@ -81,7 +115,7 @@ precedence rules are owned by [Configuration](config.md#promptkit-profiles).
Use a profile source owned by the service account, keep it readable only by
the intended operator, and supply provider credentials through the service
environment—not in the Notarius configuration or profile YAML. The maintained
[operator profile](../examples/profiles/dnd-extraction.yml) is secret-free and
[LLM profile](../examples/llm-profiles/dnd-extraction.yml) is secret-free and
can be copied as a format starting point. Validate a deployment without a
provider call or credentials:
@@ -92,11 +126,11 @@ notarius config validate --config /etc/notarius/config.yml --pipeline dnd-sessio
An unset optional `api_key_env` reaches the provider without authorization and
may receive a 401 or 403 response.
Profile paths are currently resolved from the process working directory, not
from the configuration file. The complete example's
`./examples/profiles/dnd-extraction.yml` path is valid for a repository-root
invocation only. Use absolute paths such as
`/etc/notarius/profiles/dnd-extraction.yml` for services and containers.
Configured PromptKit profile paths resolve from the file that supplies their
surviving declaration. The complete example's
`./llm-profiles/dnd-extraction.yml` path is therefore relative to that example.
Use absolute paths such as `/etc/notarius/profiles/dnd-extraction.yml` for
services and containers when that is clearer for the deployment.
## Run Lifecycle
@@ -113,13 +147,32 @@ their exit classes are defined in the [CLI reference](cli.md#output-streams-and-
## Validation Retries And Terminal Outcomes
When debug recording is enabled, per-call response artifacts include the
terminal execution summary for retry, repair, timing, and presence-aware usage
diagnosis. Normal output, receipts, and warnings do not aggregate or publish
this diagnostic metadata.
Each producer binding has one outer **retries** budget. It covers complete
producer attempts for operational failures, invalid structured output,
normalizer fallback retry, and semantic correction. It is independent from
PromptKit's structural-repair calls inside one completion and from an
LLM-backed validator's own retry budget. A semantic correction rebuilds the
ordinary producer request and supplies only the latest rejected model response
plus aggregated validator guidance; it is not a conversation replay.
PromptKit's structural-repair calls inside one completion, its configured
`promptkit.transient_retries` built-in HTTP attempts, and an LLM-backed
validator's own retry budget. The transient setting defaults to two additional
attempts for eligible provider statuses; set it explicitly to zero when an
operator needs one HTTP attempt per generation. A semantic correction rebuilds
the ordinary producer request and supplies only the latest rejected model
response plus aggregated validator guidance; it is not a conversation replay.
These budgets multiply only by their declared scopes. With two outer producer
retries, two transient retries, and one structural repair, a single producer
can issue up to 18 HTTP attempts: three producer attempts, each with an initial
generation and one repair generation, each allowing three HTTP attempts. This
is an illustration, not a total-deadline or exactly-once guarantee. Every
built-in HTTP attempt has its own generation timeout; caller cancellation
remains the overall authority and interrupts Retry-After waiting. The shared
Notarius scheduler keeps its permit through those waits, so retry budgets can
increase latency and provider cost. Observed usage can remain incomplete when
providers omit accounting fields.
After the applicable budgets are exhausted, the resolved
[`validation_policy`](config.md#pipelines) determines the result. Structural
@@ -274,7 +327,8 @@ Only a [debug-enabled run](cli.md#run) creates a bundle:
trace/
~~~
The summary contains redacted invocation and resolution information plus run,
The summary contains redacted invocation and resolution information, including
`effective-config.yml` and the separately redacted resolved pipeline, plus run,
final grouped diagnostic, checkpoint, chunk-plan, and terminal reporting artifacts. Attempt
terminal records contain bounded attempt kinds, validator outcomes, policy,
decision, PromptKit repair count, and usage; they do not contain assistant
@@ -284,7 +338,8 @@ content. Neither surface is a cache input. Do not treat a debug bundle as safe
to share merely because its configuration summary is redacted. Invocation
metadata omits reasoning effort when it is inherited, records the replacement
value when one is supplied, and records an empty value when inherited reasoning
was explicitly cleared.
was explicitly cleared. It records `config_overlay` only when a non-base
configuration overlay was selected.
Notarius never creates debug state without an explicit request and never
automatically deletes a requested bundle. If allocation succeeds, the command
@@ -316,7 +371,9 @@ Provider execution settings and the generation timeout come from the selected
PromptKit profile. The invocation-only **--reasoning-effort** and
**--clear-reasoning-effort** controls may replace or clear that profile setting
for all LLM-backed calls in one run without changing the profile. PromptKit
structural output repair happens within one structured-completion call. Its
v0.11.0 also retries eligible transient HTTP failures with its built-in default
of two additional attempts per generation. This retry work is separate from
structural output repair, which happens within one structured-completion call. Its
effective `structured_output_repair_attempts` limit is resolved from the
selected binding, then the pipeline, then the prompt declaration; see
[module bindings](config.md#module-bindings-and-validators). This is distinct
@@ -324,19 +381,20 @@ from Notarius binding **retries**, which rerun the complete module operation
and validation chain and do not consume or replenish the structural-repair
limit. The maintained production prompts declare one repair attempt, paid only
after a structural failure. One structured completion with repair budget **R**
makes at most **R + 1** serial provider calls. If one stage attempt performs
**C** structured completions, a binding with **retries: N** has a maximum of
**(N + 1) * C * (R + 1)** provider calls; LLM-backed validators have their own
corresponding invocation counts and budgets. This is an upper bound, not a
promise that every call reaches a provider.
contains at most **R + 1** serial generations. Each generation can now contain
more than one provider call when PromptKit retries an eligible transient
failure. LLM-backed validators have their own corresponding invocation and
repair budgets. These are bounds on attempted work, not a promise that every
call reaches a provider.
Timeouts are layered. Caller cancellation is the outer authority. A positive
effective generation timeout adds an inner request deadline, while zero
disables only that generation deadline. The HTTP client timeout remains a
transport-wide cap. Notarius does not add another timeout around PromptKit.
Repairs are serial within the same caller context, so their worst-case latency
and cost follow the provider-call bound above; provision run deadlines and
provider budgets accordingly. Credentials remain optional unless the selected
effective generation timeout adds an inner request deadline to each built-in
generation attempt, while zero disables only that generation deadline. The HTTP
client timeout remains a transport-wide cap. Notarius does not add another
timeout around PromptKit. Retries and repairs are serial within the same caller
context, so their worst-case latency and cost can exceed the structural-repair
bound above; provision run deadlines and provider budgets accordingly.
Credentials remain optional unless the selected
PromptKit profile requires one, in which case preparation fails before a
provider call when its configured credential is unavailable.
The pinned upstream boundary and profile-format links are in

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

@@ -0,0 +1,90 @@
# Agent Policy
**Base Policy Version:** 1.1
**Last Updated**: 2026-09-25
**Upstream Source:** `policy` commit `45591a79004c6811950f14dca554f6ff69b7f58e`.
Notarius adaptations preserve operator choice, existing explicit contracts, and
the deployment-permissions example below.
## Purpose
Agents working in this repository must read and adhere to the following house rules.
## Implementation Discipline
Simplify the mechanism, not the user's choices.
**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. Existing
explicit contracts remain authoritative; changing or weakening one requires a
deliberate decision rather than being inferred from these principles.
[ADR-0017](../adr/0017-defer-cache-access-policy-to-deployment.md) illustrates
why Unix deployments own cache access policy through permissions, umask, setgid,
and default ACLs. Notarius retains its separately adopted correctness boundaries.
## Git Commit Policy
By default, create a commit after completing each coherent 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. Do not commit after every intermediate edit or create empty commits.
Complete the appropriate validation before committing. If work is incomplete or
validation is blocked, report that state rather than presenting it as completed.
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. Do not disturb unrelated
staged or unstaged work.
4. Review `git diff --staged` and run appropriate checks for the change. Do not
repeat checks already passed for unchanged content merely to create a commit.
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,11 +1,25 @@
# Architecture
**Base Policy Version:** 1.1
**Last Updated**: 2026-09-25
**Upstream Source:** `policy` commit `45591a79004c6811950f14dca554f6ff69b7f58e`.
Notarius adapts the base template to its fixed pipeline, artifact families,
external adapters, state boundaries, and source-only distribution.
This document defines the intended high-level architecture of Notarius and the
invariants that changes must preserve. Implemented component details belong in
[Internal Overview](../internal/overview.md) and its linked documents. The
reasoning behind significant architectural choices belongs in
[ADRs](../adr/).
An adopted constraint can govern future changes without implying that every
component it could govern exists. Descriptions of implemented structure must
remain accurate; proposals and transition status belong in roadmaps and ADRs.
Constraints reflect selected requirements, not incidental implementation choices.
## System Shape
Notarius is a small, dependency-light Go application for extracting structured
@@ -38,6 +52,8 @@ Notarius is contract-first without being abstraction-heavy. Interfaces and
extension points should protect demonstrated boundaries. New abstraction is not
itself an architectural goal.
Implementation discipline is owned by [Agent Policy](agents.md).
## Layers And Dependency Direction
The application boundary is the composition root and may depend on concrete
@@ -261,9 +277,11 @@ beneath this mandatory application-wide scheduler.
## Configuration And Provenance
Configuration loading, precedence, defaults, environment overrides, redaction,
and validation are centralized. Named pipeline definitions make structural
composition explicit and discoverable. Operational overrides are permitted
Configuration loading, precedence, defaults, redaction, and validation are
centralized through the ConfigKit-backed boundary. `NOTARIUS_CONFIG` is limited
to discovery; field-level environment overlays are not configuration input.
Named pipeline definitions make structural composition explicit and discoverable.
Explicit command overrides are permitted
when they do not obscure the configured pipeline structure.
Run preparation fails before stage execution when statically discoverable
@@ -277,6 +295,13 @@ model, correction, or large payload content.
## State, Output, And Safety
Source material and model responses cross application validation boundaries.
Host access policy belongs to the deployment; hypothetical privileged actors
concurrently changing the filesystem do not justify new application defenses
without an explicit decision. This scope statement does not remove the adopted
correctness boundaries below or claim excluded scenarios cannot occur. Follow
[Agent Policy](agents.md) when a new threat or guarantee needs a decision.
Notarius exposes three filesystem surfaces with independent roots and
lifecycle:
@@ -293,6 +318,15 @@ an invocation that explicitly requests resume. Debug is never a cache input and
is never created without an explicit request. Pipeline modules receive
collaborator interfaces and never physical roots.
Deployments own cache access policy. Cache writers request conventional maximum
creation modes and let the process umask, setgid inheritance, and default ACLs
determine effective access. Notarius owns the separate safety boundary:
confined paths, symlink rejection, atomic publication, validation, and
recovery. It does not chmod, chown, chgrp, or migrate cache entries to impose
an application policy. This rule applies to chunk-plan and checkpoint cache
only; output and debug retain their separate contracts. [Operations](../operations.md)
owns deployment provisioning and retention guidance.
Only accepted, completely validated producer output is reusable checkpoint or
chunk-plan state. Rejected, structurally invalid, and validation-incomplete
results cannot become cache or checkpoint inputs, even when a
@@ -323,8 +357,9 @@ best-effort development and compilation environment, while Windows is
unsupported. Notarius distributes source releases only: an immutable source
tag and its checked-in release note identify a release. The project does not
publish executable binaries, archives, installers, container images,
checksums, signatures, or package-manager entries. Maintainer release commands
and tag guards belong to [Source Releases](../release.md).
checksums, signatures, or package-manager entries. [Release Policy](release.md)
owns publication requirements and completion; maintainer commands and guards
belong to [Source Releases](../release.md).
## Architectural Non-Goals

View File

@@ -1,5 +1,14 @@
# Documentation Policy
**Base Policy Version:** 1.3
**Last Updated**: 2026-09-25
**Upstream Source:** `policy` commit `45591a79004c6811950f14dca554f6ff69b7f58e`.
Notarius retains subprocess-consumer ownership, the distinction between
descriptive behavior and policy, and no placeholders for absent interfaces.
## Purpose
This policy assigns each documentation topic to one canonical owner. Its goal is
@@ -24,12 +33,28 @@ contract, maintain it only in the owning document.
Outside `docs/roadmap/`, documentation describes implemented behavior only.
Partial features may be described only to their implemented boundary.
ADRs are the narrow exception: an ADR may record an accepted architectural
decision before implementation, but acceptance must not be presented as proof
Architectural constraints may govern future changes without implying that the
components or capabilities they would govern already exist. This does not permit
describing a proposed system structure as implemented.
ADRs may record an accepted architectural decision before implementation, but
acceptance must not be presented as proof
that the behavior exists. The roadmap owns implementation status and sequencing
until the decision is implemented. Current architecture, user, operator,
integration, and internal documentation are updated when the behavior lands.
### Implementation Details And Policy
Documentation must distinguish an explicit contract from an incidental
implementation choice. Do not describe a restriction, guarantee, security
policy, default, or limit as authoritative merely because the current code
implements it. Documentation may accurately describe current behavior without
making it a permanent requirement; distinguish descriptive behavior from
normative policy. The
[implementation-discipline rules](agents.md#implementation-discipline)
own the criteria for adopting product restrictions and for elevating behavior
into repository policy.
### Audience And Detail
Write for the document's stated audience and include only the detail needed for
@@ -59,16 +84,18 @@ secret values.
| --- | --- | --- | --- |
| Product orientation and minimal end-to-end quickstart | `README.md` | What Notarius is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, implementation detail. |
| Contributor entry point | `docs/development.md` | Task-oriented reading guide, minimal contributor orientation, baseline validation commands, and links to canonical docs. | Package inventory, architecture rules, subsystem behavior, detailed change recipes. |
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, architectural boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
| Application architecture policy | `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` | Implementation discipline, rules against invented restrictions or guarantees, and local commit authorization. | 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. |
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression-test policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
| CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, and exit codes. | End-to-end operating procedures, configuration field definitions, runtime filesystem layout, module implementation details. |
| Configuration contract | `docs/config.md` | Discovery and precedence, file schema, fields, defaults, environment overrides, validation rules, and user-selectable module or validator keys. | Complete example files, CLI syntax, runtime state lifecycle, module implementation details. |
| Operations | `docs/operations.md` | Runtime workflows, physical filesystem and state layout, output, cache, and debug handling, resume, cleanup, permissions, recovery, and operational limits. | CLI flag syntax, configuration field definitions, logical output schemas, implementation mechanics. |
| Source release procedure | `docs/release.md` | Maintainer release selection, candidate validation, tagging, publication guards, verification, and immutable-tag recovery. | Product installation summary, CLI version semantics, historical release summaries, CI implementation detail. |
| Release policy | `docs/policy/release.md` | Versioning, compatibility, integrity, immutability, proportionate gate selection, publication controls, and release completion. | Concrete commands, product installation, historical notes, and CI implementation detail. |
| Repository-specific release procedure | `docs/release.md` | Prerequisites, candidate preparation, publication command, automated guards, optional diagnostics, and failure recovery. | General release requirements, product installation guidance, historical summaries, and current compatibility contracts. |
| Release-note history | `docs/releases/` | One checked-in historical summary for each source release made under the procedure. The note at the immutable tag is that release's record. | Current commands, behavior, contracts, and compatibility definitions. |
| Public HTTP contract, if introduced | `docs/api.md` | Routes, authentication, media types, request and response schemas, status codes, pagination, caching, idempotency, rate limits, and HTTP retry semantics. | Client walkthroughs, upstream or downstream integration internals, implementation detail. |
| Consumer guidance, if a public package or API is introduced | `docs/consumers/` | Task-oriented use of the public interface, minimal client examples, and consumer responsibilities. | HTTP wire semantics, external protocol contracts, internal implementation detail. |
| Consumer guidance | `docs/consumers/` | Task-oriented subprocess use, minimal client examples, consumer responsibilities, and guidance for any future public package or API. | Wire semantics, external protocol contracts, internal implementation detail. |
| External and durable integration contracts | `docs/integrations/` | External file formats and protocols, upstream and downstream contracts, logical output bundle paths and schemas, media types, and compatibility behavior. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, configuration defaults. |
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
| Internal component behavior | Other files under `docs/internal/` | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
@@ -99,11 +126,13 @@ other contracts.
### Releases
`docs/release.md` owns the source-release procedure. Release notes are
The [release policy](release.md) owns requirements and invariants; the
[repository procedure](../release.md) owns the commands that satisfy them.
Release notes are
historical summaries, not current-state contract owners: the checked-in note at
an immutable tag records that release, while current canonical documentation
must change with the behavior it describes. Do not use a release note to defer
or replace current documentation updates.
must change with the behavior it describes. Do not use a release procedure or
note to weaken policy, redefine current contracts, or defer documentation updates.
### Contracts And Implementation

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

@@ -0,0 +1,204 @@
# Release Policy
**Base Policy Version:** 1.1
**Last Updated**: 2026-09-25
**Upstream Source:** `policy` commit `45591a79004c6811950f14dca554f6ff69b7f58e`.
Notarius adaptations: source-only delivery, lightweight tags, branch-before-tag
publication, no prereleases, and the justified native command check below.
## 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` and the authoritative remote is `origin`.
Tags are lightweight. Publish the candidate to `main` before pushing its tag.
A successful push of the intended tag, with its source commit available on the
authoritative primary branch, completes the release. Notarius uses
branch-before-tag publication; it does not require an atomic push.
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.
Notarius publishes source only: no binaries, archives, images, packages,
checksums, signatures, or Gitea release objects. Tag CI may provide advisory
validation but publishes no deliverables and is not a completion gate.
## Versioning And Compatibility
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with
no leading zeroes. Notarius does not support prereleases.
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. Keep the note proportionate to the change. Fixed sections and
explicit no-action boilerplate are not required. Existing historical notes
need not be rewritten to adopt this format.
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. A release note must not substitute for current documentation.
Do not create retrospective notes for the pre-procedure v0.1.0, v0.2.0, or
v0.3.0 tags.
Version selection, release-note content, and retirement of completed roadmaps
are preparation responsibilities, not separate human approval gates. Preserve
planned and deferred work and repair affected links when retiring roadmaps.
Ordinary change review covers unintended files, credentials, and private data;
do not repeat it as a release-specific sign-off. Ignored local files are not
prohibited merely because they exist: they are not included in 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.
### Notarius Gate Selection
Run the default offline Go suite once and build the native `cmd/notarius` with
release version injection, checking its `--version` output. The command check
protects the actual executable entry point and linker-supplied version, which
package tests alone do not verify. Build products are temporary, not published.
Use `GOWORK=off` so a sibling checkout cannot substitute for declared dependencies.
Race tests, standalone vet, formatting/whitespace checks, module tidy audits,
standalone example validation, and Linux/Darwin cross-builds remain useful
change-specific development checks, not mandatory release gates. Default tests
already cover maintained examples. Source-only publication does not require a
binary distribution matrix. No new platform or hardware prerequisites are added.
## 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 branch push advances authoritative `main` to the candidate without
discarding remote history, before the tag is published; 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. Advisory CI or optional installation checks
may be retried against the same immutable tag. Notarius has no asynchronous
artifact delivery to recover.
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 and ambiguous-push recovery.
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,5 +1,14 @@
# Testing Policy
**Base Policy Version:** 1.1
**Last Updated**: 2026-09-25
**Upstream Source:** `policy` commit `45591a79004c6811950f14dca554f6ff69b7f58e`.
Adopted without substantive deviations; Notarius's established contracts remain
subject to [Agent Policy](agents.md).
## Purpose
Our tests exist to make **incorrect changes expensive and correct changes cheap**.
@@ -8,29 +17,16 @@ We do not optimize for test count, line coverage, exhaustive isolation, or the f
## Every test has a cost
Testing is not an unqualified good. Every test imposes both an immediate cost and a continuing lifetime cost.
A test must be:
- written and reviewed;
- understood by future maintainers and coding agents;
- executed in local and CI workflows;
- diagnosed when it fails;
- updated when legitimate behavior changes;
- maintained as fixtures, APIs, and dependencies evolve; and
- removed or rewritten when it becomes redundant, brittle, misleading, or obsolete.
Tests also create cognitive and architectural friction. They can constrain refactoring, duplicate policy, slow feedback loops, add noise to failures, and cause harmless implementation changes to require unrelated edits across the suite.
Every test has an immediate and continuing cost: writing, review, execution,
diagnosis, fixture maintenance, and updates when legitimate behavior changes.
Tests can also constrain refactoring, duplicate policy, and add failure noise.
A test is warranted only when the confidence it provides justifies these costs.
Apply this cost-benefit analysis at two levels:
Evaluate that tradeoff at two levels:
1. **Per test:** What realistic defect does this test detect, how consequential would that defect be, and is that protection worth the test's lifetime cost?
2. **Across the suite:** Does this collection provide materially more confidence than a smaller, simpler suite would?
The preferred test suite is a **lean suite that provides sufficient confidence in the risks that matter, without redundant or low-value tests**. We seek sufficient confidence with the least unnecessary testing friction, not the fewest possible tests.
Some friction is intentional. Tests should make dangerous changes—such as breaking compatibility, corrupting data, violating security boundaries, or reintroducing subtle bugs—require deliberate review. They should not make ordinary internal changes needlessly expensive.
The cost of a test is not a reason to omit testing by default. Do not cite maintenance cost abstractly. When omitting a plausible test, be able to state why the protected failure is low-risk, already covered, obvious, reversible, or cheaper to detect elsewhere. For consequential, subtle, or difficult-to-observe behavior, the presumption should favor testing.
@@ -55,7 +51,7 @@ Control clocks, randomness, environment variables, and other process-global or m
## 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.
2. Domain rules and important invariants.
@@ -67,7 +63,18 @@ Prioritize tests for:
A package-level contract is behavior relied upon by another package or major collaborator, not every observable detail of a package implementation.
For behavior involving **data integrity, destructive operations, compatibility, security, concurrency, idempotency, or recovery**, presume that durable tests are required unless the behavior is already credibly protected at another layer.
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.
@@ -85,11 +92,7 @@ Do not force all behavior through oversized end-to-end tests. Do not test every
## Test behavior, not implementation
A test should protect a decision, contract, or invariant—not memorialize the current implementation.
Before adding or retaining a test, ask:
> What realistic defect would this test catch?
A test should protect a decision, contract, or invariant. A test must not memorialize the current implementation.
A test is suspect when its main purpose is to detect that someone:
@@ -102,8 +105,6 @@ A test is suspect when its main purpose is to detect that someone:
Refactoring should normally require no test edits unless the refactored structure is itself part of the contract.
A test can be factually correct and still have negative value. Accurately describing current behavior is not enough; the protected behavior must be important enough to justify the future friction.
## Expected effects of different changes
Use the following expectations when evaluating test failures and test maintenance:
@@ -121,10 +122,11 @@ A test failing is not the same as a test needing to be edited. Many tests may co
Configurable thresholds and defaults must not be duplicated throughout the test suite.
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
// Production policy:
// Production default:
const maxConcurrency = 4
// Brittle test:
@@ -142,25 +144,30 @@ require.NoError(t, runner.Start(limit))
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
- 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.
Apply the same rule to limits, timeouts, capacities, retry counts, and ranges: test relationships and behavior, not duplicated literals.
For concurrency limits, test both kinds of behavior when relevant:
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.
Use a test-controlled limit and measure the behavior relative to that limit. Do not merely assert today's default value.
## Avoid semantic duplication across layers
Each behavior should have a clear test owner.
Each behavior should have a clear test owner. Where these layers exist:
- Parser tests own parsing cases.
- Validator tests own validation rules.
@@ -172,8 +179,6 @@ Each behavior should have a clear test owner.
Higher-level tests should not repeat every lower-level case. A single intentional policy change should not require unrelated edits across many test files.
Tests that are individually reasonable may still be collectively redundant. Evaluate the marginal value of each additional test in light of the protection already provided by the rest of the suite.
## Use test doubles deliberately
Choose the least elaborate test double that provides the required control or observation.
@@ -197,15 +202,16 @@ Do not use mocks merely to isolate every object or reproduce the implementation'
## Go-specific guidance
Use:
Select techniques according to the behavior and risk being tested. The following
are options, not a checklist that every package or application must satisfy:
- table-driven tests for meaningful behavioral categories and boundaries;
- `t.TempDir()` for real filesystem behavior;
- `httptest.Server` for realistic HTTP interactions;
- fuzz tests for parsers, normalization, path handling, and broad input spaces;
- 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;
- 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.
@@ -223,8 +229,6 @@ Use it to find untested critical branches and unexpectedly weak packages. Do not
Pure domain logic will often warrant higher coverage than CLI wiring or external adapters. Uneven coverage is acceptable when it reflects risk.
Increasing coverage is valuable only when the newly covered behavior protects a meaningful risk at an acceptable cost.
## Regression tests
A bug fix should normally include a regression test that fails before the fix and passes afterward.
@@ -253,44 +257,26 @@ Strong candidates include tests that:
Several brittle tests may encode one genuine requirement. Replace them with one durable behavior-level or invariant test rather than preserving all of them.
Deleting a low-value test can improve the quality of the suite by reducing noise, maintenance burden, and friction around legitimate change.
## Reviewing a proposed test
Use the following questions when the value, boundary, or durability of a proposed test is not self-evident. Significant test additions should be reviewable against them, but written answers are not required for every routine test.
1. What realistic defect would it catch?
2. How likely is that defect?
3. How consequential would it be?
4. Is the behavior already protected elsewhere?
5. At which layer should this behavior be owned?
6. Does the test assert a durable contract or an incidental implementation detail?
7. Could the implementation be refactored without changing the behavior and without editing this test?
8. What should cause this test to fail?
9. What legitimate changes should not cause this test to fail?
10. What ongoing maintenance, execution, and diagnostic cost will the test impose?
11. Is there a smaller or more direct test that protects the same risk?
Do not add the test when its expected lifetime cost exceeds its expected protective value.
When deciding not to test plausible behavior, record or be able to explain why the risk is low, already protected, obvious, reversible, or cheaper to detect elsewhere.
1. Which requested behavior or established contract does it protect, and what realistic defect would violate it?
2. How likely and consequential is that defect, and is it already protected elsewhere?
3. What is the narrowest stable boundary that can test it clearly?
4. Would a correct internal refactor or a change to an incidental default require test edits?
5. Does its confidence justify its maintenance, execution, and diagnostic cost, or would a smaller test suffice?
## Definition of sufficient
A test suite is sufficient when:
- important contracts and invariants are protected;
- meaningful boundaries and failure modes are exercised;
- realistic and consequential regressions are credibly protected against silent recurrence;
- behavior involving data integrity, destructive operations, compatibility, security, concurrency, idempotency, and recovery is credibly protected;
- important external boundaries have realistic integration coverage;
- representative complete workflows are tested;
- important requested behavior and established contracts are protected against realistic, consequential failures;
- boundaries and component interactions have coverage where they introduce meaningful risks not protected elsewhere;
- failures provide useful signal rather than redundant noise;
- legitimate internal changes usually do not require test edits; and
- additional tests would mostly repeat existing protection or preserve inconsequential implementation details.
Sufficiency is a risk judgment, not a coverage percentage or test count. Reassess it as the application, its users, and the consequences of failure evolve.
The governing rule is:
> Test heavily where failure is consequential, subtle, or difficult to detect after the fact. Test lightly where failure is obvious, reversible, and inexpensive—and retain no test whose lifetime cost exceeds the confidence it provides.
Sufficiency is a risk judgment, not a coverage percentage, test count, or required
mix of testing techniques. Reassess it as the application, its users, and the
consequences of failure evolve.

View File

@@ -1,164 +1,84 @@
# Source Releases
This procedure is for maintainers publishing Notarius source releases. A
release is an immutable lightweight `vMAJOR.MINOR.PATCH` tag on `main` together
with its checked-in `docs/releases/<tag>.md` note. Tag CI validates that source
candidate after publication; it does not publish or repair a release.
This is the executable runbook for [Release Policy](policy/release.md).
Notarius publishes an immutable lightweight tag and its checked-in release note,
not binaries or Gitea release objects. Linux is supported; macOS is best-effort
for development, and Windows is unsupported. `main` on `origin` is authoritative.
Notarius publishes no binaries, archives, checksums, signatures, containers,
package-manager entries, or Gitea release objects. Windows is not supported.
Do not create retrospective notes for the pre-procedure `v0.1.0`, `v0.2.0`, or
`v0.3.0` tags.
## Prepare
## Select And Describe The Release
Choose an unused stable semantic version in the form `vMAJOR.MINOR.PATCH`.
Prereleases are not supported. Before `v1.0.0`, a minor release may change a
documented CLI, configuration, durable artifact, integration, or operating
contract when its note explains the impact and required operator action. A
patch release must not intentionally break those documented contracts within
its minor line.
Create the version-matched note as part of the candidate. Every new note uses
this structure, with concise, truthful content in each section:
```markdown
# Notarius vMAJOR.MINOR.PATCH
This release ...
## Summary
## Compatibility
## Upgrade
## Changes
```
The note is a historical summary. Link to current canonical documentation for
exact behavior, and update that documentation in the candidate rather than
using the note as a substitute.
## Prepare The Candidate
Set the selected release version and disable Go workspace use for every
candidate command:
Use Git, a POSIX shell, and Go 1.25.5 or newer. Configure ordinary Git credentials
for `origin` and private-module access without putting secrets in the repository.
Prepare dependencies before offline validation where necessary:
```sh
RELEASE_VERSION=vMAJOR.MINOR.PATCH
export RELEASE_VERSION GOWORK=off
GOWORK=off GOPRIVATE=gitea.maximumdirect.net go mod download
```
Run the shared source-candidate checks from the repository. They cover module
hygiene, tests, race tests, vet, builds, formatting, whitespace, maintained
configuration validation, and the Linux and Darwin command-build matrix:
Choose an unused stable version under the policy's compatibility rules. Add
`docs/releases/<version>.md`, starting with `# Notarius <version>`, with a concise
summary and relevant compatibility/upgrade information. No fixed section list is
required. Update current documentation with behavior changes; retire only
completed roadmaps and preserve planned/deferred work. Commit the candidate on
`main` after ordinary change review. There is no separate release sign-off.
## Publish
Run one command from the repository with the actual selected version:
```sh
./scripts/check-release-source.sh "$RELEASE_VERSION"
./scripts/release.sh vMAJOR.MINOR.PATCH
```
Before committing, manually follow every changed local Markdown link and
review the candidate for unintended files, generated output, credentials, or
other unrelated changes. Commit the release note and all affected current
documentation, then run the shared checker against that exact candidate. Push
the candidate commit to `main` only after it succeeds. Record the exact commit
only after that push:
The command checks version availability on `origin`, branch and worktree state,
and the matching note. It runs the complete offline default test suite once and
a native CLI build/version check with `GOWORK=off`. The
[gate rationale](policy/release.md#notarius-gate-selection) explains the selected
checks. It then rechecks candidate and publication state, pushes the exact
candidate to `main` without force, creates a lightweight tag, and pushes only
that tag. It neither rewrites history nor publishes other local tags.
A successful tag push completes the source release. The command does not wait
for CI or verify consumer installation/module availability. Tag CI is advisory
validation only; there are no asynchronous deliverables to await.
## Failure And Recovery
Before publication, correct failed checks, commit any changes, and rerun the
command. If the candidate changes during validation, validation must run again
on the new candidate. A failed branch push publishes no tag.
On a failed or ambiguous push, the command queries the affected remote ref. If
a tag push reports failure but the intended lightweight tag is confirmed on the
remote, publication succeeded. Otherwise it stops and reports failure or unknown
status, leaving any local tag intact for inspection. Do not blindly rerun, force
push, or delete a possibly published tag.
Inspect authoritative state before retrying:
```sh
RELEASE_COMMIT=$(git rev-parse 'HEAD^{commit}')
export RELEASE_COMMIT
git ls-remote origin refs/heads/main refs/tags/vMAJOR.MINOR.PATCH
git rev-parse vMAJOR.MINOR.PATCH
```
For private-module installation, configure standard `GOPRIVATE` matching this
module and ordinary Git authentication for the hosting service before running
the verification below. The exact authentication mechanism belongs to the
maintainer environment; never record credentials or environment dumps in a
release note, command history, or repository file.
If the intended tag exists remotely, leave it immutable. If it is absent and
you have confirmed that the local tag was never published or used by a release
channel, remove only that unpublished local tag, then rerun the command. A
failed remote query is not proof of absence. Network/authentication failures
alone do not require a new version. A defect requiring changes to published
source requires a new semantically appropriate version and matching note.
## Guard And Publish The Tag
## Optional Diagnostics
Fetch current remote references, then run this guard without editing the
candidate. It requires `main`, a clean worktree and index, disabled workspace
use, a stable release version, the recorded and pushed commit, a matching note,
and unused local and remote tags:
`./scripts/check-release-source.sh <version>` runs validation without publication.
It is useful for diagnosis, but is not a required preliminary step that must
repeat the publication command's checks.
```sh
git fetch origin main --tags
Race tests, vet, module hygiene, formatting, example checks, and cross-builds may
be run when relevant to a change; they are not additional release gates. The
development guide and testing policy govern change-specific verification.
if ! printf '%s\n' "$RELEASE_VERSION" |
grep -E -x 'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' >/dev/null
then
printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2
exit 1
fi
test "$GOWORK" = off
test "$(git branch --show-current)" = main
test -z "$(git status --porcelain)"
test "$RELEASE_COMMIT" = "$(git rev-parse 'HEAD^{commit}')"
test "$RELEASE_COMMIT" = "$(git rev-parse 'origin/main^{commit}')"
test -s "docs/releases/$RELEASE_VERSION.md"
grep -F -x "# Notarius $RELEASE_VERSION" "docs/releases/$RELEASE_VERSION.md"
for heading in '## Summary' '## Compatibility' '## Upgrade' '## Changes'; do
grep -F -x "$heading" "docs/releases/$RELEASE_VERSION.md"
done
if git rev-parse -q --verify "refs/tags/$RELEASE_VERSION" >/dev/null; then
printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_VERSION" >/dev/null 2>&1; then
printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2
exit 1
fi
```
Create an explicitly lightweight tag against the guarded commit, verify its
target, and push only that tag ref:
```sh
git -c tag.gpgSign=false tag "$RELEASE_VERSION" "$RELEASE_COMMIT"
test "$(git cat-file -t "$RELEASE_VERSION")" = commit
test "$(git rev-parse "$RELEASE_VERSION^{commit}")" = "$RELEASE_COMMIT"
git push origin "refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION"
```
Never use `git push --tags`, move a published tag, or delete a published tag.
## Verify The Published Release
Confirm that the remote tag still points at the guarded commit and that the
note is available from the tagged tree:
```sh
REMOTE_TAG_COMMIT=$(git ls-remote origin "refs/tags/$RELEASE_VERSION" | awk '{print $1}')
test "$REMOTE_TAG_COMMIT" = "$RELEASE_COMMIT"
git show "$RELEASE_VERSION:docs/releases/$RELEASE_VERSION.md" >/dev/null
```
Verify a fresh source installation and its diagnostic version. The temporary
directory confines the installed command to this check:
```sh
release_verification_dir=$(mktemp -d)
trap 'rm -rf "$release_verification_dir"' 0 HUP INT TERM
mkdir -p "$release_verification_dir/bin"
GOWORK=off GOBIN="$release_verification_dir/bin" go install \
"gitea.maximumdirect.net/eric/notarius/cmd/notarius@$RELEASE_VERSION"
test "$("$release_verification_dir/bin/notarius" --version)" = "notarius $RELEASE_VERSION"
```
An exact fresh checkout and `GOWORK=off go build ./cmd/notarius` is an
equivalent source verification when local installation policy requires it.
`notarius --version` is diagnostic only; downstream compatibility remains
defined by the published receipt and artifact contracts.
## Failure And Correction Policy
If candidate validation fails before publication, fix the candidate on `main`,
rerun the shared checker, and repeat the guards. An unpublished local tag may
be deleted after inspection.
If the remote tag or tag CI reveals a defect, leave the published tag intact.
Fix the defect on `main`, choose a new patch version, write a new matching
note, and repeat this procedure. Do not weaken tag immutability or add release
assets as a workaround.
A fresh source install or exact-tag checkout/build may be used to investigate
consumer availability after release. Such checks are optional and do not delay
or reverse release completion. `notarius --version` is diagnostic; subprocess
compatibility is defined by the receipt and artifact contracts.

88
docs/releases/v0.7.0.md Normal file
View File

@@ -0,0 +1,88 @@
# Notarius v0.7.0
This release simplifies configuration management with ConfigKit and makes
cache access permissions a deployment responsibility.
## Summary
Notarius adopts ConfigKit v0.3.0 for strict configuration loading, imports,
configuration profiles, provenance, and redacted inspection. Operators can
compose whole pipeline definitions from separate files and select development
overlays without duplicating the base configuration.
Configured relative filesystem paths now resolve against their declaring
files, including imported definitions and profile replacements. The effective
configuration and its explanation reflect the paths used at runtime.
## Compatibility
- Configuration version 5 replaces version 4; there is no compatibility loader.
Numeric `version: 5` is the canonical spelling; quoted `"5"` also works.
- Module and validator bindings require objects with `module:`; external
references require objects with `path:`. Lane-level reference aliases are
removed; use binding-local references instead.
- Strict ConfigKit YAML parsing rejects aliases, merge keys, and unsupported
scalar forms. See the [configuration reference](../config.md) for the
complete contract and migration guidance.
- The seven operational environment overrides are removed:
`NOTARIUS_TOTAL_LLM_CONCURRENCY`, `NOTARIUS_STAGE_WORKERS_EXTRACT`,
`NOTARIUS_OUTPUT_DIR`, `NOTARIUS_CACHE_CHUNK_PLANS_MODE`,
`NOTARIUS_CACHE_CHUNK_PLANS_DIR`, `NOTARIUS_CACHE_CHECKPOINTS_DIR`, and
`NOTARIUS_DEBUG_DIR`. `NOTARIUS_CONFIG` still selects the configuration
file; PromptKit credential environment variables remain supported.
- Configured relative reference, PromptKit profile, output, cache, and debug
paths use the canonical declaring file's directory. Built-in relative
defaults use the main configuration directory. CLI-relative paths still use
the invocation directory; absolute paths are unchanged.
- Requested debug bundles now contain `effective-config.yml` instead of
`effective-config.json`. Inspection output is redacted and is not promised
to be reloadable configuration.
- Chunk-plan and checkpoint cache writers respect deployment permissions
through ordinary creation modes, umask, setgid inheritance, and default ACLs;
they no longer impose private permissions. Existing entries are not chmodded
or migrated. Output and debug access contracts are unchanged.
- Durable extraction artifact schemas and subprocess receipt formats are
unchanged by this release.
## Upgrade
1. Migrate deployed configurations to version 5 using the
[configuration migration guide](../config.md#version-5-migration). Expand
shorthand bindings and references, and move retired environment settings
into YAML or supported CLI overrides.
2. Review relative paths, particularly output/cache/debug directories and
PromptKit profile sources. Use absolute paths when preserving an existing
deployment location is important.
3. Review deployment access settings for cache directories. Provision umask,
groups, and ACLs as appropriate; Notarius does not choose the installation's
access policy. See [Operations](../operations.md).
4. Use `config validate --config <path> --pipeline <id>` for each pipeline,
including selected configuration profiles. Inspect resolved settings with
`config effective` and their provenance with `config explain`; see the
[CLI reference](../cli.md).
5. Update any debug-bundle tooling that consumes the former effective JSON
file. Subprocess callers may optionally select an overlay through
`--config-profile`; this is separate from PromptKit's `--llm-profile`.
The maintained [minimal](../../examples/dnd-minimal.config.yml) and
[complete](../../examples/dnd-complete.config.yml) D&D examples demonstrate
self-contained configuration and composed pipelines with a development profile.
Build from the source tag using the [source-release procedure](../release.md).
## Changes
- Replaced custom YAML parsing and environment-overlay machinery with a
ConfigKit-backed typed document boundary and detached runtime conversion.
- Added imports, configuration profiles, source-aware normalization, safe
effective/explanation commands, and source-file legends for load errors.
- Preserved explicit zeroes and empty validator overrides at supported stages,
rejected invalid reference branches before conversion, and retained
whitespace normalization for worker identifiers.
- Corrected normalization recording for ordered steps and validator sequences,
with regression coverage for runtime values and provenance.
- Accepted ConfigKit's integer representation at module-option boundaries
through checked shared conversion.
- Updated examples, configuration and subprocess guidance, and debug rendering.
- Reworked cache creation to respect deployment-selected permissions while
retaining confined paths, symlink rejection, atomic publication, validation,
and recovery.

44
docs/releases/v0.7.1.md Normal file
View File

@@ -0,0 +1,44 @@
# Notarius v0.7.1
This release updates the prompt engine and its provider catalogs while retaining
Notarius's application-owned D&D defaults and subprocess contracts.
## Summary
- Upgrade PromptKit from v0.9.0 to v0.10.0 and adopt its selected provider catalogs.
- Refresh the profile-source checkpoint identity for the updated catalog.
- Document profile ownership and upstream catalog compatibility.
## Compatibility
Notarius's CLI, configuration schema, durable artifact formats, and subprocess
receipt are unchanged. The `dnd-extraction` fallback retains its explicit model
and settings; the upgrade does not switch it to an upstream default profile.
Upstream catalog selections require review: `deepseek-3-2`, `deepseek-4-flash`,
and `deepseek-4-pro` are no longer present. Operators selecting or inheriting
those profiles must supply a complete operator-owned definition or select an
available profile. `deepseek-flash-latest` and `deepseek-pro-latest` are not
equivalent aliases and need not preserve previous settings. See the
[PromptKit integration contract](../integrations/pkg-promptkit.md#profile-sources-and-compatibility).
Existing checkpoints with the previous profile-source identity are not reused.
Source-keyed chunk plans and existing output artifacts are unaffected.
## Upgrade
Review selected LLM profile IDs and inherited bases before rebuilding from this
tag. No Notarius configuration migration is required for the maintained examples
or application-owned D&D defaults. For controlled model settings, use a complete
[operator profile](../config.md#promptkit-profiles), rather than inheriting a
volatile catalog entry. Follow the [source release procedure](../release.md)
for source installation and verification.
## Changes
- PromptKit v0.10.0 preserves the public engine API and adds two opt-in maintained
profile names; Notarius continues using its existing adapter and defaults.
- The dependency graph selects OpenRouter catalog v2.20260922.1 and Rakestrawhome
catalog v2.20260922.0.
- Profile checkpoint fingerprinting and current integration documentation now
identify the updated engine.

31
docs/releases/v0.8.0.md Normal file
View File

@@ -0,0 +1,31 @@
# Notarius v0.8.0
This release upgrades ConfigKit and PromptKit and makes provider retry behavior
explicit, inspectable, and available in debug diagnostics.
## Compatibility
Configuration selection now uses `--config-overlay` and
`overlays/<name>.yml`. The retired `--config-profile` flag and
`profiles/<name>.yml` lookup are no longer accepted. PromptKit LLM profiles are
unchanged.
`promptkit.transient_retries` defaults to two additional eligible transient
HTTP attempts per generation. Set it to `0` to disable built-in transient
retries. Existing pipeline and validator retry settings remain separate.
## Upgrade
Move selected configuration files from `profiles/` to `overlays/` and replace
every `--config-profile` invocation with `--config-overlay`. Review retry and
timeout budgets before deployment, especially where providers charge per
attempt. See [Configuration](../config.md), [Operations](../operations.md), and
the [PromptKit integration contract](../integrations/pkg-promptkit.md).
## Changes
- Upgrade ConfigKit to v0.4.0 and PromptKit to v0.11.0.
- Add application-owned generation failure and request-local execution-summary
metadata, including retry exhaustion details.
- Record execution summaries in existing debug response artifacts and safe
per-call references.

View File

@@ -0,0 +1,403 @@
# ConfigKit And PromptKit Upgrade Implementation Plan
## Purpose And Scope
Implement [ConfigKit And PromptKit Upgrades](upgrade-kit.md). That document
owns the desired behavior and operator migration; this document owns execution
order. Each numbered stage is one bounded implementation prompt for a
gpt-5.6-terra coding agent. Complete and validate stages in order.
The inspected baseline pins ConfigKit v0.3.0 and PromptKit v0.10.0. Adopt
exactly ConfigKit v0.4.0 and PromptKit v0.11.0, not whatever release happens to
be newest during implementation. The release guides and public APIs are
available in the sibling checkouts `../ConfigKit` and `../promptkit`; consult
the target tags or downloaded modules if those checkouts have advanced.
Build and test Notarius with `GOWORK=off`, without local dependency replacements.
Read `docs/development.md` and all five policies in `docs/policy/` before
implementation. In particular, simplify machinery without narrowing operator
choices or elevating incidental details into requirements. Preserve existing
contracts unless this roadmap expressly changes them. Use graph discovery for
code, then inspect the relevant source and tests; locations below identify the
reviewed baseline, not a requirement to preserve private file structure.
## Decisions And Boundaries
- Configuration selection becomes `--config-overlay` and `overlays/<name>.yml`,
with no legacy alias or fallback. Keep empty, omitted, and `default` selection
behavior. Do not rename PromptKit profiles or pipeline/runtime profile types.
- Keep configuration version 5, source-file-relative declarations, CLI override
precedence, strict loading, inspection provenance, and redaction.
- Add only the global `promptkit.transient_retries` setting: nonnegative integer,
default **2 additional attempts**, explicit zero disables. No arbitrary cap,
per-binding setting, new CLI retry flag, or new total-deadline setting.
- PromptKit owns transient HTTP retries and structural repairs; Notarius keeps
stage/validator retry budgets and disposition unchanged. The existing shared
scheduler retains its completion-scoped permit through upstream waiting.
- Add provider-neutral failure metadata and request-local execution summaries.
Do not change domain artifacts, result receipts, prompt content, validators,
acceptance policy, or introduce a new telemetry service/artifact.
- Successful retry recovery is ordinary success, not a warning. Debug remains
opt-in. Existing token fields keep their meaning; observed usage is separate.
- No release, tag, push, source-license change, or binary packaging is part of
these stages. Record upgrade implications in current integration/operator
documentation; the eventual release note is a separate release task.
For each stage, inspect the starting worktree, implement code with its focused
tests and current-behavior documentation, run the listed relevant checks, and
make one coherent local commit under agent policy. Report checks and any gaps.
Do not repeat passed checks for unchanged content merely to make a commit.
## Stage 1 — Cut Over Configuration Selection To Overlays
**Status:** Completed 2026-09-25.
**Goal:** Adopt ConfigKit v0.4.0 and finish its narrow operator-facing rename in
one coherent change. This is one prompt: it is a naming migration, not a new
configuration engine.
**Inspect:** `internal/core/config/load.go`, `loaded_config.go`, configuration
document/path/operational tests; `internal/cli/run.go`,
`configkit_commands.go`, CLI configuration and example contract tests;
`examples/profiles/development.yml`; upstream v0.4.0 migration guide.
**Work:**
1. Upgrade ConfigKit to v0.4.0 in `go.mod`/`go.sum`. Change `LoadOptions.Profile`
to `Overlay` and pass it into `configkit.Options[Document].Overlay`. Rename
only helpers, fields, locals, fixtures, and descriptions representing the
selected configuration layer.
2. Replace `--config-profile` with `--config-overlay` for `run`, `config validate`,
`config effective`, `config explain`, and `pipelines list`. Update usage,
flag parsing, argument classification, explicit-empty handling, and error
paths together. Retired flags use normal unknown-flag behavior.
3. Move `examples/profiles/development.yml` to
`examples/overlays/development.yml`; update maintained-example discovery and
selected-layer fixtures. Check file-relative paths after moving. Leave
`examples/llm-profiles/`, PromptKit profile sources, and ordinary explicit
imports alone unless an import actually points at the moved example.
4. Consume upstream overlay provenance and diagnostic names directly. Do not
rebuild composition, implement directory fallback, or translate metadata
back to profile vocabulary.
5. Update `docs/config.md`, `docs/cli.md`, `docs/integrations/pkg-configkit.md`,
`docs/internal/configuration.md`, relevant portions of `docs/internal/cli.md`,
and `docs/consumers/subprocess.md`. Review README, operations, and other
consumer examples for affected invocations. Explain the clean migration and
distinction from LLM profiles. Preserve accepted ADR-0018 and release history.
**Acceptance:** All five commands select overlays consistently. Representative
existing tests cover selected/base/empty/default behavior, overlay and explicit
override precedence, declaration-relative paths, redacted effective/explain
output, and source-aware failure labels. Include a selected legacy-only file
case proving there is no silent fallback. Retain ordinary-import behavior and
LLM profile coverage; do not duplicate every upstream parser case.
**Validate:** `GOWORK=off go test ./internal/core/config ./internal/cli`,
`GOWORK=off go mod tidy -diff`, `git diff --check`. Review occurrences of
`config-profile` and `examples/profiles` in current docs/code semantically;
historical docs and explicit legacy-rejection tests are not defects.
## Stage 2 — Upgrade The PromptKit Runtime Baseline
**Status:** Completed 2026-09-25.
**Goal:** Adopt the compatible upstream dependency and document its changed
default before adding Notarius controls. One small prompt.
**Inspect:** `internal/framework/llm/promptkit_client.go`, asset/profile identity
helpers and tests, `go.mod`, and upstream v0.11.0 release/consumer documentation.
**Work:**
1. Pin PromptKit v0.11.0 and tidy. Keep the Go version and provider catalog
selections unless the actual dependency graph requires a change; the target
release does not require either. Do not alter embedded D&D model settings.
2. Update `promptKitBuiltinProfileCatalogID` to the v0.11.0 identity in accordance
with the existing checkpoint fingerprint convention. No other cache policy
change or blanket invalidation is part of this work.
3. Update the pinned integration links in `docs/integrations/pkg-promptkit.md`,
including the relocated upstream `docs/policy/release.md`. Record the
LGPL-3.0-or-later dependency license and reference its license/notices; do not
relicense Notarius or invent a new distribution workflow.
4. Remove the obsolete no-provider-retry-loop statement in `docs/internal/llm.md`.
Describe the now-active upstream default and per-attempt timeouts in the
appropriate integration/operations sections. Do not yet describe the setting
or observations from later stages as implemented.
**Acceptance:** The prepared-execution adapter builds and its existing tests
pass without prompt/catalog changes. Current documentation acknowledges the
dependency's default retry behavior and license accurately.
**Validate:** `GOWORK=off go test ./internal/framework/llm ./internal/cli`,
`GOWORK=off go mod tidy -diff`, `git diff --check`.
## Stage 3 — Wire The Explicit Transient Retry Setting
**Status:** Completed 2026-09-25.
**Goal:** Make retry policy inspectable and configurable through the one shared
engine. One prompt covering a single vertical configuration slice.
**Inspect:** `internal/core/config/{document,config,load,operational_document,
loaded_config,validation}.go`, effective-config tests;
`internal/cli/catalog.go:buildProductionLLMClient`;
`internal/framework/llm/promptkit_client.go:NewPromptKitClient` and HTTP tests.
**Work:**
1. Add `TransientRetries *int` to `PromptKitDocument` and a resolved integer to
`PromptKitConfig`. Supply 2 through both the document default path and the
runtime `Default()` path. Preserve explicit zero; never use zero as omission.
Include it in document-to-runtime conversion and effective configuration.
2. Reject negative values through both existing document and direct runtime
validation entry points. Follow current source-aware diagnostics; do not
impose a new maximum. Keep ConfigKit's existing decoding behavior.
3. Add `TransientRetries *int` to `PromptKitClientConfig`: nil follows upstream
default, non-nil includes explicit zero. Production construction always
supplies the resolved configuration value. Pass it into `promptkit.Config`,
not per-request options. All calls through the injected shared client thus
share the setting without module changes.
4. Add the setting to `examples/dnd-complete.config.yml` and `docs/config.md`;
keep the minimal example minimal. Update configuration/LLM internal guides
for the new plumbing and operations for the zero-value opt-out.
**Acceptance/tests:** Demonstrate omitted default, explicit zero, a positive
override, negative rejection, and effective/explain inspection. Use an existing
CLI factory test to prove the resolved value reaches construction. Exercise
built-in generation with a local HTTP server: eligible failure then success,
disabled retries, exhausted configured count, and a noneligible 400 response.
An injected `LLMClient` stub does not test built-in HTTP retry wiring. Avoid
testing the upstream jitter formula or every equivalent HTTP status.
**Validate:** `GOWORK=off go test ./internal/core/config ./internal/cli ./internal/framework/llm`
and `git diff --check`.
## Stage 4 — Preserve Typed Failure And Exhaustion Metadata
**Status:** Completed 2026-09-25.
**Goal:** Distinguish provider failures without coupling callers to PromptKit.
One prompt; defer execution observations to the next stage.
**Inspect:** `internal/framework/contracts/errors.go`, adapter error conversion
in `promptkit_client.go`, redaction helpers, and existing pipeline handling of
`ErrLLMGeneration`, `ErrLLMCapacityExceeded`, and invalid structured output.
**Work:**
1. Extend the existing application-owned `LLMGenerationError` rather than
creating a parallel provider-error hierarchy. Retain `StatusCode()` and
`errors.Is(err, ErrLLMGeneration)`. Add application-owned failure-kind,
exhaustion, and exhausted-generation attempt metadata/accessors; use uint64
for upstream attempt counts. Preserve the existing constructor's callers
with sensible unspecified/generation metadata, adding a metadata constructor
or private adapter helper as needed.
2. Define kinds for `transport`, `provider_status`, `malformed_response`, and
generic `generation`. Exhaustion is an independent boolean and attempt count,
not a replacement kind. Zero attempts means unavailable in this error
projection, not proof no request happened. The count comes from
`RetryExhaustedError.Attempts()` and covers the exhausted generation, not all
repair generations combined.
3. Use `errors.Is`/`errors.As` on upstream sentinels/types. Keep cancellation and
deadline identity first, capacity handling intact, and HTTP status available
through an exhaustion wrapper. Do not unwrap application errors to upstream
provider objects or classify by prose. Preserve sanitized diagnostic text.
4. A malformed provider envelope is a generation failure; schema-invalid model
content stays `ErrInvalidStructuredOutput`. Do not change outer retry or
validator disposition logic. Document classification at the integration and
internal LLM boundaries.
**Acceptance/tests:** Adapter tests cover transport, malformed response,
noneligible status, exhausted eligible status with count/status preserved, and
cancellation/deadline/capacity regression behavior. Check typed fields and
identities, not full messages. Existing credential-redaction cases remain valid
for nested exhaustion diagnostics; use fake secrets only.
**Validate:** `GOWORK=off go test ./internal/framework/contracts ./internal/framework/llm ./internal/framework/pipeline`
and `git diff --check`.
## Stage 5 — Project Request-Local Execution Observations
**Status:** Completed 2026-09-25.
**Goal:** Return a lightweight terminal summary even when generation fails.
One prompt focused on contracts and adapter mapping, without persistence changes.
**Inspect:** `internal/framework/contracts/contracts.go`, `CompleteStructured`,
`responseFromResult`, prepared execution tests, upstream `observations.go` and
prepared-execution observer documentation.
**Work:**
1. Add an optional `Execution *LLMExecutionSummary` field to
`StructuredCompletionResponse` (`execution,omitempty`). Own these DTOs in
Notarius contracts; do not alias or serialize upstream event types.
2. The summary contains uint64 `attempts`, `transient_retries`, `repair_calls`;
integer millisecond `capacity_wait_ms`, `generation_ms`, `retry_delay_ms`,
`duration_ms`; `outcome`, `failure_kind`, `status_code`, `retries_exhausted`;
and `usage`. Usage contains prompt/completion/total/cached/cache-write counts,
each with `value`, `known`, `complete` fields. Do not omit these booleans or
known-zero values. The summary needs no prompts, response text, endpoints,
credentials, raw errors, or new copies of run/profile identifiers.
3. Map terminal outcome/failure values explicitly to application-owned strings.
Preserve the distinctions exposed by v0.11.0 (including canceled, deadline,
capacity, invalid output, configuration/load/render failures); unknown future
failures map to `other`. Reuse relevant application failure vocabulary from
Stage 4, without forcing every execution error into a generation error.
4. Attach a local observer to the `RunRequest` before `PrepareExecution` freezes
it. Retain the `ExecutionFinished` projection only: upstream supplies aggregate
counts, durations, and usage. Do not sum attempt events and terminal totals,
retain an event log, or reconstruct a second accounting system. Synchronous
request-local collection needs no global state or asynchronous queue.
5. Attach that summary to every subsequent return from `RunPrepared`, including
context/capacity/provider failure and post-result decode/validation failure.
Preparation failure has no execution event and returns no summary. Preserve
upstream execution outcome as such: a later Notarius decode error remains a
separate returned error, not a fabricated upstream terminal event.
6. Keep existing successful-result token and repair fields unchanged. Observed
`repair_calls` represents execution activity and is not a renamed legacy
`RepairAttempts`. No observation field is a billable-cost guarantee.
**Acceptance/tests:** Success, failed execution, cancellation, and no-observation
preparation failure retain the right presence. Test known zero versus unknown
and partial aggregate usage with controlled provider responses. One concurrent
completion case proves no summary cross-contamination. Check relationships and
nonnegative durations, not exact timing. Document the DTO and mapping in
`docs/internal/llm.md`; public dependency semantics stay in pkg-promptkit.
**Validate:** `GOWORK=off go test ./internal/framework/contracts ./internal/framework/llm`
and `GOWORK=off go test -race ./internal/framework/llm`.
## Stage 6 — Carry Summaries Through Existing Debug Paths
**Status:** Completed 2026-09-25.
**Goal:** Make observations useful to operators without a new output channel.
One prompt, limited to debug propagation and terminal allowlists.
**Inspect:** `internal/framework/pipeline/debug.go`, its response-content/fallback
helpers, terminal recording tests, and `debugLLMScope.references`. Trace
`debugLLMCallReference` through the existing timed stage/terminal envelopes;
updating only the detailed response file is insufficient. The CLI's separate
run-summary files do not need a new run-wide accounting mechanism.
**Work:**
1. Project the optional execution summary into existing debug response artifacts
and per-call references used by stage/terminal records. Explicitly preserve it
when `Debug.Response` or `RunResult` is absent and through content splitting
and fallback construction. Keep copies detached if existing clone paths
could otherwise share mutable pointers.
2. Use explicit typed fields as the allowlist for this metadata-only terminal
projection. Do not carry raw upstream events, newly copied provider diagnostics, or model
content into safe terminal summaries. Preserve established redaction and
detailed-trace content boundaries.
3. Leave warnings, normal console output, receipts, domain artifacts, checkpoint
payloads, and no-debug behavior unchanged. No new telemetry file or aggregation
of observed token totals into legacy totals.
4. Update `docs/internal/state.md`/relevant debug internals and `docs/operations.md`
to identify where this diagnostic information is available. Link to the
canonical explanation rather than repeating every field in consumer docs.
**Acceptance/tests:** Through the real debug wrapper and existing recorder seam,
verify success and failed/canceled completions retain summaries in response and
safe terminal records. Verify content splitting/fallback does not discard them,
and debug disabled writes nothing. Reuse existing redaction tests, adding only
the new projection assertions. No new warnings result from recovered retries.
**Validate:** `GOWORK=off go test ./internal/framework/pipeline ./internal/cli`,
`GOWORK=off go test -race ./internal/framework/pipeline`, `git diff --check`.
## Stage 7 — Verify Retry Composition And Finish Operator Guidance
**Status:** Completed 2026-09-25.
**Goal:** Validate the cross-boundary behavior that individual stages cannot
prove, then reconcile current documentation. One prompt; no generalized retry
test framework or runtime redesign.
**Inspect:** Existing adapter HTTP fixtures, scheduler cancellation tests,
pipeline stage retry/debug tests, CLI production tests; current config, LLM,
operations, and consumer documentation.
**Work and acceptance:**
- Add one representative local-HTTP scenario combining a recoverable transient
status with structurally invalid generated content followed by a successful
repair. Prove transient budgets apply independently per generation and
terminal attempt/repair/usage accounting is not doubled. Use small explicit
budgets rather than executing the illustrative 18-request worst case.
- Exercise cancellation during provider-directed retry waiting without a long
real sleep: signal that the request was received, cancel a context while a
long Retry-After is pending, and wait with a bounded test timeout. Preserve
cancellation identity and the terminal observation. Do not build a second
backoff implementation or test exact random delays.
- Review shared scheduler tests for completion-scoped permit ownership and
cancellation/release. Add a focused integration case only where existing
coverage does not protect that boundary across the new retry path. Keep
upstream backend capacity algorithms upstream.
- Confirm a recovered generation passes through ordinary success without
producing process warnings. Preserve existing outer stage retries and semantic
feedback. Do not alter limits to hide budget multiplication.
- Finish operations guidance: distinguish transient, structural, and stage/
validator budgets; give the 18-attempt single-producer illustration with its
assumptions; explain per-attempt timeout versus overall caller cancellation,
Retry-After waits, cost/latency implications, incomplete usage accounting, and
the outer scheduler holding its permit during waits. No new total deadline or
exactly-once guarantee is implied.
- Review all canonical docs changed earlier for consistency. Keep exact fields
and defaults in config, flags in CLI, dependency semantics in integrations,
mechanism in internals, and operating implications in operations. Keep the
migration note about overlays distinct from unchanged LLM profiles. Retain
historical ADR/release content; no new ADR is needed for these upgrades.
**Validate:** Run the focused adapter/pipeline/CLI tests and race tests covering
modified concurrency paths. Review tests for unnecessary upstream duplication
or exact-prose/change-detector assertions; consolidate rather than multiply them.
## Stage 8 — Final Integration Verification And Handoff
**Status:** Completed 2026-09-25.
**Goal:** Verify the complete upgraded application and documentation against the
feature roadmap. One final verification prompt; fix only defects within scope.
**Work:** Review all feature acceptance items and staged diffs. Confirm no
upstream types escaped the LLM boundary, no references to the retired config
flag remain in current examples/help, no unintended LLM profile rename occurred,
and no source-only/license/release scope expanded. Check edited relative links.
Leave the roadmap available for the user's completion review; do not publish a
release or delete planning documents automatically.
**Validation commands:**
```sh
GOWORK=off go test ./...
GOWORK=off go vet ./...
GOWORK=off go build ./...
GOWORK=off go mod tidy -diff
GOWORK=off go test -race ./internal/framework/llm ./internal/framework/pipeline
GOWORK=off go run ./cmd/notarius config validate --config examples/dnd-minimal.config.yml
GOWORK=off go run ./cmd/notarius config validate --config examples/dnd-complete.config.yml
GOWORK=off go run ./cmd/notarius config validate --config examples/dnd-complete.config.yml --config-overlay development
git diff --check
```
Reuse already-passed checks for the same unchanged candidate where appropriate.
The example/CLI tests own maintained examples; the commands above are a one-time
migration smoke check, not new permanent release gates. No live model calls or
real API keys are required. Report environment/dependency-access failures
separately from implementation failures; do not claim blocked checks passed.
**Completion criteria:** Both exact dependency pins are active; overlay migration,
retry setting, typed errors, observations, debug propagation, tests, examples,
and current documentation satisfy the feature roadmap. Report commits and
checks, plus any residual limitation. Future release preparation should explain
the breaking overlay cutover, retry default/opt-out, and upstream license change
in a minor-release note, but is not part of this implementation.
## Open Questions
None. The feature roadmap supplies the policy choices needed for implementation.
The DTO naming and stage boundaries above settle remaining implementation choices
without adding new product restrictions or compatibility promises.

123
docs/roadmap/remediation.md Normal file
View File

@@ -0,0 +1,123 @@
# Kit Upgrade Review Remediation
## Resolution
Completed 2026-09-26. No production changes were needed.
- `internal/framework/llm/promptkit_observation_test.go` now covers retries in
both initial and repair generations, aggregate versus legacy accounting,
unknown/known-zero/partial usage, retry-wait cancellation, exhausted execution,
preparation failure, and overlapping request isolation.
- `internal/framework/pipeline/debug_execution_test.go` exercises the real
scheduled client and debug wrapper together. It verifies returned and persisted
summaries for provider errors and cancellation without a body, split debug and
fallback content, terminal references, and explicit usage-presence JSON fields.
- Temporary regression probes confirmed that discarding failed responses in the
scheduler and collapsing usage presence into known zero cause the new tests
to fail. Those probes were reverted.
- Focused LLM/pipeline tests, their race-enabled suites, and
`GOWORK=off go test ./...` passed. Existing no-debug and redaction coverage was
retained. Runtime budgets, scheduler policy, and output contracts are unchanged.
The original finding below is retained as review context.
## Review Scope
Reviewed implementation commits `31b48d2` through `7ac9d94` against
[the feature roadmap](upgrade-kit.md) and [the implementation plan](implementation.md).
The review covered configuration selection/defaults, production client wiring,
error translation, execution observation, scheduler/debug propagation, current
documentation, and tests. No production model calls were made.
The implemented architecture is consistent with the intended design: ConfigKit
v0.4.0 owns composition and overlay provenance behind the configuration boundary;
PromptKit v0.11.0 owns transient retries and structural repairs behind the LLM
adapter. Notarius retains stage retry/disposition policy, completion-scoped
scheduling, application-owned error/observation types, and opt-in debug output.
No new runtime defect or unrequested restriction was established in this review.
## Finding 1 — Complete The Missing Retry And Observation Verification
**Priority:** Medium. This is a verification gap, not a demonstrated runtime
failure. Stages 5–7 are marked complete, but their consequential cross-boundary
acceptance tests were not implemented.
### Evidence And Risk
- `internal/framework/llm/promptkit_client_test.go:58` adds only a successful,
single-attempt observation assertion with positive prompt-token usage. It
does not exercise absent versus partial/known-zero usage, failed/canceled
execution summaries, preparation failure, or concurrent request isolation.
- `TestPromptKitClientAppliesConfiguredBuiltInTransientRetries` covers basic
status recovery, explicit zero, exhaustion, and a noneligible status. It
discards the response, so does not protect terminal accounting. There is no
combined transient-retry/structural-repair scenario or cancellation while
waiting on Retry-After.
- `internal/framework/llm/scheduled_client.go:40` now preserves a response when
the inner call fails. No corresponding test protects that behavior. Returning
an empty response again would silently remove precisely the failed-call
observations this feature is intended to retain.
- `internal/framework/pipeline/debug.go:267` and `:277` add execution summaries
to response artifacts and call references, plus fallback/content-splitting
propagation. Neither the debug nor terminal-debug tests were extended to
exercise these fields. Passing existing tests does not prove that failed-call
metadata reaches persisted records.
These are Notarius-owned integration risks, not a request to re-test PromptKit's
backoff algorithm. Losing failed-call observations or misrepresenting incomplete
usage would be difficult to detect in ordinary successful runs. A focused set
of behavioral tests is justified under the testing policy.
### Required Remediation
Use existing test assets, local HTTP fixtures, scheduler wrappers, and debug
recorders. Combine related assertions; no fixed test count or new test framework
is required.
1. Add an adapter scenario with a transient failure, a structurally invalid
completion, and a successful repair. Exercise retries in both generations
with small explicit budgets, and assert terminal attempts/retries/repair
counts without double-counting. Use controlled usage fields to distinguish
known zero, unknown, and partial aggregate usage. Preserve legacy usage
semantics independently.
2. Cover failed and canceled completions, including cancellation during a
Retry-After wait. Verify caller cancellation identity, bounded completion,
and retained execution metadata. Use signaling and a generous bounded test
timeout, not exact sleep durations or jitter assertions. Cover a preparation
failure with no execution summary. Add a small concurrent-call check for
request-local summary isolation, which race tests alone do not prove.
3. Exercise an error response through the real scheduled client and existing
debug wrapper/recorder seams. Assert that the summary reaches response
artifacts and terminal call references even without a provider response
body. Cover the content-splitting/fallback path with a representative response.
Reuse existing no-debug and redaction tests rather than duplicating their
whole inventories. Keep new summary assertions metadata-only.
Retain existing scheduler policy, stage/validator budgets, cancellation
disposition, warning semantics, and debug opt-in behavior. Do not introduce a
total deadline, new retry caps, telemetry artifacts, or live API requirements
to make these tests easier. Fix production code only if these checks reveal a
violation of the already selected behavior.
**Acceptance:** The added tests fail if failed responses lose their execution
summary at an adapter/wrapper boundary, usage presence is collapsed into zero,
or retry/repair totals are double-counted. They pass with the intended behavior
and provide evidence for the previously unverified stage acceptance criteria.
**Validation:** Run focused LLM/pipeline tests, race-enabled tests for those
packages, and `GOWORK=off go test ./...`. No production credentials are needed.
## Checks Completed During Review
- `GOWORK=off go test ./...`
- `GOWORK=off go vet ./...`
- `GOWORK=off go build ./...`
- `GOWORK=off go mod tidy -diff`
- `GOWORK=off go test -race ./internal/framework/llm ./internal/framework/pipeline`
- CLI configuration validation for the minimal and complete examples, including
the complete example with `--config-overlay development`.
- `git diff --check`
All passed. Existing green tests do not substitute for the missing behavioral
coverage above. Temporary diagnostic test material used during review was
removed; no application code was changed.

337
docs/roadmap/upgrade-kit.md Normal file
View File

@@ -0,0 +1,337 @@
# ConfigKit And PromptKit Upgrades
## Purpose
Adopt ConfigKit v0.4.0 and PromptKit v0.11.0 in one coordinated work set:
- Consistently call ConfigKit's selected configuration layer an **overlay**,
distinct from PromptKit LLM profiles.
- Adopt configurable transient HTTP retries, provider-neutral failure
classifications, and execution observations from PromptKit.
Preserve application/framework boundaries and keep the two upgrades as distinct
implementation workstreams. Neither requires a redesign of pipeline execution.
This feature roadmap owns the target state and scope. The ordered work and
stage-specific verification belong to the [implementation plan](implementation.md).
Neither document authorizes release publication.
## Current Baseline
The reviewed baseline is Notarius v0.7.1 (`53d9284`):
- `go.mod` pins ConfigKit v0.3.0 and PromptKit v0.10.0.
- `internal/core/config/load.go` passes `LoadOptions.Profile` into
`configkit.Options[Document].Profile`.
- `--config-profile` selects `profiles/<name>.yml` beneath the configuration
root for `run`, `config validate`, `config effective`, `config explain`, and
`pipelines list`.
- The maintained selected-layer example is
`examples/profiles/development.yml`; configuration and CLI tests create
equivalent directories and exercise selection and inspection.
- ConfigKit owns composition and inspection metadata. Notarius owns its
version 5 document, defaults, validation, source-relative path resolution,
runtime conversion, and CLI discovery.
A dependency-only trial against v0.4.0 fails to compile at `load.go:33` because
`configkit.Options.Profile` no longer exists. The review found no direct
Notarius references to `LayerProfile`, `CodeProfile*`, or `LoadSecretsDir`.
An isolated dependency-only trial against PromptKit v0.11.0 passes the complete
Notarius test suite and `go vet ./...`. That proves baseline compatibility, not
adoption of the new controls or observations. The current adapter does not
configure transient retries or attach an execution observer. It classifies
generation errors broadly and records successful-result usage and structural
repair counts. The outer scheduler holds a permit for the whole completion.
## Upstream Changes
### ConfigKit v0.4.0
The [ConfigKit v0.4.0 release guide](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.4.0/docs/releases/v0.4.0.md)
defines four breaking naming changes:
| Surface | Previous | Target |
| --- | --- | --- |
| Selection API | `Options.Profile` | `Options.Overlay` |
| Selected file | `profiles/<name>.yml` | `overlays/<name>.yml` |
| Provenance layer | `LayerProfile` / `profile` | `LayerOverlay` / `overlay` |
| Diagnostic identifiers | `CodeProfile*` / `profile.*` | `CodeOverlay*` / `overlay.*` |
There are no legacy aliases or directory fallback. Explicit imports from a
directory named `profiles` remain valid ordinary imports. Merge rules,
precedence, lifecycle, source-origin APIs, minimum Go version, and YAML parser
dependency are unchanged.
The intervening v0.3.1 release makes the opt-in `LoadSecretsDir` API ignore
dot-prefixed entries. Notarius does not use this API; adding secrets-directory
bootstrap is outside this work.
### PromptKit v0.11.0
The [PromptKit v0.11.0 release guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/releases/v0.11.0.md)
introduces additive runtime APIs and one changed default:
- Built-in generation defaults to two additional transient HTTP attempts for
statuses 429, 500, 502, 503, and 504. Engine and request controls can override
the count, including zero. Injected `LLMClient` implementations are not
automatically replayed; a custom HTTP client on the built-in path still is.
- Backoff is cancellable and honors valid provider `Retry-After` timing.
Generation and HTTP timeouts apply per attempt; the caller context bounds
the whole operation. Transport failures, malformed responses, other HTTP
statuses, and invalid generated content are not transient-retry candidates.
- `RunRequest.Observer` reports lifecycle metadata, attempts, waits, outcomes,
and presence-aware usage, including work before a failed execution. It works
with prepared execution; preparation itself emits no execution events.
- `ErrTransport`, `ErrMalformedResponse`, `ErrRetriesExhausted`, and
`RetryExhaustedError` support typed classification without parsing error text.
- The license changes to LGPL-3.0-or-later. Catalog selections and the minimum
Go version are unchanged from v0.10.0.
## Target End State
### Dependency And Ownership
Pin ConfigKit v0.4.0 and PromptKit v0.11.0 and tidy the dependency graph.
Continue using only ConfigKit's
public root API behind `internal/core/config`. No compatibility parser,
application-owned composition engine, or new configuration abstraction is
needed. PromptKit remains behind `internal/framework/llm`; its types must not
leak into domain modules or pipeline contracts. Retain the existing prepared
execution path, profile choices, and D&D model settings.
### Consistent Overlay Selection
- Replace the configuration-selection CLI flag with `--config-overlay` on all
commands that currently accept `--config-profile`.
- Rename application-owned fields, variables, helpers, help text, and test
descriptions that specifically represent this selection to overlay terms.
- Pass the selection through `configkit.Options.Overlay`.
- Select files only from `overlays/<name>.yml`, following ConfigKit's contract.
- Preserve empty and `default` selection as the composed base configuration
without a selected overlay.
- Use a clean cutover: no old CLI alias, directory fallback, automatic file
moves, or custom migration machinery. Normal unknown-flag handling is
sufficient for the retired flag; explain migration in documentation.
This rename is deliberately narrow. Preserve `llm_profile`, `--llm-profile`,
`promptkit.profile_dir`, `promptkit.profile_file`, `base_profile`, embedded
`dnd-extraction` profiles, and `examples/llm-profiles/`. Other application
concepts named `Profile`, including pipeline/runtime types, must not be renamed
merely because they share the word.
### Preserved Behavior
Retain configuration version 5, with the additive retry setting described below.
Preserve existing defaults other than the explicitly adopted transient retry policy,
imports, overlay merge behavior (including sequence replacement), explicit CLI
override precedence, strict decoding, domain validation, and secret redaction.
Do not add new restrictions or relax existing explicit contracts as part of the
rename.
Relative paths continue to resolve from the file supplying the surviving
declaration, including overlay files. Invocation-relative CLI paths and
main-file-relative built-in defaults retain their current behavior.
Effective configuration, explanations, and configuration-load diagnostics use
the new upstream overlay provenance and diagnostic vocabulary. Preserve source
file labels and actionable error context; do not translate the new metadata
back into legacy profile terminology.
Pipeline stage order, semantic acceptance, LLM selection, domain artifact
schemas, and subprocess result receipts remain unchanged. No blanket checkpoint
invalidation is added merely for the ConfigKit rename. Update the existing
PromptKit-version identity used by checkpoint fingerprinting with its dependency
pin, following the current adapter convention; no cache redesign is needed.
### Transient Retry Policy
Expose `promptkit.transient_retries` as a nonnegative integer setting with a
Notarius default of **2 additional attempts**. Explicit zero disables transient
retries. Apply the resolved setting to the shared PromptKit engine so modules,
normalizers, validators, and semantic correction calls receive the same policy.
Include the effective value in configuration inspection. Do not impose an
arbitrary application maximum or add per-binding controls in this work set.
Maintain three separate budgets and owners:
| Budget | Owner | Repeated work |
| --- | --- | --- |
| Transient retries | PromptKit | The same generation request after eligible HTTP failures |
| Structural repairs | PromptKit | A corrective generation for invalid structured output |
| Producer/validator retries | Notarius | The configured stage or validator operation, with semantic feedback where applicable |
Do not silently reduce, repurpose, or remove existing stage/validator budgets.
Each structural repair generation receives its own transient budget. For a
single-completion producer, two additional stage attempts, one structural
repair, and two transient retries permit up to 18 HTTP attempts, excluding any
separate validator calls. Document this multiplication and the possibility of
additional cost; retries do not imply exactly-once provider execution.
Preserve caller cancellation throughout generation and waiting. Do not describe
a profile's generation timeout as a total completion deadline: provider
`Retry-After` can extend waits beyond local backoff, and each HTTP attempt has
its own timeout. A new total-deadline configuration surface is outside this
upgrade; accurately document the current controls and limitations.
Retain Notarius's completion-scoped scheduler permit during upstream backoff.
PromptKit releases its active backend permit during that wait, but this does
not release the outer Notarius permit. This remains bounded and correct; do
not add an attempt-level scheduler redesign.
### Provider-Neutral Failure Diagnostics
Translate the new typed errors into application-owned classifications for
transport failures and malformed provider responses. Represent transient retry
exhaustion separately from its underlying failure/status, retaining the actual
attempt count where available. Preserve existing HTTP-status reporting,
capacity classification, cancellation, deadline handling, and redaction.
Do not turn a malformed provider envelope into a semantic rejection or confuse
it with a structurally invalid model response. Do not expose PromptKit error
types across the adapter boundary or parse prose to recover classifications.
Existing stage disposition policies continue to decide what happens after a
completion fails; this work does not redesign those policies.
### Execution Observations
Attach a request-local observer through the existing prepared-execution path.
Translate selected metadata into Notarius-owned completion/debug summaries:
- dispatched attempt, transient retry, and structural repair-call counts;
- capacity-wait, generation, retry-delay, and total execution durations;
- terminal outcome, failure classification, HTTP status, and exhaustion state;
- observed token counts with explicit known/complete indicators.
Retain available summaries on failed completions, including cancellation, rather
than discarding them with the absent `RunResult`. Preparation failures have no
execution observation; do not fabricate one. Keep existing successful-result
usage fields and their meanings unchanged. Observation totals are separate
presence-aware metadata, not a second set of tokens to add to existing usage.
Unknown counts are not known zero; partial totals are not a billing ledger.
Use lightweight per-request accumulation with no unbounded event history or
asynchronous queue. Callbacks are synchronous: avoid blocking I/O or acquisition
of scheduler resources inside them, and isolate concurrent requests. Persist
the projection through the existing opt-in debug path, including safe terminal
summaries where applicable. Do not serialize upstream event structs directly;
their JSON layout is not a public contract. Allowlist metadata and preserve
existing identifier/content handling and redaction boundaries.
Successful transient recovery is ordinary success, not a warning. This work
does not add routine console messages, change the warning policy, introduce a
new durable telemetry artifact, or add complete cost accounting.
## Required Changes
### Code And Examples
- Update `go.mod` and `go.sum` and the configuration loader/options boundary.
- Update CLI selection plumbing in `internal/cli/run.go` and
`configkit_commands.go`, including usage, flag parsing, argument classification,
explicit empty-value handling, and error paths.
- Move the maintained example to `examples/overlays/development.yml` and update
example discovery and related fixtures. Inspect relative paths when moving
files; do not assume all occurrences of `profiles` are configuration overlays.
- Update affected configuration and CLI tests, particularly document paths,
operational defaults, safe inspection, command selection, diagnostic source
labels, run contracts, and maintained-example coverage.
- Add the retry setting through the typed configuration document, defaults,
validation, runtime conversion, and CLI client-construction boundary. Update
the complete example to demonstrate it without adding unnecessary settings
to the minimal example.
- Update the PromptKit adapter's engine construction, request observation,
error conversion, and checkpoint version identity. Extend transport-neutral
response/debug contracts only with the application-owned metadata needed.
- Carry observations through the pipeline debug wrapper and safe terminal
reporting without double-counting usage or losing failed-call summaries.
### Documentation
Update current documentation with the implementation, keeping canonical
ownership under [Documentation Policy](../policy/documentation.md):
- `docs/cli.md`: new flag on every affected command and matching help behavior.
- `docs/config.md`: overlay terminology, directory layout, precedence, and the
explicit distinction from LLM profiles; transient retry setting and default.
- `docs/integrations/pkg-configkit.md`: pinned release and supported upstream
naming/inspection contract.
- `docs/internal/configuration.md` and relevant CLI internals: new selection
terminology and unchanged boundaries.
- `docs/consumers/subprocess.md` and any other current callers/examples: updated
invocation syntax. Review operations and README references where applicable.
- `docs/integrations/pkg-promptkit.md`: updated pin, typed errors, observer
support, and retry contract. Refresh pinned upstream links, including its
relocated release-policy document.
- `docs/internal/llm.md` and relevant pipeline/debug internals: observation
projection, failure handling, scheduler interaction, and separate retry
budgets. Remove the obsolete claim that PromptKit has no provider retry loop.
- `docs/operations.md`: latency/cost implications, timeout limits, opt-in debug
evidence, and how to disable transient retries.
Preserve historical release notes and the accepted text of ADR-0018. Its
ConfigKit ownership decision remains valid; a terminology migration alone does
not require a new ADR or supersession. Current documentation must not portray
the old dependency pin as the current supported version.
Record PromptKit's LGPL-3.0-or-later change in the integration documentation and
eventual release note. Review its notices and distribution implications within
Notarius's source-only release model. Do not silently relicense Notarius, copy
upstream release policy into this repository, or introduce binary packaging.
Any future binary distribution needs its own license-compliance review.
## Operator Migration And Release Impact
This work belongs in the next **minor** Notarius release, not a patch, because
both the documented selected-file layout and CLI syntax change.
Operators move selected configuration files from `profiles/` to `overlays/`
and replace `--config-profile` with `--config-overlay` in scripts, service
definitions, and subprocess callers. Preserve unrelated PromptKit profile
directories and explicit imports. Review relative paths and explicit imports
that refer to moved files. No YAML body version migration is required.
The eventual release note must explain this cutover, the newly enabled transient
retries and zero-value opt-out, and the dependency license change. PromptKit's
catalogs are unchanged from v0.10.0; this upgrade adds no further profile-name
migration or change to the D&D model defaults. Release preparation,
tagging, and publication remain a separately requested task under
[Source Releases](../release.md).
## Acceptance And Verification
- Notarius builds against ConfigKit v0.4.0 and PromptKit v0.11.0 without
compatibility shims.
- All five affected commands use the new flag; non-default overlay selection,
omitted selection, empty selection, and `default` retain their intended
behavior. The old flag is not accepted.
- Selected overlays load from the new directory. A legacy-only selected file
does not silently load; ordinary explicit imports remain supported.
- Representative tests demonstrate overlay precedence, CLI overrides,
declaration-relative paths, redacted inspection, and source-aware failures.
- Maintained examples validate, including the development overlay. LLM profile
loading and defaults remain unaffected.
- Tests prove the default transient retry policy, explicit zero, configured
counts, and negative-value rejection through the relevant configuration and
adapter boundaries. Use local HTTP test servers for eligible/noneligible
statuses, exhaustion, cancellation, and interaction with structural repair;
an injected LLM fake alone does not exercise built-in HTTP retries.
- Provider-neutral errors preserve underlying status and cancellation identity
without leaking credentials or upstream types.
- Observations are available for success and failure, preserve unknown versus
known-zero usage, avoid aggregate double-counting, and stay isolated under
concurrent completions. Successful retry recovery emits no warning.
- Representative scheduler/repair/retry checks preserve bounded execution and
cancellation; use existing coverage where it already proves the contract.
- Current documentation, help text, examples, and tests agree. Searches for old
vocabulary are reviewed semantically, excluding intentional historical and
LLM-profile references rather than enforcing a blanket text ban.
- Run focused `go test ./internal/core/config ./internal/cli ./internal/framework/llm ./internal/framework/pipeline`, followed by
`go test ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and
`git diff --check`, with `GOWORK=off`. Run race-enabled tests for the modified
adapter, scheduler, and observation integration. Validate both maintained configurations
and the complete configuration with `--config-overlay development`.
Use existing behavioral tests where possible, following
[Testing Policy](../policy/testing.md). No live model calls or production API
credentials are needed to validate this migration.

View File

@@ -1,6 +1,10 @@
version: 4
version: 5
configkit:
imports:
- path: pipelines/dnd-session.yml
promptkit:
profile_file: ./examples/profiles/dnd-extraction.yml
profile_file: ./llm-profiles/dnd-extraction.yml
transient_retries: 2
concurrency:
total_llm: 2
stage_workers:
@@ -16,145 +20,3 @@ cache:
directory: ./notarius-cache/checkpoints
debug:
directory: ./notarius-debug
pipelines:
dnd-session:
llm_profile: dnd-extraction
input: seriatim
# Stable campaign context is shared by every module that accepts these slots.
references:
party: ./dnd-party.txt
glossary: ./dnd-glossary.txt
chunk:
module: dnd/scenes
retries: 2
output:
module: json
options:
include_chunk_map: true
evidence_context:
enabled: true
window_units: 3
lanes:
- item-occurrences
- item-registry
- location-registry
- location-occurrences
- npc-registry
- spells
- combat-turns
- npc-occurrences
- enemy-events
steps:
# Establish session-wide reference artifacts before their consumers.
- id: describe-session
artifacts:
item-registry:
extract:
module: dnd/item-registry
retries: 2
merge: appendorder
normalize: dnd/item-registry
npc-registry:
extract:
module: dnd/npc-registry
retries: 2
merge: appendorder
normalize:
module: dnd/npc-registry
retries: 2
location-registry:
extract:
module: dnd/location-registry
retries: 2
merge: appendorder
normalize:
module: dnd/location-registry
retries: 2
scene-descriptions:
extract:
module: dnd/scene-descriptions
retries: 2
merge: appendorder
normalize: dnd/scene-descriptions
- id: extract-events
# Accepted registry artifacts and scene-description eligibility artifacts
# are supplied in memory to their compatible consumers in this step.
references:
location_registry:
artifact:
step: describe-session
lane: location-registry
npc_registry:
artifact:
step: describe-session
lane: npc-registry
scene_descriptions:
artifact:
step: describe-session
lane: scene-descriptions
item_registry:
artifact:
step: describe-session
lane: item-registry
artifacts:
item-occurrences:
extract:
module: dnd/item-occurrences
retries: 2
merge: appendorder
normalize: dnd/item-occurrences
spells:
extract:
module: dnd/spells
retries: 2
references:
spell_catalog: ./dnd-spell-catalog.json
merge: appendorder
# Stage-local file references are intentionally bound at each stage.
normalize:
module: dnd/spells
references:
spell_catalog: ./dnd-spell-catalog.json
combat-turns:
extract:
module: dnd/combat-turns
retries: 2
merge: appendorder
normalize: dnd/combat-turns
npc-occurrences:
extract:
module: dnd/npc-occurrences
retries: 2
merge: appendorder
normalize: dnd/npc-occurrences
location-occurrences:
extract:
module: dnd/location-occurrences
retries: 2
merge: appendorder
normalize: dnd/location-occurrences
- id: track-enemies
references:
npc_registry:
artifact:
step: describe-session
lane: npc-registry
scene_descriptions:
artifact:
step: describe-session
lane: scene-descriptions
combat_turns:
artifact:
step: extract-events
lane: combat-turns
npc_occurrences:
artifact:
step: extract-events
lane: npc-occurrences
artifacts:
enemy-events:
extract:
module: dnd/enemy-events
retries: 2
merge: appendorder
normalize: dnd/enemy-events

View File

@@ -1,9 +1,12 @@
version: 4
version: 5
pipelines:
dnd-session:
llm_profile: dnd-extraction
input: seriatim
input:
module: seriatim
artifacts:
spells:
extract: dnd/spells
normalize: dnd/spells
extract:
module: dnd/spells
normalize:
module: dnd/spells

View File

@@ -0,0 +1,8 @@
# This selects operational settings only; choose a different LLM profile with
# the pipeline's llm_profile field or the run command's --llm-profile flag.
concurrency:
total_llm: 1
stage_workers:
extract: 1
output:
directory: ./notarius-output-development

View File

@@ -0,0 +1,164 @@
pipelines:
dnd-session:
llm_profile: dnd-extraction
input:
module: seriatim
# Stable campaign context is shared by every module that accepts these slots.
references:
party:
path: ../dnd-party.txt
glossary:
path: ../dnd-glossary.txt
chunk:
module: dnd/scenes
retries: 2
output:
module: json
options:
include_chunk_map: true
evidence_context:
enabled: true
window_units: 3
lanes:
- item-occurrences
- item-registry
- location-registry
- location-occurrences
- npc-registry
- spells
- combat-turns
- npc-occurrences
- enemy-events
steps:
# Establish session-wide reference artifacts before their consumers.
- id: describe-session
artifacts:
item-registry:
extract:
module: dnd/item-registry
retries: 2
merge:
module: appendorder
normalize:
module: dnd/item-registry
npc-registry:
extract:
module: dnd/npc-registry
retries: 2
merge:
module: appendorder
normalize:
module: dnd/npc-registry
retries: 2
location-registry:
extract:
module: dnd/location-registry
retries: 2
merge:
module: appendorder
normalize:
module: dnd/location-registry
retries: 2
scene-descriptions:
extract:
module: dnd/scene-descriptions
retries: 2
merge:
module: appendorder
normalize:
module: dnd/scene-descriptions
- id: extract-events
# Accepted registry artifacts and scene-description eligibility artifacts
# are supplied in memory to their compatible consumers in this step.
references:
location_registry:
artifact:
step: describe-session
lane: location-registry
npc_registry:
artifact:
step: describe-session
lane: npc-registry
scene_descriptions:
artifact:
step: describe-session
lane: scene-descriptions
item_registry:
artifact:
step: describe-session
lane: item-registry
artifacts:
item-occurrences:
extract:
module: dnd/item-occurrences
retries: 2
merge:
module: appendorder
normalize:
module: dnd/item-occurrences
spells:
extract:
module: dnd/spells
retries: 2
references:
spell_catalog:
path: ../dnd-spell-catalog.json
merge:
module: appendorder
# Stage-local file references are intentionally bound at each stage.
normalize:
module: dnd/spells
references:
spell_catalog:
path: ../dnd-spell-catalog.json
combat-turns:
extract:
module: dnd/combat-turns
retries: 2
merge:
module: appendorder
normalize:
module: dnd/combat-turns
npc-occurrences:
extract:
module: dnd/npc-occurrences
retries: 2
merge:
module: appendorder
normalize:
module: dnd/npc-occurrences
location-occurrences:
extract:
module: dnd/location-occurrences
retries: 2
merge:
module: appendorder
normalize:
module: dnd/location-occurrences
- id: track-enemies
references:
npc_registry:
artifact:
step: describe-session
lane: npc-registry
scene_descriptions:
artifact:
step: describe-session
lane: scene-descriptions
combat_turns:
artifact:
step: extract-events
lane: combat-turns
npc_occurrences:
artifact:
step: extract-events
lane: npc-occurrences
artifacts:
enemy-events:
extract:
module: dnd/enemy-events
retries: 2
merge:
module: appendorder
normalize:
module: dnd/enemy-events

10
go.mod
View File

@@ -3,14 +3,16 @@ module gitea.maximumdirect.net/eric/notarius
go 1.25.5
require (
gitea.maximumdirect.net/eric/promptkit v0.9.0
gitea.maximumdirect.net/eric/ConfigKit v0.4.0
gitea.maximumdirect.net/eric/promptkit v0.11.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
gopkg.in/yaml.v3 v3.0.1
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require golang.org/x/text v0.40.0
require (
gitea.maximumdirect.net/eric/promptkit-backend-openrouter v1.0.0 // indirect
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome v1.0.0 // indirect
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1 // indirect
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
)

16
go.sum
View File

@@ -1,13 +1,17 @@
gitea.maximumdirect.net/eric/promptkit v0.9.0 h1:IpvDRC8L6xRxQ9hpuyKOmMc5b6MeLTKYyx+h1YAjy08=
gitea.maximumdirect.net/eric/promptkit v0.9.0/go.mod h1:oMJ/WUJImUtwJ5e+6MAGECPYAErAkOaKel0G+3T/b4E=
gitea.maximumdirect.net/eric/promptkit-backend-openrouter v1.0.0 h1:lc062euk2qseO//D762i3JaFyulDNML3eQQX7DkYTho=
gitea.maximumdirect.net/eric/promptkit-backend-openrouter v1.0.0/go.mod h1:AIa7kAu2mfrRQgcspe4L+DW51WqgnALQT60lqkEywJI=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome v1.0.0 h1:j9YY7wsTVjzke2kHH4YAzpU0oUpM+x+nXwl1IeS+2eg=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome v1.0.0/go.mod h1:4RNS+LILDg4JbS4Ts9Lwy1C92wauXJIbeQaalps4Koo=
gitea.maximumdirect.net/eric/ConfigKit v0.4.0 h1:fo+vKff3zTGeWqJIA38NtT84b6OUP//9ZmBH3lFuKgk=
gitea.maximumdirect.net/eric/ConfigKit v0.4.0/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
gitea.maximumdirect.net/eric/promptkit v0.11.0 h1:x7JguOUiInPVXK0GEitjyoRFqqvcuSTjYUvqJ+whtik=
gitea.maximumdirect.net/eric/promptkit v0.11.0/go.mod h1:0zEBVlJJC+ldDdXazAgybYBXNSe74vwFx5dyOuSos/w=
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1 h1:S2/Q/JDE+m8Ca3pDhAjVbfYrdlEhs9blj0STgNopHo0=
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1/go.mod h1:a3VBbh9oaGXD6ruxhtwHj+AWW6DY/SSyby1QtMmYSUo=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0 h1:Y0EXc3eh12eKW2NrOviVqdk3oBo6UUjopt1YAa1B3Oc=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0/go.mod h1:mtgD1E0SGm0e/I8Ffhz+AYX7GN7RKUWJXehNFIKHs4Y=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -23,7 +23,7 @@ func TestRunChunkPlanModePrecedenceAndValidation(t *testing.T) {
}{
{name: "default", wantStores: 1},
{name: "file", fileMode: "bypass"},
{name: "environment", envMode: "bypass"},
{name: "retired environment has no effect", envMode: "bypass", wantStores: 1},
{name: "cli", envMode: "refresh", cliMode: "bypass"},
}
for _, tt := range tests {
@@ -80,25 +80,14 @@ func TestRunChunkPlanModePrecedenceAndValidation(t *testing.T) {
})
for _, tt := range []struct {
name string
fileConfig bool
name string
}{
{name: "invalid environment mode"},
{name: "invalid file mode", fileConfig: true},
{name: "invalid file mode"},
} {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
if tt.fileConfig {
replaceStateTestConfigLine(t, roots.config, " mode: auto\n", " mode: invalid\n")
} else {
opts.LookupEnv = func(name string) (string, bool) {
if name == "NOTARIUS_CACHE_CHUNK_PLANS_MODE" {
return "invalid", true
}
return "", false
}
}
replaceStateTestConfigLine(t, roots.config, " mode: auto\n", " mode: invalid\n")
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
if code != 1 || stdout.Len() != 0 || stderr.Len() == 0 {
@@ -207,16 +196,20 @@ func TestRunAutoReusesPlanWhenRunInputsChange(t *testing.T) {
if err != nil {
t.Fatal(err)
}
configText := replaceRequiredOnce(t, string(data), " chunk: test/chunk\n", ` chunk:
configText := replaceRequiredOnce(t, string(data), " chunk:\n module: test/chunk\n", ` chunk:
module: test/chunk
options:
strategy: first
`)
configText = replaceRequiredOnce(t, configText, " output: test/output\n", ` other:
extract: test/extract
merge: test/merge
normalize: test/normalize
output: test/output
configText = replaceRequiredOnce(t, configText, " output:\n module: test/output\n", ` other:
extract:
module: test/extract
merge:
module: test/merge
normalize:
module: test/normalize
output:
module: test/output
`)
if err := os.WriteFile(roots.config, []byte(configText), 0o600); err != nil {
t.Fatal(err)

View File

@@ -157,13 +157,15 @@ func buildProductionLLMClient(ctx context.Context, cfg config.Config, profileID
return nil, nil, fmt.Errorf("production asset registry must not be nil")
}
recorder := llm.NewLLMProfileRecorder()
transientRetries := cfg.PromptKit.TransientRetries
client, err := llm.NewPromptKitClient(llm.PromptKitClientConfig{
ProfileDir: cfg.PromptKit.ProfileDir,
ProfileFile: cfg.PromptKit.ProfileFile,
LocalBackend: mapPromptKitLocalBackend(cfg.PromptKit.LocalBackend),
Assets: assets,
Recorder: recorder,
ReasoningEffort: overrides.ReasoningEffort,
ProfileDir: cfg.PromptKit.ProfileDir,
ProfileFile: cfg.PromptKit.ProfileFile,
TransientRetries: &transientRetries,
LocalBackend: mapPromptKitLocalBackend(cfg.PromptKit.LocalBackend),
Assets: assets,
Recorder: recorder,
ReasoningEffort: overrides.ReasoningEffort,
})
if err != nil {
return nil, nil, fmt.Errorf("create PromptKit-backed LLM client: %w", err)

View File

@@ -140,7 +140,7 @@ func TestConfigLoadingFailuresReturnOneWithPathContext(t *testing.T) {
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"config", "validate", "--config", malformed}, &stdout, &stderr, commandContractOptions(t))
if code != 1 || !strings.Contains(stderr.String(), malformed) || !strings.Contains(stderr.String(), "parse config file") || stdout.Len() != 0 {
if code != 1 || !strings.Contains(stderr.String(), "configuration contains invalid YAML") || stdout.Len() != 0 {
t.Fatalf("malformed config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}
@@ -185,14 +185,16 @@ func TestConfigValidateResolvesPipelineAndChecksSelection(t *testing.T) {
}
func TestConfigValidatePipelineDefaultProfileIsOffline(t *testing.T) {
configPath := writeCommandConfigContent(t, `version: 4
configPath := writeCommandConfigContent(t, `version: 5
pipelines:
demo:
llm_profile: dnd-extraction
input: seriatim
input:
module: seriatim
artifacts:
spells:
extract: dnd/spells
extract:
module: dnd/spells
`)
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo"}, &stdout, &stderr, Options{})
@@ -261,19 +263,21 @@ func commandContractOptionsWithLookup(t *testing.T, lookup func(string) (string,
func writeCommandConfig(t *testing.T, firstID, secondID string) string {
t.Helper()
content := fmt.Sprintf("version: 4\npipelines:\n %q:\n input: seriatim\n %q:\n input: seriatim\n", firstID, secondID)
content := fmt.Sprintf("version: 5\npipelines:\n %q:\n input:\n module: seriatim\n %q:\n input:\n module: seriatim\n", firstID, secondID)
return writeCommandConfigContent(t, content)
}
func writeResolvableCommandConfig(t *testing.T) string {
t.Helper()
return writeCommandConfigContent(t, `version: 4
return writeCommandConfigContent(t, `version: 5
pipelines:
demo:
input: seriatim
input:
module: seriatim
artifacts:
spells:
extract: dnd/spells
extract:
module: dnd/spells
`)
}

View File

@@ -0,0 +1,157 @@
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
)
func runConfigEffective(args []string, stdout, stderr io.Writer, opts Options) int {
fs := flag.NewFlagSet("config effective", flag.ContinueOnError)
fs.SetOutput(io.Discard)
configPath := fs.String("config", "", "config file path")
overlay := fs.String("config-overlay", "", "configuration overlay")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 2
}
if fs.NArg() != 0 {
fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0))
return 2
}
loaded, _, err := loadConfigKit(*configPath, *overlay, nil, opts)
if err != nil {
writeConfigLoadError(stderr, err)
return 1
}
if err := loaded.WriteEffective(stdout); err != nil {
fmt.Fprintf(stderr, "notarius: write effective configuration: %v\n", err)
return 1
}
return 0
}
func runConfigExplain(args []string, stdout, stderr io.Writer, opts Options) int {
fs := flag.NewFlagSet("config explain", flag.ContinueOnError)
fs.SetOutput(io.Discard)
configPath := fs.String("config", "", "config file path")
overlay := fs.String("config-overlay", "", "configuration overlay")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 2
}
if fs.NArg() != 1 {
fmt.Fprintln(stderr, "notarius: config explain requires exactly one JSON Pointer")
return 2
}
pointer := fs.Arg(0)
parsed, err := configkit.ParsePath(pointer)
if err != nil || parsed.String() != pointer {
fmt.Fprintf(stderr, "notarius: %q is not a canonical JSON Pointer\n", pointer)
return 2
}
loaded, _, err := loadConfigKit(*configPath, *overlay, nil, opts)
if err != nil {
writeConfigLoadError(stderr, err)
return 1
}
if err := loaded.WriteExplanation(stdout, pointer); err != nil {
fmt.Fprintf(stderr, "notarius: write configuration explanation: %v\n", err)
return 1
}
return 0
}
func runConfigValidate(args []string, stdout, stderr io.Writer, opts Options) int {
fs := flag.NewFlagSet("config validate", flag.ContinueOnError)
fs.SetOutput(io.Discard)
configPath := fs.String("config", "", "config file path")
overlay := fs.String("config-overlay", "", "configuration overlay")
pipelineID := fs.String("pipeline", "", "pipeline ID")
onlyRaw := fs.String("only", "", "comma-separated artifact lanes")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 2
}
if fs.NArg() != 0 {
fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0))
return 2
}
if strings.TrimSpace(*onlyRaw) != "" && strings.TrimSpace(*pipelineID) == "" {
fmt.Fprintln(stderr, "notarius: --only requires --pipeline")
return 2
}
only, err := parseOnly(*onlyRaw)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 2
}
loaded, path, err := loadConfigKit(*configPath, *overlay, nil, opts)
if err != nil {
writeConfigLoadError(stderr, err)
return 1
}
if strings.TrimSpace(*pipelineID) == "" {
fmt.Fprintf(stdout, "config %q is valid\n", path)
return 0
}
catalog, err := effectiveCatalog(opts)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
effective, err := loaded.Config.Resolve(config.ResolveInput{PipelineID: *pipelineID, Only: only, Catalog: catalog})
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
if err := validateExplicitPromptKitProfiles(context.Background(), effective.Config, effectiveLLMProfileIDs(effective.ResolvedPipeline), opts.promptKitAssets); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "config %q is valid for pipeline %q\n", path, strings.TrimSpace(*pipelineID))
return 0
}
func runPipelinesList(args []string, stdout, stderr io.Writer, opts Options) int {
fs := flag.NewFlagSet("pipelines list", flag.ContinueOnError)
fs.SetOutput(io.Discard)
configPath := fs.String("config", "", "config file path")
overlay := fs.String("config-overlay", "", "configuration overlay")
jsonOutput := fs.Bool("json", false, "write JSON output")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 2
}
if fs.NArg() != 0 {
fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0))
return 2
}
loaded, _, err := loadConfigKit(*configPath, *overlay, nil, opts)
if err != nil {
writeConfigLoadError(stderr, err)
return 1
}
ids := sortedPipelineIDs(loaded.Config)
if *jsonOutput {
payload, err := json.Marshal(struct {
Pipelines []string `json:"pipelines"`
}{Pipelines: ids})
if err != nil {
fmt.Fprintf(stderr, "notarius: marshal pipeline list: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "%s\n", payload)
return 0
}
for _, id := range ids {
fmt.Fprintln(stdout, id)
}
return 0
}

View File

@@ -0,0 +1,159 @@
package cli
import (
"bytes"
"context"
"errors"
"io"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestConfigKitReadOnlyCommandsLoadImportsAndOverlays(t *testing.T) {
t.Parallel()
root := t.TempDir()
path := filepath.Join(root, "notarius.yml")
writeConfigKitFile(t, filepath.Join(root, "fragment.yml"), "pipelines:\n imported:\n input: {module: input}\n")
writeConfigKitFile(t, filepath.Join(root, "overlays", "development.yml"), "pipelines:\n overlaid:\n input: {module: input}\n")
writeConfigKitFile(t, path, "version: 5\nconfigkit:\n imports:\n - path: fragment.yml\npipelines:\n base:\n input: {module: input}\n")
var stdout, stderr bytes.Buffer
if code := RunWithOptions([]string{"pipelines", "list", "--config", path, "--config-overlay", "development"}, &stdout, &stderr, commandContractOptions(t)); code != 0 || stdout.String() != "base\nimported\noverlaid\n" {
t.Fatalf("list = %d, %q, %q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
if code := RunWithOptions([]string{"config", "validate", "--config", path, "--config-overlay", "development"}, &stdout, &stderr, commandContractOptions(t)); code != 0 || !strings.Contains(stdout.String(), "is valid") {
t.Fatalf("validate = %d, %q, %q", code, stdout.String(), stderr.String())
}
for _, selection := range [][]string{nil, {"--config-overlay", ""}, {"--config-overlay", "default"}} {
stdout.Reset()
stderr.Reset()
args := append([]string{"pipelines", "list", "--config", path}, selection...)
if code := RunWithOptions(args, &stdout, &stderr, commandContractOptions(t)); code != 0 || stdout.String() != "base\nimported\n" || stderr.Len() != 0 {
t.Fatalf("base list %q = %d, %q, %q", selection, code, stdout.String(), stderr.String())
}
}
}
func TestRetiredConfigProfileFlagUsesNormalUnknownFlagBehavior(t *testing.T) {
t.Parallel()
for _, args := range [][]string{
{"run", "sample", "--config-profile=development"},
{"config", "validate", "--config-profile=development"},
{"config", "effective", "--config-profile=development"},
{"config", "explain", "--config-profile=development", ""},
{"pipelines", "list", "--config-profile=development"},
} {
t.Run(strings.Join(args[:2], " "), func(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := RunWithOptions(args, &stdout, &stderr, commandContractOptions(t)); code != 2 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "not defined") {
t.Fatalf("args=%q code=%d stdout=%q stderr=%q", args, code, stdout.String(), stderr.String())
}
})
}
}
func TestConfigKitReadOnlyCommandsRejectVersionFour(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "notarius.yml")
writeConfigKitFile(t, path, "version: 4\n")
var stdout, stderr bytes.Buffer
if code := runPipelinesList([]string{"--config", path}, &stdout, &stderr, commandContractOptions(t)); code != 1 || !strings.Contains(stderr.String(), "version") {
t.Fatalf("list = %d, %q", code, stderr.String())
}
}
func TestConfigurationInspectionCommandsRenderSafeConfigKitViews(t *testing.T) {
t.Parallel()
root := t.TempDir()
path := filepath.Join(root, "notarius.yml")
writeConfigKitFile(t, path, `version: 5
concurrency:
total_llm: 4
pipelines:
"0":
input: {module: input}
`)
writeConfigKitFile(t, filepath.Join(root, "overlays", "development.yml"), "concurrency:\n total_llm: 3\n")
writeConfigKitFile(t, filepath.Join(root, "overlays", "retired.yml"), "pipelines: null\n")
providerCalled := false
opts := commandContractOptions(t)
opts.LLMClientFactory = func(context.Context, config.Config, string, LLMRuntimeOverrides) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
providerCalled = true
return nil, nil, errors.New("LLM client must not be constructed")
}
var stdout, stderr bytes.Buffer
if code := RunWithOptions([]string{"config", "effective", "--config", path, "--config-overlay", "development"}, &stdout, &stderr, opts); code != 0 || !strings.Contains(stdout.String(), "total_llm: 3") || stderr.Len() != 0 {
t.Fatalf("effective = %d, %q, %q", code, stdout.String(), stderr.String())
}
if providerCalled {
t.Fatal("config effective constructed an LLM client")
}
stdout.Reset()
stderr.Reset()
if code := RunWithOptions([]string{"config", "explain", "--config", path, "--config-overlay", "development", "/concurrency/total_llm"}, &stdout, &stderr, opts); code != 0 || !strings.Contains(stdout.String(), `path: "/concurrency/total_llm"`) || stderr.Len() != 0 {
t.Fatalf("explain effective = %d, %q, %q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
if code := RunWithOptions([]string{"config", "explain", "--config", path, ""}, &stdout, &stderr, opts); code != 0 || !strings.Contains(stdout.String(), `path: ""`) || stderr.Len() != 0 {
t.Fatalf("explain root = %d, %q, %q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
if code := RunWithOptions([]string{"config", "explain", "--config", path, "--config-overlay", "retired", "/pipelines/0"}, &stdout, &stderr, opts); code != 0 || !strings.Contains(stdout.String(), `path: "/pipelines/0"`) || stderr.Len() != 0 {
t.Fatalf("explain removed mapping key = %d, %q, %q", code, stdout.String(), stderr.String())
}
}
func TestConfigExplainRejectsInvalidAndUnknownPointers(t *testing.T) {
t.Parallel()
path := writeCommandConfig(t, "session", "other")
for _, test := range []struct {
name string
pointer string
wantCode int
}{
{name: "missing", wantCode: 2},
{name: "malformed escape", pointer: "/pipelines~2session", wantCode: 2},
{name: "unknown", pointer: "/unknown", wantCode: 1},
{name: "invalid sequence index", pointer: "/pipelines/session/steps/+0", wantCode: 1},
} {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
args := []string{"config", "explain", "--config", path}
if test.name != "missing" {
args = append(args, test.pointer)
}
if code := RunWithOptions(args, &stdout, &stderr, commandContractOptions(t)); code != test.wantCode || stdout.Len() != 0 || stderr.Len() == 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
}
func TestConfigInspectionCommandsReportWriterFailures(t *testing.T) {
t.Parallel()
path := writeCommandConfig(t, "session", "other")
var stderr bytes.Buffer
if code := RunWithOptions([]string{"config", "effective", "--config", path}, failingInspectionWriter{}, &stderr, commandContractOptions(t)); code != 1 || !strings.Contains(stderr.String(), "write effective configuration") {
t.Fatalf("effective writer failure = %d, %q", code, stderr.String())
}
}
type failingInspectionWriter struct{}
func (failingInspectionWriter) Write([]byte) (int, error) {
return 0, io.ErrClosedPipe
}

View File

@@ -0,0 +1,49 @@
package cli
import (
"errors"
"fmt"
"io"
"os"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
)
func loadConfigKit(configPath, overlay string, overrides []configkit.Override, opts Options) (*config.LoadedConfig, string, error) {
path, err := discoverConfigPath(configPath, opts)
if err != nil {
return nil, "", err
}
workingDirectory, err := os.Getwd()
if err != nil {
return nil, "", fmt.Errorf("determine invocation directory: %w", err)
}
loaded, err := config.Load(path, config.LoadOptions{Overlay: overlay, Overrides: overrides, InvocationDirectory: workingDirectory})
if err != nil {
return nil, "", err
}
return loaded, path, nil
}
func writeConfigLoadError(output io.Writer, err error) {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
if writeErr := configkit.WriteDiagnostics(output, loadError.Diagnostics()); writeErr == nil {
// ConfigKit's locations contain source IDs. Keep its renderer and
// supply the acquired-source legend so imported/overlay errors can
// be traced to a file. Quoting also escapes terminal controls.
for _, source := range loadError.Sources() {
label := source.Path
if label == "" {
label = source.Label
}
if _, err := fmt.Fprintf(output, "source %q: %q\n", source.ID, label); err != nil {
return
}
}
return
}
}
fmt.Fprintf(output, "notarius: %v\n", err)
}

View File

@@ -0,0 +1,85 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestConfigLoadDiagnosticsIdentifyComposedSources(t *testing.T) {
t.Parallel()
for _, overlay := range []bool{false, true} {
t.Run(strconv.FormatBool(overlay), func(t *testing.T) {
root := t.TempDir()
main := filepath.Join(root, "config.yml")
fragment := filepath.Join(root, "pipeline\n\x1b.yml")
args := []string{"config", "validate", "--config", main}
if overlay {
fragment = filepath.Join(root, "overlays", "development.yml")
writeConfigKitFile(t, main, "version: 5\n")
args = append(args, "--config-overlay", "development")
} else {
writeConfigKitFile(t, main, "version: 5\nconfigkit:\n imports:\n - path: "+strconv.Quote(filepath.Base(fragment))+"\n")
}
writeConfigKitFile(t, fragment, "concurrency: {total_llm: invalid-private-value}\n")
var stdout, stderr bytes.Buffer
if code := RunWithOptions(args, &stdout, &stderr, commandContractOptions(t)); code != 1 {
t.Fatalf("status = %d; %s", code, stderr.String())
}
got := stderr.String()
if stdout.Len() != 0 || !strings.Contains(got, "decode.type") ||
!strings.Contains(got, "source-2:1:") ||
!strings.Contains(got, strconv.Quote(fragment)) ||
strings.Contains(got, "invalid-private-value") ||
strings.ContainsRune(got, '\x1b') {
t.Fatalf("stdout = %q; stderr = %q", stdout.String(), got)
}
})
}
}
func TestLoadConfigKitUsesDiscoveryOverlayAndOverrides(t *testing.T) {
t.Parallel()
root := t.TempDir()
path := filepath.Join(root, "notarius.yml")
writeConfigKitFile(t, path, "version: 5\npipelines:\n base:\n input: {module: input}\n")
overlays := filepath.Join(root, "overlays")
writeConfigKitFile(t, filepath.Join(overlays, "development.yml"), "pipelines:\n overlaid:\n input: {module: input}\n")
loaded, gotPath, err := loadConfigKit(path, "development", []configkit.Override{{Path: configkit.Keys("output", "directory"), Value: "generated", Flag: "--output"}}, commandContractOptions(t))
if err != nil {
t.Fatalf("loadConfigKit() error = %v", err)
}
if gotPath != path || loaded.Config.Pipelines["overlaid"].Input.Module != "input" || loaded.Config.Output.Directory == "generated" {
t.Fatalf("loaded = %#v, path=%q", loaded.Config, gotPath)
}
}
func TestWriteConfigLoadErrorUsesSafeDiagnostics(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "invalid.yml")
writeConfigKitFile(t, path, "version: 4\n")
_, _, err := loadConfigKit(path, "", nil, commandContractOptions(t))
if err == nil {
t.Fatal("loadConfigKit() error = nil")
}
var output bytes.Buffer
writeConfigLoadError(&output, err)
if !strings.Contains(output.String(), "version") || strings.Contains(output.String(), "notarius: configkit") {
t.Fatalf("diagnostics = %q", output.String())
}
}
func writeConfigKitFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}

View File

@@ -264,13 +264,29 @@ func completeExampleConfigWithTemporaryCache(t *testing.T) string {
updated := strings.Replace(string(content), "directory: ./notarius-cache/chunk-plans", fmt.Sprintf("directory: %q", filepath.Join(cacheRoot, "chunk-plans")), 1)
updated = strings.Replace(updated, "directory: ./notarius-cache/checkpoints", fmt.Sprintf("directory: %q", filepath.Join(cacheRoot, "checkpoints")), 1)
for relative, absolute := range map[string]string{
"./dnd-party.txt": repositoryPath("examples", "dnd-party.txt"),
"./dnd-glossary.txt": repositoryPath("examples", "dnd-glossary.txt"),
"./dnd-spell-catalog.json": repositoryPath("examples", "dnd-spell-catalog.json"),
"./llm-profiles/dnd-extraction.yml": repositoryPath("examples", "llm-profiles", "dnd-extraction.yml"),
} {
updated = strings.ReplaceAll(updated, relative, fmt.Sprintf("%q", absolute))
}
path := filepath.Join(t.TempDir(), "dnd-complete.config.yml")
root := t.TempDir()
fragment, err := os.ReadFile(repositoryPath("examples", "pipelines", "dnd-session.yml"))
if err != nil {
t.Fatal(err)
}
for relative, absolute := range map[string]string{
"../dnd-party.txt": repositoryPath("examples", "dnd-party.txt"),
"../dnd-glossary.txt": repositoryPath("examples", "dnd-glossary.txt"),
"../dnd-spell-catalog.json": repositoryPath("examples", "dnd-spell-catalog.json"),
} {
fragment = []byte(strings.ReplaceAll(string(fragment), relative, fmt.Sprintf("%q", absolute)))
}
if err := os.MkdirAll(filepath.Join(root, "pipelines"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "pipelines", "dnd-session.yml"), fragment, 0o600); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "dnd-complete.config.yml")
if err := os.WriteFile(path, []byte(updated), 0o600); err != nil {
t.Fatal(err)
}

View File

@@ -136,19 +136,26 @@ func TestMaintainedConfigurationExampleSet(t *testing.T) {
t.Fatalf("maintained configuration examples = %q, want only the minimal and complete D&D examples", got)
}
profileEntries, err := os.ReadDir(repositoryPath("examples", "profiles"))
overlayEntries, err := os.ReadDir(repositoryPath("examples", "overlays"))
if err != nil {
t.Fatal(err)
}
names = names[:0]
for _, entry := range profileEntries {
for _, entry := range overlayEntries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".yml") {
names = append(names, entry.Name())
}
}
sort.Strings(names)
if got := strings.Join(names, ","); got != "dnd-extraction.yml" {
t.Fatalf("maintained operator profiles = %q, want dnd-extraction.yml", got)
if got := strings.Join(names, ","); got != "development.yml" {
t.Fatalf("maintained configuration overlays = %q, want development.yml", got)
}
llmProfiles, err := os.ReadDir(repositoryPath("examples", "llm-profiles"))
if err != nil {
t.Fatal(err)
}
if len(llmProfiles) != 1 || llmProfiles[0].Name() != "dnd-extraction.yml" {
t.Fatalf("maintained LLM profiles = %#v, want dnd-extraction.yml", llmProfiles)
}
}

View File

@@ -23,7 +23,7 @@ func TestOversizedNPCRegistryFailsBeforeRuntimeAndCheckpointConstruction(t *test
t.Fatal(err)
}
checkpointRoot := filepath.Join(t.TempDir(), "checkpoints")
content := fmt.Sprintf(`version: 4
content := fmt.Sprintf(`version: 5
cache:
chunk_plans:
mode: bypass
@@ -32,14 +32,17 @@ cache:
directory: %q
pipelines:
dnd-session:
input: seriatim
input:
module: seriatim
artifacts:
spells:
extract:
module: dnd/spells
references:
npc_registry: %q
normalize: dnd/spells
npc_registry:
path: %q
normalize:
module: dnd/spells
`, checkpointRoot, npcPath)
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {

View File

@@ -19,8 +19,6 @@ import (
"testing/fstest"
"time"
"gopkg.in/yaml.v3"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
@@ -333,61 +331,8 @@ func TestProductionPromptAssetsPrepareWithoutProviderCredentials(t *testing.T) {
if err != nil {
t.Fatalf("production prompt engine: %v", err)
}
promptFS, err := components.assets.PromptFS()
if err != nil {
t.Fatalf("production prompt assets: %v", err)
}
type manifest struct {
ID string `yaml:"id"`
Version string `yaml:"version"`
Inputs []struct {
Name string `yaml:"name"`
} `yaml:"inputs"`
Messages []struct {
Role string `yaml:"role"`
} `yaml:"messages"`
}
preparedPrompts := 0
if err := fs.WalkDir(promptFS, ".", func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || filepath.Base(path) != "prompt.yaml" {
return nil
}
data, err := fs.ReadFile(promptFS, path)
if err != nil {
return err
}
var prompt manifest
if err := yaml.Unmarshal(data, &prompt); err != nil {
return err
}
for _, message := range prompt.Messages {
if message.Role != promptkit.RoleSystem && message.Role != promptkit.RoleUser {
return fmt.Errorf("production prompt %q uses role %q, want system or user", prompt.ID, message.Role)
}
}
inputs := make(map[string]promptkit.ArtifactRef, len(prompt.Inputs))
for _, input := range prompt.Inputs {
inputs[input.Name] = promptkit.Inline(`{}`)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: prompt.ID, PromptVersion: prompt.Version, ProfileID: "assembled-prompt-test", Inputs: inputs,
})
if err != nil {
return fmt.Errorf("prepare production prompt %q: %w", prompt.ID, err)
}
if prepared.OutputContract.RepairAttempts != 1 {
return fmt.Errorf("prompt %q repair attempts = %d, want 1", prompt.ID, prepared.OutputContract.RepairAttempts)
}
preparedPrompts++
return nil
}); err != nil {
t.Fatal(err)
}
if preparedPrompts == 0 {
t.Fatal("prepared no production prompts")
if engine == nil {
t.Fatal("production PromptKit engine is nil")
}
}
@@ -612,11 +557,16 @@ func TestNormalizeOptionsSharesProductionProfileAssetsWithDefaultRuntime(t *test
func TestProductionLLMClientFactoryUsesConfiguredLocalBackend(t *testing.T) {
var providerCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
providerCalls.Add(1)
call := providerCalls.Add(1)
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("provider path = %q, want /v1/chat/completions", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
if call == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":{"message":"transient"}}`))
return
}
_, _ = w.Write([]byte(`{
"choices": [{"message": {"role": "assistant", "content": "{\"ok\":true}"}}],
"usage": {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7}
@@ -651,6 +601,7 @@ output:
cfg := config.Default()
cfg.PromptKit.ProfileFile = profilePath
cfg.PromptKit.TransientRetries = 1
cfg.PromptKit.LocalBackend = &config.PromptKitLocalBackendConfig{
Endpoint: server.URL + "/v1",
ConcurrencyLimit: 2,
@@ -679,8 +630,8 @@ output:
if err != nil {
t.Fatalf("CompleteStructured() error = %v, want nil", err)
}
if providerCalls.Load() != 1 {
t.Fatalf("provider calls = %d, want 1", providerCalls.Load())
if providerCalls.Load() != 2 {
t.Fatalf("provider calls = %d, want 2", providerCalls.Load())
}
provider, ok := client.(contracts.LLMProfileManifestProvider)
if !ok {
@@ -737,13 +688,13 @@ func TestProductionConfigValidationCoversModuleAndVariantFailures(t *testing.T)
}{
{
name: "unknown module",
content: replaceRequiredOnce(t, base, " input: seriatim\n", " input: missing/input\n"),
content: replaceRequiredOnce(t, base, " input:\n module: seriatim\n", " input:\n module: missing/input\n"),
options: productionCLIOptions(t),
fragments: []string{"pipeline \"dnd-session\"", "input", "missing/input"},
},
{
name: "unknown validator",
content: replaceRequiredOnce(t, base, " extract: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: missing/validator\n"),
content: replaceRequiredOnce(t, base, " extract:\n module: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: missing/validator\n"),
options: productionCLIOptions(t),
fragments: []string{"validator", "missing/validator"},
},
@@ -755,7 +706,7 @@ func TestProductionConfigValidationCoversModuleAndVariantFailures(t *testing.T)
},
{
name: "deterministic validator with profile",
content: replaceRequiredOnce(t, base, " extract: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: generic/valid_json\n llm_profile: forbidden-profile\n"),
content: replaceRequiredOnce(t, base, " extract:\n module: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: generic/valid_json\n llm_profile: forbidden-profile\n"),
options: productionCLIOptions(t),
fragments: []string{"deterministic validator", "llm_profile"},
},
@@ -779,7 +730,7 @@ func TestProductionConfigValidationCoversModuleAndVariantFailures(t *testing.T)
func TestProductionNormalizeValidatorOverrideRemainsAuthoritative(t *testing.T) {
base := string(readRepositoryFile(t, "examples", "dnd-minimal.config.yml"))
content := replaceRequiredOnce(t, base, " normalize: dnd/spells\n", " normalize:\n module: dnd/spells\n validators:\n - module: generic/always_accept\n - module: generic/valid_json\n")
content := replaceRequiredOnce(t, base, " normalize:\n module: dnd/spells\n", " normalize:\n module: dnd/spells\n validators:\n - module: generic/always_accept\n - module: generic/valid_json\n")
path := writeProductionContractConfig(t, content)
components := productionTestComponents(t)
effective, err := loadMaintainedExample(t, path).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
@@ -879,7 +830,7 @@ func maintainedExampleFiles(t *testing.T) []maintainedExample {
func productionSpellCatalogContractConfig(t *testing.T) string {
t.Helper()
return fmt.Sprintf(`version: 4
return fmt.Sprintf(`version: 5
cache:
chunk_plans:
mode: bypass
@@ -888,21 +839,26 @@ cache:
directory: ""
pipelines:
dnd-session:
input: seriatim
input:
module: seriatim
references:
party: %q
glossary: %q
party:
path: %q
glossary:
path: %q
artifacts:
spells:
extract:
module: dnd/spells
retries: 2
references:
spell_catalog: %q
spell_catalog:
path: %q
normalize:
module: dnd/spells
references:
spell_catalog: %q
spell_catalog:
path: %q
`, repositoryPath("examples", "dnd-party.txt"), repositoryPath("examples", "dnd-glossary.txt"), repositoryPath("examples", "dnd-spell-catalog.json"), repositoryPath("examples", "dnd-spell-catalog.json"))
}
@@ -913,14 +869,11 @@ func writeProductionSpellCatalogContractConfig(t *testing.T) string {
func loadMaintainedExample(t *testing.T, path string) config.Config {
t.Helper()
fileConfig, err := config.LoadFileConfig(path)
loaded, err := config.Load(path, config.LoadOptions{})
if err != nil {
t.Fatalf("load maintained config %q: %v", path, err)
}
cfg := config.Default()
if err := cfg.ApplyFileConfig(fileConfig); err != nil {
t.Fatalf("apply maintained config %q: %v", path, err)
}
cfg := loaded.RuntimeConfig()
if err := cfg.Validate(); err != nil {
t.Fatalf("validate maintained config %q: %v", path, err)
}
@@ -977,7 +930,7 @@ func productionRunOptions(t *testing.T, fake *productionFakeLLMClient) Options {
}
func productionRunConfig(outputRoot, chunkModule string) string {
return fmt.Sprintf(`version: 4
return fmt.Sprintf(`version: 5
output:
directory: %q
cache:
@@ -988,8 +941,10 @@ debug:
directory: %q
pipelines:
dnd-session:
input: seriatim
chunk: %s
input:
module: seriatim
chunk:
module: %s
output:
module: json
options:
@@ -998,8 +953,10 @@ pipelines:
- id: identify-items
artifacts:
item-registry:
extract: dnd/item-registry
normalize: dnd/item-registry
extract:
module: dnd/item-registry
normalize:
module: dnd/item-registry
- id: extract-occurrences
references:
item_registry:
@@ -1008,9 +965,11 @@ pipelines:
lane: item-registry
artifacts:
spells:
extract: dnd/spells
extract:
module: dnd/spells
item-occurrences:
extract: dnd/item-occurrences
extract:
module: dnd/item-occurrences
`, outputRoot, filepath.Join(filepath.Dir(outputRoot), "debug"), chunkModule)
}

View File

@@ -194,7 +194,7 @@ func (recomputeTestOutput) Encode(_ context.Context, req contracts.OutputRequest
func newRecomputeTestRoots(t *testing.T) stateTestRoots {
t.Helper()
roots := newStateTestRoots(t)
config := fmt.Sprintf(`version: 4
config := fmt.Sprintf(`version: 5
output:
directory: %q
cache:
@@ -208,19 +208,27 @@ debug:
directory: %q
pipelines:
sample:
input: test/input
chunk: test/chunk
input:
module: test/input
chunk:
module: test/chunk
steps:
- id: first
artifacts:
producer:
extract: test/extract/producer
merge: test/merge
normalize: test/normalize
extract:
module: test/extract/producer
merge:
module: test/merge
normalize:
module: test/normalize
unrelated:
extract: test/extract/unrelated
merge: test/merge
normalize: test/normalize
extract:
module: test/extract/unrelated
merge:
module: test/merge
normalize:
module: test/normalize
- id: second
references:
upstream:
@@ -229,9 +237,12 @@ pipelines:
lane: producer
artifacts:
middle:
extract: test/extract/middle
merge: test/merge
normalize: test/normalize
extract:
module: test/extract/middle
merge:
module: test/merge
normalize:
module: test/normalize
- id: third
references:
upstream:
@@ -240,10 +251,14 @@ pipelines:
lane: middle
artifacts:
dependent:
extract: test/extract/dependent
merge: test/merge
normalize: test/normalize
output: test/recompute-output
extract:
module: test/extract/dependent
merge:
module: test/merge
normalize:
module: test/normalize
output:
module: test/recompute-output
`, roots.output, roots.plans, roots.checkpoints, roots.debug)
if err := os.WriteFile(roots.config, []byte(config), 0o600); err != nil {
t.Fatal(err)

View File

@@ -289,7 +289,7 @@ func TestReferenceMaterializationSeparatesCLIAndConfigPathOrigins(t *testing.T)
workingDir := t.TempDir()
cfg := referenceContractConfig()
configPath := filepath.Join(configDir, "config.yml")
if err := os.WriteFile(configPath, []byte("version: 4\n"), 0o600); err != nil {
if err := os.WriteFile(configPath, []byte("version: 5\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "required.txt"), []byte("config reference"), 0o600); err != nil {

View File

@@ -4,7 +4,6 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
@@ -16,6 +15,7 @@ import (
"strings"
"time"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/notarius/internal/buildinfo"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
@@ -34,8 +34,10 @@ const usage = `Usage:
notarius help
notarius --version
notarius run <pipeline-id> --input path/to/source.json [--json] [flags]
notarius config validate --config path/to/config.yml [--pipeline pipeline-id] [--only lane-a,lane-b]
notarius pipelines list --config path/to/config.yml [--json]
notarius config validate --config path/to/config.yml [--config-overlay name] [--pipeline pipeline-id] [--only lane-a,lane-b]
notarius config effective --config path/to/config.yml [--config-overlay name]
notarius config explain --config path/to/config.yml [--config-overlay name] <json-pointer>
notarius pipelines list --config path/to/config.yml [--config-overlay name] [--json]
`
type Options struct {
@@ -158,6 +160,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
configPath := fs.String("config", "", "config file path")
configOverlay := fs.String("config-overlay", "", "configuration overlay")
inputPath := fs.String("input", "", "source input file path")
onlyRaw := fs.String("only", "", "comma-separated artifact lanes")
outputDir := fs.String("output-dir", "", "output directory")
@@ -265,20 +268,12 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
return 2
}
cfg, loadedConfigPath, err := loadConfig(*configPath, opts)
loaded, loadedConfigPath, err := loadConfigKit(*configPath, *configOverlay, runConfigurationOverrides(chunkCache, *outputDir, *debugDir), opts)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
writeConfigLoadError(stderr, err)
return 1
}
if chunkCache.set {
cfg.Cache.ChunkPlans.Mode = chunkCache.value
}
if dir := strings.TrimSpace(*outputDir); dir != "" {
cfg.Output.Directory = dir
}
if dir := strings.TrimSpace(*debugDir); dir != "" {
cfg.Debug.Directory = dir
}
cfg := loaded.RuntimeConfig()
if err := cfg.Validate(); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
@@ -336,6 +331,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
InputPath: strings.TrimSpace(*inputPath),
ConfigPath: loadedConfigPath,
ConfigSource: configSource(*configPath),
ConfigOverlay: effectiveConfigOverlay(*configOverlay),
OnlyLanes: append([]string(nil), only...),
ChunkCacheOverride: chunkCache.explicitValue(),
ReasoningEffortOverride: runtimeOverrides.ReasoningEffort,
@@ -394,7 +390,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
if err := writeSummary(summary, func() error { return summary.WriteInvocation(invocation) }); err != nil {
return failPipelineCommand(stderr, commandState, terminalWriter, fmt.Errorf("write debug invocation metadata: %w", err))
}
if err := writeSummary(summary, func() error { return summary.WriteRedactedEffectiveConfig(effective) }); err != nil {
if err := writeSummary(summary, func() error { return summary.WriteEffectiveConfig(loaded) }); err != nil {
return failPipelineCommand(stderr, commandState, terminalWriter, fmt.Errorf("write debug effective config: %w", err))
}
if err := writeSummary(summary, func() error { return summary.WriteResolvedPipeline(effective) }); err != nil {
@@ -881,13 +877,39 @@ func reorderRunArgs(args []string) []string {
func runFlagTakesValue(arg string) bool {
switch arg {
case "--config", "--input", "--only", "--output-dir", "--debug-dir", "--llm-profile", "--session-id", "--reasoning-effort", "--chunk_cache", "--reference", "--without-reference", "--recompute-step":
case "--config", "--config-overlay", "--input", "--only", "--output-dir", "--debug-dir", "--llm-profile", "--session-id", "--reasoning-effort", "--chunk_cache", "--reference", "--without-reference", "--recompute-step":
return true
default:
return false
}
}
func runConfigurationOverrides(chunkCache chunkCacheFlag, outputDir, debugDir string) []configkit.Override {
overrides := make([]configkit.Override, 0, 3)
if chunkCache.set {
overrides = append(overrides, configkit.Override{
Path: configkit.Keys("cache", "chunk_plans", "mode"),
Value: string(chunkCache.value),
Flag: "--chunk_cache",
})
}
if outputDir = strings.TrimSpace(outputDir); outputDir != "" {
overrides = append(overrides, configkit.Override{
Path: configkit.Keys("output", "directory"),
Value: outputDir,
Flag: "--output-dir",
})
}
if debugDir = strings.TrimSpace(debugDir); debugDir != "" {
overrides = append(overrides, configkit.Override{
Path: configkit.Keys("debug", "directory"),
Value: debugDir,
Flag: "--debug-dir",
})
}
return overrides
}
type chunkCacheFlag struct {
value pipeline.ChunkCacheMode
set bool
@@ -941,7 +963,7 @@ func chunkPlanStoreForRun(cfg config.ChunkPlanCacheConfig, opts Options) (pipeli
func validateRunFlagValues(args []string) error {
for i, arg := range args {
if arg != "--session-id" && arg != "--reasoning-effort" {
if arg != "--config-overlay" && arg != "--session-id" && arg != "--reasoning-effort" {
continue
}
if i+1 >= len(args) || strings.HasPrefix(args[i+1], "-") {
@@ -951,6 +973,13 @@ func validateRunFlagValues(args []string) error {
return nil
}
func effectiveConfigOverlay(overlay string) string {
if overlay == "" || overlay == "default" {
return ""
}
return overlay
}
func flagWasProvided(args []string, name string) bool {
for _, arg := range args {
if arg == name || strings.HasPrefix(arg, name+"=") {
@@ -1026,6 +1055,10 @@ func runConfig(args []string, stdout, stderr io.Writer, opts Options) int {
switch args[0] {
case "validate":
return runConfigValidate(args[1:], stdout, stderr, opts)
case "effective":
return runConfigEffective(args[1:], stdout, stderr, opts)
case "explain":
return runConfigExplain(args[1:], stdout, stderr, opts)
default:
fmt.Fprintf(stderr, "notarius: unknown config subcommand %q\n", args[0])
writeUsage(stderr)
@@ -1033,67 +1066,6 @@ func runConfig(args []string, stdout, stderr io.Writer, opts Options) int {
}
}
func runConfigValidate(args []string, stdout, stderr io.Writer, opts Options) int {
fs := flag.NewFlagSet("config validate", flag.ContinueOnError)
fs.SetOutput(io.Discard)
configPath := fs.String("config", "", "config file path")
pipelineID := fs.String("pipeline", "", "pipeline ID")
onlyRaw := fs.String("only", "", "comma-separated artifact lanes")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 2
}
if fs.NArg() != 0 {
fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0))
return 2
}
if strings.TrimSpace(*onlyRaw) != "" && strings.TrimSpace(*pipelineID) == "" {
fmt.Fprintln(stderr, "notarius: --only requires --pipeline")
return 2
}
only, err := parseOnly(*onlyRaw)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 2
}
cfg, path, err := loadConfig(*configPath, opts)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
if strings.TrimSpace(*pipelineID) != "" {
catalog, err := effectiveCatalog(opts)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
effective, err := cfg.Resolve(config.ResolveInput{
PipelineID: *pipelineID,
Only: only,
Catalog: catalog,
})
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
if err := validateExplicitPromptKitProfiles(context.Background(), effective.Config, effectiveLLMProfileIDs(effective.ResolvedPipeline), opts.promptKitAssets); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "config %q is valid for pipeline %q\n", path, strings.TrimSpace(*pipelineID))
return 0
}
if err := cfg.Validate(); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "config %q is valid\n", path)
return 0
}
func runPipelines(args []string, stdout, stderr io.Writer, opts Options) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "notarius: pipelines requires a subcommand")
@@ -1110,70 +1082,6 @@ func runPipelines(args []string, stdout, stderr io.Writer, opts Options) int {
}
}
func runPipelinesList(args []string, stdout, stderr io.Writer, opts Options) int {
fs := flag.NewFlagSet("pipelines list", flag.ContinueOnError)
fs.SetOutput(io.Discard)
configPath := fs.String("config", "", "config file path")
jsonOutput := fs.Bool("json", false, "write JSON output")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 2
}
if fs.NArg() != 0 {
fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0))
return 2
}
cfg, _, err := loadConfig(*configPath, opts)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
if err := cfg.Validate(); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
ids := sortedPipelineIDs(cfg)
if *jsonOutput {
payload := struct {
Pipelines []string `json:"pipelines"`
}{Pipelines: ids}
encoded, err := json.Marshal(payload)
if err != nil {
fmt.Fprintf(stderr, "notarius: marshal pipeline list: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "%s\n", encoded)
return 0
}
for _, id := range ids {
fmt.Fprintln(stdout, id)
}
return 0
}
func loadConfig(configPath string, opts Options) (config.Config, string, error) {
path, err := discoverConfigPath(configPath, opts)
if err != nil {
return config.Config{}, "", err
}
fileCfg, err := config.LoadFileConfig(path)
if err != nil {
return config.Config{}, "", err
}
cfg := config.Default()
if err := cfg.ApplyFileConfigWithLookup(fileCfg, opts.LookupEnv); err != nil {
return config.Config{}, "", err
}
if err := cfg.ApplyEnvOverridesWithLookup(opts.LookupEnv); err != nil {
return config.Config{}, "", err
}
return cfg, path, nil
}
func discoverConfigPath(configPath string, opts Options) (string, error) {
if path := strings.TrimSpace(configPath); path != "" {
if err := requireConfigFile(path); err != nil {

View File

@@ -12,6 +12,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
"gitea.maximumdirect.net/eric/notarius/internal/framework/checkpoint"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
@@ -37,6 +38,9 @@ func TestRunControlsRejectSyntaxWithoutAllocatingState(t *testing.T) {
{name: "blank debug directory", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--debug-dir", ""}
}},
{name: "missing configuration overlay", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--config-overlay"}
}},
{name: "debug directory without debug", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--debug-dir", filepath.Join(filepath.Dir(roots.debug), "requested-debug")}
}},
@@ -79,7 +83,7 @@ func TestRecomputeStepCLIContract(t *testing.T) {
{
name: "explicit step",
configure: func(t *testing.T, roots stateTestRoots) {
replaceStateTestConfigLine(t, roots.config, " artifacts:\n items:\n extract: test/extract\n merge: test/merge\n normalize: test/normalize\n", " steps:\n - id: chosen\n artifacts:\n items:\n extract: test/extract\n merge: test/merge\n normalize: test/normalize\n")
replaceStateTestConfigLine(t, roots.config, " artifacts:\n items:\n extract:\n module: test/extract\n merge:\n module: test/merge\n normalize:\n module: test/normalize\n", " steps:\n - id: chosen\n artifacts:\n items:\n extract:\n module: test/extract\n merge:\n module: test/merge\n normalize:\n module: test/normalize\n")
},
flags: []string{"--resume", "--recompute-step", "chosen"},
wantCode: 0,
@@ -161,7 +165,7 @@ func TestRunOnlyExecutesSelectedLanes(t *testing.T) {
if err != nil {
t.Fatal(err)
}
data = []byte(replaceRequiredOnce(t, string(data), " output: test/output\n", " other:\n extract: test/extract\n output: test/output\n"))
data = []byte(replaceRequiredOnce(t, string(data), " output:\n module: test/output\n", " other:\n extract:\n module: test/extract\n output:\n module: test/output\n"))
if err := os.WriteFile(roots.config, data, 0o600); err != nil {
t.Fatal(err)
}
@@ -180,7 +184,7 @@ func TestRunOnlyExecutesSelectedLanes(t *testing.T) {
}
func TestRunStateRootsHonorEnvironmentFlagsAndDefaults(t *testing.T) {
t.Run("environment roots", func(t *testing.T) {
t.Run("retired environment roots have no effect", func(t *testing.T) {
roots := newStateTestRoots(t)
environmentOutput := filepath.Join(t.TempDir(), "environment-output")
environmentDebug := filepath.Join(t.TempDir(), "environment-debug")
@@ -193,10 +197,10 @@ func TestRunStateRootsHonorEnvironmentFlagsAndDefaults(t *testing.T) {
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertFile(t, filepath.Join(environmentOutput, filepath.Base(onlyChildDir(t, environmentOutput)), "result.json"))
onlyChildDir(t, environmentDebug)
assertAbsent(t, roots.output)
assertAbsent(t, roots.debug)
assertFile(t, filepath.Join(roots.output, filepath.Base(onlyChildDir(t, roots.output)), "result.json"))
onlyChildDir(t, roots.debug)
assertAbsent(t, environmentOutput)
assertAbsent(t, environmentDebug)
})
t.Run("command flags override environment", func(t *testing.T) {
@@ -227,23 +231,57 @@ func TestRunStateRootsHonorEnvironmentFlagsAndDefaults(t *testing.T) {
t.Fatal(err)
}
text := string(data)
text = replaceRequiredOnce(t, text, fmt.Sprintf(" directory: %q\n", roots.output), "")
text = replaceRequiredOnce(t, text, fmt.Sprintf(" directory: %q\n", roots.debug), "")
text = replaceRequiredOnce(t, text, fmt.Sprintf("output:\n directory: %q\n", roots.output), "")
text = replaceRequiredOnce(t, text, fmt.Sprintf("debug:\n directory: %q\n", roots.debug), "")
if err := os.WriteFile(roots.config, []byte(text), 0o600); err != nil {
t.Fatal(err)
}
workDir := t.TempDir()
t.Chdir(workDir)
opts := newStateTestHarness().options()
result := runWithStateRoots(t, roots, opts, nil)
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertFile(t, filepath.Join(workDir, "notarius-output", filepath.Base(onlyChildDir(t, filepath.Join(workDir, "notarius-output"))), "result.json"))
onlyChildDir(t, filepath.Join(workDir, "notarius-debug"))
configDirectory := filepath.Dir(roots.config)
assertFile(t, filepath.Join(configDirectory, "notarius-output", filepath.Base(onlyChildDir(t, filepath.Join(configDirectory, "notarius-output"))), "result.json"))
onlyChildDir(t, filepath.Join(configDirectory, "notarius-debug"))
})
}
func TestRunAppliesConfigurationOverlayAndRecordsSelection(t *testing.T) {
roots := newStateTestRoots(t)
overlayDirectory := filepath.Join(filepath.Dir(roots.config), "overlays")
overlayOutput := filepath.Join(overlayDirectory, "overlay-output")
overlayDebug := filepath.Join(overlayDirectory, "overlay-debug")
writeConfigKitFile(t, filepath.Join(overlayDirectory, "development.yml"), "output:\n directory: ./overlay-output\ndebug:\n directory: ./overlay-debug\n")
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{
"run", "sample", "--config", roots.config, "--config-overlay", "development",
"--input", roots.input, "--chunk_cache", "bypass", "--debug",
}, &stdout, &stderr, newStateTestHarness().options())
if code != 0 || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertFile(t, filepath.Join(overlayOutput, filepath.Base(onlyChildDir(t, overlayOutput)), "result.json"))
bundle := onlyChildDir(t, overlayDebug)
var invocation debugbundle.Invocation
readStateTestSummaryJSON(t, bundle, "invocation.json", &invocation)
if invocation.ConfigOverlay != "development" {
t.Fatalf("config overlay = %q, want development", invocation.ConfigOverlay)
}
}
func TestEffectiveConfigOverlayOmitsBaseSelection(t *testing.T) {
for _, overlay := range []string{"", "default"} {
if got := effectiveConfigOverlay(overlay); got != "" {
t.Fatalf("effectiveConfigOverlay(%q) = %q, want empty", overlay, got)
}
}
if got := effectiveConfigOverlay("development"); got != "development" {
t.Fatalf("effectiveConfigOverlay(development) = %q", got)
}
}
func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) {
t.Run("one effective profile reaches the factory and modules", func(t *testing.T) {
roots := newStateTestRoots(t)
@@ -567,7 +605,7 @@ func TestRunFactoryAndPreparationFailuresAreProcessFailures(t *testing.T) {
if err != nil {
t.Fatal(err)
}
data = []byte(replaceRequiredOnce(t, string(data), "extract: test/extract", "extract: test/failing-extract"))
data = []byte(replaceRequiredOnce(t, string(data), "module: test/extract", "module: test/failing-extract"))
if err := os.WriteFile(roots.config, data, 0o600); err != nil {
t.Fatal(err)
}

View File

@@ -92,7 +92,7 @@ func TestRunResultReportsSuccessfulRejection(t *testing.T) {
if err != nil {
t.Fatal(err)
}
configBytes = []byte(replaceRequiredOnce(t, string(configBytes), " normalize: test/normalize\n", " normalize:\n module: test/normalize\n validators:\n - generic/always_reject\n validation_policy:\n semantic_rejection: reject_output\n"))
configBytes = []byte(replaceRequiredOnce(t, string(configBytes), " normalize:\n module: test/normalize\n", " normalize:\n module: test/normalize\n validators:\n - module: generic/always_reject\n validation_policy:\n semantic_rejection: reject_output\n"))
if err := os.WriteFile(roots.config, configBytes, 0o600); err != nil {
t.Fatal(err)
}

View File

@@ -57,7 +57,6 @@ func TestRunStateSurfaceMatrix(t *testing.T) {
}
}
assertAnyFile(t, roots.checkpoints)
assertRestrictedTree(t, roots.checkpoints)
if debug {
bundle := onlyChildDir(t, roots.debug)
assertFile(t, filepath.Join(bundle, "summary", "invocation.json"))
@@ -259,7 +258,7 @@ func TestRunRedactsSensitiveModuleOptionsFromConfigAndPipelineSummaries(t *testi
if err != nil {
t.Fatal(err)
}
configText := replaceRequiredOnce(t, string(data), " input: test/input\n", ` input:
configText := replaceRequiredOnce(t, string(data), " input:\n module: test/input\n", ` input:
module: test/input
options:
api_key: CONFIG_SUMMARY_SECRET_SENTINEL
@@ -277,7 +276,7 @@ func TestRunRedactsSensitiveModuleOptionsFromConfigAndPipelineSummaries(t *testi
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
}
summaryRoot := filepath.Join(onlyChildDir(t, roots.debug), "summary")
for _, name := range []string{"effective-config.json", "resolved-pipeline.json"} {
for _, name := range []string{"effective-config.yml", "resolved-pipeline.json"} {
contents, err := os.ReadFile(filepath.Join(summaryRoot, name))
if err != nil {
t.Fatal(err)
@@ -688,7 +687,7 @@ func newStateTestRoots(t *testing.T) stateTestRoots {
t.Fatal(err)
}
roots.config = filepath.Join(base, "config.yml")
config := fmt.Sprintf("version: 4\noutput:\n directory: %q\ncache:\n chunk_plans:\n directory: %q\n mode: auto\n checkpoints:\n enabled: true\n directory: %q\ndebug:\n directory: %q\npipelines:\n sample:\n input: test/input\n chunk: test/chunk\n artifacts:\n items:\n extract: test/extract\n merge: test/merge\n normalize: test/normalize\n output: test/output\n", roots.output, roots.plans, roots.checkpoints, roots.debug)
config := fmt.Sprintf("version: 5\noutput:\n directory: %q\ncache:\n chunk_plans:\n directory: %q\n mode: auto\n checkpoints:\n enabled: true\n directory: %q\ndebug:\n directory: %q\npipelines:\n sample:\n input:\n module: test/input\n chunk:\n module: test/chunk\n artifacts:\n items:\n extract:\n module: test/extract\n merge:\n module: test/merge\n normalize:\n module: test/normalize\n output:\n module: test/output\n", roots.output, roots.plans, roots.checkpoints, roots.debug)
if err := os.WriteFile(roots.config, []byte(config), 0o600); err != nil {
t.Fatal(err)
}

View File

@@ -0,0 +1,53 @@
//go:build linux || darwin
package cli
import (
"fmt"
"os"
"path/filepath"
"syscall"
"testing"
)
func TestRunCreatesReusableGroupAccessibleCacheState(t *testing.T) {
previousUmask := syscall.Umask(0o007)
defer syscall.Umask(previousUmask)
roots := newStateTestRoots(t)
harness := newStateTestHarness()
result := runStateTest(t, roots, harness.options(), false, false, "auto")
if result.code != 0 {
t.Fatalf("initial run code=%d stderr=%q", result.code, result.stderr)
}
assertGroupAccessibleCacheTree(t, roots.plans)
assertGroupAccessibleCacheTree(t, roots.checkpoints)
extractCalls := harness.extractCalls
result = runStateTest(t, roots, harness.options(), false, true, "auto")
if result.code != 0 {
t.Fatalf("resume run code=%d stderr=%q", result.code, result.stderr)
}
if harness.extractCalls != extractCalls {
t.Fatalf("extract calls after reuse = %d, want %d", harness.extractCalls, extractCalls)
}
}
func assertGroupAccessibleCacheTree(t *testing.T, root string) {
t.Helper()
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
want := os.FileMode(0o660)
if info.IsDir() {
want = 0o770
}
if got := info.Mode().Perm(); got != want {
return fmt.Errorf("%s mode = %#o, want %#o", path, got, want)
}
return nil
}); err != nil {
t.Fatal(err)
}
}

View File

@@ -4,8 +4,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
const SupportedFileConfigVersion = 4
type Config struct {
PromptKit PromptKitConfig `json:"promptkit,omitempty"`
Pipelines map[string]pipeline.PipelineProfile `json:"pipelines"`
@@ -16,9 +14,10 @@ type Config struct {
}
type PromptKitConfig struct {
ProfileDir string `json:"profile_dir,omitempty"`
ProfileFile string `json:"profile_file,omitempty"`
LocalBackend *PromptKitLocalBackendConfig `json:"local_backend,omitempty"`
ProfileDir string `json:"profile_dir,omitempty"`
ProfileFile string `json:"profile_file,omitempty"`
TransientRetries int `json:"transient_retries"`
LocalBackend *PromptKitLocalBackendConfig `json:"local_backend,omitempty"`
}
type PromptKitLocalBackendConfig struct {
@@ -29,12 +28,12 @@ type PromptKitLocalBackendConfig struct {
type ConcurrencyConfig struct {
TotalLLM int `json:"total_llm"`
StageWorkers map[string]int `json:"stage_workers"`
extractWorkersConfigured bool
defaultedExtractWorkers int
}
const defaultLLMConcurrency = 16
const (
defaultLLMConcurrency = 16
defaultTransientRetries = 2
)
type OutputConfig struct {
Directory string `json:"directory"`
@@ -60,11 +59,11 @@ type DebugConfig struct {
func Default() Config {
return Config{
PromptKit: PromptKitConfig{TransientRetries: defaultTransientRetries},
Pipelines: map[string]pipeline.PipelineProfile{},
Concurrency: ConcurrencyConfig{
TotalLLM: defaultLLMConcurrency,
StageWorkers: map[string]int{"extract": defaultLLMConcurrency},
defaultedExtractWorkers: defaultLLMConcurrency,
TotalLLM: defaultLLMConcurrency,
StageWorkers: map[string]int{"extract": defaultLLMConcurrency},
},
Output: OutputConfig{Directory: "./notarius-output"},
Cache: CacheConfig{ChunkPlans: ChunkPlanCacheConfig{Mode: pipeline.ChunkCacheAuto}},
@@ -97,21 +96,15 @@ func cloneIntMap(in map[string]int) map[string]int {
return out
}
func (c *ConcurrencyConfig) recomputeStageWorkerDefaults() {
if c == nil {
return
}
if c.StageWorkers == nil {
c.StageWorkers = make(map[string]int)
}
if !c.extractWorkersConfigured {
if value, ok := c.StageWorkers["extract"]; ok && (c.defaultedExtractWorkers == 0 || value != c.defaultedExtractWorkers) {
c.extractWorkersConfigured = true
return
func (c Config) withDerivedStageWorkers() Config {
c = cloneConfig(c)
if _, exists := c.Concurrency.StageWorkers["extract"]; !exists {
if c.Concurrency.StageWorkers == nil {
c.Concurrency.StageWorkers = make(map[string]int)
}
c.StageWorkers["extract"] = c.TotalLLM
c.defaultedExtractWorkers = c.TotalLLM
c.Concurrency.StageWorkers["extract"] = c.Concurrency.TotalLLM
}
return c
}
func clonePipelineProfile(in pipeline.PipelineProfile) pipeline.PipelineProfile {

View File

@@ -0,0 +1,123 @@
package config
import "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
// Document is the version 5 configuration shape decoded by ConfigKit. It is
// deliberately separate from Config so YAML presence and composition concerns
// do not reach runtime pipeline types.
type Document struct {
Version string `yaml:"version"`
PromptKit *PromptKitDocument `yaml:"promptkit,omitempty"`
Pipelines map[string]PipelineDocument `yaml:"pipelines,omitempty"`
Concurrency *ConcurrencyDocument `yaml:"concurrency,omitempty"`
Output *OutputDocument `yaml:"output,omitempty"`
Cache *CacheDocument `yaml:"cache,omitempty"`
Debug *DebugDocument `yaml:"debug,omitempty"`
}
type PromptKitDocument struct {
ProfileDir *string `yaml:"profile_dir,omitempty"`
ProfileFile *string `yaml:"profile_file,omitempty"`
TransientRetries *int `yaml:"transient_retries,omitempty"`
LocalBackend *PromptKitLocalBackendDocument `yaml:"local_backend,omitempty"`
}
type PromptKitLocalBackendDocument struct {
Endpoint *string `yaml:"endpoint,omitempty"`
ConcurrencyLimit *int `yaml:"concurrency_limit,omitempty"`
}
type PipelineDocument struct {
LLMProfile *string `yaml:"llm_profile,omitempty"`
StructuredOutputRepairAttempts *int `yaml:"structured_output_repair_attempts,omitempty"`
ValidationPolicy *ValidationPolicyDocument `yaml:"validation_policy,omitempty"`
Input BindingDocument `yaml:"input"`
Chunk *BindingDocument `yaml:"chunk,omitempty"`
Artifacts map[string]ArtifactLaneDocument `yaml:"artifacts,omitempty"`
Steps []PipelineStepDocument `yaml:"steps,omitempty"`
Output *BindingDocument `yaml:"output,omitempty"`
References map[string]ReferenceDocument `yaml:"references,omitempty"`
}
type PipelineStepDocument struct {
ID string `yaml:"id"`
Artifacts map[string]ArtifactLaneDocument `yaml:"artifacts"`
References map[string]ReferenceDocument `yaml:"references,omitempty"`
}
type ArtifactLaneDocument struct {
Extract BindingDocument `yaml:"extract"`
Merge *BindingDocument `yaml:"merge,omitempty"`
Normalize *BindingDocument `yaml:"normalize,omitempty"`
Validators []ValidatorDocument `yaml:"validators,omitempty"`
References map[string]ReferenceDocument `yaml:"references,omitempty"`
}
// BindingDocument represents a producer binding. ValidatorDocument is a
// deliberately finite subset so nested validator-only fields are rejected by
// ConfigKit's strict decoder.
type BindingDocument struct {
Module string `yaml:"module"`
LLMProfile *string `yaml:"llm_profile,omitempty"`
StructuredOutputRepairAttempts *int `yaml:"structured_output_repair_attempts,omitempty"`
ValidationPolicy *ValidationPolicyDocument `yaml:"validation_policy,omitempty"`
Retries int `yaml:"retries,omitempty"`
Options map[string]any `yaml:"options,omitempty"`
References map[string]ReferenceDocument `yaml:"references,omitempty"`
Validators []ValidatorDocument `yaml:"validators,omitempty"`
}
type ValidatorDocument struct {
Module string `yaml:"module"`
LLMProfile *string `yaml:"llm_profile,omitempty"`
StructuredOutputRepairAttempts *int `yaml:"structured_output_repair_attempts,omitempty"`
Retries int `yaml:"retries,omitempty"`
Options map[string]any `yaml:"options,omitempty"`
}
// ReferenceDocument is the explicit version 5 union. Later document
// validation establishes which locations permit generated references and
// requires exactly one populated branch.
type ReferenceDocument struct {
Path *string `yaml:"path,omitempty"`
Artifact *ArtifactReferenceDocument `yaml:"artifact,omitempty"`
}
type ArtifactReferenceDocument struct {
Step string `yaml:"step"`
Lane string `yaml:"lane"`
}
type ValidationPolicyDocument struct {
ProducerStructuralFailure *pipeline.ProducerStructuralFailureAction `yaml:"producer_structural_failure,omitempty"`
SemanticRejection *pipeline.SemanticRejectionAction `yaml:"semantic_rejection,omitempty"`
ValidatorFailure *pipeline.ValidatorFailureAction `yaml:"validator_failure,omitempty"`
}
type ConcurrencyDocument struct {
TotalLLM *int `yaml:"total_llm,omitempty"`
StageWorkers map[string]int `yaml:"stage_workers,omitempty"`
}
type OutputDocument struct {
Directory *string `yaml:"directory,omitempty"`
}
type CacheDocument struct {
ChunkPlans *ChunkPlanCacheDocument `yaml:"chunk_plans,omitempty"`
Checkpoints *CheckpointCacheDocument `yaml:"checkpoints,omitempty"`
}
type ChunkPlanCacheDocument struct {
Directory *string `yaml:"directory,omitempty"`
Mode *string `yaml:"mode,omitempty"`
}
type CheckpointCacheDocument struct {
Enabled *bool `yaml:"enabled,omitempty"`
Directory *string `yaml:"directory,omitempty"`
}
type DebugDocument struct {
Directory *string `yaml:"directory,omitempty"`
}

View File

@@ -0,0 +1,26 @@
package config
import configkit "gitea.maximumdirect.net/eric/ConfigKit"
// documentNormalizer records leaf provenance and, while visiting a sequence,
// covers its atomic mutation boundary. ConfigKit audits sequences as whole
// values, so a record for only a descendant is insufficient. The outermost
// sequence covers nested sequences as well; unchanged visits record nothing.
type documentNormalizer struct {
*configkit.Resolution
sequence *configkit.Path
}
func (n documentNormalizer) inSequence(path configkit.Path) documentNormalizer {
if n.sequence == nil {
n.sequence = &path
}
return n
}
func (n documentNormalizer) Record(path configkit.Path, kind configkit.ChangeKind, reason string, dependencies ...configkit.Path) {
n.Resolution.Record(path, kind, reason, dependencies...)
if n.sequence != nil {
n.Resolution.Record(*n.sequence, kind, reason, append([]configkit.Path{path}, dependencies...)...)
}
}

View File

@@ -0,0 +1,167 @@
package config
import (
"path/filepath"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func normalizeDocumentPaths(document *Document, context *configkit.Resolution, state *documentLoadState) {
resolution := documentNormalizer{Resolution: context}
if document == nil {
return
}
initializeDocumentMainDirectory(resolution, state)
if document.PromptKit != nil {
normalizeDocumentPath(resolution, state, configkit.Keys("promptkit", "profile_dir"), &document.PromptKit.ProfileDir, false, "resolved PromptKit profile directory")
normalizeDocumentPath(resolution, state, configkit.Keys("promptkit", "profile_file"), &document.PromptKit.ProfileFile, false, "resolved PromptKit profile file")
}
if document.Output != nil {
normalizeDocumentPath(resolution, state, configkit.Keys("output", "directory"), &document.Output.Directory, false, "resolved output directory")
}
if document.Cache != nil {
if document.Cache.ChunkPlans != nil {
normalizeDocumentPath(resolution, state, configkit.Keys("cache", "chunk_plans", "directory"), &document.Cache.ChunkPlans.Directory, true, "resolved chunk-plan cache directory")
}
if document.Cache.Checkpoints != nil {
normalizeDocumentPath(resolution, state, configkit.Keys("cache", "checkpoints", "directory"), &document.Cache.Checkpoints.Directory, true, "resolved checkpoint cache directory")
}
}
if document.Debug != nil {
normalizeDocumentPath(resolution, state, configkit.Keys("debug", "directory"), &document.Debug.Directory, false, "resolved debug directory")
}
for _, id := range sortedPipelineDocumentKeys(document.Pipelines) {
profile := document.Pipelines[id]
path := configkit.Keys("pipelines").Key(id)
normalizeDocumentReferencePaths(profile.References, path.Key("references"), resolution, state)
normalizeDocumentBindingReferencePaths(&profile.Input, path.Key("input"), resolution, state)
if profile.Chunk != nil {
normalizeDocumentBindingReferencePaths(profile.Chunk, path.Key("chunk"), resolution, state)
}
if profile.Output != nil {
normalizeDocumentBindingReferencePaths(profile.Output, path.Key("output"), resolution, state)
}
for laneID, lane := range profile.Artifacts {
normalizeDocumentLaneReferencePaths(&lane, path.Key("artifacts").Key(laneID), resolution, state)
profile.Artifacts[laneID] = lane
}
stepResolution := resolution.inSequence(path.Key("steps"))
for index := range profile.Steps {
step := &profile.Steps[index]
stepPath := path.Key("steps").Index(index)
normalizeDocumentReferencePaths(step.References, stepPath.Key("references"), stepResolution, state)
for laneID, lane := range step.Artifacts {
normalizeDocumentLaneReferencePaths(&lane, stepPath.Key("artifacts").Key(laneID), stepResolution, state)
step.Artifacts[laneID] = lane
}
}
document.Pipelines[id] = profile
}
}
func initializeDocumentMainDirectory(resolution documentNormalizer, state *documentLoadState) {
if state == nil || state.mainDirectory != "" {
return
}
info, ok := resolution.InputInfo(configkit.Keys("version"))
if ok && len(info.Origin.Locations) == 1 {
if source, sourceOK := resolution.Source(info.Origin.Locations[0].SourceID); sourceOK && source.Path != "" {
state.mainDirectory = filepath.Dir(source.Path)
return
}
}
// Byte and reader sources are test-oriented private entry points. They have
// no declaring file, so preserve their established invocation-directory
// semantics without extending that fallback to file-backed production loads.
state.mainDirectory = cleanInvocationDirectory(".")
}
func normalizeDocumentBindingReferencePaths(binding *BindingDocument, path configkit.Path, resolution documentNormalizer, state *documentLoadState) {
if binding != nil {
normalizeDocumentReferencePaths(binding.References, path.Key("references"), resolution, state)
}
}
func normalizeDocumentLaneReferencePaths(lane *ArtifactLaneDocument, path configkit.Path, resolution documentNormalizer, state *documentLoadState) {
if lane == nil {
return
}
normalizeDocumentBindingReferencePaths(&lane.Extract, path.Key("extract"), resolution, state)
normalizeDocumentBindingReferencePaths(lane.Merge, path.Key("merge"), resolution, state)
normalizeDocumentBindingReferencePaths(lane.Normalize, path.Key("normalize"), resolution, state)
}
func normalizeDocumentReferencePaths(references map[string]ReferenceDocument, path configkit.Path, resolution documentNormalizer, state *documentLoadState) {
for _, name := range sortedReferenceDocumentKeys(references) {
source := references[name]
sourcePath := path.Key(name).Key("path")
if source.Path != nil && *source.Path != "" && !filepath.IsAbs(*source.Path) {
base, ok := documentPathBase(resolution, state, sourcePath)
if !ok {
resolution.Error(sourcePath, "relative path has no unambiguous source base")
} else {
value := filepath.Clean(filepath.Join(base, *source.Path))
if value != *source.Path {
resolution.Record(sourcePath, configkit.ChangeNormalization, "resolved external reference path")
source.Path = documentValue(value)
}
}
references[name] = source
}
}
}
func normalizeDocumentPath(resolution documentNormalizer, state *documentLoadState, path configkit.Path, target **string, optional bool, reason string) {
if target == nil || *target == nil || **target == "" || filepath.IsAbs(**target) {
return
}
base, ok := documentPathBase(resolution, state, path)
if !ok {
resolution.Error(path, "relative path has no unambiguous source base")
return
}
value := filepath.Clean(filepath.Join(base, **target))
if optional && **target == "" {
return
}
if *target != nil && **target != value {
configkit.Normalize(resolution.Resolution, path, target, documentValue(value), reason)
}
}
func documentPathBase(resolution documentNormalizer, state *documentLoadState, path configkit.Path) (string, bool) {
original := path
if state != nil {
original = state.originalPath(path)
}
info, ok := resolution.InputInfo(original)
if !ok {
if state != nil && state.mainDirectory != "" {
return state.mainDirectory, true
}
return "", false
}
switch info.Origin.Layer {
case configkit.LayerCLI:
if state != nil && state.invocationDirectory != "" {
return state.invocationDirectory, true
}
case configkit.LayerDefault:
if state != nil && state.mainDirectory != "" {
return state.mainDirectory, true
}
default:
if len(info.Origin.Locations) != 1 {
return "", false
}
source, ok := resolution.Source(info.Origin.Locations[0].SourceID)
if !ok || source.Path == "" {
if state != nil && state.mainDirectory != "" {
return state.mainDirectory, true
}
return "", false
}
return filepath.Dir(source.Path), true
}
return "", false
}

View File

@@ -0,0 +1,144 @@
package config
import (
"os"
"path/filepath"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestDocumentPathsUseDeclaringFilesAcrossImports(t *testing.T) {
t.Parallel()
root := t.TempDir()
main := filepath.Join(root, "notarius.yml")
fragmentDirectory := filepath.Join(root, "fragments")
if err := os.MkdirAll(fragmentDirectory, 0o700); err != nil {
t.Fatal(err)
}
fragment := filepath.Join(fragmentDirectory, "pipelines.yml")
if err := os.WriteFile(fragment, []byte(`pipelines:
imported:
input: {module: input}
references:
source: {path: ./from-fragment.txt}
`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(main, []byte(`version: 5
configkit:
imports:
- path: fragments/pipelines.yml
output: {directory: ./output}
debug: {directory: ./debug}
pipelines:
main:
input: {module: input}
references:
source: {path: ./from-main.txt}
`), 0o600); err != nil {
t.Fatal(err)
}
loaded, err := loadConfig(main, LoadOptions{})
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if got := loaded.Config.Pipelines["main"].References["source"].Path; got != filepath.Join(root, "from-main.txt") {
t.Fatalf("main reference = %q", got)
}
if got := loaded.Config.Pipelines["imported"].References["source"].Path; got != filepath.Join(fragmentDirectory, "from-fragment.txt") {
t.Fatalf("fragment reference = %q", got)
}
if loaded.Config.Output.Directory != filepath.Join(root, "output") || loaded.Config.Debug.Directory != filepath.Join(root, "debug") {
t.Fatalf("destinations = %#v %#v", loaded.Config.Output, loaded.Config.Debug)
}
}
func TestDocumentPathsUseInvocationDirectoryForOverrides(t *testing.T) {
t.Parallel()
root := t.TempDir()
configPath := filepath.Join(root, "notarius.yml")
if err := os.WriteFile(configPath, []byte("version: 5\n"), 0o600); err != nil {
t.Fatal(err)
}
invocation := t.TempDir()
loaded, err := loadConfig(configPath, LoadOptions{
InvocationDirectory: invocation,
Overrides: []configkit.Override{{Path: configkit.Keys("output", "directory"), Value: "generated", Flag: "--output"}},
})
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if got := loaded.Config.Output.Directory; got != filepath.Join(invocation, "generated") {
t.Fatalf("override directory = %q", got)
}
}
func TestDocumentPathsUseOverlayDeclarationDirectory(t *testing.T) {
t.Parallel()
root := t.TempDir()
overlays := filepath.Join(root, "overlays")
if err := os.MkdirAll(overlays, 0o700); err != nil {
t.Fatal(err)
}
main := filepath.Join(root, "notarius.yml")
if err := os.WriteFile(main, []byte("version: 5\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(overlays, "development.yml"), []byte("output: {directory: ./overlay-output}\n"), 0o600); err != nil {
t.Fatal(err)
}
loaded, err := loadConfig(main, LoadOptions{Overlay: "development"})
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if got := loaded.Config.Output.Directory; got != filepath.Join(overlays, "overlay-output") {
t.Fatalf("overlay directory = %q", got)
}
}
func TestDocumentLoadDoesNotFallBackToLegacyProfileDirectory(t *testing.T) {
t.Parallel()
root := t.TempDir()
main := filepath.Join(root, "notarius.yml")
if err := os.WriteFile(main, []byte("version: 5\n"), 0o600); err != nil {
t.Fatal(err)
}
legacy := filepath.Join(root, "profiles", "development.yml")
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacy, []byte("output: {directory: ./legacy-output}\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadConfig(main, LoadOptions{Overlay: "development"}); err == nil {
t.Fatal("loadConfig() error = nil, want missing overlay")
}
}
func TestDocumentPathsKeepEmptyCacheDirectoriesAndAbsolutePaths(t *testing.T) {
t.Parallel()
root := t.TempDir()
configPath := filepath.Join(root, "notarius.yml")
abs := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(configPath, []byte("version: 5\ncache:\n chunk_plans: {directory: ''}\n checkpoints: {directory: ''}\npipelines:\n main:\n input: {module: input}\n references:\n outside: {path: "+abs+"}\n"), 0o600); err != nil {
t.Fatal(err)
}
loaded, err := loadConfig(configPath, LoadOptions{})
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if loaded.Config.Cache.ChunkPlans.Directory != "" || loaded.Config.Cache.Checkpoints.Directory != "" {
t.Fatalf("cache directories = %#v", loaded.Config.Cache)
}
if got := loaded.Config.Pipelines["main"].References["outside"].Path; got != abs {
t.Fatalf("absolute reference = %q", got)
}
}

View File

@@ -0,0 +1,154 @@
package config
import (
"errors"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestDocumentLoadAcceptsVersion5AndCanonicalShapes(t *testing.T) {
t.Parallel()
for _, source := range []string{
`version: 5
pipelines:
session:
input:
module: seriatim
references:
party:
path: ./party.txt
steps:
- id: describe
artifacts:
spells:
extract:
module: dnd/spells
validators:
- module: generic/valid_json
options:
strict: true
- id: use-artifact
references:
spells:
artifact:
step: describe
lane: spells
artifacts:
locations:
extract:
module: dnd/locations
`,
`version: "5"
pipelines:
session:
input:
module: seriatim
`,
} {
loaded, err := loadDocumentSource(configkit.Bytes("document.yml", []byte(source)), LoadOptions{})
if err != nil {
t.Fatalf("loadDocumentSource() error = %v", err)
}
profile, ok := loaded.Config.Pipelines["session"]
if !ok || profile.Input.Module != "seriatim" {
t.Fatalf("pipeline = %#v", profile)
}
}
}
func TestDocumentLoadRejectsUnsupportedVersionsBeforeDocumentDecoding(t *testing.T) {
t.Parallel()
tests := []struct {
name string
source string
code configkit.Code
}{
{name: "missing", source: "unknown: value\n", code: configkit.CodeVersionError},
{name: "null", source: "version: null\nunknown: value\n", code: configkit.CodeVersionError},
{name: "version four", source: "version: 4\nunknown: value\n", code: configkit.CodeVersionError},
{name: "unsupported", source: "version: 6\nunknown: value\n", code: configkit.CodeVersionError},
{name: "noninteger", source: "version: 5.0\n", code: configkit.CodeVersionType},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := loadDocumentSource(configkit.Bytes("document.yml", []byte(test.source)), LoadOptions{})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error = %v, want ConfigKit load error", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != "/version" {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if strings.Contains(diagnostics[0].Message, "unknown") {
t.Fatalf("document decoding ran before version validation: %#v", diagnostics)
}
})
}
}
func TestDocumentLoadRejectsShorthandAndUnknownFields(t *testing.T) {
t.Parallel()
tests := []struct {
name string
source string
code configkit.Code
path string
}{
{
name: "binding shorthand",
source: "version: 5\npipelines:\n session:\n input: seriatim\n",
code: configkit.CodeDecodeType,
path: "/pipelines/session/input",
},
{
name: "validator nested producer field",
source: "version: 5\npipelines:\n session:\n input:\n module: seriatim\n validators:\n - module: generic/valid_json\n validators: []\n",
code: configkit.CodeDecodeUnknownField,
path: "/pipelines/session/input/validators/0/validators",
},
{
name: "unknown field",
source: "version: 5\npipelines:\n session:\n input:\n module: seriatim\n unknown: true\n",
code: configkit.CodeDecodeUnknownField,
path: "/pipelines/session/input/unknown",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := loadDocumentSource(configkit.Bytes("document.yml", []byte(test.source)), LoadOptions{})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error = %v, want ConfigKit load error", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != test.path {
t.Fatalf("diagnostics = %#v", diagnostics)
}
})
}
}
func TestDocumentDefinitionReturnsIndependentDefaults(t *testing.T) {
t.Parallel()
first, err := loadDocumentSource(configkit.Bytes("first.yml", []byte("version: 5\n")), LoadOptions{})
if err != nil {
t.Fatal(err)
}
first.Config.Pipelines["changed"] = PipelineDocument{}
first.Config.Concurrency.StageWorkers["extract"] = 3
second, err := loadDocumentSource(configkit.Bytes("second.yml", []byte("version: 5\n")), LoadOptions{})
if err != nil {
t.Fatal(err)
}
if len(second.Config.Pipelines) != 0 || second.Config.Concurrency.StageWorkers["extract"] != defaultLLMConcurrency || len(second.Config.Concurrency.StageWorkers) != 1 {
t.Fatalf("defaults aliased across loads: %#v", second.Config)
}
}

View File

@@ -26,7 +26,7 @@ type EffectiveConfig struct {
}
func (c Config) Resolve(input ResolveInput) (EffectiveConfig, error) {
c.Concurrency.recomputeStageWorkerDefaults()
c = c.withDerivedStageWorkers()
if err := c.Validate(); err != nil {
return EffectiveConfig{}, err
}

View File

@@ -1,103 +0,0 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func LoadFromEnv() (Config, error) {
cfg := Default()
if err := cfg.applyEnvOverridesWithLookup(os.LookupEnv); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c *Config) ApplyEnvOverrides() error {
return c.applyEnvOverridesWithLookup(os.LookupEnv)
}
func (c *Config) ApplyEnvOverridesWithLookup(lookup func(string) (string, bool)) error {
return c.applyEnvOverridesWithLookup(lookup)
}
func (c *Config) applyEnvOverridesWithLookup(lookup func(string) (string, bool)) error {
if c == nil {
return fmt.Errorf("config must not be nil")
}
if raw, ok := lookup("NOTARIUS_TOTAL_LLM_CONCURRENCY"); ok {
value, err := parseIntEnv("NOTARIUS_TOTAL_LLM_CONCURRENCY", raw)
if err != nil {
return err
}
c.Concurrency.TotalLLM = value
}
if raw, ok := lookup("NOTARIUS_STAGE_WORKERS_EXTRACT"); ok {
value, err := parseIntEnv("NOTARIUS_STAGE_WORKERS_EXTRACT", raw)
if err != nil {
return err
}
if c.Concurrency.StageWorkers == nil {
c.Concurrency.StageWorkers = make(map[string]int)
}
c.Concurrency.StageWorkers["extract"] = value
c.Concurrency.extractWorkersConfigured = true
}
c.Concurrency.recomputeStageWorkerDefaults()
if raw, ok := lookup("NOTARIUS_OUTPUT_DIR"); ok {
c.Output.Directory = strings.TrimSpace(raw)
if c.Output.Directory == "" {
return fmt.Errorf("NOTARIUS_OUTPUT_DIR: must not be empty")
}
if strings.ContainsRune(c.Output.Directory, '\x00') {
return fmt.Errorf("NOTARIUS_OUTPUT_DIR: must not contain NUL")
}
}
if raw, ok := lookup("NOTARIUS_CACHE_CHUNK_PLANS_MODE"); ok {
mode, err := pipeline.ParseChunkCacheMode(raw)
if err != nil {
return fmt.Errorf("NOTARIUS_CACHE_CHUNK_PLANS_MODE: %w", err)
}
c.Cache.ChunkPlans.Mode = mode
}
if raw, ok := lookup("NOTARIUS_CACHE_CHUNK_PLANS_DIR"); ok {
c.Cache.ChunkPlans.Directory = cleanOptionalPath(raw)
if c.Cache.ChunkPlans.Directory == "" {
return fmt.Errorf("NOTARIUS_CACHE_CHUNK_PLANS_DIR: must not be empty")
}
if strings.ContainsRune(c.Cache.ChunkPlans.Directory, '\x00') {
return fmt.Errorf("NOTARIUS_CACHE_CHUNK_PLANS_DIR: must not contain NUL")
}
}
if raw, ok := lookup("NOTARIUS_CACHE_CHECKPOINTS_DIR"); ok {
c.Cache.Checkpoints.Directory = cleanOptionalPath(raw)
if c.Cache.Checkpoints.Directory == "" {
return fmt.Errorf("NOTARIUS_CACHE_CHECKPOINTS_DIR: must not be empty")
}
if strings.ContainsRune(c.Cache.Checkpoints.Directory, '\x00') {
return fmt.Errorf("NOTARIUS_CACHE_CHECKPOINTS_DIR: must not contain NUL")
}
}
if raw, ok := lookup("NOTARIUS_DEBUG_DIR"); ok {
c.Debug.Directory = strings.TrimSpace(raw)
if c.Debug.Directory == "" {
return fmt.Errorf("NOTARIUS_DEBUG_DIR: must not be empty")
}
if strings.ContainsRune(c.Debug.Directory, '\x00') {
return fmt.Errorf("NOTARIUS_DEBUG_DIR: must not contain NUL")
}
}
return nil
}
func parseIntEnv(name string, raw string) (int, error) {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return 0, fmt.Errorf("%s: must be an integer", name)
}
return value, nil
}

View File

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

View File

@@ -1,902 +0,0 @@
package config
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gopkg.in/yaml.v3"
)
type FileConfig struct {
Version int `yaml:"version"`
PromptKit *FilePromptKitConfig `yaml:"promptkit,omitempty"`
Pipelines map[string]FilePipelineProfile `yaml:"pipelines,omitempty"`
Concurrency *FileConcurrencyConfig `yaml:"concurrency,omitempty"`
Output *FileOutputConfig `yaml:"output,omitempty"`
Cache *FileCacheConfig `yaml:"cache,omitempty"`
Debug *FileDebugConfig `yaml:"debug,omitempty"`
}
type FilePromptKitConfig struct {
ProfileDir *string `yaml:"profile_dir,omitempty"`
ProfileFile *string `yaml:"profile_file,omitempty"`
LocalBackend *FilePromptKitLocalBackendConfig `yaml:"local_backend,omitempty"`
}
type FilePromptKitLocalBackendConfig struct {
Endpoint *string `yaml:"endpoint,omitempty"`
ConcurrencyLimit *int `yaml:"concurrency_limit,omitempty"`
}
type FilePipelineProfile struct {
LLMProfile *string `yaml:"llm_profile,omitempty"`
StructuredOutputRepairAttempts *int `yaml:"structured_output_repair_attempts,omitempty"`
ValidationPolicy *pipeline.ValidationPolicyOverride `yaml:"validation_policy,omitempty"`
Input fileModuleBinding `yaml:"input"`
Chunk *fileModuleBinding `yaml:"chunk,omitempty"`
Artifacts map[string]FileArtifactLaneProfile `yaml:"artifacts,omitempty"`
Steps []FilePipelineStepProfile `yaml:"steps,omitempty"`
Output *fileModuleBinding `yaml:"output,omitempty"`
References map[string]fileReferenceSource `yaml:"references,omitempty"`
artifactsSet bool `yaml:"-"`
stepsSet bool `yaml:"-"`
llmProfileSet bool `yaml:"-"`
}
func (p *FilePipelineProfile) UnmarshalYAML(node *yaml.Node) error {
if err := validateStructuredOutputRepairAttemptsNode(node, "pipeline profile"); err != nil {
return err
}
type plainFilePipelineProfile FilePipelineProfile
var decoded plainFilePipelineProfile
seen, err := decodeKnownMapping(node, &decoded, map[string]struct{}{
"llm_profile": {}, "structured_output_repair_attempts": {}, "validation_policy": {}, "input": {}, "chunk": {}, "artifacts": {}, "steps": {}, "output": {}, "references": {},
}, "pipeline profile")
if err != nil {
return err
}
*p = FilePipelineProfile(decoded)
if validationPolicyNode, ok := mappingValue(node, "validation_policy"); ok {
policy, err := parseValidationPolicy(validationPolicyNode, "pipeline profile")
if err != nil {
return err
}
p.ValidationPolicy = policy
}
_, p.artifactsSet = seen["artifacts"]
_, p.stepsSet = seen["steps"]
_, p.llmProfileSet = seen["llm_profile"]
return nil
}
func (s *FilePipelineStepProfile) UnmarshalYAML(node *yaml.Node) error {
type plainFilePipelineStepProfile FilePipelineStepProfile
var decoded plainFilePipelineStepProfile
if _, err := decodeKnownMapping(node, &decoded, map[string]struct{}{
"id": {}, "artifacts": {}, "references": {},
}, "pipeline step"); err != nil {
return err
}
*s = FilePipelineStepProfile(decoded)
return nil
}
func (l *FileArtifactLaneProfile) UnmarshalYAML(node *yaml.Node) error {
type plainFileArtifactLaneProfile FileArtifactLaneProfile
var decoded plainFileArtifactLaneProfile
if _, err := decodeKnownMapping(node, &decoded, map[string]struct{}{
"extract": {}, "merge": {}, "normalize": {}, "validators": {}, "references": {},
}, "artifact lane"); err != nil {
return err
}
*l = FileArtifactLaneProfile(decoded)
return nil
}
func decodeKnownMapping(node *yaml.Node, target any, allowed map[string]struct{}, context string) (map[string]struct{}, error) {
if node.Kind != yaml.MappingNode {
return nil, fmt.Errorf("%s must be an object", context)
}
if err := node.Decode(target); err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(node.Content)/2)
for i := 0; i < len(node.Content); i += 2 {
key := node.Content[i].Value
if _, exists := seen[key]; exists {
return nil, fmt.Errorf("%s field %q is duplicated", context, key)
}
if _, ok := allowed[key]; !ok {
return nil, fmt.Errorf("field %s not found in %s", key, context)
}
seen[key] = struct{}{}
}
return seen, nil
}
type FilePipelineStepProfile struct {
ID string `yaml:"id"`
Artifacts map[string]FileArtifactLaneProfile `yaml:"artifacts"`
References map[string]fileReferenceSource `yaml:"references,omitempty"`
}
type FileArtifactLaneProfile struct {
Extract fileModuleBinding `yaml:"extract"`
Merge *fileModuleBinding `yaml:"merge,omitempty"`
Normalize *fileModuleBinding `yaml:"normalize,omitempty"`
Validators []fileModuleBinding `yaml:"validators,omitempty"`
References map[string]fileReferenceSource `yaml:"references,omitempty"`
}
type FileConcurrencyConfig struct {
TotalLLM *int `yaml:"total_llm,omitempty"`
StageWorkers map[string]int `yaml:"stage_workers,omitempty"`
}
type FileOutputConfig struct {
Directory *string `yaml:"directory,omitempty"`
}
type FileCacheConfig struct {
ChunkPlans *FileChunkPlanCacheConfig `yaml:"chunk_plans,omitempty"`
Checkpoints *FileCheckpointCacheConfig `yaml:"checkpoints,omitempty"`
}
type FileChunkPlanCacheConfig struct {
Directory *string `yaml:"directory,omitempty"`
Mode *string `yaml:"mode,omitempty"`
}
type FileCheckpointCacheConfig struct {
Enabled *bool `yaml:"enabled,omitempty"`
Directory *string `yaml:"directory,omitempty"`
}
type FileDebugConfig struct {
Directory *string `yaml:"directory,omitempty"`
}
type fileModuleBinding struct {
Module string
LLMProfile string
StructuredOutputRepairAttempts *int
ValidationPolicy *pipeline.ValidationPolicyOverride
Retries int
Options map[string]any
References map[string]fileReferenceSource
Validators pipeline.ValidatorOverride
}
type fileReferenceSource struct {
path string
artifact *pipeline.ArtifactReference
}
func (source *fileReferenceSource) UnmarshalYAML(node *yaml.Node) error {
if source == nil {
return fmt.Errorf("reference source must not be nil")
}
switch node.Kind {
case yaml.ScalarNode:
if node.Tag != "!!str" {
return fmt.Errorf("external reference path must be a string")
}
path := strings.TrimSpace(node.Value)
if path == "" {
return fmt.Errorf("external reference path must not be empty")
}
source.path = path
source.artifact = nil
return nil
case yaml.MappingNode:
if len(node.Content) != 2 || node.Content[0].Value != "artifact" {
return fmt.Errorf("reference source mapping must contain only artifact")
}
artifactNode := node.Content[1]
if artifactNode.Kind != yaml.MappingNode {
return fmt.Errorf("artifact reference must be an object")
}
var step, lane string
seen := map[string]bool{}
for i := 0; i < len(artifactNode.Content); i += 2 {
key := artifactNode.Content[i].Value
value := artifactNode.Content[i+1]
if seen[key] {
return fmt.Errorf("artifact reference field %q is duplicated", key)
}
seen[key] = true
if value.Tag != "!!str" {
return fmt.Errorf("artifact reference field %q must be a string", key)
}
switch key {
case "step":
step = strings.TrimSpace(value.Value)
case "lane":
lane = strings.TrimSpace(value.Value)
default:
return fmt.Errorf("field %s not found in artifact reference", key)
}
}
if step == "" || lane == "" {
return fmt.Errorf("artifact reference step and lane must not be empty")
}
source.path = ""
source.artifact = &pipeline.ArtifactReference{Step: step, Lane: lane}
return nil
default:
return fmt.Errorf("reference source must be a string or object")
}
}
func (source fileReferenceSource) toPipelineSource() pipeline.ReferenceSource {
if source.artifact != nil {
artifact := *source.artifact
return pipeline.ReferenceSource{Artifact: &artifact}
}
return pipeline.ExternalReference(source.path)
}
func fileReferenceSourcesToPipeline(values map[string]fileReferenceSource) map[string]pipeline.ReferenceSource {
if len(values) == 0 {
return nil
}
out := make(map[string]pipeline.ReferenceSource, len(values))
for key, value := range values {
out[strings.TrimSpace(key)] = value.toPipelineSource()
}
return out
}
func (b *fileModuleBinding) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
var module string
if err := node.Decode(&module); err != nil {
return fmt.Errorf("module binding must be a string or object")
}
b.Module = strings.TrimSpace(module)
return nil
case yaml.MappingNode:
seen := make(map[string]struct{}, len(node.Content)/2)
for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
if _, exists := seen[keyNode.Value]; exists {
return fmt.Errorf("module binding field %q is duplicated", keyNode.Value)
}
seen[keyNode.Value] = struct{}{}
switch keyNode.Value {
case "module":
var module string
if err := valueNode.Decode(&module); err != nil {
return err
}
b.Module = strings.TrimSpace(module)
case "llm_profile":
var llmProfile string
if err := valueNode.Decode(&llmProfile); err != nil {
return err
}
b.LLMProfile = strings.TrimSpace(llmProfile)
if b.LLMProfile == "" {
return fmt.Errorf("llm_profile must not be empty when set")
}
case "structured_output_repair_attempts":
attempts, err := parseStructuredOutputRepairAttempts(valueNode, "module binding")
if err != nil {
return err
}
b.StructuredOutputRepairAttempts = attempts
case "validation_policy":
policy, err := parseValidationPolicy(valueNode, "module binding")
if err != nil {
return err
}
b.ValidationPolicy = policy
case "retries":
var retries int
if err := valueNode.Decode(&retries); err != nil {
return err
}
b.Retries = retries
case "options":
var options map[string]any
if err := valueNode.Decode(&options); err != nil {
return err
}
b.Options = normalizeOptions(options)
case "references":
var references map[string]fileReferenceSource
if err := valueNode.Decode(&references); err != nil {
return err
}
b.References = references
case "validators":
b.Validators.Set = true
var validators []fileModuleBinding
if err := valueNode.Decode(&validators); err != nil {
return err
}
b.Validators.Validators = make([]pipeline.ModuleBinding, len(validators))
for i, validator := range validators {
b.Validators.Validators[i] = validator.toPipelineBinding()
}
default:
return fmt.Errorf("field %s not found in module binding", keyNode.Value)
}
}
return nil
default:
return fmt.Errorf("module binding must be a string or object")
}
}
func (b fileModuleBinding) toPipelineBinding() pipeline.ModuleBinding {
return pipeline.ModuleBinding{
Module: strings.TrimSpace(b.Module),
LLMProfile: strings.TrimSpace(b.LLMProfile),
StructuredOutputRepairAttempts: cloneStructuredOutputRepairAttempts(b.StructuredOutputRepairAttempts),
ValidationPolicy: cloneValidationPolicyOverride(b.ValidationPolicy),
Retries: b.Retries,
Options: cloneOptions(b.Options),
References: fileReferenceSourcesToPipeline(b.References),
Validators: cloneValidatorOverride(b.Validators),
}
}
func mappingValue(node *yaml.Node, key string) (*yaml.Node, bool) {
for i := 0; i < len(node.Content); i += 2 {
if node.Content[i].Value == key {
return node.Content[i+1], true
}
}
return nil, false
}
func parseValidationPolicy(node *yaml.Node, context string) (*pipeline.ValidationPolicyOverride, error) {
if node == nil || node.Tag == "!!null" || node.Kind != yaml.MappingNode {
return nil, fmt.Errorf("%s validation_policy must be an object", context)
}
policy := &pipeline.ValidationPolicyOverride{}
seen := make(map[string]struct{}, len(node.Content)/2)
for i := 0; i < len(node.Content); i += 2 {
key := node.Content[i].Value
value := node.Content[i+1]
if _, exists := seen[key]; exists {
return nil, fmt.Errorf("%s validation_policy field %q is duplicated", context, key)
}
seen[key] = struct{}{}
if value.Tag == "!!null" || value.Kind != yaml.ScalarNode || value.Tag != "!!str" {
return nil, fmt.Errorf("%s validation_policy.%s must be a string", context, key)
}
switch key {
case "producer_structural_failure":
value := pipeline.ProducerStructuralFailureAction(value.Value)
policy.ProducerStructuralFailure = &value
case "semantic_rejection":
value := pipeline.SemanticRejectionAction(value.Value)
policy.SemanticRejection = &value
case "validator_failure":
value := pipeline.ValidatorFailureAction(value.Value)
policy.ValidatorFailure = &value
default:
return nil, fmt.Errorf("field %s not found in %s validation_policy", key, context)
}
}
if err := policy.Validate(); err != nil {
return nil, fmt.Errorf("%s validation_policy: %w", context, err)
}
return policy, nil
}
func validateStructuredOutputRepairAttemptsNode(node *yaml.Node, context string) error {
if node.Kind != yaml.MappingNode {
return fmt.Errorf("%s must be an object", context)
}
for i := 0; i < len(node.Content); i += 2 {
if node.Content[i].Value != "structured_output_repair_attempts" {
continue
}
if _, err := parseStructuredOutputRepairAttempts(node.Content[i+1], context); err != nil {
return err
}
}
return nil
}
func parseStructuredOutputRepairAttempts(node *yaml.Node, context string) (*int, error) {
if node.Tag == "!!null" {
return nil, fmt.Errorf("%s structured_output_repair_attempts must not be null", context)
}
if node.Kind != yaml.ScalarNode || node.Tag != "!!int" {
return nil, fmt.Errorf("%s structured_output_repair_attempts must be an integer", context)
}
var attempts int
if err := node.Decode(&attempts); err != nil {
return nil, fmt.Errorf("%s structured_output_repair_attempts must be an integer: %w", context, err)
}
if attempts < 0 || attempts > 3 {
return nil, fmt.Errorf("%s structured_output_repair_attempts must be between zero and three", context)
}
return &attempts, nil
}
func cloneStructuredOutputRepairAttempts(attempts *int) *int {
if attempts == nil {
return nil
}
value := *attempts
return &value
}
func LoadFileConfig(path string) (FileConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config file %q: %w", path, err)
}
cfg, err := ParseFileConfigYAML(data)
if err != nil {
return FileConfig{}, fmt.Errorf("parse config file %q: %w", path, err)
}
return cfg, nil
}
func ParseFileConfigYAML(data []byte) (FileConfig, error) {
var header struct {
Version int `yaml:"version"`
}
if err := yaml.Unmarshal(data, &header); err != nil {
return FileConfig{}, fmt.Errorf("decode yaml version header: %w", err)
}
if header.Version == 0 {
return FileConfig{}, fmt.Errorf("config version is required")
}
if header.Version == 2 {
return FileConfig{}, fmt.Errorf("config version 2 is no longer supported; migrate the file using the version 2-to-3 migration in docs/config.md")
}
if header.Version == 3 {
return FileConfig{}, fmt.Errorf("config version 3 is no longer supported; change \"version: 3\" to \"version: 4\" and rename \"scriptorium:\" to \"promptkit:\"")
}
if header.Version != SupportedFileConfigVersion {
return FileConfig{}, fmt.Errorf("unsupported config version %d (supported version is %d)", header.Version, SupportedFileConfigVersion)
}
var fileCfg FileConfig
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(&fileCfg); err != nil {
return FileConfig{}, fmt.Errorf("decode yaml: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return FileConfig{}, fmt.Errorf("config must contain exactly one YAML document")
} else if err != io.EOF {
return FileConfig{}, fmt.Errorf("decode trailing yaml document: %w", err)
}
return fileCfg, nil
}
func (c *Config) ApplyFileConfig(fileCfg FileConfig) error {
return c.applyFileConfigWithLookup(fileCfg, os.LookupEnv)
}
func (c *Config) ApplyFileConfigWithLookup(fileCfg FileConfig, lookup func(string) (string, bool)) error {
return c.applyFileConfigWithLookup(fileCfg, lookup)
}
func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(string) (string, bool)) error {
_ = lookup
if c == nil {
return fmt.Errorf("config must not be nil")
}
if fileCfg.Version != SupportedFileConfigVersion {
return fmt.Errorf("unsupported config version %d", fileCfg.Version)
}
if c.Pipelines == nil {
c.Pipelines = map[string]pipeline.PipelineProfile{}
}
pipelineIDs, rawPipelineIDs, err := normalizedMapKeys(fileCfg.Pipelines, "pipeline id")
if err != nil {
return err
}
for _, pipelineID := range pipelineIDs {
filePipeline := fileCfg.Pipelines[rawPipelineIDs[pipelineID]]
hasArtifacts := filePipeline.artifactsSet || filePipeline.Artifacts != nil
hasSteps := filePipeline.stepsSet || filePipeline.Steps != nil
if hasArtifacts && hasSteps {
return fmt.Errorf("pipeline %q must not declare both artifacts and steps", pipelineID)
}
if hasSteps && len(filePipeline.Steps) == 0 {
return fmt.Errorf("pipeline %q must declare at least one ordered step", pipelineID)
}
if hasSteps {
seenSteps := make(map[string]struct{}, len(filePipeline.Steps))
seenLanes := make(map[string]struct{})
for index, step := range filePipeline.Steps {
stepID := strings.TrimSpace(step.ID)
if stepID == "" {
return fmt.Errorf("pipeline %q step[%d] id must not be empty", pipelineID, index)
}
if _, ok := seenSteps[stepID]; ok {
return fmt.Errorf("pipeline %q step id %q is duplicated after trimming", pipelineID, stepID)
}
seenSteps[stepID] = struct{}{}
laneIDs, rawLaneIDs, err := normalizedMapKeys(step.Artifacts, fmt.Sprintf("pipeline %q step %q artifact lane id", pipelineID, stepID))
if err != nil {
return err
}
for _, laneID := range laneIDs {
if _, ok := seenLanes[laneID]; ok {
return fmt.Errorf("pipeline %q artifact lane id %q is duplicated across steps", pipelineID, laneID)
}
seenLanes[laneID] = struct{}{}
fileLane := step.Artifacts[rawLaneIDs[laneID]]
if err := validateFileLaneReferences(pipelineID, stepID, laneID, fileLane); err != nil {
return err
}
}
if err := validateFileReferenceSources(step.References, fmt.Sprintf("pipeline %q step %q reference slot", pipelineID, stepID)); err != nil {
return err
}
}
}
if _, _, err := normalizedMapKeys(filePipeline.Artifacts, fmt.Sprintf("pipeline %q artifact lane id", pipelineID)); err != nil {
return err
}
if err := validateFileReferenceSources(filePipeline.References, fmt.Sprintf("pipeline %q reference slot", pipelineID)); err != nil {
return err
}
if filePipeline.Chunk != nil {
if _, _, err := normalizedMapKeys(filePipeline.Chunk.References, fmt.Sprintf("pipeline %q chunk reference slot", pipelineID)); err != nil {
return err
}
}
if _, _, err := normalizedMapKeys(filePipeline.Input.References, fmt.Sprintf("pipeline %q input reference slot", pipelineID)); err != nil {
return err
}
if filePipeline.Output != nil {
if _, _, err := normalizedMapKeys(filePipeline.Output.References, fmt.Sprintf("pipeline %q output reference slot", pipelineID)); err != nil {
return err
}
}
for rawLaneID, fileLane := range filePipeline.Artifacts {
laneID := strings.TrimSpace(rawLaneID)
if laneID == "" {
continue
}
if _, _, err := normalizedMapKeys(fileLane.References, fmt.Sprintf("pipeline %q lane %q reference slot", pipelineID, laneID)); err != nil {
return err
}
if _, _, err := normalizedMapKeys(fileLane.Extract.References, fmt.Sprintf("pipeline %q lane %q extract reference slot", pipelineID, laneID)); err != nil {
return err
}
if fileLane.Merge != nil {
if _, _, err := normalizedMapKeys(fileLane.Merge.References, fmt.Sprintf("pipeline %q lane %q merge reference slot", pipelineID, laneID)); err != nil {
return err
}
}
if fileLane.Normalize != nil {
if _, _, err := normalizedMapKeys(fileLane.Normalize.References, fmt.Sprintf("pipeline %q lane %q normalize reference slot", pipelineID, laneID)); err != nil {
return err
}
}
for i, validator := range fileLane.Validators {
if _, _, err := normalizedMapKeys(validator.References, fmt.Sprintf("pipeline %q lane %q validator[%d] reference slot", pipelineID, laneID, i)); err != nil {
return err
}
}
}
}
if fileCfg.PromptKit != nil {
if fileCfg.PromptKit.ProfileDir != nil {
value := strings.TrimSpace(*fileCfg.PromptKit.ProfileDir)
if value == "" {
return fmt.Errorf("promptkit.profile_dir must not be empty when set")
}
c.PromptKit.ProfileDir = value
}
if fileCfg.PromptKit.ProfileFile != nil {
value := strings.TrimSpace(*fileCfg.PromptKit.ProfileFile)
if value == "" {
return fmt.Errorf("promptkit.profile_file must not be empty when set")
}
c.PromptKit.ProfileFile = value
}
if fileCfg.PromptKit.LocalBackend != nil {
if fileCfg.PromptKit.LocalBackend.Endpoint == nil {
return fmt.Errorf("promptkit.local_backend.endpoint must not be empty when set")
}
endpoint := strings.TrimSpace(*fileCfg.PromptKit.LocalBackend.Endpoint)
if endpoint == "" {
return fmt.Errorf("promptkit.local_backend.endpoint must not be empty when set")
}
localBackend := PromptKitLocalBackendConfig{Endpoint: endpoint}
if fileCfg.PromptKit.LocalBackend.ConcurrencyLimit != nil {
localBackend.ConcurrencyLimit = *fileCfg.PromptKit.LocalBackend.ConcurrencyLimit
}
c.PromptKit.LocalBackend = &localBackend
}
}
for _, pipelineID := range pipelineIDs {
filePipeline := fileCfg.Pipelines[rawPipelineIDs[pipelineID]]
llmProfile := ""
if filePipeline.llmProfileSet || filePipeline.LLMProfile != nil {
if filePipeline.LLMProfile == nil || strings.TrimSpace(*filePipeline.LLMProfile) == "" {
return fmt.Errorf("pipeline %q llm_profile must not be empty when set", pipelineID)
}
llmProfile = strings.TrimSpace(*filePipeline.LLMProfile)
}
hasSteps := filePipeline.stepsSet || filePipeline.Steps != nil
laneIDs, rawLaneIDs, err := normalizedMapKeys(filePipeline.Artifacts, fmt.Sprintf("pipeline %q artifact lane id", pipelineID))
if err != nil {
return err
}
profile := pipeline.PipelineProfile{
ID: pipelineID,
LLMProfile: llmProfile,
StructuredOutputRepairAttempts: cloneStructuredOutputRepairAttempts(filePipeline.StructuredOutputRepairAttempts),
ValidationPolicy: cloneValidationPolicyOverride(filePipeline.ValidationPolicy),
Input: filePipeline.Input.toPipelineBinding(),
Artifacts: make(map[string]pipeline.ArtifactLaneProfile, len(filePipeline.Artifacts)),
References: fileReferenceSourcesToPipeline(filePipeline.References),
}
if filePipeline.Chunk != nil {
profile.Chunk = filePipeline.Chunk.toPipelineBinding()
}
if filePipeline.Output != nil {
profile.Output = filePipeline.Output.toPipelineBinding()
}
for _, laneID := range laneIDs {
fileLane := filePipeline.Artifacts[rawLaneIDs[laneID]]
extract := fileLane.Extract.toPipelineBinding()
extract.References = mergeReferenceSources(fileReferenceSourcesToPipeline(fileLane.References), extract.References)
lane := pipeline.ArtifactLaneProfile{
Extract: extract,
References: fileReferenceSourcesToPipeline(fileLane.References),
}
if fileLane.Merge != nil {
lane.Merge = fileLane.Merge.toPipelineBinding()
}
if fileLane.Normalize != nil {
lane.Normalize = fileLane.Normalize.toPipelineBinding()
}
if len(fileLane.Validators) > 0 {
lane.Validators = make([]pipeline.ModuleBinding, len(fileLane.Validators))
for i, validator := range fileLane.Validators {
lane.Validators[i] = validator.toPipelineBinding()
}
}
profile.Artifacts[laneID] = lane
}
if hasSteps {
profile.Artifacts = nil
profile.Steps = make([]pipeline.PipelineStepProfile, len(filePipeline.Steps))
for i, fileStep := range filePipeline.Steps {
stepID := strings.TrimSpace(fileStep.ID)
step := pipeline.PipelineStepProfile{
ID: stepID,
Artifacts: make(map[string]pipeline.ArtifactLaneProfile, len(fileStep.Artifacts)),
References: fileReferenceSourcesToPipeline(fileStep.References),
}
stepLaneIDs, stepRawLaneIDs, err := normalizedMapKeys(fileStep.Artifacts, fmt.Sprintf("pipeline %q step %q artifact lane id", pipelineID, stepID))
if err != nil {
return err
}
for _, laneID := range stepLaneIDs {
fileLane := fileStep.Artifacts[stepRawLaneIDs[laneID]]
extract := fileLane.Extract.toPipelineBinding()
extract.References = mergeReferenceSources(fileReferenceSourcesToPipeline(fileLane.References), extract.References)
lane := pipeline.ArtifactLaneProfile{Extract: extract, References: fileReferenceSourcesToPipeline(fileLane.References)}
if fileLane.Merge != nil {
lane.Merge = fileLane.Merge.toPipelineBinding()
}
if fileLane.Normalize != nil {
lane.Normalize = fileLane.Normalize.toPipelineBinding()
}
if len(fileLane.Validators) > 0 {
lane.Validators = make([]pipeline.ModuleBinding, len(fileLane.Validators))
for index, validator := range fileLane.Validators {
lane.Validators[index] = validator.toPipelineBinding()
}
}
step.Artifacts[laneID] = lane
}
profile.Steps[i] = step
}
}
c.Pipelines[pipelineID] = profile
}
if fileCfg.Concurrency != nil && fileCfg.Concurrency.TotalLLM != nil {
c.Concurrency.TotalLLM = *fileCfg.Concurrency.TotalLLM
}
if fileCfg.Concurrency != nil && fileCfg.Concurrency.StageWorkers != nil {
workers, configured, err := normalizeStageWorkers(fileCfg.Concurrency.StageWorkers)
if err != nil {
return err
}
c.Concurrency.StageWorkers = workers
c.Concurrency.extractWorkersConfigured = configured
}
c.Concurrency.recomputeStageWorkerDefaults()
if fileCfg.Output != nil && fileCfg.Output.Directory != nil {
c.Output.Directory = strings.TrimSpace(*fileCfg.Output.Directory)
if c.Output.Directory == "" {
return fmt.Errorf("output.directory must not be empty")
}
if strings.ContainsRune(c.Output.Directory, '\x00') {
return fmt.Errorf("output.directory must not contain NUL")
}
}
if fileCfg.Cache != nil {
if fileCfg.Cache.ChunkPlans != nil {
if fileCfg.Cache.ChunkPlans.Mode != nil {
mode, err := pipeline.ParseChunkCacheMode(*fileCfg.Cache.ChunkPlans.Mode)
if err != nil {
return fmt.Errorf("cache.chunk_plans.mode: %w", err)
}
c.Cache.ChunkPlans.Mode = mode
}
if fileCfg.Cache.ChunkPlans.Directory != nil {
c.Cache.ChunkPlans.Directory = cleanOptionalPath(*fileCfg.Cache.ChunkPlans.Directory)
if strings.ContainsRune(c.Cache.ChunkPlans.Directory, '\x00') {
return fmt.Errorf("cache.chunk_plans.directory must not contain NUL")
}
}
}
if fileCfg.Cache.Checkpoints != nil {
if fileCfg.Cache.Checkpoints.Enabled != nil {
c.Cache.Checkpoints.Enabled = *fileCfg.Cache.Checkpoints.Enabled
}
if fileCfg.Cache.Checkpoints.Directory != nil {
c.Cache.Checkpoints.Directory = cleanOptionalPath(*fileCfg.Cache.Checkpoints.Directory)
if strings.ContainsRune(c.Cache.Checkpoints.Directory, '\x00') {
return fmt.Errorf("cache.checkpoints.directory must not contain NUL")
}
}
}
}
if fileCfg.Debug != nil && fileCfg.Debug.Directory != nil {
c.Debug.Directory = strings.TrimSpace(*fileCfg.Debug.Directory)
if c.Debug.Directory == "" {
return fmt.Errorf("debug.directory must not be empty")
}
if strings.ContainsRune(c.Debug.Directory, '\x00') {
return fmt.Errorf("debug.directory must not contain NUL")
}
}
return nil
}
func cleanOptionalPath(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return filepath.Clean(value)
}
func normalizeStageWorkers(values map[string]int) (map[string]int, bool, error) {
workers := make(map[string]int, len(values))
configured := false
for rawKey, value := range values {
key := strings.TrimSpace(rawKey)
if key == "" {
return nil, false, fmt.Errorf("concurrency.stage_workers key must not be empty")
}
if key != "extract" {
return nil, false, fmt.Errorf("concurrency.stage_workers key %q is not supported", rawKey)
}
if _, exists := workers[key]; exists {
return nil, false, fmt.Errorf("concurrency.stage_workers key %q is duplicated after trimming", key)
}
workers[key] = value
configured = true
}
return workers, configured, nil
}
func normalizedMapKeys[T any](values map[string]T, keyName string) ([]string, map[string]string, error) {
keys := make([]string, 0, len(values))
rawByNormalized := make(map[string]string, len(values))
for rawID := range values {
id := strings.TrimSpace(rawID)
if id == "" {
return nil, nil, fmt.Errorf("%s must not be empty", keyName)
}
if _, ok := rawByNormalized[id]; ok {
return nil, nil, fmt.Errorf("%s %q is duplicated after trimming", keyName, id)
}
rawByNormalized[id] = rawID
keys = append(keys, id)
}
sort.Strings(keys)
return keys, rawByNormalized, nil
}
func validateFileReferenceSources(values map[string]fileReferenceSource, context string) error {
seen := make(map[string]struct{}, len(values))
for rawSlot, source := range values {
slot := strings.TrimSpace(rawSlot)
if slot == "" {
return fmt.Errorf("%s must not be empty", context)
}
if _, ok := seen[slot]; ok {
return fmt.Errorf("%s %q is duplicated after trimming", context, slot)
}
seen[slot] = struct{}{}
if source.artifact != nil {
if strings.TrimSpace(source.artifact.Step) == "" || strings.TrimSpace(source.artifact.Lane) == "" {
return fmt.Errorf("%s %q artifact selector step and lane must not be empty", context, slot)
}
if strings.TrimSpace(source.path) != "" {
return fmt.Errorf("%s %q must contain either an external path or artifact selector", context, slot)
}
continue
}
if strings.TrimSpace(source.path) == "" {
return fmt.Errorf("%s %q source must not be empty", context, slot)
}
}
return nil
}
func validateFileLaneReferences(pipelineID, stepID, laneID string, lane FileArtifactLaneProfile) error {
prefix := fmt.Sprintf("pipeline %q step %q lane %q", pipelineID, stepID, laneID)
references := []struct {
label string
values map[string]fileReferenceSource
}{
{label: "reference slot", values: lane.References},
{label: "extract reference slot", values: lane.Extract.References},
}
if lane.Merge != nil {
references = append(references, struct {
label string
values map[string]fileReferenceSource
}{label: "merge reference slot", values: lane.Merge.References})
}
if lane.Normalize != nil {
references = append(references, struct {
label string
values map[string]fileReferenceSource
}{label: "normalize reference slot", values: lane.Normalize.References})
}
for _, item := range references {
if err := validateFileReferenceSources(item.values, prefix+" "+item.label); err != nil {
return err
}
}
for index, validator := range lane.Validators {
if err := validateFileReferenceSources(validator.References, fmt.Sprintf("%s validator[%d] reference slot", prefix, index)); err != nil {
return err
}
}
return nil
}
func mergeReferenceSources(base map[string]pipeline.ReferenceSource, override map[string]pipeline.ReferenceSource) map[string]pipeline.ReferenceSource {
if len(base) == 0 && len(override) == 0 {
return nil
}
out := make(map[string]pipeline.ReferenceSource, len(base)+len(override))
for key, value := range base {
out[key] = value
}
for key, value := range override {
out[key] = value
}
return out
}
func normalizeOptions(options map[string]any) map[string]any {
if len(options) == 0 {
return nil
}
return cloneOptions(options)
}

View File

@@ -1,928 +0,0 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestDefaultReturnsDocumentedValuesAndIndependentMaps(t *testing.T) {
first := Default()
if first.Concurrency.TotalLLM != 16 || first.Concurrency.StageWorkers["extract"] != 16 {
t.Fatalf("concurrency defaults = %#v", first.Concurrency)
}
if first.Output.Directory != "./notarius-output" || first.Debug.Directory != "./notarius-debug" {
t.Fatalf("output/debug defaults = %#v, %#v", first.Output, first.Debug)
}
if first.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto || first.Cache.ChunkPlans.Directory != "" || first.Cache.Checkpoints.Enabled || first.Cache.Checkpoints.Directory != "" {
t.Fatalf("cache defaults = %#v", first.Cache)
}
if len(first.Pipelines) != 0 {
t.Fatalf("pipeline defaults = %#v", first.Pipelines)
}
first.Concurrency.StageWorkers["extract"] = 99
first.Concurrency.StageWorkers["other"] = 100
first.Pipelines["changed"] = pipeline.PipelineProfile{}
second := Default()
if second.Concurrency.StageWorkers["extract"] != 16 || len(second.Concurrency.StageWorkers) != 1 || len(second.Pipelines) != 0 {
t.Fatalf("Default() returned state shared with an earlier result: %#v", second)
}
}
func TestFileConfigMinimalVersion4AppliesOverDefaults(t *testing.T) {
file := parseFileConfig(t, "version: 4\n")
cfg := Default()
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatal(err)
}
if cfg.Output.Directory != "./notarius-output" || cfg.Debug.Directory != "./notarius-debug" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto {
t.Fatalf("minimal file changed unrelated defaults: %#v", cfg)
}
if cfg.Concurrency.TotalLLM != 16 || cfg.Concurrency.StageWorkers["extract"] != 16 || len(cfg.Pipelines) != 0 {
t.Fatalf("minimal file did not retain defaults: %#v", cfg)
}
}
func TestParseFileConfigYAMLRejectsAdditionalDocuments(t *testing.T) {
tests := []struct {
name string
source string
wantErr bool
}{
{name: "trailing whitespace", source: "version: 4\n\n \t", wantErr: false},
{name: "trailing comment", source: "version: 4\n# trailing comment\n", wantErr: false},
{name: "second valid document", source: "version: 4\n---\nversion: 4\n", wantErr: true},
{name: "second empty document", source: "version: 4\n---\n", wantErr: true},
{name: "second malformed document", source: "version: 4\n---\nversion: [\n", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(tt.source))
if (err != nil) != tt.wantErr {
t.Fatalf("ParseFileConfigYAML() error = %v, want error=%t", err, tt.wantErr)
}
})
}
}
func TestFilePipelineLLMProfileIsPresenceAwareAndDetached(t *testing.T) {
const pipelineYAML = `version: 4
pipelines:
main:
%s
input: input
artifacts:
lane:
extract: extract
`
t.Run("omitted", func(t *testing.T) {
file := parseFileConfig(t, fmt.Sprintf(pipelineYAML, ""))
if file.Pipelines["main"].LLMProfile != nil || file.Pipelines["main"].llmProfileSet {
t.Fatalf("parsed pipeline profile = %#v, want omitted llm profile", file.Pipelines["main"])
}
cfg := Default()
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatal(err)
}
if got := cfg.Pipelines["main"].LLMProfile; got != "" {
t.Fatalf("pipeline llm profile = %q, want empty", got)
}
})
t.Run("trimmed and detached", func(t *testing.T) {
file := parseFileConfig(t, fmt.Sprintf(pipelineYAML, "llm_profile: ' configured-profile '"))
cfg := Default()
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatal(err)
}
if got := cfg.Pipelines["main"].LLMProfile; got != "configured-profile" {
t.Fatalf("pipeline llm profile = %q, want trimmed value", got)
}
*file.Pipelines["main"].LLMProfile = "changed-profile"
if got := cfg.Pipelines["main"].LLMProfile; got != "configured-profile" {
t.Fatalf("effective config aliases parsed file: %q", got)
}
if got := cloneConfig(cfg).Pipelines["main"].LLMProfile; got != "configured-profile" {
t.Fatalf("cloned pipeline llm profile = %q", got)
}
data, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
var roundTripped Config
if err := json.Unmarshal(data, &roundTripped); err != nil {
t.Fatal(err)
}
if got := roundTripped.Pipelines["main"].LLMProfile; got != "configured-profile" {
t.Fatalf("round-tripped pipeline llm profile = %q", got)
}
})
for _, value := range []string{"''", "' '", "null"} {
t.Run("explicit empty "+value, func(t *testing.T) {
file := parseFileConfig(t, fmt.Sprintf(pipelineYAML, "llm_profile: "+value))
cfg := Default()
err := cfg.ApplyFileConfig(file)
if err == nil || !strings.Contains(err.Error(), `pipeline "main" llm_profile must not be empty`) {
t.Fatalf("ApplyFileConfig() error = %v, want explicit-empty rejection", err)
}
})
}
}
func TestStructuredOutputRepairAttemptsFileConfigurationPreservesPresenceAndOwnership(t *testing.T) {
const pipelineYAML = `version: 4
pipelines:
main:
%s
input: input
artifacts:
lane:
extract:
module: extract
structured_output_repair_attempts: 2
validators:
- module: validator
structured_output_repair_attempts: 3
`
t.Run("omitted pipeline value remains absent", func(t *testing.T) {
file := parseFileConfig(t, fmt.Sprintf(pipelineYAML, ""))
if got := file.Pipelines["main"].StructuredOutputRepairAttempts; got != nil {
t.Fatalf("file pipeline repair attempts = %v, want nil", *got)
}
cfg := Default()
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatal(err)
}
if got := cfg.Pipelines["main"].StructuredOutputRepairAttempts; got != nil {
t.Fatalf("pipeline repair attempts = %v, want nil", *got)
}
if got := cfg.Pipelines["main"].Input.StructuredOutputRepairAttempts; got != nil {
t.Fatalf("scalar input binding repair attempts = %v, want nil", *got)
}
})
t.Run("zero is explicit and survives configuration boundaries", func(t *testing.T) {
file := parseFileConfig(t, fmt.Sprintf(pipelineYAML, "structured_output_repair_attempts: 0"))
cfg := Default()
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatal(err)
}
profile := cfg.Pipelines["main"]
if profile.StructuredOutputRepairAttempts == nil || *profile.StructuredOutputRepairAttempts != 0 {
t.Fatalf("pipeline repair attempts = %v, want explicit zero", profile.StructuredOutputRepairAttempts)
}
lane := profile.Artifacts["lane"]
if lane.Extract.StructuredOutputRepairAttempts == nil || *lane.Extract.StructuredOutputRepairAttempts != 2 {
t.Fatalf("extract repair attempts = %v, want 2", lane.Extract.StructuredOutputRepairAttempts)
}
if lane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts == nil || *lane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts != 3 {
t.Fatalf("validator repair attempts = %v, want 3", lane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts)
}
*file.Pipelines["main"].StructuredOutputRepairAttempts = 1
if got := *cfg.Pipelines["main"].StructuredOutputRepairAttempts; got != 0 {
t.Fatalf("applied config aliased file configuration: got %d, want 0", got)
}
fileProfile := file.Pipelines["main"]
fileLane := fileProfile.Artifacts["lane"]
*fileLane.Extract.StructuredOutputRepairAttempts = 1
*fileLane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts = 1
fileProfile.Artifacts["lane"] = fileLane
file.Pipelines["main"] = fileProfile
configuredLane := cfg.Pipelines["main"].Artifacts["lane"]
if got := *configuredLane.Extract.StructuredOutputRepairAttempts; got != 2 {
t.Fatalf("configured extract aliased file configuration: got %d, want 2", got)
}
if got := *configuredLane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts; got != 3 {
t.Fatalf("configured validator aliased file configuration: got %d, want 3", got)
}
cloned := cloneConfig(cfg)
*cloned.Pipelines["main"].StructuredOutputRepairAttempts = 1
if got := *cfg.Pipelines["main"].StructuredOutputRepairAttempts; got != 0 {
t.Fatalf("cloned config aliased source configuration: got %d, want 0", got)
}
redacted := cfg.Redacted()
*redacted.Pipelines["main"].StructuredOutputRepairAttempts = 1
if got := *cfg.Pipelines["main"].StructuredOutputRepairAttempts; got != 0 {
t.Fatalf("redacted config aliased source configuration: got %d, want 0", got)
}
summary := cfg.RedactedSummaryPayload().(Config)
if summary.Pipelines["main"].StructuredOutputRepairAttempts == nil || *summary.Pipelines["main"].StructuredOutputRepairAttempts != 0 {
t.Fatalf("redacted summary pipeline repair attempts = %v, want explicit zero", summary.Pipelines["main"].StructuredOutputRepairAttempts)
}
data, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
var roundTripped Config
if err := json.Unmarshal(data, &roundTripped); err != nil {
t.Fatal(err)
}
roundTrippedProfile := roundTripped.Pipelines["main"]
if roundTrippedProfile.StructuredOutputRepairAttempts == nil || *roundTrippedProfile.StructuredOutputRepairAttempts != 0 {
t.Fatalf("round-tripped pipeline repair attempts = %v, want explicit zero", roundTrippedProfile.StructuredOutputRepairAttempts)
}
roundTrippedLane := roundTrippedProfile.Artifacts["lane"]
if roundTrippedLane.Extract.StructuredOutputRepairAttempts == nil || *roundTrippedLane.Extract.StructuredOutputRepairAttempts != 2 {
t.Fatalf("round-tripped extract repair attempts = %v, want 2", roundTrippedLane.Extract.StructuredOutputRepairAttempts)
}
if roundTrippedLane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts == nil || *roundTrippedLane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts != 3 {
t.Fatalf("round-tripped validator repair attempts = %v, want 3", roundTrippedLane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts)
}
})
}
func TestStructuredOutputRepairAttemptsFileConfigurationRejectsInvalidValues(t *testing.T) {
const pipelineYAML = `version: 4
pipelines:
main:
input: input
artifacts:
lane:
extract: extract
%s
`
const bindingYAML = `version: 4
pipelines:
main:
input:
module: input
%s
artifacts:
lane:
extract: extract
`
for _, tt := range []struct {
name string
source string
want string
}{
{name: "null pipeline value", source: "structured_output_repair_attempts: null", want: "pipeline profile structured_output_repair_attempts must not be null"},
{name: "fractional pipeline value", source: "structured_output_repair_attempts: 1.5", want: "pipeline profile structured_output_repair_attempts must be an integer"},
{name: "quoted pipeline value", source: "structured_output_repair_attempts: '1'", want: "pipeline profile structured_output_repair_attempts must be an integer"},
{name: "out of range pipeline value", source: "structured_output_repair_attempts: 4", want: "pipeline profile structured_output_repair_attempts must be between zero and three"},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(fmt.Sprintf(pipelineYAML, tt.source)))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ParseFileConfigYAML() error = %v, want %q", err, tt.want)
}
})
}
for _, tt := range []struct {
name string
source string
want string
}{
{name: "null binding value", source: "structured_output_repair_attempts: null", want: "module binding structured_output_repair_attempts must not be null"},
{name: "noninteger binding value", source: "structured_output_repair_attempts: true", want: "module binding structured_output_repair_attempts must be an integer"},
{name: "out of range binding value", source: "structured_output_repair_attempts: -1", want: "module binding structured_output_repair_attempts must be between zero and three"},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(fmt.Sprintf(bindingYAML, tt.source)))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ParseFileConfigYAML() error = %v, want %q", err, tt.want)
}
})
}
}
func TestFileModuleBindingRejectsExplicitEmptyLLMProfile(t *testing.T) {
const configYAML = `version: 4
pipelines:
main:
input:
module: input
llm_profile: %s
artifacts:
lane:
extract: extract
`
for _, value := range []string{"''", "' '", "null"} {
t.Run(value, func(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(fmt.Sprintf(configYAML, value)))
if err == nil || !strings.Contains(err.Error(), "llm_profile must not be empty when set") {
t.Fatalf("ParseFileConfigYAML() error = %v, want explicit-empty binding profile rejection", err)
}
})
}
}
func TestFilePromptKitProfileSourcesSurviveConfigBoundaries(t *testing.T) {
tests := []struct {
name string
yaml string
want PromptKitConfig
}{
{
name: "profile directory",
yaml: "version: 4\npromptkit:\n profile_dir: ' ./profiles '\n",
want: PromptKitConfig{ProfileDir: "./profiles"},
},
{
name: "profile file",
yaml: "version: 4\npromptkit:\n profile_file: ' ./profiles.yml '\n",
want: PromptKitConfig{ProfileFile: "./profiles.yml"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := applyFileConfig(t, tt.yaml)
if cfg.PromptKit != tt.want {
t.Fatalf("PromptKit config = %#v, want %#v", cfg.PromptKit, tt.want)
}
if got := cloneConfig(cfg).PromptKit; got != tt.want {
t.Fatalf("cloned PromptKit config = %#v, want %#v", got, tt.want)
}
if got := cfg.Redacted().PromptKit; got != tt.want {
t.Fatalf("redacted PromptKit config = %#v, want %#v", got, tt.want)
}
data, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if _, ok := payload["promptkit"]; !ok {
t.Fatalf("runtime JSON keys = %v, want promptkit", payload)
}
if _, ok := payload["scriptorium"]; ok {
t.Fatalf("runtime JSON keys = %v, must not contain removed section", payload)
}
})
}
}
func TestFilePromptKitLocalBackendSurvivesConfigBoundaries(t *testing.T) {
tests := []struct {
name string
concurrencyYAML string
wantConcurrency int
}{
{name: "omitted concurrency defaults to zero"},
{name: "positive concurrency is preserved", concurrencyYAML: " concurrency_limit: 2\n", wantConcurrency: 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file := parseFileConfig(t, "version: 4\npromptkit:\n local_backend:\n endpoint: ' http://localhost:8000/v1 '\n"+tt.concurrencyYAML)
cfg := Default()
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatalf("ApplyFileConfig() error = %v", err)
}
want := PromptKitLocalBackendConfig{
Endpoint: "http://localhost:8000/v1",
ConcurrencyLimit: tt.wantConcurrency,
}
if cfg.PromptKit.LocalBackend == nil || *cfg.PromptKit.LocalBackend != want {
t.Fatalf("local backend config = %#v, want %#v", cfg.PromptKit.LocalBackend, want)
}
*file.PromptKit.LocalBackend.Endpoint = "http://changed.example/v1"
if file.PromptKit.LocalBackend.ConcurrencyLimit != nil {
*file.PromptKit.LocalBackend.ConcurrencyLimit = 99
}
if *cfg.PromptKit.LocalBackend != want {
t.Fatalf("effective config aliases parsed file model: %#v", cfg.PromptKit.LocalBackend)
}
cloned := cloneConfig(cfg)
if cloned.PromptKit.LocalBackend == cfg.PromptKit.LocalBackend || *cloned.PromptKit.LocalBackend != want {
t.Fatalf("cloned local backend = %#v, want detached %#v", cloned.PromptKit.LocalBackend, want)
}
cloned.PromptKit.LocalBackend.Endpoint = "http://clone.example/v1"
if *cfg.PromptKit.LocalBackend != want {
t.Fatalf("mutating clone changed source config: %#v", cfg.PromptKit.LocalBackend)
}
redacted := cfg.Redacted()
if redacted.PromptKit.LocalBackend == cfg.PromptKit.LocalBackend || *redacted.PromptKit.LocalBackend != want {
t.Fatalf("redacted local backend = %#v, want detached %#v", redacted.PromptKit.LocalBackend, want)
}
data, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
var payload struct {
PromptKit map[string]json.RawMessage `json:"promptkit"`
}
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
localJSON, ok := payload.PromptKit["local_backend"]
if !ok {
t.Fatalf("runtime PromptKit JSON keys = %v, want local_backend", payload.PromptKit)
}
var localPayload map[string]json.RawMessage
if err := json.Unmarshal(localJSON, &localPayload); err != nil {
t.Fatalf("unmarshal local_backend JSON: %v", err)
}
if _, ok := localPayload["endpoint"]; !ok {
t.Fatalf("runtime local_backend JSON keys = %v, want endpoint", localPayload)
}
if _, ok := localPayload["concurrency_limit"]; !ok {
t.Fatalf("runtime local_backend JSON keys = %v, want concurrency_limit", localPayload)
}
})
}
}
func TestFilePromptKitLocalBackendRequiresEndpoint(t *testing.T) {
for _, tt := range []struct {
name string
yaml string
}{
{name: "missing", yaml: "version: 4\npromptkit:\n local_backend: {}\n"},
{name: "empty", yaml: "version: 4\npromptkit:\n local_backend:\n endpoint: ''\n"},
{name: "blank", yaml: "version: 4\npromptkit:\n local_backend:\n endpoint: ' '\n"},
} {
t.Run(tt.name, func(t *testing.T) {
file := parseFileConfig(t, tt.yaml)
cfg := Default()
err := cfg.ApplyFileConfig(file)
if err == nil || !strings.Contains(err.Error(), "promptkit.local_backend.endpoint") {
t.Fatalf("ApplyFileConfig() error = %v, want endpoint field context", err)
}
})
}
}
func TestFilePromptKitExplicitEmptyProfileSourcesAreRejected(t *testing.T) {
for _, field := range []string{"profile_dir", "profile_file"} {
t.Run(field, func(t *testing.T) {
file := parseFileConfig(t, "version: 4\npromptkit:\n "+field+": ''\n")
cfg := Default()
err := cfg.ApplyFileConfig(file)
if err == nil || !strings.Contains(err.Error(), "promptkit."+field+" must not be empty") {
t.Fatalf("ApplyFileConfig() error = %v, want explicit-empty rejection", err)
}
})
}
}
func TestFilePromptKitProfileSourcesRemainMutuallyExclusive(t *testing.T) {
cfg := applyFileConfig(t, `version: 4
promptkit:
profile_dir: ./profiles
profile_file: ./profiles.yml
`)
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "promptkit profile_dir and profile_file are mutually exclusive") {
t.Fatalf("Validate() error = %v, want mutually exclusive profile sources", err)
}
}
func TestFileConfigMissingVersionIsReportedBeforeFieldDecoding(t *testing.T) {
_, err := ParseFileConfigYAML([]byte("workspace:\n directory: /tmp/old\n"))
if err == nil || !strings.Contains(err.Error(), "config version is required") {
t.Fatalf("missing version error = %v", err)
}
}
func TestFileConfigVersion3ReportsPromptKitMigration(t *testing.T) {
_, err := ParseFileConfigYAML([]byte("version: 3\nscriptorium:\n profile_dir: ./profiles\n"))
if err == nil ||
!strings.Contains(err.Error(), `change "version: 3" to "version: 4"`) ||
!strings.Contains(err.Error(), `rename "scriptorium:" to "promptkit:"`) {
t.Fatalf("version 3 error = %v, want actionable version and section migration", err)
}
}
func TestFileConfigRejectsUnknownCurrentAndRemovedFields(t *testing.T) {
tests := []struct {
name string
yaml string
want string
}{
{
name: "removed diagnostics",
yaml: "version: 4\ndiagnostics: {}\n",
want: "field diagnostics not found",
},
{
name: "removed llm profiles",
yaml: "version: 4\nllm_profiles: {}\n",
want: "field llm_profiles not found",
},
{
name: "removed scriptorium section",
yaml: "version: 4\nscriptorium: {}\n",
want: "field scriptorium not found",
},
{
name: "version 2 migration",
yaml: "version: 2\nworkspace:\n directory: /tmp/old\n",
want: "version 2-to-3 migration",
},
{
name: "pipeline field",
yaml: "version: 4\npipelines:\n main:\n unknown: true\n",
want: "field unknown not found",
},
{
name: "lane field",
yaml: "version: 4\npipelines:\n main:\n artifacts:\n spells:\n unknown: true\n",
want: "field unknown not found",
},
{
name: "module binding field",
yaml: "version: 4\npipelines:\n main:\n input:\n module: seriatim\n unknown: true\n",
want: "field unknown not found in module binding",
},
{
name: "checkpoint field",
yaml: "version: 4\ncache:\n checkpoints:\n unknown: true\n",
want: "field unknown not found",
},
{
name: "checkpoint enabled type",
yaml: "version: 4\ncache:\n checkpoints:\n enabled: definitely\n",
want: "cannot unmarshal",
},
{
name: "local backend field",
yaml: "version: 4\npromptkit:\n local_backend:\n endpoint: http://localhost:8000/v1\n unknown: true\n",
want: "field unknown not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(tt.yaml))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want context %q", err, tt.want)
}
})
}
}
func TestFileConfigModuleBindingsPreserveFormsAndValidatorPresence(t *testing.T) {
cfg := applyFileConfig(t, `version: 4
pipelines:
main:
input: seriatim
chunk:
module: generic
llm_profile: chunk-profile
retries: 2
options:
max_units: 25
references:
glossary: ./glossary.md
validators: []
artifacts:
spells:
extract:
module: dnd/spells
options:
nested:
enabled: true
merge: appendorder
normalize: noop
`)
profile := cfg.Pipelines["main"]
if profile.Input.Module != "seriatim" || profile.Input.Validators.Set {
t.Fatalf("shorthand binding = %#v", profile.Input)
}
if profile.Chunk.Module != "generic" || profile.Chunk.LLMProfile != "chunk-profile" || profile.Chunk.Retries != 2 ||
!reflect.DeepEqual(profile.Chunk.Options, map[string]any{"max_units": 25}) ||
!reflect.DeepEqual(profile.Chunk.References, pipeline.ExternalReferenceMap(map[string]string{"glossary": "./glossary.md"})) {
t.Fatalf("object binding = %#v", profile.Chunk)
}
if !profile.Chunk.Validators.Set || len(profile.Chunk.Validators.Validators) != 0 {
t.Fatalf("explicit empty validators = %#v", profile.Chunk.Validators)
}
if profile.Artifacts["spells"].Extract.Module != "dnd/spells" ||
!reflect.DeepEqual(profile.Artifacts["spells"].Extract.Options, map[string]any{
"nested": map[string]any{"enabled": true},
}) {
t.Fatalf("extract binding = %#v", profile.Artifacts["spells"].Extract)
}
if profile.Artifacts["spells"].Merge.Module != "appendorder" || profile.Artifacts["spells"].Normalize.Module != "noop" {
t.Fatalf("stage shorthand bindings = %#v", profile.Artifacts["spells"])
}
}
func TestFileConfigReferencePrecedenceIsRetained(t *testing.T) {
cfg := applyFileConfig(t, `version: 4
pipelines:
main:
input: seriatim
references:
pipeline-only: ./pipeline.txt
shared: ./pipeline-shared.txt
chunk:
module: generic
references:
chunk-only: ./chunk.txt
artifacts:
spells:
references:
lane-only: ./lane.txt
shared: ./lane-shared.txt
overridden: ./lane.txt
extract:
module: dnd/spells
references:
extract-only: ./extract.txt
overridden: ./extract-overridden.txt
merge:
module: appendorder
references:
merge-only: ./merge.txt
normalize:
module: noop
references:
normalize-only: ./normalize.txt
`)
profile := cfg.Pipelines["main"]
if !reflect.DeepEqual(profile.References, pipeline.ExternalReferenceMap(map[string]string{
"pipeline-only": "./pipeline.txt",
"shared": "./pipeline-shared.txt",
})) {
t.Fatalf("pipeline references = %#v", profile.References)
}
if !reflect.DeepEqual(profile.Chunk.References, pipeline.ExternalReferenceMap(map[string]string{"chunk-only": "./chunk.txt"})) {
t.Fatalf("chunk references = %#v", profile.Chunk.References)
}
lane := profile.Artifacts["spells"]
if !reflect.DeepEqual(lane.References, pipeline.ExternalReferenceMap(map[string]string{
"lane-only": "./lane.txt",
"shared": "./lane-shared.txt",
"overridden": "./lane.txt",
})) {
t.Fatalf("lane compatibility references = %#v", lane.References)
}
if !reflect.DeepEqual(lane.Extract.References, pipeline.ExternalReferenceMap(map[string]string{
"lane-only": "./lane.txt",
"shared": "./lane-shared.txt",
"overridden": "./extract-overridden.txt",
"extract-only": "./extract.txt",
})) {
t.Fatalf("extract references = %#v", lane.Extract.References)
}
if !reflect.DeepEqual(lane.Merge.References, pipeline.ExternalReferenceMap(map[string]string{"merge-only": "./merge.txt"})) ||
!reflect.DeepEqual(lane.Normalize.References, pipeline.ExternalReferenceMap(map[string]string{"normalize-only": "./normalize.txt"})) {
t.Fatalf("merge/normalize references = %#v, %#v", lane.Merge.References, lane.Normalize.References)
}
}
func TestFileConfigStageLocalValidatorsPreserveOrderAndFields(t *testing.T) {
cfg := applyFileConfig(t, `version: 4
pipelines:
main:
input: seriatim
chunk:
module: generic
validators:
- generic/always_accept
- module: generic/valid_json
llm_profile: validator-profile
options:
schema: compact
artifacts:
spells:
extract:
module: dnd/spells
validators:
- module: extract/dnd/spells/shape
options:
strict: true
merge:
module: appendorder
validators:
- generic/always_accept
normalize:
module: noop
validators:
- module: generic/valid_json
options:
mode: normalized
`)
profile := cfg.Pipelines["main"]
chunkValidators := profile.Chunk.Validators.Validators
if !profile.Chunk.Validators.Set || len(chunkValidators) != 2 || chunkValidators[0].Module != "generic/always_accept" ||
chunkValidators[1].Module != "generic/valid_json" || chunkValidators[1].LLMProfile != "validator-profile" ||
!reflect.DeepEqual(chunkValidators[1].Options, map[string]any{"schema": "compact"}) {
t.Fatalf("chunk validators = %#v", profile.Chunk.Validators)
}
lane := profile.Artifacts["spells"]
if len(lane.Extract.Validators.Validators) != 1 || lane.Extract.Validators.Validators[0].Module != "extract/dnd/spells/shape" ||
!reflect.DeepEqual(lane.Extract.Validators.Validators[0].Options, map[string]any{"strict": true}) {
t.Fatalf("extract validators = %#v", lane.Extract.Validators)
}
if len(lane.Merge.Validators.Validators) != 1 || lane.Merge.Validators.Validators[0].Module != "generic/always_accept" {
t.Fatalf("merge validators = %#v", lane.Merge.Validators)
}
if len(lane.Normalize.Validators.Validators) != 1 || lane.Normalize.Validators.Validators[0].Module != "generic/valid_json" ||
!reflect.DeepEqual(lane.Normalize.Validators.Validators[0].Options, map[string]any{"mode": "normalized"}) {
t.Fatalf("normalize validators = %#v", lane.Normalize.Validators)
}
}
func TestFileConfigStateSectionsApplyIndependently(t *testing.T) {
cfg := applyFileConfig(t, `version: 4
promptkit:
profile_dir: ./profiles
concurrency:
total_llm: 7
output:
directory: ./output
cache:
chunk_plans:
directory: ./plans
mode: bypass
checkpoints:
enabled: true
directory: ./checkpoints
debug:
directory: ./debug
`)
if cfg.PromptKit.ProfileDir != "./profiles" || cfg.PromptKit.ProfileFile != "" {
t.Fatalf("promptkit = %#v", cfg.PromptKit)
}
if cfg.Concurrency.TotalLLM != 7 || cfg.Concurrency.StageWorkers["extract"] != 7 {
t.Fatalf("concurrency = %#v", cfg.Concurrency)
}
if cfg.Output.Directory != "./output" || cfg.Cache.ChunkPlans.Directory != "plans" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheBypass ||
!cfg.Cache.Checkpoints.Enabled || cfg.Cache.Checkpoints.Directory != "checkpoints" || cfg.Debug.Directory != "./debug" {
t.Fatalf("state sections = %#v, %#v, %#v, %#v", cfg.Output, cfg.Cache, cfg.Debug, cfg.PromptKit)
}
if cfg.Output.Directory == cfg.Cache.ChunkPlans.Directory || cfg.Cache.ChunkPlans.Directory == cfg.Cache.Checkpoints.Directory || cfg.Cache.Checkpoints.Directory == cfg.Debug.Directory {
t.Fatal("state roots were coupled")
}
}
func TestFileConfigCheckpointEnabledCanBeExplicitlyDisabled(t *testing.T) {
cfg := applyFileConfig(t, "version: 4\ncache:\n checkpoints:\n enabled: true\n")
if !cfg.Cache.Checkpoints.Enabled || !cloneConfig(cfg).Cache.Checkpoints.Enabled {
t.Fatalf("enabled checkpoint config was not retained: %#v", cfg.Cache.Checkpoints)
}
file := parseFileConfig(t, "version: 4\ncache:\n checkpoints:\n enabled: false\n")
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatal(err)
}
if cfg.Cache.Checkpoints.Enabled {
t.Fatalf("explicit false checkpoint config was not applied: %#v", cfg.Cache.Checkpoints)
}
}
func TestFileConfigRejectsTrimmedKeyCollisions(t *testing.T) {
tests := []struct {
name string
yaml string
want string
}{
{
name: "pipeline ids",
yaml: "version: 4\npipelines:\n main: {}\n ' main ': {}\n",
want: "pipeline id \"main\" is duplicated after trimming",
},
{
name: "lane ids",
yaml: "version: 4\npipelines:\n main:\n artifacts:\n spells: {}\n ' spells ': {}\n",
want: "artifact lane id \"spells\" is duplicated after trimming",
},
{
name: "reference slots",
yaml: "version: 4\npipelines:\n main:\n references:\n slot: ./one.txt\n ' slot ': ./two.txt\n",
want: "reference slot \"slot\" is duplicated after trimming",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file := parseFileConfig(t, tt.yaml)
cfg := Default()
err := cfg.ApplyFileConfig(file)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ApplyFileConfig() error = %v, want context %q", err, tt.want)
}
})
}
}
func TestFileConfigParsesOrderedStepsAndReferenceSources(t *testing.T) {
file := parseFileConfig(t, `version: 4
pipelines:
session:
input: seriatim
steps:
- id: identify-npcs
artifacts:
npc_registry:
extract: dnd/npc-registry
- id: grounded-events
references:
npc_registry:
artifact:
step: identify-npcs
lane: npc_registry
artifacts:
spells:
extract: dnd/spells
`)
cfg := Default()
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatal(err)
}
profile := cfg.Pipelines["session"]
if len(profile.Steps) != 2 || profile.Steps[0].ID != "identify-npcs" || profile.Steps[1].ID != "grounded-events" {
t.Fatalf("steps = %#v", profile.Steps)
}
source := profile.Steps[1].References["npc_registry"]
if source.Artifact == nil || source.Artifact.Step != "identify-npcs" || source.Artifact.Lane != "npc_registry" {
t.Fatalf("generated source = %#v", source)
}
}
func TestFileConfigRejectsAmbiguousReferenceSourceForms(t *testing.T) {
for _, source := range []string{
"artifact: {step: a, lane: b, extra: c}",
"artifact: {step: 1, lane: b}",
"1",
} {
_, err := ParseFileConfigYAML([]byte("version: 4\npipelines:\n p:\n input: text\n references:\n slot: " + source + "\n"))
if err == nil {
t.Fatalf("ParseFileConfigYAML(%q) error = nil", source)
}
}
}
func TestFileConfigRejectsEmptyAndAmbiguousPipelineShapes(t *testing.T) {
tests := []struct {
name string
yaml string
want string
}{
{
name: "empty steps",
yaml: "version: 4\npipelines:\n p:\n input: text\n steps: []\n",
want: "at least one ordered step",
},
{
name: "both forms",
yaml: "version: 4\npipelines:\n p:\n input: text\n artifacts: {}\n steps: []\n",
want: "both artifacts and steps",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file := parseFileConfig(t, tt.yaml)
cfg := Default()
err := cfg.ApplyFileConfig(file)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ApplyFileConfig() error = %v, want context %q", err, tt.want)
}
})
}
}
func TestLoadFileConfigReportsPathAndOperationContext(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "missing.yml")
_, err := LoadFileConfig(missing)
if err == nil || !strings.Contains(err.Error(), "read config file") || !strings.Contains(err.Error(), missing) {
t.Fatalf("missing-file error = %v", err)
}
malformed := filepath.Join(dir, "malformed.yml")
if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil {
t.Fatal(err)
}
_, err = LoadFileConfig(malformed)
if err == nil || !strings.Contains(err.Error(), "parse config file") || !strings.Contains(err.Error(), malformed) {
t.Fatalf("malformed-file error = %v", err)
}
}
func parseFileConfig(t *testing.T, source string) FileConfig {
t.Helper()
file, err := ParseFileConfigYAML([]byte(source))
if err != nil {
t.Fatalf("ParseFileConfigYAML() error = %v", err)
}
return file
}
func applyFileConfig(t *testing.T, source string) Config {
t.Helper()
cfg := Default()
if err := cfg.ApplyFileConfig(parseFileConfig(t, source)); err != nil {
t.Fatalf("ApplyFileConfig() error = %v", err)
}
return cfg
}

View File

@@ -0,0 +1,104 @@
package config
import (
"path/filepath"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// LoadOptions contains configuration selections supplied by an application
// boundary. It is intentionally narrow: configuration discovery and CLI flag
// parsing remain outside this package.
type LoadOptions struct {
Overlay string
Overrides []configkit.Override
InvocationDirectory string
}
// loadDocument composes and validates a version 5 file with ConfigKit before
// runtime conversion, retaining source provenance and frozen inspection output.
func loadDocument(path string, options LoadOptions) (*configkit.Result[Document], error) {
return loadDocumentWithSource(configkit.File(path), options, true)
}
func loadDocumentSource(source configkit.Source, options LoadOptions) (*configkit.Result[Document], error) {
return loadDocumentWithSource(source, options, false)
}
func loadDocumentWithSource(source configkit.Source, options LoadOptions, imports bool) (*configkit.Result[Document], error) {
state := newDocumentLoadState(options.InvocationDirectory)
return configkit.Load(source, configkit.Options[Document]{
Definition: documentDefinition(state),
Imports: imports,
Overlay: options.Overlay,
Overrides: options.Overrides,
})
}
func cleanInvocationDirectory(value string) string {
if value == "" {
return ""
}
absolute, err := filepath.Abs(value)
if err != nil {
return ""
}
return filepath.Clean(absolute)
}
func documentDefinition(state *documentLoadState) configkit.Definition[Document] {
return configkit.Definition[Document]{
Defaults: documentDefaults,
Resolve: func(document *Document, resolution *configkit.Resolution) error {
return resolveDocumentOperations(document, resolution, state)
},
Validate: validateDocumentOperations,
Version: &configkit.VersionRule{
Path: configkit.Keys("version"),
Check: validateDocumentVersion,
},
Secret: sensitiveDocumentPath,
}
}
func documentDefaults() Document {
return Document{
PromptKit: &PromptKitDocument{
TransientRetries: documentValue(defaultTransientRetries),
},
Pipelines: map[string]PipelineDocument{},
Concurrency: &ConcurrencyDocument{
TotalLLM: documentValue(defaultLLMConcurrency),
StageWorkers: map[string]int{},
},
Output: &OutputDocument{Directory: documentValue("./notarius-output")},
Cache: &CacheDocument{
ChunkPlans: &ChunkPlanCacheDocument{
Directory: documentValue(""),
Mode: documentValue("auto"),
},
Checkpoints: &CheckpointCacheDocument{
Enabled: documentValue(false),
Directory: documentValue(""),
},
},
Debug: &DebugDocument{Directory: documentValue("./notarius-debug")},
}
}
func documentValue[T any](value T) *T { return &value }
func validateDocumentVersion(version configkit.Version, validation *configkit.Validation) error {
if !version.Present || version.Null {
validation.Error(configkit.Keys("version"), "configuration version is required")
return nil
}
if version.Text == "4" {
validation.Error(configkit.Keys("version"), "configuration version 4 is no longer supported; migrate to version 5")
return nil
}
if version.Text != "5" {
validation.Error(configkit.Keys("version"), "unsupported configuration version")
}
return nil
}

View File

@@ -0,0 +1,326 @@
package config
import (
"fmt"
"io"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
// LoadedConfig keeps the finalized runtime configuration together with the
// private ConfigKit inspection snapshot that produced it.
type LoadedConfig struct {
Config Config
result *configkit.Result[Document]
}
// RuntimeConfig returns an independently owned runtime configuration.
func (loaded *LoadedConfig) RuntimeConfig() Config {
if loaded == nil {
return Config{}
}
return cloneConfig(loaded.Config)
}
// WriteEffective renders ConfigKit's frozen, redacted effective document.
func (loaded *LoadedConfig) WriteEffective(output io.Writer) error {
if loaded == nil || loaded.result == nil {
return fmt.Errorf("loaded configuration is unavailable")
}
return loaded.result.WriteEffective(output)
}
// WriteExplanation renders ConfigKit's safe explanation for a JSON-pointer
// path without exposing ConfigKit types at this package boundary.
func (loaded *LoadedConfig) WriteExplanation(output io.Writer, pointer string) error {
if loaded == nil || loaded.result == nil {
return fmt.Errorf("loaded configuration is unavailable")
}
path, err := configkit.ParsePath(pointer)
if err != nil {
return fmt.Errorf("parse configuration explanation path: %w", err)
}
return loaded.result.WriteExplanation(output, path)
}
func loadConfig(path string, options LoadOptions) (*LoadedConfig, error) {
result, err := loadDocument(path, options)
if err != nil {
return nil, err
}
return newLoadedConfig(result)
}
// Load reads a version 5 configuration file into runtime configuration and a
// private inspection snapshot.
func Load(path string, options LoadOptions) (*LoadedConfig, error) {
return loadConfig(path, options)
}
func loadConfigSource(source configkit.Source, options LoadOptions) (*LoadedConfig, error) {
result, err := loadDocumentSource(source, options)
if err != nil {
return nil, err
}
return newLoadedConfig(result)
}
func newLoadedConfig(result *configkit.Result[Document]) (*LoadedConfig, error) {
if result == nil {
return nil, fmt.Errorf("configuration result is unavailable")
}
runtime, err := documentToConfig(result.Config)
if err != nil {
return nil, err
}
return &LoadedConfig{Config: runtime, result: result}, nil
}
func documentToConfig(document Document) (Config, error) {
config := Default()
config.Pipelines = documentPipelinesToRuntime(document.Pipelines)
if document.Concurrency != nil {
config.Concurrency.StageWorkers = documentIntMap(document.Concurrency.StageWorkers)
if document.Concurrency.TotalLLM != nil {
config.Concurrency.TotalLLM = *document.Concurrency.TotalLLM
}
}
if document.PromptKit != nil {
if document.PromptKit.ProfileDir != nil {
config.PromptKit.ProfileDir = *document.PromptKit.ProfileDir
}
if document.PromptKit.ProfileFile != nil {
config.PromptKit.ProfileFile = *document.PromptKit.ProfileFile
}
if document.PromptKit.TransientRetries != nil {
config.PromptKit.TransientRetries = *document.PromptKit.TransientRetries
}
if document.PromptKit.LocalBackend != nil {
backend := PromptKitLocalBackendConfig{}
if document.PromptKit.LocalBackend.Endpoint != nil {
backend.Endpoint = *document.PromptKit.LocalBackend.Endpoint
}
if document.PromptKit.LocalBackend.ConcurrencyLimit != nil {
backend.ConcurrencyLimit = *document.PromptKit.LocalBackend.ConcurrencyLimit
}
config.PromptKit.LocalBackend = &backend
}
}
if document.Output != nil && document.Output.Directory != nil {
config.Output.Directory = *document.Output.Directory
}
if document.Cache != nil {
if document.Cache.ChunkPlans != nil {
if document.Cache.ChunkPlans.Directory != nil {
config.Cache.ChunkPlans.Directory = *document.Cache.ChunkPlans.Directory
}
if document.Cache.ChunkPlans.Mode != nil {
mode, err := pipeline.ParseChunkCacheMode(*document.Cache.ChunkPlans.Mode)
if err != nil {
return Config{}, fmt.Errorf("convert chunk-plan cache mode: %w", err)
}
config.Cache.ChunkPlans.Mode = mode
}
}
if document.Cache.Checkpoints != nil {
if document.Cache.Checkpoints.Enabled != nil {
config.Cache.Checkpoints.Enabled = *document.Cache.Checkpoints.Enabled
}
if document.Cache.Checkpoints.Directory != nil {
config.Cache.Checkpoints.Directory = *document.Cache.Checkpoints.Directory
}
}
}
if document.Debug != nil && document.Debug.Directory != nil {
config.Debug.Directory = *document.Debug.Directory
}
return config, nil
}
func documentPipelinesToRuntime(documents map[string]PipelineDocument) map[string]pipeline.PipelineProfile {
if documents == nil {
return nil
}
profiles := make(map[string]pipeline.PipelineProfile, len(documents))
for id, document := range documents {
profiles[id] = documentPipelineToRuntime(id, document)
}
return profiles
}
func documentPipelineToRuntime(id string, document PipelineDocument) pipeline.PipelineProfile {
profile := pipeline.PipelineProfile{
ID: id,
StructuredOutputRepairAttempts: cloneDocumentInt(document.StructuredOutputRepairAttempts),
ValidationPolicy: documentPolicyToRuntime(document.ValidationPolicy),
Input: documentBindingToRuntime(document.Input),
Artifacts: documentLanesToRuntime(document.Artifacts),
References: documentReferencesToRuntime(document.References),
}
if document.LLMProfile != nil {
profile.LLMProfile = *document.LLMProfile
}
if document.Chunk != nil {
profile.Chunk = documentBindingToRuntime(*document.Chunk)
}
if document.Output != nil {
profile.Output = documentBindingToRuntime(*document.Output)
}
if document.Steps != nil {
profile.Steps = make([]pipeline.PipelineStepProfile, len(document.Steps))
for index, step := range document.Steps {
profile.Steps[index] = pipeline.PipelineStepProfile{
ID: step.ID,
Artifacts: documentLanesToRuntime(step.Artifacts),
References: documentReferencesToRuntime(step.References),
}
}
}
return profile
}
func documentLanesToRuntime(documents map[string]ArtifactLaneDocument) map[string]pipeline.ArtifactLaneProfile {
if documents == nil {
return nil
}
lanes := make(map[string]pipeline.ArtifactLaneProfile, len(documents))
for id, document := range documents {
lane := pipeline.ArtifactLaneProfile{
Extract: documentBindingToRuntime(document.Extract),
References: documentReferencesToRuntime(document.References),
}
if document.Merge != nil {
lane.Merge = documentBindingToRuntime(*document.Merge)
}
if document.Normalize != nil {
lane.Normalize = documentBindingToRuntime(*document.Normalize)
}
if document.Validators != nil {
lane.Validators = make([]pipeline.ModuleBinding, len(document.Validators))
for index, validator := range document.Validators {
lane.Validators[index] = documentValidatorToRuntime(validator)
}
}
lanes[id] = lane
}
return lanes
}
func documentBindingToRuntime(document BindingDocument) pipeline.ModuleBinding {
binding := pipeline.ModuleBinding{
Module: document.Module,
StructuredOutputRepairAttempts: cloneDocumentInt(document.StructuredOutputRepairAttempts),
ValidationPolicy: documentPolicyToRuntime(document.ValidationPolicy),
Retries: document.Retries,
Options: documentOptions(document.Options),
References: documentReferencesToRuntime(document.References),
}
if document.LLMProfile != nil {
binding.LLMProfile = *document.LLMProfile
}
if document.Validators != nil {
binding.Validators.Set = true
binding.Validators.Validators = make([]pipeline.ModuleBinding, len(document.Validators))
for index, validator := range document.Validators {
binding.Validators.Validators[index] = documentValidatorToRuntime(validator)
}
}
return binding
}
func documentValidatorToRuntime(document ValidatorDocument) pipeline.ModuleBinding {
binding := pipeline.ModuleBinding{
Module: document.Module,
StructuredOutputRepairAttempts: cloneDocumentInt(document.StructuredOutputRepairAttempts),
Retries: document.Retries,
Options: documentOptions(document.Options),
}
if document.LLMProfile != nil {
binding.LLMProfile = *document.LLMProfile
}
return binding
}
func documentReferencesToRuntime(documents map[string]ReferenceDocument) map[string]pipeline.ReferenceSource {
if documents == nil {
return nil
}
references := make(map[string]pipeline.ReferenceSource, len(documents))
for name, document := range documents {
if document.Artifact != nil {
references[name] = pipeline.GeneratedReference(document.Artifact.Step, document.Artifact.Lane)
continue
}
if document.Path != nil {
references[name] = pipeline.ExternalReference(*document.Path)
}
}
return references
}
func documentPolicyToRuntime(document *ValidationPolicyDocument) *pipeline.ValidationPolicyOverride {
if document == nil {
return nil
}
policy := pipeline.ValidationPolicyOverride{}
if document.ProducerStructuralFailure != nil {
value := *document.ProducerStructuralFailure
policy.ProducerStructuralFailure = &value
}
if document.SemanticRejection != nil {
value := *document.SemanticRejection
policy.SemanticRejection = &value
}
if document.ValidatorFailure != nil {
value := *document.ValidatorFailure
policy.ValidatorFailure = &value
}
return &policy
}
func documentIntMap(values map[string]int) map[string]int {
if values == nil {
return nil
}
result := make(map[string]int, len(values))
for key, value := range values {
result[key] = value
}
return result
}
func cloneDocumentInt(value *int) *int {
if value == nil {
return nil
}
result := *value
return &result
}
func documentOptions(values map[string]any) map[string]any {
if values == nil {
return nil
}
result := make(map[string]any, len(values))
for key, value := range values {
result[key] = cloneDocumentOption(value)
}
return result
}
func cloneDocumentOption(value any) any {
switch typed := value.(type) {
case map[string]any:
return documentOptions(typed)
case []any:
result := make([]any, len(typed))
for index, item := range typed {
result[index] = cloneDocumentOption(item)
}
return result
default:
return typed
}
}

View File

@@ -0,0 +1,223 @@
package config
import (
"bytes"
"strconv"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
units "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/chunk/units"
jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/output/json"
)
func TestLoadedConfigConvertsDocumentsAndResolvesRuntimePipeline(t *testing.T) {
t.Parallel()
loaded, err := loadConfigSource(configkit.Bytes("document.yml", []byte(`version: 5
pipelines:
session:
llm_profile: local
structured_output_repair_attempts: 0
input:
module: input
chunk:
module: generic
validators: []
artifacts:
score:
extract:
module: extract
validators:
- module: configured-a
options:
nested:
level: 1
`)), LoadOptions{})
if err != nil {
t.Fatalf("loadConfigSource() error = %v", err)
}
profile := loaded.Config.Pipelines["session"]
if profile.LLMProfile != "local" || profile.StructuredOutputRepairAttempts == nil || *profile.StructuredOutputRepairAttempts != 0 {
t.Fatalf("pipeline profile = %#v", profile)
}
if !profile.Chunk.Validators.Set || profile.Chunk.Validators.Validators == nil || len(profile.Chunk.Validators.Validators) != 0 {
t.Fatalf("empty validator chain was not preserved: %#v", profile.Chunk.Validators)
}
validator := profile.Artifacts["score"].Extract.Validators.Validators[0]
if validator.Module != "configured-a" || validator.Options["nested"].(map[string]any)["level"] == nil {
t.Fatalf("validator conversion = %#v", validator)
}
effective, err := loaded.Config.Resolve(ResolveInput{PipelineID: "session", Catalog: effectiveCatalog(t)})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if effective.ResolvedPipeline.ID != "session" || effective.ResolvedPipeline.Digest == "" {
t.Fatalf("resolved pipeline = %#v", effective.ResolvedPipeline)
}
}
func TestLoadedConfigResolvesPromptKitTransientRetriesAndRetainsInspection(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
document string
want int
}{
{name: "omitted", document: "version: 5\n", want: defaultTransientRetries},
{name: "disabled", document: "version: 5\npromptkit:\n transient_retries: 0\n", want: 0},
{name: "configured", document: "version: 5\npromptkit:\n transient_retries: 5\n", want: 5},
} {
t.Run(test.name, func(t *testing.T) {
loaded, err := loadConfigSource(configkit.Bytes("document.yml", []byte(test.document)), LoadOptions{})
if err != nil {
t.Fatal(err)
}
if got := loaded.Config.PromptKit.TransientRetries; got != test.want {
t.Fatalf("transient retries = %d, want %d", got, test.want)
}
var effective bytes.Buffer
if err := loaded.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if !strings.Contains(effective.String(), "transient_retries: "+strconv.Itoa(test.want)) {
t.Fatalf("effective configuration = %q", effective.String())
}
var explanation bytes.Buffer
if err := loaded.WriteExplanation(&explanation, "/promptkit/transient_retries"); err != nil {
t.Fatal(err)
}
if !strings.Contains(explanation.String(), strconv.Itoa(test.want)) {
t.Fatalf("explanation = %q", explanation.String())
}
})
}
}
func TestLoadedConfigOwnsRuntimeValuesAndKeepsFrozenInspection(t *testing.T) {
t.Parallel()
loaded, err := loadConfigSource(configkit.Bytes("document.yml", []byte(`version: 5
pipelines:
session:
input: {module: input}
artifacts:
score:
extract:
module: extract
options:
nested: {value: original}
`)), LoadOptions{})
if err != nil {
t.Fatalf("loadConfigSource() error = %v", err)
}
first := loaded.RuntimeConfig()
first.Pipelines["session"] = pipelineProfileWithChangedOption(first.Pipelines["session"])
second := loaded.RuntimeConfig()
if got := second.Pipelines["session"].Artifacts["score"].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
t.Fatalf("runtime configuration aliases caller mutation: %#v", got)
}
loaded.Config.Pipelines["session"] = pipelineProfileWithChangedOption(loaded.Config.Pipelines["session"])
var effective bytes.Buffer
if err := loaded.WriteEffective(&effective); err != nil {
t.Fatalf("WriteEffective() error = %v", err)
}
if !strings.Contains(effective.String(), "original") || strings.Contains(effective.String(), "changed") {
t.Fatalf("effective document changed with runtime mutation: %q", effective.String())
}
}
func TestLoadedConfigPreservesConfigKitIntegerOptionsForModules(t *testing.T) {
t.Parallel()
loaded, err := loadConfigSource(configkit.Bytes("document.yml", []byte(`version: 5
pipelines:
session:
input: {module: input}
chunk:
module: generic
options:
max_units: 3
overlap_units: 1
output:
module: json
options:
evidence_context:
enabled: true
lanes: [score]
window_units: 2
`)), LoadOptions{})
if err != nil {
t.Fatalf("loadConfigSource() error = %v", err)
}
profile := loaded.Config.Pipelines["session"]
chunkOptions, err := units.DecodeOptions(profile.Chunk.Options)
if err != nil || chunkOptions.MaxUnits != 3 || chunkOptions.OverlapUnits != 1 {
t.Fatalf("chunk options = %#v, %v", chunkOptions, err)
}
outputOptions, err := jsonoutput.DecodeOptions(profile.Output.Options)
if err != nil || !outputOptions.EvidenceContext.Enabled || outputOptions.EvidenceContext.WindowUnits != 2 {
t.Fatalf("output options = %#v, %v", outputOptions, err)
}
}
func TestLoadedConfigRedactsSensitiveNestedOptions(t *testing.T) {
t.Parallel()
loaded, err := loadConfigSource(configkit.Bytes("document.yml", []byte(`version: 5
pipelines:
session:
input:
module: input
options:
nested:
api_key: input-secret
artifacts:
score:
extract:
module: extract
validators:
- module: configured-a
options:
credentials:
token: validator-secret
`)), LoadOptions{})
if err != nil {
t.Fatalf("loadConfigSource() error = %v", err)
}
var effective bytes.Buffer
if err := loaded.WriteEffective(&effective); err != nil {
t.Fatalf("WriteEffective() error = %v", err)
}
for _, secret := range []string{"input-secret", "validator-secret"} {
if strings.Contains(effective.String(), secret) {
t.Fatalf("effective configuration exposes %q: %q", secret, effective.String())
}
}
if !strings.Contains(effective.String(), "[REDACTED]") {
t.Fatalf("effective configuration did not mark sensitive values: %q", effective.String())
}
var explanation bytes.Buffer
if err := loaded.WriteExplanation(&explanation, "/pipelines/session/artifacts/score/extract/validators/0/options/credentials/token"); err != nil {
t.Fatalf("WriteExplanation() error = %v", err)
}
if strings.Contains(explanation.String(), "validator-secret") || !strings.Contains(explanation.String(), "[REDACTED]") {
t.Fatalf("explanation exposes sensitive value: %q", explanation.String())
}
}
func pipelineProfileWithChangedOption(profile pipeline.PipelineProfile) pipeline.PipelineProfile {
profile = clonePipelineProfile(profile)
nested := profile.Artifacts["score"].Extract.Options["nested"].(map[string]any)
nested["value"] = "changed"
lane := profile.Artifacts["score"]
lane.Extract.Options["nested"] = nested
profile.Artifacts["score"] = lane
return profile
}

View File

@@ -0,0 +1,317 @@
package config
import (
"fmt"
"net/url"
"sort"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func resolveDocumentOperations(document *Document, resolution *configkit.Resolution, state *documentLoadState) error {
if document == nil {
return nil
}
normalizeDocumentPromptKit(document.PromptKit, resolution)
normalizeDocumentState(document, resolution)
normalizeDocumentPipelines(document, resolution, state)
normalizeDocumentPaths(document, resolution, state)
if document.Concurrency != nil {
document.Concurrency.StageWorkers = normalizeDocumentMap(document.Concurrency.StageWorkers,
configkit.Keys("concurrency", "stage_workers"), "stage worker identifier",
documentNormalizer{Resolution: resolution}, state)
}
deriveDocumentExtractWorkers(document, resolution, state)
return nil
}
func normalizeDocumentPromptKit(promptKit *PromptKitDocument, resolution *configkit.Resolution) {
if promptKit == nil {
return
}
profileDirPath := configkit.Keys("promptkit", "profile_dir")
profileFilePath := configkit.Keys("promptkit", "profile_file")
if promptKit.ProfileDir == nil && resolution.IsExplicit(profileDirPath) {
resolution.Error(profileDirPath, "must not be null")
}
if promptKit.ProfileFile == nil && resolution.IsExplicit(profileFilePath) {
resolution.Error(profileFilePath, "must not be null")
}
normalizeDocumentString(resolution, profileDirPath, &promptKit.ProfileDir, "trimmed PromptKit profile directory")
normalizeDocumentString(resolution, profileFilePath, &promptKit.ProfileFile, "trimmed PromptKit profile file")
if promptKit.LocalBackend == nil {
return
}
endpointPath := configkit.Keys("promptkit", "local_backend", "endpoint")
limitPath := configkit.Keys("promptkit", "local_backend", "concurrency_limit")
normalizeDocumentString(resolution, endpointPath, &promptKit.LocalBackend.Endpoint, "trimmed PromptKit local backend endpoint")
if promptKit.LocalBackend.ConcurrencyLimit == nil && !resolution.IsExplicit(limitPath) {
configkit.Default(resolution, limitPath, &promptKit.LocalBackend.ConcurrencyLimit,
documentValue(0), "defaulted PromptKit local backend concurrency limit")
}
}
func normalizeDocumentState(document *Document, resolution *configkit.Resolution) {
if document.Output != nil {
normalizeDocumentString(resolution, configkit.Keys("output", "directory"), &document.Output.Directory, "trimmed output directory")
}
if document.Cache != nil {
if document.Cache.ChunkPlans != nil {
normalizeDocumentString(resolution, configkit.Keys("cache", "chunk_plans", "directory"), &document.Cache.ChunkPlans.Directory, "trimmed chunk-plan cache directory")
normalizeDocumentString(resolution, configkit.Keys("cache", "chunk_plans", "mode"), &document.Cache.ChunkPlans.Mode, "trimmed chunk-plan cache mode")
}
if document.Cache.Checkpoints != nil {
normalizeDocumentString(resolution, configkit.Keys("cache", "checkpoints", "directory"), &document.Cache.Checkpoints.Directory, "trimmed checkpoint cache directory")
}
}
if document.Debug != nil {
normalizeDocumentString(resolution, configkit.Keys("debug", "directory"), &document.Debug.Directory, "trimmed debug directory")
}
}
func normalizeDocumentString(resolution *configkit.Resolution, path configkit.Path, target **string, reason string) {
if target == nil || *target == nil || !resolution.IsExplicit(path) {
return
}
configkit.Normalize(resolution, path, target, documentValue(strings.TrimSpace(**target)), reason)
}
func deriveDocumentExtractWorkers(document *Document, resolution *configkit.Resolution, state *documentLoadState) {
if document.Concurrency == nil || document.Concurrency.TotalLLM == nil {
return
}
if document.Concurrency.StageWorkers == nil {
document.Concurrency.StageWorkers = make(map[string]int)
}
path := configkit.Keys("concurrency", "stage_workers", "extract")
// Presence belongs to the input spelling, not the key produced by trimming.
// A moved explicit count must never be replaced by a derived default.
if resolution.IsExplicit(state.originalPath(path)) {
return
}
workers := document.Concurrency.StageWorkers
value := workers["extract"]
configkit.Default(resolution, path, &value, *document.Concurrency.TotalLLM,
"derived extraction workers from total LLM concurrency", configkit.Keys("concurrency", "total_llm"))
workers["extract"] = value
}
func validateDocumentOperations(document *Document, validation *configkit.Validation) {
if document == nil {
return
}
validateDocumentConcurrency(document.Concurrency, validation)
validateDocumentPromptKit(document.PromptKit, validation)
validateDocumentState(document, validation)
validateDocumentPipelines(document, validation)
}
func validateDocumentConcurrency(concurrency *ConcurrencyDocument, validation *configkit.Validation) {
if concurrency == nil {
validation.Error(configkit.Keys("concurrency"), "must not be null")
return
}
totalPath := configkit.Keys("concurrency", "total_llm")
if concurrency.TotalLLM == nil {
validation.Error(totalPath, "must not be null")
return
}
totalValid := true
if err := validateTotalLLMConcurrency(*concurrency.TotalLLM); err != nil {
validation.Error(totalPath, err.Error())
totalValid = false
}
keys := make([]string, 0, len(concurrency.StageWorkers))
for key := range concurrency.StageWorkers {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if err := validateStageWorkerKey(key); err != nil {
validation.Error(configkit.Keys("concurrency", "stage_workers").Key(key), err.Error())
}
}
if !totalValid {
return
}
if workers, ok := concurrency.StageWorkers["extract"]; ok {
if err := validateExtractWorkerCount(workers, *concurrency.TotalLLM); err != nil {
validation.Error(configkit.Keys("concurrency", "stage_workers", "extract"), err.Error())
}
}
}
func validateDocumentPromptKit(promptKit *PromptKitDocument, validation *configkit.Validation) {
if promptKit == nil {
return
}
profileDirPath := configkit.Keys("promptkit", "profile_dir")
profileFilePath := configkit.Keys("promptkit", "profile_file")
profileDir := ""
if promptKit.ProfileDir != nil {
profileDir = *promptKit.ProfileDir
if strings.TrimSpace(profileDir) == "" {
validation.Error(profileDirPath, "must not be empty when set")
}
}
profileFile := ""
if promptKit.ProfileFile != nil {
profileFile = *promptKit.ProfileFile
if strings.TrimSpace(profileFile) == "" {
validation.Error(profileFilePath, "must not be empty when set")
}
}
if err := validatePromptKitProfileSources(profileDir, profileFile); err != nil {
validation.Error(configkit.Keys("promptkit"), err.Error())
}
if promptKit.TransientRetries == nil {
validation.Error(configkit.Keys("promptkit", "transient_retries"), "must be an integer")
} else if err := validatePromptKitTransientRetries(*promptKit.TransientRetries); err != nil {
validation.Error(configkit.Keys("promptkit", "transient_retries"), err.Error())
}
if promptKit.LocalBackend == nil {
return
}
endpointPath := configkit.Keys("promptkit", "local_backend", "endpoint")
limitPath := configkit.Keys("promptkit", "local_backend", "concurrency_limit")
if promptKit.LocalBackend.Endpoint == nil {
validation.Error(endpointPath, "must be nonempty when local backend is configured")
}
if promptKit.LocalBackend.ConcurrencyLimit == nil {
validation.Error(limitPath, "must be an integer when local backend is configured")
}
if promptKit.LocalBackend.Endpoint != nil {
if err := validatePromptKitLocalBackendEndpoint(*promptKit.LocalBackend.Endpoint); err != nil {
validation.Error(endpointPath, err.Error())
}
}
if promptKit.LocalBackend.ConcurrencyLimit != nil {
if err := validatePromptKitLocalBackendConcurrencyLimit(*promptKit.LocalBackend.ConcurrencyLimit); err != nil {
validation.Error(limitPath, err.Error())
}
}
}
func validateDocumentState(document *Document, validation *configkit.Validation) {
if document.Output == nil || document.Output.Directory == nil {
validation.Error(configkit.Keys("output", "directory"), "must not be empty")
} else if err := validateRequiredPath("output.directory", *document.Output.Directory); err != nil {
validation.Error(configkit.Keys("output", "directory"), err.Error())
}
if document.Cache == nil || document.Cache.ChunkPlans == nil || document.Cache.ChunkPlans.Mode == nil {
validation.Error(configkit.Keys("cache", "chunk_plans", "mode"), "must be a supported cache mode")
} else if err := validateChunkPlanCacheMode(*document.Cache.ChunkPlans.Mode); err != nil {
validation.Error(configkit.Keys("cache", "chunk_plans", "mode"), err.Error())
}
if document.Cache != nil && document.Cache.ChunkPlans != nil && document.Cache.ChunkPlans.Directory != nil {
if err := validateOptionalPath("cache.chunk_plans.directory", *document.Cache.ChunkPlans.Directory); err != nil {
validation.Error(configkit.Keys("cache", "chunk_plans", "directory"), err.Error())
}
}
if document.Cache != nil && document.Cache.Checkpoints != nil && document.Cache.Checkpoints.Directory != nil {
if err := validateOptionalPath("cache.checkpoints.directory", *document.Cache.Checkpoints.Directory); err != nil {
validation.Error(configkit.Keys("cache", "checkpoints", "directory"), err.Error())
}
}
if document.Debug == nil || document.Debug.Directory == nil {
validation.Error(configkit.Keys("debug", "directory"), "must not be empty")
} else if err := validateRequiredPath("debug.directory", *document.Debug.Directory); err != nil {
validation.Error(configkit.Keys("debug", "directory"), err.Error())
}
}
func validateTotalLLMConcurrency(total int) error {
if total <= 0 {
return fmt.Errorf("total LLM concurrency must be greater than zero")
}
return nil
}
func validateStageWorkerKey(key string) error {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("concurrency.stage_workers key must not be empty")
}
if key != "extract" {
return fmt.Errorf("concurrency.stage_workers key %q is not supported", key)
}
return nil
}
func validateExtractWorkerCount(workers, total int) error {
if workers < 1 || workers > total {
return fmt.Errorf("concurrency.stage_workers.extract must be between 1 and concurrency.total_llm (%d)", total)
}
return nil
}
func validatePromptKitProfileSources(profileDir, profileFile string) error {
if strings.TrimSpace(profileDir) != "" && strings.TrimSpace(profileFile) != "" {
return fmt.Errorf("promptkit profile_dir and profile_file are mutually exclusive")
}
return nil
}
func validatePromptKitLocalBackend(endpoint string, concurrencyLimit int) error {
if err := validatePromptKitLocalBackendEndpoint(endpoint); err != nil {
return err
}
return validatePromptKitLocalBackendConcurrencyLimit(concurrencyLimit)
}
func validatePromptKitLocalBackendEndpoint(endpoint string) error {
if strings.TrimSpace(endpoint) == "" {
return fmt.Errorf("promptkit.local_backend.endpoint must not be empty when set")
}
parsed, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil ||
(!strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https")) ||
!parsed.IsAbs() ||
parsed.Hostname() == "" ||
parsed.User != nil ||
parsed.RawQuery != "" ||
parsed.ForceQuery ||
strings.Contains(endpoint, "#") {
return fmt.Errorf("promptkit.local_backend.endpoint must be an absolute HTTP or HTTPS URL with a host and no user information, query, or fragment")
}
return nil
}
func validatePromptKitLocalBackendConcurrencyLimit(concurrencyLimit int) error {
if concurrencyLimit < 0 {
return fmt.Errorf("promptkit.local_backend.concurrency_limit must not be negative")
}
return nil
}
func validatePromptKitTransientRetries(transientRetries int) error {
if transientRetries < 0 {
return fmt.Errorf("promptkit.transient_retries must not be negative")
}
return nil
}
func validateRequiredPath(name, value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s must not be empty", name)
}
return validateOptionalPath(name, value)
}
func validateOptionalPath(name, value string) error {
if strings.ContainsRune(value, '\x00') {
return fmt.Errorf("%s must not contain NUL", name)
}
return nil
}
func validateChunkPlanCacheMode(value string) error {
if _, err := pipeline.ParseChunkCacheMode(value); err != nil {
return fmt.Errorf("cache.chunk_plans.mode: %w", err)
}
return nil
}

View File

@@ -0,0 +1,245 @@
package config
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestWorkerKeyNormalizationPreservesExplicitCount(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name, workers string
want int
errorPath string
}{
{name: "omitted", workers: "{}", want: 4},
{name: "padded", workers: "{' extract ': 2}", want: 2},
{name: "explicit invalid", workers: "{' extract ': 0}", errorPath: "/concurrency/stage_workers/extract"},
{name: "collision", workers: "{' extract ': 2, extract: 3}", errorPath: "/concurrency/stage_workers/extract"},
} {
t.Run(test.name, func(t *testing.T) {
loaded, err := loadConfigSource(configkit.Bytes("workers.yml", []byte(
"version: 5\nconcurrency:\n total_llm: 4\n stage_workers: "+test.workers+"\n")), LoadOptions{})
if test.errorPath != "" {
assertDocumentDiagnosticAt(t, err, test.errorPath)
return
}
if err != nil {
t.Fatal(err)
}
if got := loaded.Config.Concurrency.StageWorkers["extract"]; got != test.want {
t.Fatalf("workers = %d, want %d", got, test.want)
}
var explanation bytes.Buffer
if err := loaded.WriteExplanation(&explanation, "/concurrency/stage_workers"); err != nil {
t.Fatal(err)
}
if test.name == "padded" && (!strings.Contains(explanation.String(), "normalization") ||
!strings.Contains(explanation.String(), " extract ") ||
strings.Contains(explanation.String(), "derived extraction workers")) {
t.Fatalf("explicit worker provenance = %s", explanation.String())
}
})
}
}
func TestDocumentLoadAppliesOperationalDefaultsAndNormalization(t *testing.T) {
t.Parallel()
loaded, err := loadDocumentSource(configkit.Bytes("document.yml", []byte(`version: 5
concurrency:
total_llm: 4
promptkit:
profile_dir: " ./profiles "
local_backend:
endpoint: " https://localhost:8080/v1 "
output:
directory: " ./output "
cache:
chunk_plans:
mode: " refresh "
directory: " "
checkpoints:
directory: " "
debug:
directory: " ./debug "
`)), LoadOptions{})
if err != nil {
t.Fatal(err)
}
if loaded.Config.Concurrency.TotalLLM == nil || *loaded.Config.Concurrency.TotalLLM != 4 || loaded.Config.Concurrency.StageWorkers["extract"] != 4 {
t.Fatalf("concurrency = %#v", loaded.Config.Concurrency)
}
workingDirectory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if loaded.Config.PromptKit.ProfileDir == nil || *loaded.Config.PromptKit.ProfileDir != filepath.Join(workingDirectory, "profiles") ||
loaded.Config.PromptKit.LocalBackend == nil || loaded.Config.PromptKit.LocalBackend.Endpoint == nil || *loaded.Config.PromptKit.LocalBackend.Endpoint != "https://localhost:8080/v1" ||
loaded.Config.PromptKit.LocalBackend.ConcurrencyLimit == nil || *loaded.Config.PromptKit.LocalBackend.ConcurrencyLimit != 0 {
t.Fatalf("promptkit = %#v", loaded.Config.PromptKit)
}
if loaded.Config.Output.Directory == nil || *loaded.Config.Output.Directory != filepath.Join(workingDirectory, "output") ||
loaded.Config.Cache.ChunkPlans.Mode == nil || *loaded.Config.Cache.ChunkPlans.Mode != "refresh" ||
loaded.Config.Cache.ChunkPlans.Directory == nil || *loaded.Config.Cache.ChunkPlans.Directory != "" ||
loaded.Config.Cache.Checkpoints.Directory == nil || *loaded.Config.Cache.Checkpoints.Directory != "" ||
loaded.Config.Debug.Directory == nil || *loaded.Config.Debug.Directory != filepath.Join(workingDirectory, "debug") {
t.Fatalf("state = %#v, %#v, %#v", loaded.Config.Output, loaded.Config.Cache, loaded.Config.Debug)
}
if diagnostics := loaded.Diagnostics(); len(diagnostics) != 0 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
extractHistory := loaded.Metadata().History(configkit.Keys("concurrency", "stage_workers", "extract"))
if len(extractHistory) == 0 || extractHistory[len(extractHistory)-1].Origin.Operation != configkit.ChangeDerivedDefault {
t.Fatalf("extract history = %#v", extractHistory)
}
outputHistory := loaded.Metadata().History(configkit.Keys("output", "directory"))
if len(outputHistory) == 0 || outputHistory[len(outputHistory)-1].Origin.Operation != configkit.ChangeNormalization {
t.Fatalf("output history = %#v", outputHistory)
}
}
func TestDocumentLoadDerivesWorkersAfterOverlayAndCLIOverrides(t *testing.T) {
t.Parallel()
directory := t.TempDir()
main := filepath.Join(directory, "config.yml")
overlays := filepath.Join(directory, "overlays")
if err := os.Mkdir(overlays, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(main, []byte("version: 5\nconcurrency:\n total_llm: 4\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(overlays, "development.yml"), []byte("concurrency:\n total_llm: 3\n"), 0o600); err != nil {
t.Fatal(err)
}
overlaid, err := loadDocument(main, LoadOptions{Overlay: "development"})
if err != nil {
t.Fatal(err)
}
if overlaid.Config.Concurrency.TotalLLM == nil || *overlaid.Config.Concurrency.TotalLLM != 3 || overlaid.Config.Concurrency.StageWorkers["extract"] != 3 {
t.Fatalf("overlaid concurrency = %#v", overlaid.Config.Concurrency)
}
overridden, err := loadDocument(main, LoadOptions{Overrides: []configkit.Override{{
Path: configkit.Keys("concurrency", "total_llm"), Value: 2, Flag: "--total-llm",
}}})
if err != nil {
t.Fatal(err)
}
if overridden.Config.Concurrency.TotalLLM == nil || *overridden.Config.Concurrency.TotalLLM != 2 || overridden.Config.Concurrency.StageWorkers["extract"] != 2 {
t.Fatalf("overridden concurrency = %#v", overridden.Config.Concurrency)
}
overlaidAndOverridden, err := loadDocument(main, LoadOptions{Overlay: "development", Overrides: []configkit.Override{{
Path: configkit.Keys("concurrency", "total_llm"), Value: 2, Flag: "--total-llm",
}}})
if err != nil {
t.Fatal(err)
}
if overlaidAndOverridden.Config.Concurrency.TotalLLM == nil || *overlaidAndOverridden.Config.Concurrency.TotalLLM != 2 || overlaidAndOverridden.Config.Concurrency.StageWorkers["extract"] != 2 {
t.Fatalf("overlaid and overridden concurrency = %#v", overlaidAndOverridden.Config.Concurrency)
}
}
func TestDocumentLoadRetainsExplicitWorkerAndRejectsInvalidOperationalValues(t *testing.T) {
t.Parallel()
loaded, err := loadDocumentSource(configkit.Bytes("explicit.yml", []byte(`version: 5
concurrency:
total_llm: 4
stage_workers:
extract: 0
`)), LoadOptions{})
if loaded != nil {
t.Fatalf("loaded = %#v, want validation failure", loaded.Config)
}
assertDocumentDiagnostic(t, err, configkit.CodeValidateError, "/concurrency/stage_workers/extract")
_, err = loadDocumentSource(configkit.Bytes("invalid.yml", []byte(`version: 5
concurrency:
total_llm: 0
output:
directory: ""
promptkit:
local_backend:
endpoint: "ftp://localhost"
concurrency_limit: -1
`)), LoadOptions{})
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error = %v, want ConfigKit load error", err)
}
seen := make(map[string]bool)
for _, diagnostic := range loadError.Diagnostics() {
if diagnostic.Code != configkit.CodeValidateError || diagnostic.Primary == nil {
t.Fatalf("diagnostic = %#v", diagnostic)
}
seen[diagnostic.Path.String()] = true
}
for _, path := range []string{"/concurrency/total_llm", "/output/directory", "/promptkit/local_backend/endpoint", "/promptkit/local_backend/concurrency_limit"} {
if !seen[path] {
t.Fatalf("diagnostics missing %s: %#v", path, loadError.Diagnostics())
}
}
}
func TestDocumentLoadDistinguishesRequiredAndOptionalPathValues(t *testing.T) {
t.Parallel()
loaded, err := loadDocumentSource(configkit.Bytes("cache.yml", []byte(`version: 5
cache:
chunk_plans:
directory: " "
checkpoints:
directory: " "
`)), LoadOptions{})
if err != nil {
t.Fatal(err)
}
if loaded.Config.Cache.ChunkPlans.Directory == nil || *loaded.Config.Cache.ChunkPlans.Directory != "" ||
loaded.Config.Cache.Checkpoints.Directory == nil || *loaded.Config.Cache.Checkpoints.Directory != "" {
t.Fatalf("cache directories = %#v", loaded.Config.Cache)
}
_, err = loadDocumentSource(configkit.Bytes("output.yml", []byte("version: 5\noutput:\n directory: ' '\n")), LoadOptions{})
assertDocumentDiagnostic(t, err, configkit.CodeValidateError, "/output/directory")
_, err = loadDocumentSource(configkit.Bytes("promptkit.yml", []byte("version: 5\npromptkit:\n profile_file: null\n local_backend: {}\n")), LoadOptions{})
assertDocumentDiagnostic(t, err, configkit.CodeResolveError, "/promptkit/profile_file")
_, err = loadDocumentSource(configkit.Bytes("empty-source.yml", []byte("version: 5\npromptkit:\n profile_dir: ' '\n")), LoadOptions{})
assertDocumentDiagnostic(t, err, configkit.CodeValidateError, "/promptkit/profile_dir")
_, err = loadDocumentSource(configkit.Bytes("missing-endpoint.yml", []byte("version: 5\npromptkit:\n local_backend: {}\n")), LoadOptions{})
assertDocumentDiagnostic(t, err, configkit.CodeValidateError, "/promptkit/local_backend/endpoint")
}
func TestDocumentLoadRejectsNegativePromptKitTransientRetries(t *testing.T) {
t.Parallel()
_, err := loadDocumentSource(configkit.Bytes("promptkit.yml", []byte("version: 5\npromptkit:\n transient_retries: -1\n")), LoadOptions{})
assertDocumentDiagnostic(t, err, configkit.CodeValidateError, "/promptkit/transient_retries")
}
func assertDocumentDiagnostic(t *testing.T, err error, code configkit.Code, path string) {
t.Helper()
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error = %v, want ConfigKit load error", err)
}
for _, diagnostic := range loadError.Diagnostics() {
if diagnostic.Code == code && diagnostic.Path.String() == path {
return
}
}
t.Fatalf("diagnostics = %#v, want %s at %s", loadError.Diagnostics(), code, path)
}

View File

@@ -0,0 +1,513 @@
package config
import (
"sort"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
// documentLoadState keeps the spelling supplied by the user while resolver
// operations move map entries to their canonical keys. It is intentionally
// load-local: source-location handling consumes it during a later resolver
// pass and it never becomes runtime configuration state.
type documentLoadState struct {
originalPaths map[string]configkit.Path
mainDirectory string
invocationDirectory string
}
func newDocumentLoadState(invocationDirectory string) *documentLoadState {
return &documentLoadState{originalPaths: make(map[string]configkit.Path), invocationDirectory: cleanInvocationDirectory(invocationDirectory)}
}
func (state *documentLoadState) rememberPath(normalized, original configkit.Path) {
if state != nil {
state.originalPaths[normalized.String()] = original
}
}
// originalPath follows recorded map-key moves back to the spelling present in
// the source. Resolver code that needs a source coordinate must use this
// rather than reconstructing a path from a normalized identifier.
func (state *documentLoadState) originalPath(path configkit.Path) configkit.Path {
if state == nil || len(state.originalPaths) == 0 {
return path
}
value := path.String()
for {
changed := false
segments := strings.Split(strings.TrimPrefix(value, "/"), "/")
for length := 1; length <= len(segments); length++ {
prefix := "/" + strings.Join(segments[:length], "/")
original, ok := state.originalPaths[prefix]
if !ok {
continue
}
value = original.String() + strings.TrimPrefix(value, prefix)
changed = true
break
}
if !changed {
break
}
}
if original, err := configkit.ParsePath(value); err == nil {
return original
}
return path
}
func normalizeDocumentPipelines(document *Document, resolution *configkit.Resolution, state *documentLoadState) {
if document == nil {
return
}
validateDocumentPipelinePresence(document, resolution)
normalizer := documentNormalizer{Resolution: resolution}
pipelinesPath := configkit.Keys("pipelines")
for _, id := range sortedPipelineDocumentKeys(document.Pipelines) {
profile := document.Pipelines[id]
normalizePipelineDocument(&profile, pipelinesPath.Key(id), normalizer, state)
document.Pipelines[id] = profile
}
document.Pipelines = normalizeDocumentMap(document.Pipelines, pipelinesPath, "pipeline identifier", normalizer, state)
}
func validateDocumentPipelinePresence(document *Document, resolution *configkit.Resolution) {
pipelinesPath := configkit.Keys("pipelines")
for _, id := range sortedPipelineDocumentKeys(document.Pipelines) {
profile := document.Pipelines[id]
path := pipelinesPath.Key(id)
validatePipelineDocumentPresence(profile, path, resolution)
}
}
func validatePipelineDocumentPresence(profile PipelineDocument, path configkit.Path, resolution *configkit.Resolution) {
rejectDocumentNull(resolution, path.Key("llm_profile"), profile.LLMProfile == nil)
rejectDocumentNull(resolution, path.Key("structured_output_repair_attempts"), profile.StructuredOutputRepairAttempts == nil)
rejectDocumentNull(resolution, path.Key("validation_policy"), profile.ValidationPolicy == nil)
validateDocumentPolicyPresence(profile.ValidationPolicy, path.Key("validation_policy"), resolution)
stepsPath := path.Key("steps")
if resolution.IsExplicit(stepsPath) && profile.Steps == nil {
resolution.Error(stepsPath, "must not be null")
}
if resolution.IsExplicit(stepsPath) && len(profile.Steps) == 0 {
resolution.Error(stepsPath, "must contain at least one ordered step")
}
if resolution.IsExplicit(stepsPath) && resolution.IsExplicit(path.Key("artifacts")) {
resolution.Error(stepsPath, "must not be declared with artifacts", path.Key("artifacts"))
}
validateBindingDocumentPresence(profile.Input, path.Key("input"), resolution)
validateOptionalBindingDocumentPresence(profile.Chunk, path.Key("chunk"), resolution)
validateOptionalBindingDocumentPresence(profile.Output, path.Key("output"), resolution)
validateReferenceDocumentMapPresence(profile.References, path.Key("references"), resolution)
for _, laneID := range sortedLaneDocumentKeys(profile.Artifacts) {
validateArtifactLaneDocumentPresence(profile.Artifacts[laneID], path.Key("artifacts").Key(laneID), resolution)
}
for index, step := range profile.Steps {
stepPath := stepsPath.Index(index)
validateReferenceDocumentMapPresence(step.References, stepPath.Key("references"), resolution)
for _, laneID := range sortedLaneDocumentKeys(step.Artifacts) {
validateArtifactLaneDocumentPresence(step.Artifacts[laneID], stepPath.Key("artifacts").Key(laneID), resolution)
}
}
}
func validateArtifactLaneDocumentPresence(lane ArtifactLaneDocument, path configkit.Path, resolution *configkit.Resolution) {
if resolution.IsExplicit(path.Key("references")) {
resolution.Error(path.Key("references"), "artifact-lane references are not supported; use binding references")
}
validateBindingDocumentPresence(lane.Extract, path.Key("extract"), resolution)
validateOptionalBindingDocumentPresence(lane.Merge, path.Key("merge"), resolution)
validateOptionalBindingDocumentPresence(lane.Normalize, path.Key("normalize"), resolution)
}
func validateOptionalBindingDocumentPresence(binding *BindingDocument, path configkit.Path, resolution *configkit.Resolution) {
if binding == nil {
if resolution.IsExplicit(path) {
resolution.Error(path, "must not be null when configured")
}
return
}
validateBindingDocumentPresence(*binding, path, resolution)
}
func validateBindingDocumentPresence(binding BindingDocument, path configkit.Path, resolution *configkit.Resolution) {
rejectDocumentNull(resolution, path.Key("llm_profile"), binding.LLMProfile == nil)
rejectDocumentNull(resolution, path.Key("structured_output_repair_attempts"), binding.StructuredOutputRepairAttempts == nil)
rejectDocumentNull(resolution, path.Key("validation_policy"), binding.ValidationPolicy == nil)
validateDocumentPolicyPresence(binding.ValidationPolicy, path.Key("validation_policy"), resolution)
if binding.Validators == nil && resolution.IsExplicit(path.Key("validators")) {
resolution.Error(path.Key("validators"), "must not be null")
}
validateReferenceDocumentMapPresence(binding.References, path.Key("references"), resolution)
for index, validator := range binding.Validators {
validatorPath := path.Key("validators").Index(index)
rejectDocumentNull(resolution, validatorPath.Key("llm_profile"), validator.LLMProfile == nil)
rejectDocumentNull(resolution, validatorPath.Key("structured_output_repair_attempts"), validator.StructuredOutputRepairAttempts == nil)
}
}
func validateReferenceDocumentMapPresence(references map[string]ReferenceDocument, path configkit.Path, resolution *configkit.Resolution) {
for _, name := range sortedReferenceDocumentKeys(references) {
source := references[name]
sourcePath := path.Key(name)
rejectDocumentNull(resolution, sourcePath.Key("path"), source.Path == nil)
rejectDocumentNull(resolution, sourcePath.Key("artifact"), source.Artifact == nil)
}
}
func rejectDocumentNull(resolution *configkit.Resolution, path configkit.Path, isNil bool) {
if isNil && resolution.IsExplicit(path) {
resolution.Error(path, "must not be null")
}
}
func validateDocumentPolicyPresence(policy *ValidationPolicyDocument, path configkit.Path, resolution *configkit.Resolution) {
if policy == nil {
return
}
rejectDocumentNull(resolution, path.Key("producer_structural_failure"), policy.ProducerStructuralFailure == nil)
rejectDocumentNull(resolution, path.Key("semantic_rejection"), policy.SemanticRejection == nil)
rejectDocumentNull(resolution, path.Key("validator_failure"), policy.ValidatorFailure == nil)
}
func normalizePipelineDocument(profile *PipelineDocument, path configkit.Path, resolution documentNormalizer, state *documentLoadState) {
normalizePipelineDocumentString(resolution, path.Key("llm_profile"), &profile.LLMProfile, "trimmed pipeline LLM profile")
normalizeDocumentBinding(&profile.Input, path.Key("input"), resolution, state)
if profile.Chunk != nil {
normalizeDocumentBinding(profile.Chunk, path.Key("chunk"), resolution, state)
}
if profile.Output != nil {
normalizeDocumentBinding(profile.Output, path.Key("output"), resolution, state)
}
profile.References = normalizeDocumentReferences(profile.References, path.Key("references"), resolution, state)
for _, laneID := range sortedLaneDocumentKeys(profile.Artifacts) {
lane := profile.Artifacts[laneID]
normalizeArtifactLaneDocument(&lane, path.Key("artifacts").Key(laneID), resolution, state)
profile.Artifacts[laneID] = lane
}
profile.Artifacts = normalizeDocumentMap(profile.Artifacts, path.Key("artifacts"), "artifact lane identifier", resolution, state)
resolution = resolution.inSequence(path.Key("steps"))
for index := range profile.Steps {
step := &profile.Steps[index]
stepPath := path.Key("steps").Index(index)
normalizeDocumentValue(resolution, stepPath.Key("id"), &step.ID, strings.TrimSpace(step.ID), "trimmed step identifier")
step.References = normalizeDocumentReferences(step.References, stepPath.Key("references"), resolution, state)
for _, laneID := range sortedLaneDocumentKeys(step.Artifacts) {
lane := step.Artifacts[laneID]
normalizeArtifactLaneDocument(&lane, stepPath.Key("artifacts").Key(laneID), resolution, state)
step.Artifacts[laneID] = lane
}
step.Artifacts = normalizeDocumentMap(step.Artifacts, stepPath.Key("artifacts"), "artifact lane identifier", resolution, state)
}
}
func normalizeArtifactLaneDocument(lane *ArtifactLaneDocument, path configkit.Path, resolution documentNormalizer, state *documentLoadState) {
normalizeDocumentBinding(&lane.Extract, path.Key("extract"), resolution, state)
if lane.Merge != nil {
normalizeDocumentBinding(lane.Merge, path.Key("merge"), resolution, state)
}
if lane.Normalize != nil {
normalizeDocumentBinding(lane.Normalize, path.Key("normalize"), resolution, state)
}
resolution = resolution.inSequence(path.Key("validators"))
for index := range lane.Validators {
normalizeValidatorDocument(&lane.Validators[index], path.Key("validators").Index(index), resolution)
}
}
func normalizeDocumentBinding(binding *BindingDocument, path configkit.Path, resolution documentNormalizer, state *documentLoadState) {
normalizeDocumentValue(resolution, path.Key("module"), &binding.Module, strings.TrimSpace(binding.Module), "trimmed module identifier")
normalizePipelineDocumentString(resolution, path.Key("llm_profile"), &binding.LLMProfile, "trimmed binding LLM profile")
binding.References = normalizeDocumentReferences(binding.References, path.Key("references"), resolution, state)
resolution = resolution.inSequence(path.Key("validators"))
for index := range binding.Validators {
normalizeValidatorDocument(&binding.Validators[index], path.Key("validators").Index(index), resolution)
}
}
func normalizeValidatorDocument(validator *ValidatorDocument, path configkit.Path, resolution documentNormalizer) {
normalizeDocumentValue(resolution, path.Key("module"), &validator.Module, strings.TrimSpace(validator.Module), "trimmed validator module identifier")
normalizePipelineDocumentString(resolution, path.Key("llm_profile"), &validator.LLMProfile, "trimmed validator LLM profile")
}
func normalizeDocumentReferences(references map[string]ReferenceDocument, path configkit.Path, resolution documentNormalizer, state *documentLoadState) map[string]ReferenceDocument {
for _, name := range sortedReferenceDocumentKeys(references) {
source := references[name]
sourcePath := path.Key(name)
normalizePipelineDocumentString(resolution, sourcePath.Key("path"), &source.Path, "trimmed reference path")
if source.Artifact != nil {
normalizeDocumentValue(resolution, sourcePath.Key("artifact").Key("step"), &source.Artifact.Step, strings.TrimSpace(source.Artifact.Step), "trimmed artifact step selector")
normalizeDocumentValue(resolution, sourcePath.Key("artifact").Key("lane"), &source.Artifact.Lane, strings.TrimSpace(source.Artifact.Lane), "trimmed artifact lane selector")
}
references[name] = source
}
return normalizeDocumentMap(references, path, "reference name", resolution, state)
}
func normalizeDocumentValue[T comparable](resolution documentNormalizer, path configkit.Path, target *T, value T, reason string) {
if target == nil || !resolution.IsExplicit(path) {
return
}
if *target != value {
resolution.Record(path, configkit.ChangeNormalization, reason)
*target = value
}
}
func normalizePipelineDocumentString(resolution documentNormalizer, path configkit.Path, target **string, reason string) {
if target == nil || *target == nil || !resolution.IsExplicit(path) {
return
}
value := strings.TrimSpace(**target)
if **target != value {
resolution.Record(path, configkit.ChangeNormalization, reason)
**target = value
}
}
func normalizeDocumentMap[T any](values map[string]T, path configkit.Path, kind string, resolution documentNormalizer, state *documentLoadState) map[string]T {
if len(values) == 0 {
return values
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
canonical := make(map[string]string, len(values))
valid := true
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
resolution.Error(path.Key(key), kind+" must not be empty")
valid = false
continue
}
if first, exists := canonical[trimmed]; exists {
resolution.Error(path.Key(key), kind+" collides after whitespace normalization", path.Key(first))
valid = false
continue
}
canonical[trimmed] = key
}
if !valid {
return values
}
result := make(map[string]T, len(values))
changed := false
dependencies := make([]configkit.Path, 0, len(keys))
for _, key := range keys {
trimmed := strings.TrimSpace(key)
result[trimmed] = values[key]
dependencies = append(dependencies, path.Key(key))
if trimmed != key {
changed = true
state.rememberPath(path.Key(trimmed), path.Key(key))
}
}
if changed {
resolution.Record(path, configkit.ChangeNormalization, "normalized "+kind+" keys", dependencies...)
values = result
}
return values
}
func validateDocumentPipelines(document *Document, validation *configkit.Validation) {
if document == nil {
return
}
pipelinesPath := configkit.Keys("pipelines")
for _, id := range sortedPipelineDocumentKeys(document.Pipelines) {
profile := document.Pipelines[id]
path := pipelinesPath.Key(id)
validatePipelineDocument(profile, path, validation)
}
}
func validatePipelineDocument(profile PipelineDocument, path configkit.Path, validation *configkit.Validation) {
validateSelectedDocumentProfile(profile.LLMProfile, path.Key("llm_profile"), validation)
validateDocumentRepairAttempts(profile.StructuredOutputRepairAttempts, path.Key("structured_output_repair_attempts"), validation)
validateDocumentPolicy(profile.ValidationPolicy, path.Key("validation_policy"), validation)
validateDocumentBinding(profile.Input, path.Key("input"), "input", false, false, validation)
if profile.Chunk != nil {
validateDocumentBinding(*profile.Chunk, path.Key("chunk"), "chunk", true, true, validation)
}
if profile.Output != nil {
validateDocumentBinding(*profile.Output, path.Key("output"), "output", false, false, validation)
}
validateDocumentReferences(profile.References, path.Key("references"), false, validation)
seenLanes := make(map[string]configkit.Path)
if profile.Steps != nil {
seenSteps := make(map[string]configkit.Path)
for index, step := range profile.Steps {
stepPath := path.Key("steps").Index(index)
if strings.TrimSpace(step.ID) == "" {
validation.Error(stepPath.Key("id"), "must not be empty")
} else if first, exists := seenSteps[step.ID]; exists {
validation.Error(stepPath.Key("id"), "must be unique", first)
} else {
seenSteps[step.ID] = stepPath.Key("id")
}
validateDocumentReferences(step.References, stepPath.Key("references"), true, validation)
validateDocumentLanes(step.Artifacts, stepPath.Key("artifacts"), seenLanes, validation)
}
return
}
validateDocumentLanes(profile.Artifacts, path.Key("artifacts"), seenLanes, validation)
}
func validateDocumentLanes(lanes map[string]ArtifactLaneDocument, path configkit.Path, seen map[string]configkit.Path, validation *configkit.Validation) {
for _, id := range sortedLaneDocumentKeys(lanes) {
lanePath := path.Key(id)
if first, exists := seen[id]; exists {
validation.Error(lanePath, "artifact lane identifier must be unique across steps", first)
} else {
seen[id] = lanePath
}
lane := lanes[id]
validateDocumentBinding(lane.Extract, lanePath.Key("extract"), "extract", true, true, validation)
if lane.Merge != nil {
validateDocumentBinding(*lane.Merge, lanePath.Key("merge"), "merge", true, true, validation)
}
if lane.Normalize != nil {
validateDocumentBinding(*lane.Normalize, lanePath.Key("normalize"), "normalize", true, true, validation)
}
if len(lane.Validators) > 0 {
validation.Error(lanePath.Key("validators"), "artifact-lane validators are not supported; attach validators to a binding")
}
}
}
func validateDocumentBinding(binding BindingDocument, path configkit.Path, slot string, referencesAllowed, validatorsAllowed bool, validation *configkit.Validation) {
if strings.TrimSpace(binding.Module) == "" {
validation.Error(path.Key("module"), "must not be empty")
}
validateSelectedDocumentProfile(binding.LLMProfile, path.Key("llm_profile"), validation)
validateDocumentRepairAttempts(binding.StructuredOutputRepairAttempts, path.Key("structured_output_repair_attempts"), validation)
if binding.Retries < 0 {
validation.Error(path.Key("retries"), "must be greater than or equal to zero")
}
if binding.ValidationPolicy != nil {
if slot != "chunk" && slot != "extract" && slot != "merge" && slot != "normalize" {
validation.Error(path.Key("validation_policy"), "is not supported for this binding")
}
validateDocumentPolicy(binding.ValidationPolicy, path.Key("validation_policy"), validation)
}
if len(binding.References) > 0 && !referencesAllowed {
validation.Error(path.Key("references"), "are not supported for this binding")
}
if referencesAllowed {
validateDocumentReferences(binding.References, path.Key("references"), true, validation)
}
if binding.Validators != nil && !validatorsAllowed {
validation.Error(path.Key("validators"), "are not supported for this binding")
}
for index, validator := range binding.Validators {
validateValidatorDocument(validator, path.Key("validators").Index(index), validation)
}
}
func validateValidatorDocument(validator ValidatorDocument, path configkit.Path, validation *configkit.Validation) {
if strings.TrimSpace(validator.Module) == "" {
validation.Error(path.Key("module"), "must not be empty")
}
validateSelectedDocumentProfile(validator.LLMProfile, path.Key("llm_profile"), validation)
validateDocumentRepairAttempts(validator.StructuredOutputRepairAttempts, path.Key("structured_output_repair_attempts"), validation)
if validator.Retries < 0 {
validation.Error(path.Key("retries"), "must be greater than or equal to zero")
}
}
func validateDocumentReferences(references map[string]ReferenceDocument, path configkit.Path, generatedAllowed bool, validation *configkit.Validation) {
for _, name := range sortedReferenceDocumentKeys(references) {
source := references[name]
sourcePath := path.Key(name)
hasPath := source.Path != nil
hasArtifact := source.Artifact != nil
if hasPath == hasArtifact {
validation.Error(sourcePath, "must select exactly one reference source")
continue
}
if hasPath {
if strings.TrimSpace(*source.Path) == "" {
validation.Error(sourcePath.Key("path"), "must not be empty")
}
continue
}
if !generatedAllowed {
validation.Error(sourcePath.Key("artifact"), "is not supported here; use an external path")
continue
}
if strings.TrimSpace(source.Artifact.Step) == "" {
validation.Error(sourcePath.Key("artifact").Key("step"), "must not be empty")
}
if strings.TrimSpace(source.Artifact.Lane) == "" {
validation.Error(sourcePath.Key("artifact").Key("lane"), "must not be empty")
}
}
}
func validateSelectedDocumentProfile(value *string, path configkit.Path, validation *configkit.Validation) {
if value != nil && strings.TrimSpace(*value) == "" {
validation.Error(path, "must not be empty when set")
}
}
func validateDocumentRepairAttempts(value *int, path configkit.Path, validation *configkit.Validation) {
if value != nil && (*value < 0 || *value > 3) {
validation.Error(path, "must be between zero and three")
}
}
func validateDocumentPolicy(policy *ValidationPolicyDocument, path configkit.Path, validation *configkit.Validation) {
if policy == nil {
return
}
if policy.ProducerStructuralFailure != nil {
switch *policy.ProducerStructuralFailure {
case pipeline.ProducerStructuralFailureFailRun, pipeline.ProducerStructuralFailureRejectOutput:
default:
validation.Error(path.Key("producer_structural_failure"), "must be fail_run or reject_output")
}
}
if policy.SemanticRejection != nil {
switch *policy.SemanticRejection {
case pipeline.SemanticRejectionFailRun, pipeline.SemanticRejectionRejectOutput:
default:
validation.Error(path.Key("semantic_rejection"), "must be fail_run or reject_output")
}
}
if policy.ValidatorFailure != nil {
switch *policy.ValidatorFailure {
case pipeline.ValidatorFailureWarnContinue, pipeline.ValidatorFailureFailRun:
default:
validation.Error(path.Key("validator_failure"), "must be warn_continue or fail_run")
}
}
}
func sortedPipelineDocumentKeys(values map[string]PipelineDocument) []string {
return sortedDocumentKeys(values)
}
func sortedLaneDocumentKeys(values map[string]ArtifactLaneDocument) []string {
return sortedDocumentKeys(values)
}
func sortedReferenceDocumentKeys(values map[string]ReferenceDocument) []string {
return sortedDocumentKeys(values)
}
func sortedDocumentKeys[T any](values map[string]T) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}

View File

@@ -0,0 +1,263 @@
package config
import (
"errors"
"fmt"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestDocumentRejectsExplicitInvalidBranches(t *testing.T) {
t.Parallel()
for _, slot := range []string{"input", "output"} {
t.Run(slot+" empty validators", func(t *testing.T) {
body := "input: {module: input, validators: []}"
if slot == "output" {
body = "input: {module: input}\n output: {module: output, validators: []}"
}
_, err := loadConfigSource(configkit.Bytes("document.yml", []byte("version: 5\npipelines:\n session:\n "+body+"\n")), LoadOptions{})
assertDocumentDiagnosticAt(t, err, "/pipelines/session/"+slot+"/validators")
})
}
for _, value := range []string{
"{path: ''}", "{path: ' '}",
"{path: '', artifact: {step: previous, lane: registry}}",
"{path: ' ', artifact: {step: previous, lane: registry}}",
"{path: null, artifact: {step: previous, lane: registry}}",
} {
t.Run(value, func(t *testing.T) {
content := fmt.Sprintf("version: 5\npipelines:\n session:\n input: {module: input}\n artifacts:\n lane:\n extract:\n module: extract\n references:\n party: %s\n", value)
_, err := loadConfigSource(configkit.Bytes("document.yml", []byte(content)), LoadOptions{})
path := "/pipelines/session/artifacts/lane/extract/references/party"
if value == "{path: ''}" || value == "{path: ' '}" || value == "{path: null, artifact: {step: previous, lane: registry}}" {
path += "/path"
}
assertDocumentDiagnosticAt(t, err, path)
})
}
}
func TestDocumentPreservesSupportedEmptyValidatorsAndReferenceForms(t *testing.T) {
t.Parallel()
loaded, err := loadConfigSource(configkit.Bytes("document.yml", []byte(`version: 5
pipelines:
session:
input: {module: input}
chunk: {module: chunk, validators: []}
artifacts:
lane:
validators: []
extract:
module: extract
validators: []
references:
external: {path: /data/party.txt}
generated: {artifact: {step: previous, lane: registry}}
merge: {module: merge, validators: []}
normalize: {module: normalize, validators: []}
`)), LoadOptions{})
if err != nil {
t.Fatal(err)
}
profile := loaded.Config.Pipelines["session"]
lane := profile.Artifacts["lane"]
for _, override := range []bool{profile.Chunk.Validators.Set, lane.Extract.Validators.Set, lane.Merge.Validators.Set, lane.Normalize.Validators.Set} {
if !override {
t.Fatal("supported empty validator override lost explicitness")
}
}
if len(lane.Validators) != 0 || lane.Extract.References["external"].Path != "/data/party.txt" ||
lane.Extract.References["generated"].Artifact.Lane != "registry" {
t.Fatalf("lane = %#v", lane)
}
}
func TestDocumentPipelineNormalizesCanonicalIdentifiers(t *testing.T) {
t.Parallel()
loaded, err := loadDocumentSource(configkit.Bytes("document.yml", []byte(`version: 5
pipelines:
" session ":
llm_profile: " local "
input:
module: " seriatim "
artifacts:
" spells ":
extract:
module: " dnd/spells "
structured_output_repair_attempts: 0
validators: []
merge:
module: dnd/merge
references:
previous:
artifact:
step: " default "
lane: " spells "
`)), LoadOptions{})
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
t.Fatalf("loadDocumentSource() diagnostics = %#v", loadError.Diagnostics())
}
t.Fatalf("loadDocumentSource() error = %v", err)
}
profile, ok := loaded.Config.Pipelines["session"]
if !ok || profile.Input.Module != "seriatim" || profile.LLMProfile == nil || *profile.LLMProfile != "local" {
t.Fatalf("normalized pipeline = %#v", loaded.Config.Pipelines)
}
lane, ok := profile.Artifacts["spells"]
if !ok || lane.Extract.StructuredOutputRepairAttempts == nil || *lane.Extract.StructuredOutputRepairAttempts != 0 || lane.Extract.Validators == nil || len(lane.Extract.Validators) != 0 {
t.Fatalf("normalized lane = %#v", profile.Artifacts)
}
selector := lane.Merge.References["previous"].Artifact
if selector == nil || selector.Step != "default" || selector.Lane != "spells" {
t.Fatalf("artifact selector = %#v", selector)
}
}
func TestDocumentPipelineRejectsInvalidShapes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
source string
path string
}{
{
name: "both pipeline forms",
source: `version: 5
pipelines:
session:
input: {module: seriatim}
artifacts: {}
steps:
- id: first
artifacts: {}
`,
path: "/pipelines/session/steps",
},
{
name: "empty ordered steps",
source: `version: 5
pipelines:
session:
input: {module: seriatim}
steps: []
`,
path: "/pipelines/session/steps",
},
{
name: "normalized pipeline collision",
source: `version: 5
pipelines:
session: {input: {module: seriatim}}
" session ": {input: {module: seriatim}}
`,
path: "/pipelines/session",
},
{
name: "duplicate lane across ordered steps",
source: `version: 5
pipelines:
session:
input: {module: seriatim}
steps:
- id: first
artifacts: {summary: {extract: {module: dnd/summary}}}
- id: second
artifacts: {summary: {extract: {module: dnd/summary}}}
`,
path: "/pipelines/session/steps/1/artifacts/summary",
},
{
name: "invalid reference union",
source: `version: 5
pipelines:
session:
input: {module: seriatim}
references:
party: {path: ./party.txt, artifact: {step: one, lane: summary}}
`,
path: "/pipelines/session/references/party",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := loadDocumentSource(configkit.Bytes("document.yml", []byte(test.source)), LoadOptions{})
assertDocumentDiagnosticAt(t, err, test.path)
})
}
}
func TestDocumentPipelineRejectsInvalidBindingPlacementAndPresence(t *testing.T) {
t.Parallel()
tests := []struct {
name string
source string
path string
}{
{
name: "input validators",
source: `version: 5
pipelines:
session:
input:
module: seriatim
validators: [{module: generic/valid_json}]
`,
path: "/pipelines/session/input/validators",
},
{
name: "blank selected profile",
source: `version: 5
pipelines:
session:
llm_profile: " "
input: {module: seriatim}
`,
path: "/pipelines/session/llm_profile",
},
{
name: "null validators",
source: `version: 5
pipelines:
session:
input: {module: seriatim}
chunk: {module: generic/chunk, validators: null}
`,
path: "/pipelines/session/chunk/validators",
},
{
name: "negative retry budget",
source: `version: 5
pipelines:
session:
input: {module: seriatim, retries: -1}
`,
path: "/pipelines/session/input/retries",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := loadDocumentSource(configkit.Bytes("document.yml", []byte(test.source)), LoadOptions{})
assertDocumentDiagnosticAt(t, err, test.path)
})
}
}
func assertDocumentDiagnosticAt(t *testing.T, err error, path string) {
t.Helper()
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error = %v, want ConfigKit load error", err)
}
for _, diagnostic := range loadError.Diagnostics() {
if diagnostic.Path.String() == path {
return
}
}
t.Fatalf("diagnostics = %#v, want a diagnostic at %s", loadError.Diagnostics(), path)
}

View File

@@ -4,28 +4,10 @@ import (
"reflect"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func (c Config) Redacted() Config {
return redactConfig(cloneConfig(c))
}
func (c Config) RedactedSummaryPayload() any {
return c.Redacted()
}
func (e EffectiveConfig) RedactedSummaryPayload() any {
return EffectiveConfig{
Config: e.Config.Redacted(),
PipelineID: e.PipelineID,
Only: append([]string(nil), e.Only...),
ReferenceOverrides: append([]pipeline.ReferenceBinding(nil), e.ReferenceOverrides...),
ReferenceUnbinds: append([]pipeline.ReferenceUnbind(nil), e.ReferenceUnbinds...),
ResolvedPipeline: cloneResolvedPipeline(e.ResolvedPipeline),
}
}
func (e EffectiveConfig) RedactedResolvedPipelinePayload() pipeline.ResolvedPipeline {
return cloneResolvedPipeline(e.ResolvedPipeline)
}
@@ -91,31 +73,6 @@ func cloneResolvedArtifactLane(in pipeline.ResolvedArtifactLane) pipeline.Resolv
return out
}
func redactConfig(cfg Config) Config {
for id, profile := range cfg.Pipelines {
profile.Input = redactBinding(profile.Input)
profile.Chunk = redactBinding(profile.Chunk)
profile.Output = redactBinding(profile.Output)
redactLanes := func(lanes map[string]pipeline.ArtifactLaneProfile) {
for laneID, lane := range lanes {
lane.Extract = redactBinding(lane.Extract)
lane.Merge = redactBinding(lane.Merge)
lane.Normalize = redactBinding(lane.Normalize)
for i := range lane.Validators {
lane.Validators[i] = redactBinding(lane.Validators[i])
}
lanes[laneID] = lane
}
}
redactLanes(profile.Artifacts)
for i := range profile.Steps {
redactLanes(profile.Steps[i].Artifacts)
}
cfg.Pipelines[id] = profile
}
return cfg
}
func redactBinding(binding pipeline.ModuleBinding) pipeline.ModuleBinding {
binding.Options = redactOptions(binding.Options)
for i := range binding.Validators.Validators {
@@ -188,3 +145,21 @@ func sensitiveConfigKey(key string) bool {
key = strings.ToLower(key)
return strings.Contains(key, "api_key") || strings.Contains(key, "apikey") || strings.Contains(key, "authorization") || strings.Contains(key, "bearer") || strings.Contains(key, "password") || strings.Contains(key, "secret") || strings.Contains(key, "token")
}
// sensitiveDocumentPath applies the existing option-key classification to the
// fully typed ConfigKit document. Dynamic option maps can be nested below any
// producer or validator, so every key after an options container participates.
func sensitiveDocumentPath(path configkit.Path) bool {
segments := strings.Split(strings.TrimPrefix(path.String(), "/"), "/")
withinOptions := false
for _, segment := range segments {
segment = strings.ReplaceAll(strings.ReplaceAll(segment, "~1", "/"), "~0", "~")
if withinOptions && sensitiveConfigKey(segment) {
return true
}
if segment == "options" {
withinOptions = true
}
}
return false
}

View File

@@ -114,83 +114,6 @@ func TestRedactedResolvedPipelinePayloadRedactsEveryBinding(t *testing.T) {
}
}
func TestRedactedEffectiveConfigPayloadDoesNotAliasSource(t *testing.T) {
binding := redactionTestBinding("effective")
effective := EffectiveConfig{
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
"redaction-test": {Input: binding},
}},
ResolvedPipeline: pipeline.ResolvedPipeline{Input: binding},
}
payload := effective.RedactedSummaryPayload().(EffectiveConfig)
payload.Config.Pipelines["redaction-test"].Input.Options["safe"] = "mutated"
payload.ResolvedPipeline.Input.Options["safe"] = "mutated"
assertRedactionTestBindingUnchanged(t, effective.Config.Pipelines["redaction-test"].Input, "effective")
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.Input, "effective")
}
func TestRedactedSummaryPayloadsCoverEveryEffectiveConfigBinding(t *testing.T) {
bindings := map[string]pipeline.ModuleBinding{}
for _, name := range []string{"input", "chunk", "output", "extract", "merge", "normalize", "lane-validator"} {
bindings[name] = redactionTestBinding("summary-" + name)
}
effective := EffectiveConfig{
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
"redaction-test": {
Input: bindings["input"],
Chunk: bindings["chunk"],
Output: bindings["output"],
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"safe-lane": {
Extract: bindings["extract"],
Merge: bindings["merge"],
Normalize: bindings["normalize"],
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
},
},
},
}},
ResolvedPipeline: pipeline.ResolvedPipeline{
Input: bindings["input"],
Chunk: bindings["chunk"],
Output: bindings["output"],
Steps: []pipeline.ResolvedPipelineStep{{
ID: "default",
ArtifactLanes: []pipeline.ResolvedArtifactLane{{
ID: "safe-lane",
Extract: bindings["extract"],
Merge: bindings["merge"],
Normalize: bindings["normalize"],
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
}},
}},
},
}
payload, ok := effective.RedactedSummaryPayload().(EffectiveConfig)
if !ok {
t.Fatal("RedactedSummaryPayload() returned an unexpected type")
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
text := string(encoded)
for name := range bindings {
if strings.Contains(text, "summary-"+name+"-secret") || strings.Contains(text, "summary-"+name+"-nested-secret") {
t.Fatalf("summary payload contains sensitive option for %q: %s", name, text)
}
if !strings.Contains(text, "summary-"+name+"-safe") {
t.Fatalf("summary payload omitted safe option for %q: %s", name, text)
}
}
if !strings.Contains(text, "[REDACTED]") {
t.Fatalf("summary payload contains no redaction marker: %s", text)
}
}
func TestRedactedResolvedPipelinePayloadHandlesTypedOptionContainers(t *testing.T) {
type optionMap map[string]string
type optionList []optionMap
@@ -225,39 +148,6 @@ func TestRedactedResolvedPipelinePayloadHandlesTypedOptionContainers(t *testing.
}
}
func TestRedactedEffectiveConfigPayloadOwnsValidationPolicies(t *testing.T) {
semantic := pipeline.SemanticRejectionRejectOutput
validator := pipeline.ValidatorFailureFailRun
configured := &pipeline.ValidationPolicyOverride{SemanticRejection: &semantic, ValidatorFailure: &validator}
effective := EffectiveConfig{
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
"main": {ValidationPolicy: configured},
}},
ResolvedPipeline: pipeline.ResolvedPipeline{
ConfiguredValidationPolicy: configured,
ChunkValidationPolicy: pipeline.ValidationPolicy{
ProducerStructuralFailure: pipeline.ProducerStructuralFailureFailRun,
SemanticRejection: pipeline.SemanticRejectionRejectOutput,
ValidatorFailure: pipeline.ValidatorFailureFailRun,
},
},
}
payload := effective.RedactedSummaryPayload().(EffectiveConfig)
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(encoded), `"configured_validation_policy":{"semantic_rejection":"reject_output","validator_failure":"fail_run"}`) || !strings.Contains(string(encoded), `"chunk_validation_policy":{"producer_structural_failure":"fail_run","semantic_rejection":"reject_output","validator_failure":"fail_run"}`) {
t.Fatalf("redacted payload omitted validation policy: %s", encoded)
}
*payload.Config.Pipelines["main"].ValidationPolicy.SemanticRejection = pipeline.SemanticRejectionFailRun
*payload.ResolvedPipeline.ConfiguredValidationPolicy.ValidatorFailure = pipeline.ValidatorFailureWarnContinue
if *effective.Config.Pipelines["main"].ValidationPolicy.SemanticRejection != pipeline.SemanticRejectionRejectOutput || *effective.ResolvedPipeline.ConfiguredValidationPolicy.ValidatorFailure != pipeline.ValidatorFailureFailRun {
t.Fatal("redacted payload aliases validation policy")
}
}
func redactionTestBinding(name string) pipeline.ModuleBinding {
return pipeline.ModuleBinding{
Module: "safe-" + name,

View File

@@ -0,0 +1,92 @@
package config
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSequenceNormalizationPreservesRuntimeAndProvenance(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name, body, pointer, want string
}{
{
name: "step identifier",
body: "steps:\n - id: ' first '\n artifacts: {spells: {extract: {module: dnd/spells}}}",
pointer: "/pipelines/session/steps/0/id",
want: "first",
},
{
name: "standalone validator",
body: "artifacts:\n spells:\n extract:\n module: dnd/spells\n validators: [{module: ' generic/valid_json '}]",
pointer: "/pipelines/session/artifacts/spells/extract/validators/0/module",
want: "generic/valid_json",
},
{
name: "nested fields and source paths",
body: `steps:
- id: first
artifacts:
" spells ":
extract:
module: " dnd/spells "
llm_profile: " local "
validators: [{module: " generic/valid_json "}]
references:
" party ": {path: "./party.txt"}
registry: {artifact: {step: " previous ", lane: " nouns "}}`,
// Map-key moves are explained at their enclosing container, where
// the original declaration and the new canonical keys coexist.
pointer: "/pipelines/session/steps",
want: "party.txt",
},
} {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "config.yml")
if err := os.WriteFile(path, []byte("version: 5\npipelines:\n session:\n input: {module: seriatim}\n "+test.body+"\n"), 0o600); err != nil {
t.Fatal(err)
}
loaded, err := Load(path, LoadOptions{})
if err != nil {
t.Fatal(err)
}
profile := loaded.Config.Pipelines["session"]
switch test.name {
case "step identifier":
if profile.Steps[0].ID != "first" {
t.Fatalf("step = %#v", profile.Steps[0])
}
case "standalone validator":
if profile.Artifacts["spells"].Extract.Validators.Validators[0].Module != test.want {
t.Fatal("validator was not normalized")
}
default:
binding := profile.Steps[0].Artifacts["spells"].Extract
if binding.Module != "dnd/spells" || binding.LLMProfile != "local" ||
binding.Validators.Validators[0].Module != "generic/valid_json" ||
binding.References["party"].Path != filepath.Join(root, "party.txt") ||
binding.References["registry"].Artifact.Step != "previous" ||
binding.References["registry"].Artifact.Lane != "nouns" {
t.Fatalf("binding = %#v", binding)
}
}
var effective, explanation bytes.Buffer
if err := loaded.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if err := loaded.WriteExplanation(&explanation, test.pointer); err != nil {
t.Fatal(err)
}
if !strings.Contains(effective.String(), test.want) ||
!strings.Contains(explanation.String(), test.want) ||
!strings.Contains(explanation.String(), path) ||
!strings.Contains(explanation.String(), "normalization") {
t.Fatalf("missing effective value or provenance:\n%s\n%s", effective.String(), explanation.String())
}
})
}
}

View File

@@ -2,7 +2,6 @@ package config
import (
"fmt"
"net/url"
"sort"
"strings"
@@ -10,15 +9,14 @@ import (
)
func (c Config) Validate() error {
c.Concurrency.recomputeStageWorkerDefaults()
if err := validatePromptKit(c.PromptKit); err != nil {
return err
}
if err := validateStateSurfaces(c); err != nil {
return err
}
if c.Concurrency.TotalLLM <= 0 {
return fmt.Errorf("total LLM concurrency must be greater than zero")
if err := validateTotalLLMConcurrency(c.Concurrency.TotalLLM); err != nil {
return err
}
if err := validateStageWorkers(c.Concurrency); err != nil {
return err
@@ -33,70 +31,45 @@ func validateStageWorkers(cfg ConcurrencyConfig) error {
}
sort.Strings(keys)
for _, key := range keys {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("concurrency.stage_workers key must not be empty")
}
if key != "extract" {
return fmt.Errorf("concurrency.stage_workers key %q is not supported", key)
if err := validateStageWorkerKey(key); err != nil {
return err
}
}
extractWorkers, ok := cfg.StageWorkers["extract"]
if !ok {
extractWorkers = cfg.TotalLLM
}
if extractWorkers < 1 || extractWorkers > cfg.TotalLLM {
return fmt.Errorf("concurrency.stage_workers.extract must be between 1 and concurrency.total_llm (%d)", cfg.TotalLLM)
}
return nil
return validateExtractWorkerCount(extractWorkers, cfg.TotalLLM)
}
func validatePromptKit(cfg PromptKitConfig) error {
if strings.TrimSpace(cfg.ProfileDir) != "" && strings.TrimSpace(cfg.ProfileFile) != "" {
return fmt.Errorf("promptkit profile_dir and profile_file are mutually exclusive")
if err := validatePromptKitProfileSources(cfg.ProfileDir, cfg.ProfileFile); err != nil {
return err
}
if err := validatePromptKitTransientRetries(cfg.TransientRetries); err != nil {
return err
}
if cfg.LocalBackend == nil {
return nil
}
endpoint := strings.TrimSpace(cfg.LocalBackend.Endpoint)
if endpoint == "" {
return fmt.Errorf("promptkit.local_backend.endpoint must not be empty when set")
}
parsed, err := url.Parse(endpoint)
if err != nil ||
(!strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https")) ||
!parsed.IsAbs() ||
parsed.Hostname() == "" ||
parsed.User != nil ||
parsed.RawQuery != "" ||
parsed.ForceQuery ||
strings.Contains(endpoint, "#") {
return fmt.Errorf("promptkit.local_backend.endpoint must be an absolute HTTP or HTTPS URL with a host and no user information, query, or fragment")
}
if cfg.LocalBackend.ConcurrencyLimit < 0 {
return fmt.Errorf("promptkit.local_backend.concurrency_limit must not be negative")
}
return nil
return validatePromptKitLocalBackend(cfg.LocalBackend.Endpoint, cfg.LocalBackend.ConcurrencyLimit)
}
func validateStateSurfaces(cfg Config) error {
if strings.TrimSpace(cfg.Output.Directory) == "" {
return fmt.Errorf("output.directory must not be empty")
if err := validateRequiredPath("output.directory", cfg.Output.Directory); err != nil {
return err
}
if strings.TrimSpace(cfg.Debug.Directory) == "" {
return fmt.Errorf("debug.directory must not be empty")
if err := validateRequiredPath("debug.directory", cfg.Debug.Directory); err != nil {
return err
}
if err := cfg.Cache.ChunkPlans.Mode.Validate(); err != nil {
return fmt.Errorf("cache.chunk_plans.mode: %w", err)
if err := validateChunkPlanCacheMode(string(cfg.Cache.ChunkPlans.Mode)); err != nil {
return err
}
for name, value := range map[string]string{
"output.directory": cfg.Output.Directory,
"cache.chunk_plans.directory": cfg.Cache.ChunkPlans.Directory,
"cache.checkpoints.directory": cfg.Cache.Checkpoints.Directory,
"debug.directory": cfg.Debug.Directory,
} {
if strings.ContainsRune(value, '\x00') {
return fmt.Errorf("%s must not contain NUL", name)
}
if err := validateOptionalPath("cache.chunk_plans.directory", cfg.Cache.ChunkPlans.Directory); err != nil {
return err
}
if err := validateOptionalPath("cache.checkpoints.directory", cfg.Cache.Checkpoints.Directory); err != nil {
return err
}
return nil
}

View File

@@ -25,7 +25,6 @@ func TestValidateConcurrencyRules(t *testing.T) {
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 0}
cfg.Concurrency.extractWorkersConfigured = true
},
want: "stage_workers.extract must be between 1",
},
@@ -34,7 +33,6 @@ func TestValidateConcurrencyRules(t *testing.T) {
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 4}
cfg.Concurrency.extractWorkersConfigured = true
},
want: "stage_workers.extract must be between 1",
},
@@ -43,7 +41,6 @@ func TestValidateConcurrencyRules(t *testing.T) {
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 1}
cfg.Concurrency.extractWorkersConfigured = true
},
},
{
@@ -51,7 +48,6 @@ func TestValidateConcurrencyRules(t *testing.T) {
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 3}
cfg.Concurrency.extractWorkersConfigured = true
},
},
{
@@ -93,6 +89,22 @@ func TestValidatePromptKitSourcesAreMutuallyExclusive(t *testing.T) {
assertValidationContains(t, cfg, "promptkit profile_dir and profile_file are mutually exclusive")
}
func TestValidatePromptKitTransientRetries(t *testing.T) {
if got := Default().PromptKit.TransientRetries; got != defaultTransientRetries {
t.Fatalf("default transient retries = %d, want %d", got, defaultTransientRetries)
}
for _, retries := range []int{0, 4} {
cfg := Default()
cfg.PromptKit.TransientRetries = retries
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() with transient retries %d error = %v", retries, err)
}
}
negative := Default()
negative.PromptKit.TransientRetries = -1
assertValidationContains(t, negative, "promptkit.transient_retries must not be negative")
}
func TestValidatePromptKitLocalBackendEndpoints(t *testing.T) {
tests := []struct {
name string

View File

@@ -4,17 +4,19 @@ import (
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestValidationPolicyFileConfigurationIsStrictAndPresenceAware(t *testing.T) {
const valid = `version: 4
const valid = `version: 5
pipelines:
main:
validation_policy:
producer_structural_failure: reject_output
semantic_rejection: fail_run
input: seriatim
input:
module: seriatim
chunk:
module: generic
validation_policy:
@@ -26,7 +28,11 @@ pipelines:
validation_policy:
semantic_rejection: reject_output
`
cfg := applyFileConfig(t, valid)
loaded, err := loadDocumentSource(configkit.Bytes("valid.yml", []byte(valid)), LoadOptions{})
if err != nil {
t.Fatal(err)
}
cfg := loaded.Config
profile := cfg.Pipelines["main"]
if profile.ValidationPolicy == nil || profile.ValidationPolicy.ProducerStructuralFailure == nil || *profile.ValidationPolicy.ProducerStructuralFailure != pipeline.ProducerStructuralFailureRejectOutput || profile.ValidationPolicy.SemanticRejection == nil || *profile.ValidationPolicy.SemanticRejection != pipeline.SemanticRejectionFailRun || profile.ValidationPolicy.ValidatorFailure != nil {
t.Fatalf("pipeline validation policy = %#v", profile.ValidationPolicy)
@@ -50,8 +56,8 @@ pipelines:
{"invalid enum", strings.Replace(valid, "semantic_rejection: fail_run", "semantic_rejection: continue", 1)},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := ParseFileConfigYAML([]byte(test.yaml)); err == nil {
t.Fatal("ParseFileConfigYAML() error = nil, want strict validation-policy rejection")
if _, err := loadDocumentSource(configkit.Bytes("invalid.yml", []byte(test.yaml)), LoadOptions{}); err == nil {
t.Fatal("loadDocumentSource() error = nil, want strict validation-policy rejection")
}
})
}

View File

@@ -3,6 +3,8 @@ package debugbundle
import (
"bytes"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
@@ -89,7 +91,7 @@ func TestSummaryWriterWritesEverySummaryArtifact(t *testing.T) {
if err := summary.WriteInvocation(Invocation{Operation: "run"}); err != nil {
t.Fatal(err)
}
if err := summary.WriteRedactedEffectiveConfig(testRedactedSummaryPayload{}); err != nil {
if err := summary.WriteEffectiveConfig(testEffectiveConfigWriter{content: "version: 5\n"}); err != nil {
t.Fatal(err)
}
if err := summary.WriteResolvedPipeline(testRedactedResolvedPipelinePayload{}); err != nil {
@@ -119,7 +121,7 @@ func TestSummaryWriterWritesEverySummaryArtifact(t *testing.T) {
for _, name := range []string{
ArtifactInvocationMetadata,
ArtifactEffectiveConfig,
ArtifactEffectiveConfigYAML,
ArtifactResolvedPipeline,
ArtifactResolvedReferences,
ArtifactCheckpointEvents,
@@ -139,6 +141,20 @@ func TestSummaryWriterWritesEverySummaryArtifact(t *testing.T) {
}
}
func TestWriteEffectiveConfigDoesNotPublishPartialRendering(t *testing.T) {
bundle, err := Allocate(t.TempDir(), testBundleRunID, time.Unix(0, 42))
if err != nil {
t.Fatal(err)
}
if err := bundle.Summary().WriteEffectiveConfig(testEffectiveConfigWriter{err: errors.New("render failed")}); err == nil {
t.Fatal("WriteEffectiveConfig() error = nil, want render failure")
}
path := filepath.Join(bundle.SummaryRoot(), ArtifactEffectiveConfigYAML)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("effective configuration artifact exists after rendering failure: %v", err)
}
}
func TestWriteInvocationPreservesReasoningEffortOverrideStates(t *testing.T) {
replacement := "focused"
cleared := ""
@@ -216,10 +232,17 @@ func TestSummaryWriterInternalWritesConfineArtifacts(t *testing.T) {
}
}
type testRedactedSummaryPayload struct{}
type testEffectiveConfigWriter struct {
content string
err error
}
func (testRedactedSummaryPayload) RedactedSummaryPayload() any {
return map[string]any{"redacted": true}
func (writer testEffectiveConfigWriter) WriteEffective(output io.Writer) error {
if writer.err != nil {
return writer.err
}
_, err := io.WriteString(output, writer.content)
return err
}
type testRedactedResolvedPipelinePayload struct{}

View File

@@ -1,7 +1,9 @@
package debugbundle
import (
"bytes"
"fmt"
"io"
"strings"
"time"
@@ -12,19 +14,19 @@ import (
)
const (
ArtifactInvocationMetadata = "invocation.json"
ArtifactEffectiveConfig = "effective-config.json"
ArtifactResolvedPipeline = "resolved-pipeline.json"
ArtifactResolvedReferences = "resolved-references.json"
ArtifactCheckpointEvents = "checkpoint-events.json"
ArtifactRunManifest = "run-manifest.json"
ArtifactChunkPlan = "chunk-plan.json"
ArtifactRunReport = "run-report.json"
ArtifactDiagnostics = "final-diagnostics.json"
ArtifactErrorLog = "error.log"
ArtifactInvocationMetadata = "invocation.json"
ArtifactEffectiveConfigYAML = "effective-config.yml"
ArtifactResolvedPipeline = "resolved-pipeline.json"
ArtifactResolvedReferences = "resolved-references.json"
ArtifactCheckpointEvents = "checkpoint-events.json"
ArtifactRunManifest = "run-manifest.json"
ArtifactChunkPlan = "chunk-plan.json"
ArtifactRunReport = "run-report.json"
ArtifactDiagnostics = "final-diagnostics.json"
ArtifactErrorLog = "error.log"
)
type RedactedSummaryPayload interface{ RedactedSummaryPayload() any }
type EffectiveConfigWriter interface{ WriteEffective(io.Writer) error }
type RedactedResolvedPipelinePayload interface {
RedactedResolvedPipelinePayload() pipeline.ResolvedPipeline
}
@@ -37,6 +39,7 @@ type Invocation struct {
InputPath string `json:"input_path,omitempty"`
ConfigPath string `json:"config_path,omitempty"`
ConfigSource string `json:"config_source,omitempty"`
ConfigOverlay string `json:"config_overlay,omitempty"`
OnlyLanes []string `json:"only_lanes,omitempty"`
ChunkCacheOverride string `json:"chunk_cache_override,omitempty"`
ReasoningEffortOverride *string `json:"reasoning_effort_override,omitempty"`
@@ -80,11 +83,15 @@ func (w *SummaryWriter) WriteInvocation(payload Invocation) error {
}
return w.writeJSON(ArtifactInvocationMetadata, payload)
}
func (w *SummaryWriter) WriteRedactedEffectiveConfig(payload RedactedSummaryPayload) error {
func (w *SummaryWriter) WriteEffectiveConfig(payload EffectiveConfigWriter) error {
if payload == nil {
return fmt.Errorf("redacted summary payload must not be nil")
return fmt.Errorf("effective configuration writer must not be nil")
}
return w.writeJSON(ArtifactEffectiveConfig, payload.RedactedSummaryPayload())
var rendered bytes.Buffer
if err := payload.WriteEffective(&rendered); err != nil {
return fmt.Errorf("render effective configuration: %w", err)
}
return w.writeBytes(ArtifactEffectiveConfigYAML, rendered.Bytes())
}
func (w *SummaryWriter) WriteResolvedPipeline(payload RedactedResolvedPipelinePayload) error {
if payload == nil {

View File

@@ -2,7 +2,10 @@
package fileio
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path"
@@ -92,6 +95,16 @@ func WriteJSON(root, name string, payload any, dirMode, fileMode os.FileMode) er
return WriteBytes(root, name, append(data, '\n'), dirMode, fileMode)
}
// WriteJSONWithProcessPermissions writes indented JSON beneath root using the
// process and filesystem creation policy for new directories and files.
func WriteJSONWithProcessPermissions(root, name string, payload any) error {
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Errorf("marshal artifact %q: %w", name, err)
}
return WriteBytesWithProcessPermissions(root, name, append(data, '\n'))
}
func WriteBytes(root, name string, data []byte, dirMode, fileMode os.FileMode) error {
target, err := SafePath(root, name)
if err != nil {
@@ -109,6 +122,26 @@ func WriteBytes(root, name string, data []byte, dirMode, fileMode os.FileMode) e
return nil
}
// WriteBytesWithProcessPermissions atomically writes data beneath root while
// allowing the process umask and filesystem default ACLs to select effective
// permissions for newly created entries.
func WriteBytesWithProcessPermissions(root, name string, data []byte) error {
target, err := SafePath(root, name)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(target), 0o777); err != nil {
return fmt.Errorf("write artifact %q: %w", name, err)
}
if err := rejectSymlinkComponents(root, name); err != nil {
return err
}
if err := writeAtomicWithProcessPermissions(target, data); err != nil {
return fmt.Errorf("write artifact %q: %w", name, err)
}
return nil
}
func rejectSymlinkComponents(root, name string) error {
absRoot, err := filepath.Abs(root)
if err != nil {
@@ -163,3 +196,51 @@ func writeAtomic(target string, data []byte, fileMode os.FileMode) error {
keep = false
return nil
}
func writeAtomicWithProcessPermissions(target string, data []byte) error {
directory := filepath.Dir(target)
base := filepath.Base(target)
seen := make(map[string]struct{}, 32)
for attempt := 0; attempt < 32; attempt++ {
suffix := make([]byte, 16)
if _, err := rand.Read(suffix); err != nil {
return fmt.Errorf("generate temporary file name: %w", err)
}
temporaryPath := filepath.Join(directory, "."+base+".tmp-"+hex.EncodeToString(suffix))
if _, exists := seen[temporaryPath]; exists {
continue
}
seen[temporaryPath] = struct{}{}
temporary, err := os.OpenFile(temporaryPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o666)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return err
}
return writeAndPublish(temporary, temporaryPath, target, data)
}
return fmt.Errorf("create temporary file for %q: exhausted unique names", target)
}
func writeAndPublish(temporary *os.File, temporaryPath, target string, data []byte) error {
keep := true
defer func() {
if keep {
_ = os.Remove(temporaryPath)
}
}()
if _, err := temporary.Write(data); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryPath, target); err != nil {
return err
}
keep = false
return nil
}

View File

@@ -0,0 +1,131 @@
//go:build linux || darwin
package fileio
import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
)
func TestWriteBytesWithProcessPermissionsHonorsUmask(t *testing.T) {
for _, test := range []struct {
name string
umask int
dirMode os.FileMode
fileMode os.FileMode
}{
{name: "private", umask: 0o077, dirMode: 0o700, fileMode: 0o600},
{name: "group", umask: 0o007, dirMode: 0o770, fileMode: 0o660},
} {
t.Run(test.name, func(t *testing.T) {
previousUmask := syscall.Umask(test.umask)
defer syscall.Umask(previousUmask)
root := filepath.Join(t.TempDir(), "cache")
if err := WriteBytesWithProcessPermissions(root, "nested/value", []byte("value")); err != nil {
t.Fatal(err)
}
for path, want := range map[string]os.FileMode{
root: test.dirMode,
filepath.Join(root, "nested"): test.dirMode,
filepath.Join(root, "nested", "value"): test.fileMode,
} {
assertPermissions(t, path, want)
}
assertNoTemporarySiblings(t, filepath.Join(root, "nested"))
})
}
}
func TestWriteBytesWithProcessPermissionsLeavesExistingRootUntouched(t *testing.T) {
previousUmask := syscall.Umask(0o077)
defer syscall.Umask(previousUmask)
root := filepath.Join(t.TempDir(), "cache")
syscall.Umask(0)
if err := os.Mkdir(root, 0o751); err != nil {
t.Fatal(err)
}
syscall.Umask(0o077)
if err := WriteBytesWithProcessPermissions(root, "nested/value", []byte("value")); err != nil {
t.Fatal(err)
}
assertPermissions(t, root, 0o751)
}
func TestWriteBytesWithProcessPermissionsReplacementUsesCurrentPolicy(t *testing.T) {
root := filepath.Join(t.TempDir(), "cache")
previousUmask := syscall.Umask(0o077)
defer syscall.Umask(previousUmask)
if err := WriteBytesWithProcessPermissions(root, "value", []byte("before")); err != nil {
t.Fatal(err)
}
before, err := os.Stat(filepath.Join(root, "value"))
if err != nil {
t.Fatal(err)
}
syscall.Umask(0o007)
if err := WriteBytesWithProcessPermissions(root, "value", []byte("after")); err != nil {
t.Fatal(err)
}
after, err := os.Stat(filepath.Join(root, "value"))
if err != nil {
t.Fatal(err)
}
if before.Sys().(*syscall.Stat_t).Ino == after.Sys().(*syscall.Stat_t).Ino {
t.Fatal("replacement retained the old inode")
}
assertPermissions(t, filepath.Join(root, "value"), 0o660)
data, err := os.ReadFile(filepath.Join(root, "value"))
if err != nil {
t.Fatal(err)
}
if string(data) != "after" {
t.Fatalf("replacement content = %q, want %q", data, "after")
}
}
func TestWriteJSONWithProcessPermissionsFormatsJSON(t *testing.T) {
previousUmask := syscall.Umask(0o077)
defer syscall.Umask(previousUmask)
root := filepath.Join(t.TempDir(), "cache")
if err := WriteJSONWithProcessPermissions(root, "value.json", map[string]string{"value": "ok"}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(root, "value.json"))
if err != nil {
t.Fatal(err)
}
if string(data) != "{\n \"value\": \"ok\"\n}\n" {
t.Fatalf("JSON = %q", data)
}
}
func assertPermissions(t *testing.T, path string, want os.FileMode) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != want {
t.Fatalf("%s mode = %#o, want %#o", path, got, want)
}
}
func assertNoTemporarySiblings(t *testing.T, directory string) {
t.Helper()
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.Contains(entry.Name(), ".tmp-") {
t.Fatalf("temporary file remains: %s", entry.Name())
}
}
}

View File

@@ -0,0 +1,98 @@
//go:build linux || darwin
package checkpoint
import (
"os"
"path/filepath"
"syscall"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestFilesystemCheckpointPermissionsFollowUmask(t *testing.T) {
for _, test := range []struct {
name string
umask int
dirMode os.FileMode
fileMode os.FileMode
}{
{name: "private", umask: 0o077, dirMode: 0o700, fileMode: 0o600},
{name: "group", umask: 0o007, dirMode: 0o770, fileMode: 0o660},
} {
t.Run(test.name, func(t *testing.T) {
previousUmask := syscall.Umask(test.umask)
defer syscall.Umask(previousUmask)
root := filepath.Join(t.TempDir(), "checkpoints")
identity, err := NewIdentity(representativeIdentityInput())
if err != nil {
t.Fatal(err)
}
recorder, err := NewFilesystemRecorder(root, identity)
if err != nil {
t.Fatal(err)
}
if err := recorder.SourceSucceeded("source-module", sourceDocumentForPermissions()); err != nil {
t.Fatal(err)
}
relative, err := identity.RelativePath()
if err != nil {
t.Fatal(err)
}
identityRoot := filepath.Join(root, filepath.FromSlash(relative))
sourceRoot := filepath.Join(identityRoot, "source")
for path, want := range map[string]os.FileMode{
root: test.dirMode,
identityRoot: test.dirMode,
sourceRoot: test.dirMode,
filepath.Join(sourceRoot, "manifest.json"): test.fileMode,
filepath.Join(sourceRoot, "source-document.json"): test.fileMode,
} {
assertCheckpointPermissions(t, path, want)
}
})
}
}
func TestFilesystemCheckpointLeavesExistingRootUntouched(t *testing.T) {
previousUmask := syscall.Umask(0o077)
defer syscall.Umask(previousUmask)
root := filepath.Join(t.TempDir(), "checkpoints")
syscall.Umask(0)
if err := os.Mkdir(root, 0o751); err != nil {
t.Fatal(err)
}
syscall.Umask(0o077)
identity, err := NewIdentity(representativeIdentityInput())
if err != nil {
t.Fatal(err)
}
recorder, err := NewFilesystemRecorder(root, identity)
if err != nil {
t.Fatal(err)
}
if err := recorder.SourceSucceeded("source-module", sourceDocumentForPermissions()); err != nil {
t.Fatal(err)
}
assertCheckpointPermissions(t, root, 0o751)
}
func sourceDocumentForPermissions() *source.SourceDocument {
document := checkpointDocument()
return &document
}
func assertCheckpointPermissions(t *testing.T, path string, want os.FileMode) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != want {
t.Fatalf("%s mode = %#o, want %#o", path, got, want)
}
}

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"unicode/utf8"
@@ -106,27 +105,6 @@ func TestFilesystemCheckpointRoundTripsAllStages(t *testing.T) {
})
}
if runtime.GOOS != "windows" {
t.Run("restrictive permissions", func(t *testing.T) {
root := filepath.Join(fixture.root, mustRelativePath(t, fixture.identity))
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
want := os.FileMode(0o600)
if info.IsDir() {
want = 0o700
}
if got := info.Mode().Perm(); got != want {
t.Errorf("%s permissions = %o, want %o", path, got, want)
}
return nil
})
if err != nil {
t.Fatal(err)
}
})
}
}
func TestFilesystemCheckpointRejectsMissingAndCorruptState(t *testing.T) {

View File

@@ -217,7 +217,7 @@ func (r *FilesystemRecorder) writeJSON(name string, payload any) error {
if r == nil || strings.TrimSpace(r.root) == "" {
return nil
}
return fileio.WriteJSON(r.root, name, payload, 0o700, 0o600)
return fileio.WriteJSONWithProcessPermissions(r.root, name, payload)
}
func (r *FilesystemRecorder) timestamp() time.Time {

View File

@@ -140,7 +140,7 @@ func (s *filesystemStore) openRoot(create bool) (*os.Root, error) {
return nil, fmt.Errorf("chunk plan store must not be nil")
}
if create {
if err := os.MkdirAll(s.root, 0o700); err != nil {
if err := os.MkdirAll(s.root, 0o777); err != nil {
return nil, err
}
}
@@ -148,19 +148,6 @@ func (s *filesystemStore) openRoot(create bool) (*os.Root, error) {
if err != nil {
return nil, err
}
if !create {
return root, nil
}
rootDirectory, err := root.Open(".")
if err != nil {
_ = root.Close()
return nil, err
}
defer rootDirectory.Close()
if err := rootDirectory.Chmod(0o700); err != nil {
_ = root.Close()
return nil, err
}
return root, nil
}
@@ -207,7 +194,7 @@ func openDigestRootWithHooks(root *os.Root, digestDir string, create bool, hooks
if !create {
return nil, entryMissing, nil
}
if err := root.Mkdir(digestDir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
if err := root.Mkdir(digestDir, 0o777); err != nil && !errors.Is(err, os.ErrExist) {
return nil, entryPresent, err
}
continue
@@ -238,22 +225,6 @@ func openDigestRootWithHooks(root *os.Root, digestDir string, create bool, hooks
}
continue
}
if create {
directory, openErr := digestRoot.Open(".")
if openErr != nil {
_ = digestRoot.Close()
return nil, entryPresent, openErr
}
chmodErr := directory.Chmod(0o700)
closeErr := directory.Close()
if chmodErr != nil || closeErr != nil {
_ = digestRoot.Close()
if chmodErr != nil {
return nil, entryPresent, chmodErr
}
return nil, entryPresent, closeErr
}
}
return digestRoot, entryPresent, nil
}
}
@@ -433,10 +404,6 @@ func writeAtomicWithHooks(root *os.Root, target string, data []byte, hooks atomi
_ = root.Remove(tempPath)
}
}()
if err := temp.Chmod(0o600); err != nil {
_ = temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return err
@@ -467,7 +434,7 @@ func createTemporaryFile(root *os.Root, target string) (*os.File, string, error)
return nil, "", err
}
path := strings.TrimSuffix(target, planFileName) + ".plan.json.tmp-" + hex.EncodeToString(suffix[:])
file, err := root.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
file, err := root.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o666)
if errors.Is(err, os.ErrExist) {
continue
}

View File

@@ -0,0 +1,76 @@
//go:build linux || darwin
package chunkplan
import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
)
func TestFilesystemStorePermissions(t *testing.T) {
for _, test := range []struct {
name string
umask int
dirMode os.FileMode
fileMode os.FileMode
}{
{name: "private", umask: 0o077, dirMode: 0o700, fileMode: 0o600},
{name: "group", umask: 0o007, dirMode: 0o770, fileMode: 0o660},
} {
t.Run(test.name, func(t *testing.T) {
previousUmask := syscall.Umask(test.umask)
defer syscall.Umask(previousUmask)
root := filepath.Join(t.TempDir(), "plans")
store := newStore(t, root)
if err := store.Save(testRecord(t, 1)); err != nil {
t.Fatal(err)
}
digestDir := filepath.Join(root, strings.TrimPrefix(testSourceDigest, "sha256:"))
for path, want := range map[string]os.FileMode{
root: test.dirMode,
digestDir: test.dirMode,
filepath.Join(digestDir, "plan.json"): test.fileMode,
} {
assertStorePermissions(t, path, want)
}
})
}
}
func TestFilesystemStoreLeavesExistingDirectoriesUntouched(t *testing.T) {
previousUmask := syscall.Umask(0o077)
defer syscall.Umask(previousUmask)
root := filepath.Join(t.TempDir(), "plans")
digestDir := filepath.Join(root, strings.TrimPrefix(testSourceDigest, "sha256:"))
syscall.Umask(0)
if err := os.Mkdir(root, 0o751); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(digestDir, 0o753); err != nil {
t.Fatal(err)
}
syscall.Umask(0o077)
store := newStore(t, root)
if err := store.Save(testRecord(t, 1)); err != nil {
t.Fatal(err)
}
assertStorePermissions(t, root, 0o751)
assertStorePermissions(t, digestDir, 0o753)
}
func assertStorePermissions(t *testing.T, path string, want os.FileMode) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != want {
t.Fatalf("%s mode = %#o, want %#o", path, got, want)
}
}

View File

@@ -61,28 +61,6 @@ func TestFilesystemStoreRoundTripAndExactPath(t *testing.T) {
}
}
func TestFilesystemStorePermissions(t *testing.T) {
root := filepath.Join(t.TempDir(), "plans")
store := newStore(t, root)
if err := store.Save(testRecord(t, 1)); err != nil {
t.Fatal(err)
}
digestDir := filepath.Join(root, strings.TrimPrefix(testSourceDigest, "sha256:"))
for path, want := range map[string]os.FileMode{
root: 0o700,
digestDir: 0o700,
filepath.Join(digestDir, "plan.json"): 0o600,
} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != want {
t.Fatalf("%s mode = %04o, want %04o", path, got, want)
}
}
}
func TestFilesystemStoreMissingAndOperationalErrors(t *testing.T) {
root := filepath.Join(t.TempDir(), "plans")
store := newStore(t, root)

View File

@@ -21,15 +21,45 @@ type StructuredCompletionRequest struct {
}
type StructuredCompletionResponse struct {
Content json.RawMessage `json:"content"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
RepairAttempts int `json:"repair_attempts,omitempty"`
Debug *LLMDebugMaterial `json:"debug,omitempty"`
Content json.RawMessage `json:"content"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
RepairAttempts int `json:"repair_attempts,omitempty"`
Debug *LLMDebugMaterial `json:"debug,omitempty"`
Execution *LLMExecutionSummary `json:"execution,omitempty"`
}
type LLMObservedTokenCount struct {
Value int `json:"value"`
Known bool `json:"known"`
Complete bool `json:"complete"`
}
type LLMObservedTokenUsage struct {
PromptTokens LLMObservedTokenCount `json:"prompt_tokens"`
CompletionTokens LLMObservedTokenCount `json:"completion_tokens"`
TotalTokens LLMObservedTokenCount `json:"total_tokens"`
CachedTokens LLMObservedTokenCount `json:"cached_tokens"`
CacheWriteTokens LLMObservedTokenCount `json:"cache_write_tokens"`
}
type LLMExecutionSummary struct {
Attempts uint64 `json:"attempts"`
TransientRetries uint64 `json:"transient_retries"`
RepairCalls uint64 `json:"repair_calls"`
CapacityWaitMS int64 `json:"capacity_wait_ms"`
GenerationMS int64 `json:"generation_ms"`
RetryDelayMS int64 `json:"retry_delay_ms"`
DurationMS int64 `json:"duration_ms"`
Outcome string `json:"outcome"`
FailureKind string `json:"failure_kind"`
StatusCode int `json:"status_code"`
RetriesExhausted bool `json:"retries_exhausted"`
Usage LLMObservedTokenUsage `json:"usage"`
}
type StructuredLLMClient interface {

View File

@@ -13,16 +13,42 @@ var ErrLLMCapacityExceeded = errors.New("LLM capacity exceeded")
// ErrLLMGeneration identifies a provider generation failure.
var ErrLLMGeneration = errors.New("LLM generation failed")
type LLMGenerationFailureKind string
const (
LLMGenerationFailureGeneration LLMGenerationFailureKind = "generation"
LLMGenerationFailureTransport LLMGenerationFailureKind = "transport"
LLMGenerationFailureProviderStatus LLMGenerationFailureKind = "provider_status"
LLMGenerationFailureMalformedResponse LLMGenerationFailureKind = "malformed_response"
)
type LLMGenerationMetadata struct {
FailureKind LLMGenerationFailureKind
RetriesExhausted bool
Attempts uint64
}
type LLMGenerationError struct {
status int
diagnostic string
status int
diagnostic string
failureKind LLMGenerationFailureKind
exhausted bool
attempts uint64
}
func NewLLMGenerationError(status int, diagnostic string) *LLMGenerationError {
return NewLLMGenerationErrorWithMetadata(status, diagnostic, LLMGenerationMetadata{})
}
func NewLLMGenerationErrorWithMetadata(status int, diagnostic string, metadata LLMGenerationMetadata) *LLMGenerationError {
if status < 0 {
status = 0
}
return &LLMGenerationError{status: status, diagnostic: diagnostic}
kind := metadata.FailureKind
if kind == "" {
kind = LLMGenerationFailureGeneration
}
return &LLMGenerationError{status: status, diagnostic: diagnostic, failureKind: kind, exhausted: metadata.RetriesExhausted, attempts: metadata.Attempts}
}
func (e *LLMGenerationError) Error() string {
if e == nil || e.diagnostic == "" {
@@ -37,3 +63,21 @@ func (e *LLMGenerationError) StatusCode() int {
}
return e.status
}
func (e *LLMGenerationError) FailureKind() LLMGenerationFailureKind {
if e == nil || e.failureKind == "" {
return LLMGenerationFailureGeneration
}
return e.failureKind
}
func (e *LLMGenerationError) RetriesExhausted() bool {
return e != nil && e.exhausted
}
func (e *LLMGenerationError) Attempts() uint64 {
if e == nil {
return 0
}
return e.attempts
}

View File

@@ -0,0 +1,22 @@
package contracts
import (
"errors"
"testing"
)
func TestLLMGenerationErrorPreservesApplicationMetadata(t *testing.T) {
legacy := NewLLMGenerationError(503, "provider failure")
if !errors.Is(legacy, ErrLLMGeneration) || legacy.StatusCode() != 503 || legacy.FailureKind() != LLMGenerationFailureGeneration || legacy.RetriesExhausted() || legacy.Attempts() != 0 {
t.Fatalf("legacy generation error = %#v", legacy)
}
err := NewLLMGenerationErrorWithMetadata(429, "provider failure", LLMGenerationMetadata{
FailureKind: LLMGenerationFailureProviderStatus,
RetriesExhausted: true,
Attempts: 3,
})
if !errors.Is(err, ErrLLMGeneration) || err.StatusCode() != 429 || err.FailureKind() != LLMGenerationFailureProviderStatus || !err.RetriesExhausted() || err.Attempts() != 3 {
t.Fatalf("generation error = %#v", err)
}
}

View File

@@ -26,15 +26,16 @@ type PromptKitLocalBackendConfig struct {
}
type PromptKitClientConfig struct {
ProfileDir string
ProfileFile string
LocalBackend *PromptKitLocalBackendConfig
Assets *AssetRegistry
Timeout time.Duration
HTTPClient *http.Client
EngineOptions []promptkit.Option
Recorder *LLMProfileRecorder
ReasoningEffort *string
ProfileDir string
ProfileFile string
TransientRetries *int
LocalBackend *PromptKitLocalBackendConfig
Assets *AssetRegistry
Timeout time.Duration
HTTPClient *http.Client
EngineOptions []promptkit.Option
Recorder *LLMProfileRecorder
ReasoningEffort *string
}
type PromptKitClient struct {
@@ -79,9 +80,10 @@ func NewPromptKitClient(cfg PromptKitClientConfig) (*PromptKitClient, error) {
options = append(options, cfg.EngineOptions...)
engine, err := promptkit.NewEngine(promptkit.Config{
ProfileDir: profileSource.ProfileDir,
Timeout: cfg.Timeout,
HTTPClient: cfg.HTTPClient,
ProfileDir: profileSource.ProfileDir,
Timeout: cfg.Timeout,
HTTPClient: cfg.HTTPClient,
TransientRetries: cfg.TransientRetries,
}, options...)
if err != nil {
return nil, fmt.Errorf("create PromptKit engine: %w", err)
@@ -128,10 +130,10 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
return contracts.StructuredCompletionResponse{}, fmt.Errorf("structured completion prompt_id must not be empty")
}
sessionID := strings.TrimSpace(req.SessionID)
var execution *promptkit.ExecutionTargetOverride
var targetOverride *promptkit.ExecutionTargetOverride
if c.reasoningEffort != nil {
reasoningEffort := *c.reasoningEffort
execution = &promptkit.ExecutionTargetOverride{
targetOverride = &promptkit.ExecutionTargetOverride{
ReasoningEffort: &reasoningEffort,
}
}
@@ -143,9 +145,15 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
SessionID: sessionID,
Inputs: promptKitInputs(req.Inputs),
Vars: promptKitVars(req, sessionID),
Execution: execution,
Execution: targetOverride,
AppendedMessages: appendedMessages,
}
var executionSummary *contracts.LLMExecutionSummary
runReq.Observer = func(event promptkit.ExecutionEvent) {
if event.Kind == promptkit.ExecutionFinished {
executionSummary = promptKitExecutionSummary(event)
}
}
if req.StructuredOutputRepairAttempts != nil {
inspection, err := c.engine.InspectPrompt(ctx, promptID, strings.TrimSpace(req.PromptVersion))
if err != nil {
@@ -170,12 +178,12 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
result, err := c.engine.RunPrepared(ctx, prepared)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return contracts.StructuredCompletionResponse{}, ctxErr
return promptKitResponseWithExecution(contracts.StructuredCompletionResponse{}, executionSummary), ctxErr
}
if errors.Is(err, promptkit.ErrCapacityExceeded) {
var capacityErr *promptkit.CapacityError
if errors.As(err, &capacityErr) && strings.TrimSpace(capacityErr.BackendID) != "" {
return contracts.StructuredCompletionResponse{}, fmt.Errorf(
return promptKitResponseWithExecution(contracts.StructuredCompletionResponse{}, executionSummary), fmt.Errorf(
"run PromptKit prompt %q on backend %q: %w: %v",
promptID,
strings.TrimSpace(capacityErr.BackendID),
@@ -183,7 +191,7 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
redactPromptKitError(err),
)
}
return contracts.StructuredCompletionResponse{}, fmt.Errorf(
return promptKitResponseWithExecution(contracts.StructuredCompletionResponse{}, executionSummary), fmt.Errorf(
"run PromptKit prompt %q: %w: %v",
promptID,
contracts.ErrLLMCapacityExceeded,
@@ -192,20 +200,26 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
}
if errors.Is(err, promptkit.ErrLLMGenerate) {
var generationErr *promptkit.GenerationError
var exhaustedErr *promptkit.RetryExhaustedError
status := 0
metadata := contracts.LLMGenerationMetadata{FailureKind: promptKitGenerationFailureKind(err)}
if errors.As(err, &exhaustedErr) {
metadata.RetriesExhausted = true
metadata.Attempts = exhaustedErr.Attempts()
}
response := contracts.StructuredCompletionResponse{Debug: &contracts.LLMDebugMaterial{Prompt: promptKitDebugPrompt(&preparedDetails)}}
if errors.As(err, &generationErr) {
status = generationErr.StatusCode()
response.Debug.Response = promptKitDebugGenerationError(&preparedDetails, generationErr)
}
return response, contracts.NewLLMGenerationError(status, fmt.Sprintf("run PromptKit prompt %q: %v", promptID, redactPromptKitError(err)))
return promptKitResponseWithExecution(response, executionSummary), contracts.NewLLMGenerationErrorWithMetadata(status, fmt.Sprintf("run PromptKit prompt %q: %v", promptID, redactPromptKitError(err)), metadata)
}
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %v", promptID, redactPromptKitError(err))
return promptKitResponseWithExecution(contracts.StructuredCompletionResponse{}, executionSummary), fmt.Errorf("run PromptKit prompt %q: %v", promptID, redactPromptKitError(err))
}
if result == nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %w: empty result", promptID, contracts.ErrInvalidStructuredOutput)
return promptKitResponseWithExecution(contracts.StructuredCompletionResponse{}, executionSummary), fmt.Errorf("run PromptKit prompt %q: %w: empty result", promptID, contracts.ErrInvalidStructuredOutput)
}
response := c.responseFromResult(result, &preparedDetails)
response := promptKitResponseWithExecution(c.responseFromResult(result, &preparedDetails), executionSummary)
if result.Validation.Status == promptkit.ValidationFailed || !result.Validation.IsValid {
return response, fmt.Errorf("run PromptKit prompt %q: %w: validation failed: %s", promptID, contracts.ErrInvalidStructuredOutput, strings.Join(result.Validation.Errors, "; "))
}
@@ -218,6 +232,61 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
return response, nil
}
func promptKitResponseWithExecution(response contracts.StructuredCompletionResponse, execution *contracts.LLMExecutionSummary) contracts.StructuredCompletionResponse {
response.Execution = execution
return response
}
func promptKitExecutionSummary(event promptkit.ExecutionEvent) *contracts.LLMExecutionSummary {
count := func(value promptkit.ObservedTokenCount) contracts.LLMObservedTokenCount {
return contracts.LLMObservedTokenCount{Value: value.Value, Known: value.Known, Complete: value.Complete}
}
return &contracts.LLMExecutionSummary{
Attempts: event.Attempts, TransientRetries: event.TransientRetries, RepairCalls: event.RepairCalls,
CapacityWaitMS: event.CapacityWait.Milliseconds(), GenerationMS: event.GenerationDuration.Milliseconds(), RetryDelayMS: event.RetryDelay.Milliseconds(), DurationMS: event.Duration.Milliseconds(),
Outcome: promptKitExecutionOutcome(event.Outcome), FailureKind: promptKitExecutionFailure(event.Failure), StatusCode: event.StatusCode, RetriesExhausted: event.RetriesExhausted,
Usage: contracts.LLMObservedTokenUsage{PromptTokens: count(event.Usage.PromptTokens), CompletionTokens: count(event.Usage.CompletionTokens), TotalTokens: count(event.Usage.TotalTokens), CachedTokens: count(event.Usage.CachedTokens), CacheWriteTokens: count(event.Usage.CacheWriteTokens)},
}
}
func promptKitExecutionOutcome(value promptkit.ExecutionOutcome) string {
switch value {
case promptkit.ExecutionOutcomeSuccess:
return "success"
case promptkit.ExecutionOutcomeInvalidOutput:
return "invalid_output"
case promptkit.ExecutionOutcomeError:
return "error"
default:
return "other"
}
}
func promptKitExecutionFailure(value promptkit.ExecutionFailureKind) string {
switch value {
case promptkit.ExecutionFailureNone:
return ""
case promptkit.ExecutionFailureCanceled, promptkit.ExecutionFailureDeadline, promptkit.ExecutionFailureTransport, promptkit.ExecutionFailureProviderStatus, promptkit.ExecutionFailureMalformedResponse, promptkit.ExecutionFailureCapacity, promptkit.ExecutionFailureInvalidConfig, promptkit.ExecutionFailureInvalidRequest, promptkit.ExecutionFailurePromptNotFound, promptkit.ExecutionFailureProfileNotFound, promptkit.ExecutionFailurePromptLoad, promptkit.ExecutionFailureProfileLoad, promptkit.ExecutionFailureArtifactLoad, promptkit.ExecutionFailurePromptRender, promptkit.ExecutionFailureValidation, promptkit.ExecutionFailureGeneration:
return string(value)
default:
return "other"
}
}
func promptKitGenerationFailureKind(err error) contracts.LLMGenerationFailureKind {
switch {
case errors.Is(err, promptkit.ErrTransport):
return contracts.LLMGenerationFailureTransport
case errors.Is(err, promptkit.ErrMalformedResponse):
return contracts.LLMGenerationFailureMalformedResponse
}
var generationErr *promptkit.GenerationError
if errors.As(err, &generationErr) && generationErr.StatusCode() != 0 {
return contracts.LLMGenerationFailureProviderStatus
}
return contracts.LLMGenerationFailureGeneration
}
func promptKitCorrectionMessages(correction *contracts.SemanticCorrection) ([]promptkit.RenderedMessage, error) {
owned, err := contracts.CloneSemanticCorrection(correction)
if err != nil {

View File

@@ -55,6 +55,10 @@ func TestPromptKitClientMapsPromptRequestAndUnmarshalsOutput(t *testing.T) {
if resp.PromptTokens != 11 || resp.CompletionTokens != 7 || resp.TotalTokens != 18 {
t.Fatalf("usage = %#v, want mapped token counts", resp)
}
if resp.Execution == nil || resp.Execution.Outcome != "success" || resp.Execution.Attempts != 1 ||
!resp.Execution.Usage.PromptTokens.Known || resp.Execution.Usage.PromptTokens.Value != 11 {
t.Fatalf("execution summary = %#v", resp.Execution)
}
if resp.Debug == nil || resp.Debug.Prompt == nil {
t.Fatalf("debug prompt = nil, want prepared prompt material")
}
@@ -971,6 +975,83 @@ model: local-model
}
}
func TestPromptKitClientAppliesConfiguredBuiltInTransientRetries(t *testing.T) {
for _, test := range []struct {
name string
retries int
statuses []int
wantCalls int32
wantErr bool
}{
{name: "eligible failure then success", retries: 1, statuses: []int{http.StatusServiceUnavailable, http.StatusOK}, wantCalls: 2},
{name: "disabled", retries: 0, statuses: []int{http.StatusServiceUnavailable}, wantCalls: 1, wantErr: true},
{name: "exhausted", retries: 1, statuses: []int{http.StatusServiceUnavailable, http.StatusServiceUnavailable}, wantCalls: 2, wantErr: true},
{name: "noneligible client error", retries: 2, statuses: []int{http.StatusBadRequest}, wantCalls: 1, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
index := calls.Add(1) - 1
if int(index) >= len(test.statuses) {
t.Errorf("unexpected HTTP attempt %d", index+1)
w.WriteHeader(http.StatusInternalServerError)
return
}
status := test.statuses[index]
w.Header().Set("Content-Type", "application/json")
if status != http.StatusOK {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"error":{"message":"provider failure"}}`))
return
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"ok\":true}"}}]}`))
}))
defer server.Close()
profilePath := filepath.Join(t.TempDir(), "profile.yml")
if err := os.WriteFile(profilePath, []byte("id: retry-profile\nendpoint: "+server.URL+"/v1\nmodel: retry-model\n"), 0o600); err != nil {
t.Fatal(err)
}
client, err := NewPromptKitClient(PromptKitClientConfig{
Assets: newTestPromptKitAssets(t),
ProfileFile: profilePath,
TransientRetries: &test.retries,
})
if err != nil {
t.Fatal(err)
}
var out map[string]any
_, err = client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{
PromptID: "adapter.test",
ProfileID: "retry-profile",
SessionID: "retry-test",
Inputs: contracts.LLMInputSet{
"transcript": contracts.NewLLMInputMaterial("transcript", "application/json", []byte(`{"source":true}`), "", ""),
},
}, &out)
if (err != nil) != test.wantErr {
t.Fatalf("CompleteStructured() error = %v, want error=%t", err, test.wantErr)
}
if test.wantErr {
var generation *contracts.LLMGenerationError
if !errors.As(err, &generation) || generation.FailureKind() != contracts.LLMGenerationFailureProviderStatus {
t.Fatalf("generation error = %#v, want provider-status classification", generation)
}
wantExhausted := test.name == "exhausted"
if generation.RetriesExhausted() != wantExhausted {
t.Fatalf("retries exhausted = %t, want %t", generation.RetriesExhausted(), wantExhausted)
}
if wantExhausted && (generation.Attempts() != 2 || generation.StatusCode() != http.StatusServiceUnavailable) {
t.Fatalf("exhausted generation = %#v", generation)
}
}
if got := calls.Load(); got != test.wantCalls {
t.Fatalf("HTTP calls = %d, want %d", got, test.wantCalls)
}
})
}
}
func TestLLMProfileRecorderDistinguishesEffectiveTargets(t *testing.T) {
recorder := NewLLMProfileRecorder()
for _, profile := range []artifacts.LLMProfileManifest{
@@ -1309,6 +1390,51 @@ func TestPromptKitClientForwardsConfiguredTransportTimeout(t *testing.T) {
}
}
func TestPromptKitClientClassifiesBuiltInTransportAndMalformedResponses(t *testing.T) {
for _, test := range []struct {
name string
kind contracts.LLMGenerationFailureKind
roundTrip func(*http.Request) (*http.Response, error)
}{
{
name: "transport",
kind: contracts.LLMGenerationFailureTransport,
roundTrip: func(*http.Request) (*http.Response, error) {
return nil, errors.New("transport unavailable")
},
},
{
name: "malformed response",
kind: contracts.LLMGenerationFailureMalformedResponse,
roundTrip: func(request *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not-json")), Request: request}, nil
},
},
} {
t.Run(test.name, func(t *testing.T) {
client, err := NewPromptKitClient(PromptKitClientConfig{
Assets: newTestPromptKitAssets(t),
HTTPClient: &http.Client{Transport: roundTripFunc(test.roundTrip)},
EngineOptions: []promptkit.Option{promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
ID: "default-profile", Endpoint: "http://promptkit.test/v1", Model: "default-model",
}))},
})
if err != nil {
t.Fatal(err)
}
var out map[string]any
_, err = client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{
PromptID: "adapter.test", SessionID: "failure-kind-test",
Inputs: contracts.LLMInputSet{"transcript": contracts.NewLLMInputMaterial("transcript", "application/json", []byte(`{"source":true}`), "", "")},
}, &out)
var generation *contracts.LLMGenerationError
if !errors.As(err, &generation) || generation.FailureKind() != test.kind || generation.RetriesExhausted() || generation.Attempts() != 0 {
t.Fatalf("generation error = %#v, want kind %q", generation, test.kind)
}
})
}
}
func TestPromptKitClientClassifiesEmptyStructuredCompletion(t *testing.T) {
client := newTestPromptKitClient(t, &fakePromptKitLLM{allowEmpty: true})

View File

@@ -0,0 +1,245 @@
package llm
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/promptkit"
)
func newObservedHTTPClient(t *testing.T, handler http.HandlerFunc, retries int, transport http.RoundTripper) *PromptKitClient {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client, err := NewPromptKitClient(PromptKitClientConfig{
Assets: newTestPromptKitAssets(t), TransientRetries: &retries,
HTTPClient: &http.Client{Transport: transport},
EngineOptions: []promptkit.Option{promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
ID: "default-profile", Endpoint: server.URL + "/v1", Model: "test-model",
}))},
})
if err != nil {
t.Fatal(err)
}
return client
}
func observedRequest() contracts.StructuredCompletionRequest {
return contracts.StructuredCompletionRequest{
PromptID: "adapter.test", SessionID: "observation-test",
Inputs: contracts.LLMInputSet{"transcript": contracts.NewLLMInputMaterial("transcript", "application/json", []byte(`{}`), "", "")},
}
}
func TestPromptKitObservationsComposeTransientRetriesAndRepair(t *testing.T) {
var calls atomic.Int32
client := newObservedHTTPClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch calls.Add(1) {
case 1, 3:
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = io.WriteString(w, `{"error":{"message":"try again"}}`)
case 2:
_, _ = io.WriteString(w, `{"choices":[{"message":{"content":"{\"bad\":true}"}}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5,"prompt_tokens_details":{"cached_tokens":0}}}`)
case 4:
_, _ = io.WriteString(w, `{"choices":[{"message":{"content":"{\"ok\":true}"}}],"usage":{"prompt_tokens":7,"completion_tokens":0,"total_tokens":7}}`)
default:
t.Error("unexpected extra HTTP attempt")
w.WriteHeader(http.StatusBadRequest)
}
}, 1, nil)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req := observedRequest()
repairs := 1
req.StructuredOutputRepairAttempts = &repairs
var out struct{ OK bool }
response, err := client.CompleteStructured(ctx, req, &out)
if err != nil || !out.OK {
t.Fatalf("completion = (%+v, %v), output = %+v", response, err, out)
}
summary := response.Execution
if calls.Load() != 4 || summary == nil || summary.Attempts != 4 || summary.TransientRetries != 2 || summary.RepairCalls != 1 || summary.Outcome != "success" || summary.RetriesExhausted {
t.Fatalf("calls = %d, execution = %+v", calls.Load(), summary)
}
wantUsage := contracts.LLMObservedTokenUsage{
PromptTokens: contracts.LLMObservedTokenCount{Value: 10, Known: true},
CompletionTokens: contracts.LLMObservedTokenCount{Value: 2, Known: true},
TotalTokens: contracts.LLMObservedTokenCount{Value: 12, Known: true},
CachedTokens: contracts.LLMObservedTokenCount{Known: true},
}
// Failed HTTP attempts contribute unknown usage, not known zero. Neither
// terminal totals nor legacy result totals should be added a second time.
if summary.Usage != wantUsage {
t.Fatalf("observed usage = %+v, want %+v", summary.Usage, wantUsage)
}
if response.PromptTokens != 10 || response.CompletionTokens != 2 || response.TotalTokens != 12 || response.RepairAttempts != 1 {
t.Fatalf("legacy result accounting changed: %+v", response)
}
}
type closeSignalingBody struct {
io.ReadCloser
closed chan<- struct{}
}
func (b closeSignalingBody) Close() error {
err := b.ReadCloser.Close()
b.closed <- struct{}{}
return err
}
func TestPromptKitObservationSurvivesCanceledRetryWait(t *testing.T) {
consumed := make(chan struct{}, 2)
transport := http.DefaultTransport.(*http.Transport).Clone()
t.Cleanup(transport.CloseIdleConnections)
var calls atomic.Int32
client := newObservedHTTPClient(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Retry-After", "3600")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = io.WriteString(w, `{"error":{"message":"busy"}}`)
}, 1, roundTripFunc(func(req *http.Request) (*http.Response, error) {
response, err := transport.RoundTrip(req)
if err == nil {
response.Body = closeSignalingBody{ReadCloser: response.Body, closed: consumed}
}
return response, err
}))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
type result struct {
response contracts.StructuredCompletionResponse
err error
}
done := make(chan result, 1)
go func() {
var out map[string]any
response, err := client.CompleteStructured(ctx, observedRequest(), &out)
done <- result{response, err}
}()
// The failure has been consumed, so cancellation must interrupt the pending
// retry wait rather than depend on another provider response or its timeout.
select {
case <-consumed:
case <-time.After(5 * time.Second):
t.Fatal("provider response was not consumed")
}
cancel()
select {
case got := <-done:
if !errors.Is(got.err, context.Canceled) || got.response.Execution == nil {
t.Fatalf("canceled completion = (%+v, %v)", got.response, got.err)
}
summary := got.response.Execution
if summary.Outcome != "error" || summary.FailureKind != "canceled" || summary.Attempts != 1 || summary.RetriesExhausted || calls.Load() != 1 {
t.Fatalf("canceled execution = %+v, HTTP calls = %d", summary, calls.Load())
}
case <-time.After(5 * time.Second):
t.Fatal("cancellation did not interrupt retry waiting")
}
}
func TestPromptKitObservationsSeparatePreparationAndExecutionFailure(t *testing.T) {
var calls atomic.Int32
client := newObservedHTTPClient(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = io.WriteString(w, `{"error":{"message":"busy"}}`)
}, 1, nil)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req := observedRequest()
req.PromptID = "missing-prompt"
var out map[string]any
response, err := client.CompleteStructured(ctx, req, &out)
if err == nil || response.Execution != nil || calls.Load() != 0 {
t.Fatalf("preparation failure = (%+v, %v), calls = %d", response, err, calls.Load())
}
response, err = client.CompleteStructured(ctx, observedRequest(), &out)
var generation *contracts.LLMGenerationError
if !errors.As(err, &generation) || response.Execution == nil {
t.Fatalf("execution failure = (%+v, %v)", response, err)
}
summary := response.Execution
if summary.Outcome != "error" || summary.FailureKind != "provider_status" || summary.StatusCode != http.StatusServiceUnavailable || !summary.RetriesExhausted || summary.Attempts != 2 || summary.TransientRetries != 1 || calls.Load() != 2 {
t.Fatalf("failed execution = %+v, calls = %d", summary, calls.Load())
}
if summary.Usage != (contracts.LLMObservedTokenUsage{}) {
t.Fatalf("failed attempts should have unknown usage: %+v", summary.Usage)
}
}
func TestPromptKitObservationsAreIsolatedAcrossConcurrentCalls(t *testing.T) {
arrived := make(chan struct{}, 2)
release := make(chan struct{})
client := newObservedHTTPClient(t, func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
return
}
tokens := 11
if strings.Contains(string(body), "second-call") {
tokens = 22
}
arrived <- struct{}{}
select {
case <-release:
case <-r.Context().Done():
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []any{map[string]any{"message": map[string]string{"content": `{"ok":true}`}}},
"usage": map[string]int{"prompt_tokens": tokens, "completion_tokens": 0, "total_tokens": tokens},
})
}, 0, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
type result struct {
want int
response contracts.StructuredCompletionResponse
err error
}
done := make(chan result, 2)
for name, tokens := range map[string]int{"first-call": 11, "second-call": 22} {
go func() {
req := observedRequest()
req.Vars = map[string]any{"custom": name}
var out map[string]any
response, err := client.CompleteStructured(ctx, req, &out)
done <- result{tokens, response, err}
}()
}
for i := 0; i < 2; i++ {
select {
case <-arrived:
case <-ctx.Done():
t.Fatal("requests did not overlap")
}
}
close(release)
for i := 0; i < 2; i++ {
select {
case got := <-done:
summary := got.response.Execution
if got.err != nil || summary == nil {
t.Fatalf("concurrent completion = (%+v, %v)", got.response, got.err)
}
if summary.Attempts != 1 || summary.Usage.PromptTokens != (contracts.LLMObservedTokenCount{Value: got.want, Known: true, Complete: true}) || summary.Usage.CompletionTokens != (contracts.LLMObservedTokenCount{Known: true, Complete: true}) || summary.Usage.CachedTokens != (contracts.LLMObservedTokenCount{}) {
t.Fatalf("execution for %d tokens = %+v", got.want, summary)
}
case <-ctx.Done():
t.Fatal("concurrent completions did not return")
}
}
}

View File

@@ -17,7 +17,7 @@ const (
promptKitLocalBackendMarker = "notarius:promptkit-local-backend:v1"
// The built-in profile catalog is compiled into this pinned PromptKit
// release. Update this identity when the dependency is upgraded.
promptKitBuiltinProfileCatalogID = "promptkit:v0.9.0:builtin-profiles"
promptKitBuiltinProfileCatalogID = "promptkit:v0.11.0:builtin-profiles"
)
func promptKitProfileFingerprint(profileDir, profileFile, fallbackProfileDigest string) (CheckpointFingerprint, error) {

View File

@@ -38,7 +38,7 @@ func (c *scheduledClient) CompleteStructured(ctx context.Context, req contracts.
return callErr
})
if err != nil {
return contracts.StructuredCompletionResponse{}, err
return response, err
}
return response, nil
}

View File

@@ -69,3 +69,22 @@ func RejectUnknownOptions(options map[string]any, allowed ...string) error {
}
return fmt.Errorf("unknown options %q", unknown)
}
// IntOption accepts the two integer representations admitted at the module
// boundary: native int values from programmatic bindings and int64 values from
// ConfigKit dynamic option maps. Callers remain responsible for their own
// domain constraints.
func IntOption(value any) (int, bool) {
switch typed := value.(type) {
case int:
return typed, true
case int64:
converted := int(typed)
if int64(converted) != typed {
return 0, false
}
return converted, true
default:
return 0, false
}
}

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