provared 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,244 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (14 October 2026)
4
+
5
+ - **The command-line checker counts the keys it was given.** With two
6
+ passkeys named, its summary said "yes: the passkey you trust"; it now
7
+ says "the passkeys you trust", and "the recorders you expect" where
8
+ more than one recorder is named; the same key named twice counts once.
9
+ Only the words change; tests hold them. The guide gives the fingerprint
10
+ of the checking page built as one file for this version, which changed
11
+ because the checker it holds, and the page itself, changed.
12
+ - **The README's count of review rounds is brought up to date: 40.** The
13
+ 25 recorded by version 0.3.0, and 15 since: three of the library's
14
+ changes of 11 to 13 October (the seal rule, then a narrow review of four
15
+ commits and a check of its fixes), three of the signing page, six of the
16
+ recorder beside Claude Code, two of the record page's book in a release,
17
+ and one of the asynchronous reads below. Each reviewer is a fresh
18
+ session given the code, the documents and a brief of what to look at;
19
+ the README now says so, and that a fault may be stated as a limit rather
20
+ than fixed.
21
+ - **The two framework packages require version 0.4.0**, so that an
22
+ agent behind either goes on after its book is sealed (below).
23
+ `provared-langchain` becomes 0.1.1, which also carries the keywords,
24
+ the homepage and the mark added on 10 and 11 October.
25
+ `pydantic-ai-provared` 0.1.0 is its first version; its README already
26
+ gave the command to install it. Both are published separately, on the
27
+ Python Package Index.
28
+ - **The LangChain and Pydantic AI packages read the stub writer safely
29
+ from asynchronous code**: `await middleware.snapshot()` and
30
+ `await capability.snapshot()` give the book and the cancellations the
31
+ writer holds, waiting in a thread of their own. The READMEs said to
32
+ read it with `asyncio.to_thread(writer.snapshot)`; but in the event
33
+ loop's shared threads such a read waits for the action in progress,
34
+ and enough of them at once leave no shared thread for a tool that
35
+ needs one, so the tool and the reads wait for ever. Found while
36
+ building a package for another framework; checked by an independent
37
+ reviewer, and a test in each package fails without it.
38
+ - **A proposed action dated by text gets a plain refusal.** The check
39
+ before acting took a time given as text, or a number or a Date that
40
+ gives no time, to the catch-all "the checker met something it did not
41
+ expect" (`check-failed`). It now answers `bad-field`: "when: must be a
42
+ number of milliseconds, or a Date" (the stub writer's own words), or
43
+ "when: does not give a time" (new words, for a number such as Infinity,
44
+ a Date that is not a time, or a Date wrapped in a Proxy). In Python a
45
+ datetime counts as dated only where its time zone gives an offset, as
46
+ Python's own rule for an "aware" time says. Nothing that was allowed is
47
+ refused, and nothing that was refused is allowed. The stub writer
48
+ answers in the same words: its "before" and "act" took a Date wrapped
49
+ in a Proxy to a plain TypeError, and now answer "when: does not give a
50
+ time", as the check before acting does; its "record", which does not
51
+ ask first, took any time that gives none to a plain "Invalid time
52
+ value" error, and now refuses it with `bad-field` in those words. In
53
+ both languages; the shared test file for the check before acting gains
54
+ two cases, and in the stub writer's scenarios the one step that hands in
55
+ a time of Infinity changes its words, and the one step that records an
56
+ action at a time outside the range of times changes its answer. Tests
57
+ in both languages hold a Date in a Proxy and, in Python, a time zone
58
+ that gives no offset.
59
+ - **A record's signature of the wrong length is invalid on every
60
+ device.** An Ed25519 signature is 64 bytes, an ML-DSA-87 signature 4,627
61
+ and an SLH-DSA-SHA2-256s signature 29,792 (RFC 8032, FIPS 204, FIPS 205).
62
+ One of another length can never check, and a device that has the method
63
+ already said "invalid"; a device without it said "not checked on this
64
+ device". Now every device says "invalid", for every signature of a
65
+ record, the passkey's Ed25519 signature on a slip among them. So, on a
66
+ device without SLH-DSA, a seal whose third signature is cut short or
67
+ empty is a problem, not something the check before acting goes on
68
+ without. The signature inside a time-stamp from a service is not
69
+ covered: on a device without its method a time-stamp is not checked,
70
+ whatever its length, and is not counted. A third signature of the right
71
+ length that would not check is still not seen on a device without
72
+ SLH-DSA. The format description states the rule in section 3.9 and
73
+ what it means for a seal in section 20; the list of threats says that a
74
+ third signature of the right length is not seen there. In both
75
+ languages; the shared test files for a whole book and for the check
76
+ before acting gain a case each, and tests in both languages hold each
77
+ method's length, shorter and longer, with the method and without it.
78
+ - **The check before acting goes on without a seal's third signature, and
79
+ says so** (decision F-106; this changes choice 66 of the draft). Found on
80
+ 11 October 2026: a device that cannot check SLH-DSA-SHA2-256s, which is
81
+ every Python stub writer (and so the LangChain and Pydantic AI packages)
82
+ and every browser, was refused every action as soon as its book held a
83
+ seal, because the check allowed nothing unless every signature in the
84
+ record so far had been checked on that device. Now, where the only
85
+ signatures not checked are the third ones of seals whose first two were
86
+ checked there, and nothing else was left unchecked, the check goes on and
87
+ its answer carries `notChecked: ['SLH-DSA-SHA2-256s']`. Any other
88
+ signature left unchecked still refuses everything, and the full checker's
89
+ answers are unchanged. In both languages: the shared test file for the
90
+ check before acting gains seven cases with a seal, besides the one with
91
+ a third signature cut short counted above (found missing by two
92
+ independent reviews, which showed that weaker rules passed every test:
93
+ the last case, a permission and a seal alone on a device without
94
+ ML-DSA-87, holds the rule that the seal's ML-DSA-87 signature must have
95
+ been checked), and the shared test file for the stub writer's scenarios
96
+ is remade. Tests in both languages hold the same rule.
97
+ - **The signing page names the agent's keys by the fingerprint of their
98
+ key set** before the passkey is asked, worked out by the library's own
99
+ `keySetFingerprint` (as a recorder's key set is named), and asks the
100
+ person to compare it with the one the agent's software printed when it
101
+ made its keys: a link, or a permission pasted from elsewhere, could
102
+ carry someone else's keys under a familiar name. Keys that are not a
103
+ key set are refused there, in the library's own words. A test holds the
104
+ page's words against the library's fingerprint.
105
+ - **The signing page fills itself from a link, and shows everything a
106
+ link could slip in before the passkey is asked.** A link to
107
+ prova.red/sign/ may hold a starting permission after "#"; that part
108
+ stays in the browser and is never sent to a server. A link that cannot
109
+ be read leaves the page's own starting point and says why; a second
110
+ link opened in the same tab fills the page again. Because a link can
111
+ name anything, the page now shows, besides the agent's key-set
112
+ fingerprint (above): each service's keys by the fingerprint of their key
113
+ set, as a checker names them (keys that are not a key set are refused,
114
+ in the library's own words); and the agent's software statement, marked
115
+ as the person's statement and not proof of what runs. Shown inside
116
+ another page, it offers nothing to sign or cancel, since another site
117
+ could frame it filled from a link of its choosing. It now has a content
118
+ security policy that lets it load only its own scripts and style sheet
119
+ and send nothing anywhere; the message of the commit that added the
120
+ link said its policy was unchanged, but the page had none. Tests hold
121
+ each point. Checked by an independent reviewer over three rounds.
122
+ - **The recorder beside Claude Code is a package of its own,
123
+ `provared-claude-code` 0.1.0** (published separately, on npm), in
124
+ `integrations/claude-code/`, in place of the recorder of 10 October.
125
+ `init` makes the keys, writes the hooks and prints a link to the signing
126
+ page filled in with a starting permission; `use` puts the signed
127
+ permission in place; `renew` takes a new permission while the recorder
128
+ runs, with no moment in which a call finds it gone. A permission from a
129
+ passkey the recorder has not trusted before is taken only with
130
+ `--new-passkey`, and the passkeys it trusts are kept in
131
+ `trusted-passkeys.json`. The hooks `init` writes match every tool,
132
+ record calls that failed, and refuse a call where the hook itself fails
133
+ (`"onFailure": "block"`). A command is named by reading its line three
134
+ ways (as a shell splits it, from every known program's name, and with
135
+ the first version's patterns), and the strictest name counts; a line
136
+ handed to a shell, to `eval`, `iex` or `Invoke-Expression`, and the
137
+ command `find -exec` runs are read the same way. A call allowed under
138
+ one permission and taken while a renewal switched to the next is
139
+ recorded under the one it was allowed under, and the stub of a call that
140
+ was taken is always written. `keys` and `init` print the fingerprint of
141
+ the agent's key set, which the signing page asks the person to compare;
142
+ `seal` shows the recorder's error. Checked by an independent reviewer in
143
+ six rounds; each fault found is a test (57 in all).
144
+ - **The record page's book and the checker's answer are kept in a release
145
+ of the repository, not in the repository itself**: they grow by
146
+ megabytes a day, and GitHub refuses a file over 100 MB.
147
+ `tools/make-record-page.mjs` writes their SHA-256 fingerprints beside
148
+ the page (`site/records/files.sha256`); the site's publishing run
149
+ fetches both from the release "record" and publishes nothing unless each
150
+ matches, and every file the page links is there. Readers see no change.
151
+ The tool now reads the growing book once, into the page's own folder,
152
+ leaving out a last line still being written, so that the page, the
153
+ checker's answer and the fingerprints describe one book (the page of
154
+ 14 October said 3,771 receipts above a summary of 3,770 stubs). It
155
+ counts the passkeys that signed the book's permissions from the book,
156
+ and refuses a passkey named to it that signed none. Checked by an
157
+ independent reviewer in two rounds.
158
+ - **What browsers check, tried again on 13 October 2026.** Chrome 155,
159
+ Edge 155 and Brave 155 each offer Ed25519 and ML-DSA (all three
160
+ strengths) to a page and check real signatures made with them; none of
161
+ them offers SLH-DSA. The README, the guide, the list of threats, the
162
+ home page, the worked example and the tool that writes the record page
163
+ now say so; they said, from a try on 12 October, that Chrome 154 did
164
+ not check ML-DSA. No code changes.
165
+ - **The recorder beside Claude Code appends to the book, and no longer
166
+ loses a receipt while another program reads the file.** It wrote the
167
+ whole book to a temporary file and renamed it over the old one; on
168
+ Windows that is refused while anyone holds the file open (an uploader,
169
+ a virus scanner), and the hook then reported "the action was taken and
170
+ is not in the record". Found on 11 October 2026, when a program that
171
+ uploads the book was reading it every two seconds: 7 of one helper's 23
172
+ tool calls were taken and not recorded. A book only grows, so the
173
+ recorder now appends the new lines; where the file does not match the
174
+ book it is replaced, with retries for up to two seconds. A test holds
175
+ the file open while the recorder saves.
176
+
177
+ - 2026-10-11: **one mark everywhere**: a permission slip with a
178
+ signature, white on red (#8f000f), as `site/favicon.svg`, in the
179
+ social preview `site/preview.png` beside the word, as inline SVG in the
180
+ header of every page at prova.red, of the checking page and of the
181
+ signing page (no page makes a request for it), and at the top of the
182
+ README and of the three Python packages' READMEs, which the registries
183
+ show with the next published version. The four colours of the parts stay as they are inside the
184
+ pages. The style sheet gains one rule for the mark and a new version
185
+ number. No code changes.
186
+ - 2026-10-10: **a real record on prova.red**, `tools/make-record-page.mjs`:
187
+ writes `site/records/` from the book that the recorder beside Claude
188
+ Code keeps. It copies the book and its slip beside the page, runs the
189
+ command-line checker with the passkey and the recorder the reader is
190
+ told to trust, keeps the whole answer as a text file, and writes the
191
+ page from what the checker answered: the slip in the checker's own
192
+ words, the counts by day, by kind of action and by tool, the seals,
193
+ any entry outside the permission, the summary exactly as printed, and
194
+ what the check does not show. The page says that a refused call
195
+ leaves no entry and that every receipt is one-sided. It is run again
196
+ each day while the permission lasts; the home page links to it. A
197
+ test runs the tool on the sample record into a folder of its own.
198
+ - 2026-10-10: **the pages at prova.red, the README and the framework
199
+ packages, after the adoption review**: the line under each page's
200
+ title names its reader and then the slogan; "How it is built" on the
201
+ home page is two sentences; the developer page says "the person"
202
+ throughout, names the nearest other projects beside what the
203
+ author found nowhere else, and gives the author's own reason for
204
+ building it; the plain-words page names Article 12 and
205
+ Article 26 of the AI Act, no longer repeats its opening, and asks a
206
+ reader who breaks it to open an issue; every page ends with two
207
+ lines about the author; the README shows the LangChain example; the
208
+ two framework packages gain keywords and a homepage. No code changes.
209
+ - 2026-10-10: **a signing page**, `page/sign.html`, served at
210
+ prova.red/sign/: a person writes a permission as JSON (the agent's
211
+ name and two public keys, the actions, limits, conditions, stated
212
+ rules, with whom, from when until when, and why), sees it in words,
213
+ signs it with a passkey made for the website, and saves the signed
214
+ file; the page shows the passkey's thumbprint and the permission's
215
+ fingerprint. It can also sign a cancellation of a permission signed
216
+ with the same passkey. It uses the library's browser part and
217
+ `render.js`, loads nothing from anywhere else and sends nothing. The
218
+ workflow serves it beside the checking page.
219
+ - 2026-10-10: **the recorder beside Claude Code**, `integrations/claude-code/`:
220
+ a hook that Claude Code runs before and after each tool call, and a
221
+ small service on 127.0.0.1 that holds the stub writer open on one book
222
+ under a slip the person signed. Before a call, the check before acting
223
+ refuses a call outside the slip and Claude Code is told why; after it,
224
+ a stub is written naming the tool and the SHA-256 fingerprint of its
225
+ arguments; the service seals the book on request with the recorder's
226
+ three keys. Tool calls are named from the shared list by the hook,
227
+ not by the agent (a command that removes something is
228
+ `provared.data.delete`; one that publishes is `provared.code.release`);
229
+ tools that only talk to the person are not recorded. The agent's two
230
+ keys and the recorder's three are kept as JSON Web Keys in files the
231
+ person keeps. If the service is not running, a call is refused: no
232
+ record, no action. The folder names a product, as the framework
233
+ packages do; the library names none, and the npm package does not
234
+ hold it. Five tests run the hook as Claude Code runs it.
235
+ - **The tests clean up after themselves.** Nine test files made a
236
+ temporary folder for each run and left it behind; on 13 October 2026
237
+ the computer's temporary folder held about 2,800 of them. A small
238
+ helper, `test/helpers/temp.mjs`, now makes each folder and removes it
239
+ when that test file's tests end. Run with an empty temporary folder,
240
+ the library's tests leave nothing in it.
241
+
3
242
  ## 0.3.0 (10 October 2026)
4
243
 
5
244
  - 2026-10-10: **a development stand-in for a passkey**, `provared/dev`
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Provared
1
+ # <img src="https://prova.red/favicon.svg" alt="" width="36"> Provared
2
2
 
3
3
  Proof of what an AI agent was allowed to do, and what it then did.
4
4
 
@@ -72,7 +72,19 @@ The same in Python is
72
72
  `from provared.dev import development_recorder`. For a LangChain agent,
73
73
  `pip install provared-langchain` puts every tool call behind the same
74
74
  check with no change to the tools
75
- ([python/integrations/provared-langchain](python/integrations/provared-langchain)).
75
+ ([python/integrations/provared-langchain](python/integrations/provared-langchain)):
76
+
77
+ ```py
78
+ from langchain.agents import create_agent
79
+ from provared_langchain import ProvaredMiddleware
80
+
81
+ agent = create_agent(model, tools=[order_supplies, send_message],
82
+ middleware=[..., ProvaredMiddleware(writer, {
83
+ 'order_supplies': {'action': 'supplies.order', 'with': 'supplier',
84
+ 'amount': lambda a: {'unit': 'GBP', 'value': a['total_gbp']}},
85
+ 'send_message': 'provared.message.send',
86
+ })]) # last among the middleware that wrap tool calls
87
+ ```
76
88
 
77
89
  ## The four parts
78
90
 
@@ -129,7 +141,12 @@ each part protects against, is in
129
141
  in plain words for a reader who does not write code.
130
142
  - **[The checking page](https://prova.red/check/)**, which checks a
131
143
  record in a browser and loads nothing from anywhere; also built as
132
- one file, attached to each release.
144
+ one file, attached to each release. It uses only the signing methods
145
+ the browser has built in. Tried on the author's computer on
146
+ 13 October 2026: Chrome 155, Edge 155 and Brave 155 each check the
147
+ quantum-safe signatures on receipts (ML-DSA). None of the three has the
148
+ third method a seal is signed with (SLH-DSA), so in a browser a book
149
+ with a seal is never a pass. The command-line checker checks all three.
133
150
  - **The core of the format as an individual Internet-Draft** at the
134
151
  IETF:
135
152
  [draft-izmaylov-agent-permission-receipts](https://datatracker.ietf.org/doc/draft-izmaylov-agent-permission-receipts/).
@@ -139,12 +156,13 @@ each part protects against, is in
139
156
 
140
157
  ## Status
141
158
 
142
- **Version 0.3.0, a draft.** Published as it is, by one author in their
159
+ **Version 0.4.0, a draft.** Published as it is, by one author in their
143
160
  own time, built with AI tools since 3 October 2026; fixes as time
144
- allows. The code has been through 25 rounds of review by AI reviewers,
145
- each a fresh session given only the code and the documents; every fault
146
- was fixed and kept as a test. No person other than the author has
147
- reviewed it yet. That is an invitation. The format may still change.
161
+ allows. The code has been through 40 rounds of review by AI reviewers,
162
+ each reviewer a fresh session given the code, the documents and a brief
163
+ of what to look at; every fault was fixed, or stated as a limit, and
164
+ kept as a test. No person other than the author has reviewed it yet.
165
+ That is an invitation. The format may still change.
148
166
  Every published version stays on npm and the Python Package Index, so a
149
167
  record made with one version can always be checked by that version.
150
168
 
@@ -457,7 +457,9 @@ function named(s) {
457
457
  }
458
458
 
459
459
  function trustWords(s) {
460
- if (named(s)) return s.counts.seals ? 'yes: the passkey you trust, and the recorder you expect' : 'yes: the passkey you trust';
460
+ const passkeys = new Set(args.issuerKeys).size === 1 ? 'the passkey you trust' : 'the passkeys you trust';
461
+ const recorders = new Set(args.sealKeys).size === 1 ? 'the recorder you expect' : 'the recorders you expect';
462
+ if (named(s)) return s.counts.seals ? `yes: ${passkeys}, and ${recorders}` : `yes: ${passkeys}`;
461
463
  if (args.issuerKeys.length === 0) return 'NO: you named no passkey you trust (--issuer), so anyone could have made this record. This is not a pass.';
462
464
  return 'NO: the record has a seal, and you named no recorder you expect (--sealer), so anyone could have sealed it. This is not a pass.';
463
465
  }
package/docs/guide.md CHANGED
@@ -6,7 +6,7 @@ Everything about the format, the library, the checker, the stub writer beside an
6
6
 
7
7
  ## Status
8
8
 
9
- **Version 0.3.0, a draft.** Published as it is, by one author in their
9
+ **Version 0.4.0, a draft.** Published as it is, by one author in their
10
10
  own time; fixes as time allows. The format may still change, so do not
11
11
  rely on a draft record staying checkable.
12
12
 
@@ -149,11 +149,11 @@ in a browser straight from the disk; it needs no server. The file allows
149
149
  itself no request of any kind: its content security policy lets nothing
150
150
  be loaded from anywhere. The tool prints the file's SHA-256 fingerprint,
151
151
  so that a copy can be compared with it. (Tried on 8 October 2026 in the
152
- three browser builds this repository is tested with.) The file for
153
- version 0.3.0 is attached to the release on GitHub; it is the same
154
- file, byte for byte, as for version 0.2.0, because nothing the page
155
- holds changed. Its SHA-256 fingerprint is
156
- `7e142fa119458ce00c5fdd6e1cafc75cb3bb4672624157a0a0ef4624f92812ae`.
152
+ three browser builds this repository is tested with, and on 13 October
153
+ 2026 in Chrome 155, Edge 155 and Brave 155.) The file for
154
+ version 0.4.0 is attached to the release on GitHub. Its SHA-256
155
+ fingerprint is
156
+ `49da6617ff493ddda2b2b65bfb6bedebdf8cf07ea6faa32b039f065eda207307`.
157
157
 
158
158
  **Run the demonstration in a browser.**
159
159
 
@@ -388,7 +388,8 @@ const answer = await checkBefore(
388
388
  { issuerKeys: ['the thumbprint of the passkey you trust'] },
389
389
  );
390
390
 
391
- answer.allowed; // true only if the record so far is sound, fully checked, and the action is inside the slip
391
+ answer.allowed; // true only if the record so far is sound, fully checked (but for a seal's third signature, below), and the action is inside the slip
392
+ answer.notChecked; // present only where the device went on without checking a seal's third signature (SLH-DSA)
392
393
  answer.breaches; // why not, in the checker's own codes
393
394
  answer.problems; // why the question could not be answered with a yes
394
395
  answer.needs; // what the slip asks to go with it: 'approval', 'countersignature'
@@ -562,9 +563,17 @@ names no agent software, and works with any function.
562
563
  offers a quantum-safe passkey yet.
563
564
  - **Whoever keeps the book** signs each seal with three keys: the same
564
565
  two, and SLH-DSA-SHA2-256s, which rests only on fingerprint functions.
565
- - **Browsers are only now adding ML-DSA, and have no SLH-DSA.** Where a
566
- browser lacks a method, the checking page says that those signatures
567
- were not checked, and does not show a pass. Node.js checks all of them.
566
+ - **The browsers tried have ML-DSA; none has SLH-DSA.** Tried on the
567
+ author's computer on 13 October 2026: Chrome 155, Edge 155 and
568
+ Brave 155 each offer Ed25519 and ML-DSA (all three strengths) to a
569
+ page, and check real signatures made with them. None of the three
570
+ offers SLH-DSA. So in these browsers the checking page checks every
571
+ signature of a slip, a stub, a countersignature and the other records,
572
+ but not a seal's third signature: a book with a seal reads "not
573
+ complete" there, and the time-stamp beside the seal is not counted.
574
+ Where a browser lacks a method, the checking page says that those
575
+ signatures were not checked, and does not show a pass. Node.js checks
576
+ all of them.
568
577
 
569
578
  ## Using the library
570
579
 
@@ -58,10 +58,10 @@ keys. People decide what it means.
58
58
  under a seal, whose three signatures include two quantum-safe ones.
59
59
  The outside time-stamps themselves are of today's kind.
60
60
  - **A signature that cannot be checked is not a pass.** Where a device
61
- has no built-in ML-DSA, the checker says so, and never reports the
62
- record as intact. An entry none of whose signatures the device could
63
- check is not treated as evidence: what it says is not shown, and it is
64
- not compared with its slip.
61
+ has no built-in ML-DSA, or no SLH-DSA for a seal, the checker says
62
+ so, and never reports the record as intact. An entry none of whose
63
+ signatures the device could check is not treated as evidence: what it
64
+ says is not shown, and it is not compared with its slip.
65
65
  - **A record does not show that an action was wise, lawful or wanted.**
66
66
  - **A badly written slip is faithfully recorded as a badly written slip.**
67
67
 
@@ -450,7 +450,12 @@ within 5 minutes for a time-stamp from a service, and to within 2 hours
450
450
  and 5 minutes for a block time-stamp). A6 cannot make a
451
451
  seal: it needs the recorder's three keys. A8 must break Ed25519, ML-DSA-87
452
452
  and SLH-DSA together to forge a seal; the third rests only on fingerprint
453
- functions. Nobody can move a time-stamp to another seal: it is made over
453
+ functions. One exception, stated: the check before acting on a device
454
+ without SLH-DSA (the Python version, every browser) goes on after a seal
455
+ whose first two signatures check there, so on that device a seal rests on
456
+ Ed25519 and ML-DSA-87 alone, and a third signature of the right length
457
+ that would not check is not seen. A device with SLH-DSA, such as the
458
+ command-line checker, sees it. Nobody can move a time-stamp to another seal: it is made over
454
459
  the seal's fingerprint.
455
460
 
456
461
  A block time-stamp is a second witness of the time, which rests on no
@@ -490,8 +495,12 @@ person checking gives.
490
495
  does not read what a certificate says its key is for. It checks that
491
496
  the stated time lies within the certificate's dates, and leaves the
492
497
  rest to the person who chose to trust it.
493
- - **A browser.** No browser offers SLH-DSA or ML-DSA to a page yet. A
494
- checker there reports a seal as not fully checked, never as a pass.
498
+ - **A browser.** Tried on the author's computer on 13 October 2026:
499
+ Chrome 155, Edge 155 and Brave 155 offer ML-DSA to a page; none of the
500
+ three offers SLH-DSA. So a checker in a browser
501
+ reports a seal as not fully checked, never as a pass, and counts no
502
+ time-stamp beside it. In a browser without ML-DSA, every record signed
503
+ with ML-DSA is reported as not fully checked too.
495
504
  - **A few bytes of a time-stamp's wrapper** are not covered by the
496
505
  service's signature (the list of fingerprint methods, and the lines
497
506
  that name the certificate by its issuer). Changing them changes nothing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "provared",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Proof of what an AI agent was allowed to do, and what it then did. A passkey-signed permission with limits a machine can check, a signed receipt for each action, and a free checker. Evidence, not a verdict.",
5
5
  "author": "Pavel Izmaylov",
6
6
  "license": "Apache-2.0 AND CC-BY-4.0",
package/page/index.html CHANGED
@@ -8,7 +8,7 @@
8
8
  </head>
9
9
  <body>
10
10
  <main>
11
- <h1>Check a record</h1>
11
+ <h1><svg class="mark" viewBox="0 0 64 64" width="32" height="32" aria-hidden="true"><circle cx="32" cy="32" r="32" fill="#8f000f"/><path d="M20 15 H37 L45 23 V49 H20 Z" fill="#ffffff"/><path d="M37 15 V23 H45" fill="none" stroke="#8f000f" stroke-width="2.5" stroke-linejoin="round"/><path d="M25 29 H40 M25 35 H36" stroke="#8f000f" stroke-width="2.5" stroke-linecap="round"/><path d="M24.5 43 C27 39, 29 46, 31.5 42.5 C34 39, 36 46, 40.5 41.5" fill="none" stroke="#8f000f" stroke-width="3" stroke-linecap="round"/></svg>Check a record</h1>
12
12
  <p class="motto">Provared, draft version 0. Evidence, not a verdict.</p>
13
13
  <p>Choose a record file. It is checked here, in this browser. Nothing is sent anywhere.</p>
14
14
  <p>
package/page/style.css CHANGED
@@ -148,3 +148,6 @@ details.more summary { cursor: pointer; font-weight: 600; }
148
148
  details.more label { display: block; margin-top: 0.7rem; }
149
149
  details.more input[type="file"] { display: block; max-width: 100%; margin-top: 0.3rem; color: var(--text); }
150
150
  details.more textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, monospace; font-size: 0.85rem; color: var(--text); background: var(--page); border: 1px solid var(--line); border-radius: 0.3rem; }
151
+
152
+ /* The mark: a permission slip with a signature, beside a heading or the site's name. */
153
+ .mark { vertical-align: -0.2em; margin-right: 0.4em; }
@@ -226,10 +226,22 @@ Because the slip names both keys, taking the quantum-safe signature off a
226
226
  stub does not leave a valid stub.
227
227
 
228
228
  A checker on a device with no built-in ML-DSA MUST report that the
229
- quantum-safe signature could not be checked, and MUST NOT report the
230
- record as intact. An entry none of whose signatures the device could
231
- check MUST NOT be treated as evidence: what it says is not shown as fact,
232
- and it is not compared with its slip.
229
+ quantum-safe signature could not be checked (unless it is of the wrong
230
+ length, below), and MUST NOT report the record as intact. An entry none
231
+ of whose signatures the device could check MUST NOT be treated as
232
+ evidence: what it says is not shown as fact, and it is not compared with
233
+ its slip.
234
+
235
+ **A signature of the wrong length.** A signature whose length is not its
236
+ method's (64 bytes for Ed25519, 4,627 for ML-DSA-87, 29,792 for
237
+ SLH-DSA-SHA2-256s) can never check. A checker MUST report it as invalid,
238
+ whether or not the device has the method: it is a problem with the
239
+ record, not a signature left unchecked. This holds for every signature of
240
+ a record, the passkey's Ed25519 signature on a slip among them, and so
241
+ for the check of a whole book or of a Show as for the check before acting
242
+ (section 20). It does not reach the signature inside a time-stamp from a
243
+ service (section 21.2): a device without that time-stamp's method does
244
+ not check it, whatever its length, and does not count it.
233
245
 
234
246
  A recorder is known by a key set of exactly three keys: Ed25519,
235
247
  ML-DSA-87, then SLH-DSA-SHA2-256s (FIPS 205, the plain method with an
@@ -238,8 +250,8 @@ order, and counts as signed only if all three check. The third rests only
238
250
  on fingerprint functions, so it would stand even if the mathematics
239
251
  behind ML-DSA were broken. When this draft was written, no browser
240
252
  offered it to a page, and Node.js offered it outside the standard
241
- interface: a checker in a browser reports it as not checked, which is
242
- never a pass.
253
+ interface: a checker in a browser reports it as not checked (or, if it is
254
+ of the wrong length, as invalid), which is never a pass.
243
255
 
244
256
  The issuer's passkey signature on a slip is of today's kind only. No
245
257
  device offers a quantum-safe passkey.
@@ -918,7 +930,18 @@ holds too much. A checker reports nothing new for it, because the
918
930
  finding stands with the later stub. The answer MUST be that nothing is allowed
919
931
  (`record-not-sound`) if the book so far has a problem, if any signature
920
932
  in it could not be checked on that device, or if the check was not told
921
- which issuer keys it trusts. An action dated before the last stub of
933
+ which issuer keys it trusts. One exception is allowed: where every
934
+ signature that could not be checked is the third signature of a seal
935
+ (section 21.1) whose first two signatures were checked on that device,
936
+ and nothing else was left unchecked, the check MAY go on, and its answer
937
+ MUST then name the method it did not check. A seal widens no permission;
938
+ a device without that method would otherwise be refused everything once
939
+ the book holds a seal. On such a device a seal rests on its first two
940
+ signatures only: a third signature of the right length that would not
941
+ check is not seen there. A third signature of the wrong length is not
942
+ left unchecked: as section 3.9 sets out, it is invalid on every device,
943
+ so the seal is a problem there and nothing is allowed. An action dated
944
+ before the last stub of
922
945
  its own chain (the agent's, or the helper's under its pass; section 7)
923
946
  is refused (`time-went-backwards`).
924
947
 
package/src/guard.js CHANGED
@@ -50,9 +50,11 @@ function lineBound(c, approval) {
50
50
  * @param {object} options the same options as checkBook
51
51
  * @param {string[]} options.issuerKeys the thumbprints of the passkeys this check trusts. It must be given:
52
52
  * a slip signed by any other key allows nothing
53
- * @returns {Promise<{allowed: boolean, problems: {code: string, message: string}[], breaches: {code: string, message: string}[], needs: string[]}>}
53
+ * @returns {Promise<{allowed: boolean, problems: {code: string, message: string}[], breaches: {code: string, message: string}[], needs: string[], notChecked?: string[]}>}
54
54
  * "allowed" is true only if the record so far is sound, this device
55
- * checked every signature in it, and the action would be inside the slip.
55
+ * checked every signature in it (or every one but the third signature of
56
+ * a seal whose first two it checked, which "notChecked" then names), and
57
+ * the action would be inside the slip.
56
58
  * "needs" lists what the slip asks to go with the action. A needed
57
59
  * approval must be handed over, or the action is not allowed. A needed
58
60
  * countersignature cannot be known beforehand: the action is allowed, and
@@ -76,6 +78,54 @@ export function checkBeforeRead(read, proposal, options = {}) {
76
78
  return decide(read, proposal, options);
77
79
  }
78
80
 
81
+ // The methods not checked on this device, if the only thing not checked is
82
+ // the third signature of one or more seals whose Ed25519 and ML-DSA-87
83
+ // signatures were checked here; otherwise null. It counts as the checker
84
+ // counts for "fully checked" (book.js): a signature whose method is not
85
+ // built in, or an entry with nothing to check, is not checked; a time-stamp
86
+ // from a service the reader did not name is not "unchecked", it is uncounted.
87
+ function onlySealThirdUnchecked(result) {
88
+ if (result.summary.fullyChecked) return null;
89
+ let skipped = false;
90
+ let excused = 0;
91
+ const missing = new Set();
92
+ const note = (list) => { if (list.length === 0) skipped = true; for (const x of list) if (x.state === 'unavailable') missing.add(x.method); };
93
+ const stamps = (list) => { for (const x of list) if (x.state === 'unavailable') missing.add('stamp'); };
94
+ for (const e of result.entries) {
95
+ if (e.kind === 'seal') {
96
+ const sigs = e.signatures ?? [];
97
+ const by = (method) => sigs.find((x) => x.method === method);
98
+ const third = sigs.length === 3 && by('Ed25519')?.state === 'valid' && by('ML-DSA-87')?.state === 'valid' && by(SEAL_THIRD)?.state === 'unavailable';
99
+ if (third) excused++; else note(sigs);
100
+ stamps(e.stamps ?? []);
101
+ } else if (e.kind === 'slip') {
102
+ if (!e.signature || e.signature.state === 'unavailable') missing.add(e.signature ? e.signature.alg : 'passkey');
103
+ else if (e.signature.state === 'unchecked') skipped = true;
104
+ } else if (e.kind === 'stub') {
105
+ note(e.signatures ?? []);
106
+ const counter = e.countersignature ?? { state: 'absent', signatures: [] };
107
+ if (counter.state !== 'absent') note(counter.signatures ?? []);
108
+ const approval = e.approval ?? { state: 'absent' };
109
+ if (approval.state === 'unavailable') missing.add(approval.alg);
110
+ } else if (e.kind === 'cancellation') {
111
+ if (!e.signature) skipped = true;
112
+ else if (e.signature.state === 'unavailable') missing.add(e.signature.alg);
113
+ stamps(e.stamps ?? []);
114
+ } else if (['refusal', 'terms', 'vouching', 'withdrawal', 'pass', 'acknowledgement'].includes(e.kind)) {
115
+ note(e.signatures ?? []);
116
+ } else {
117
+ skipped = true;
118
+ }
119
+ }
120
+ for (const h of result.held ?? []) {
121
+ stamps(h.stamps ?? []);
122
+ for (const a of h.acknowledgements ?? []) note(a.signatures ?? []);
123
+ }
124
+ return !skipped && missing.size === 0 && excused > 0 ? [SEAL_THIRD] : null;
125
+ }
126
+
127
+ const SEAL_THIRD = 'SLH-DSA-SHA2-256s';
128
+
79
129
  async function decide(read, proposal, options) {
80
130
  const answer = { allowed: false, problems: [], breaches: [], needs: [] };
81
131
  try {
@@ -85,15 +135,33 @@ async function decide(read, proposal, options) {
85
135
  }
86
136
  const { result, state, beside } = await read();
87
137
  if (result.summary.problemFound) throw notSound('A problem was found in the record so far. It must be looked at before the agent acts again.');
88
- // A record this device could only partly check is not a pass, here either.
89
- if (!result.summary.fullyChecked) throw notSound('This device could not check every signature in the record so far.');
138
+ // A record this device could only partly check is not a pass, here
139
+ // either, with one stated exception: a seal's third signature
140
+ // (SLH-DSA), where its first two were checked here. A seal widens no
141
+ // permission, and a device without that method (the Python version,
142
+ // every browser) would otherwise be refused everything once the book
143
+ // holds a seal. The answer names what was not checked.
144
+ const notChecked = onlySealThirdUnchecked(result);
145
+ if (!result.summary.fullyChecked && !notChecked) throw notSound('This device could not check every signature in the record so far.');
146
+ if (notChecked) answer.notChecked = notChecked;
90
147
  if (!result.entries.every((e) => (e.kind === 'stub' || e.kind === 'pass' ? e.compared === true : true))) {
91
148
  throw notSound('This device could not compare every stub and every pass in the record so far with its slip.');
92
149
  }
93
150
  if (result.size >= MAX_ENTRIES) throw new Refusal('too-large', 'The book is full: it holds 100,000 entries.');
94
151
 
95
152
  f.members(proposal, ['action', 'slip'], ['amount', 'approval', 'details', 'pass', 'terms', 'when', 'with'], 'proposal');
96
- const c = { action: proposal.action, when: formatTime(proposal.when ?? Date.now()) };
153
+ // The time, as the stub writer takes it: a number of milliseconds or a
154
+ // Date. Text is read by rules that differ between devices.
155
+ const given = proposal.when ?? Date.now();
156
+ if (typeof given !== 'number' && !(given instanceof Date)) throw new Refusal('bad-field', 'when: must be a number of milliseconds, or a Date.');
157
+ let ms;
158
+ try {
159
+ ms = new Date(given).getTime();
160
+ } catch {
161
+ ms = NaN; // a Date wrapped in a Proxy, which Date cannot read
162
+ }
163
+ if (!Number.isFinite(ms)) throw new Refusal('bad-field', 'when: does not give a time.');
164
+ const c = { action: proposal.action, when: formatTime(ms) };
97
165
  for (const name of ['amount', 'details', 'with']) if (proposal[name] !== undefined) c[name] = proposal[name];
98
166
  validateRequest(c);
99
167
  const when = f.time(c.when, 'when');
package/src/recorder.js CHANGED
@@ -70,10 +70,18 @@ const notWritten = (why) => new Refusal('record-not-sound', why);
70
70
 
71
71
  // A time handed in: a number of milliseconds, or a Date. Text, true or a
72
72
  // list would be read by rules that differ from one device to another (text
73
- // with no time zone is taken as local time), so they are refused.
73
+ // with no time zone is taken as local time), so they are refused. A Date
74
+ // that cannot be read (one wrapped in a Proxy) gives no time, as a Date
75
+ // that is not a time gives none, and the check before acting refuses it.
74
76
  function timeOf(when) {
75
77
  if (typeof when === 'number') return new Date(when).getTime();
76
- if (when instanceof Date) return when.getTime();
78
+ if (when instanceof Date) {
79
+ try {
80
+ return when.getTime();
81
+ } catch {
82
+ return NaN;
83
+ }
84
+ }
77
85
  throw new Refusal('bad-field', 'when: must be a number of milliseconds, or a Date.');
78
86
  }
79
87
 
@@ -775,6 +783,8 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
775
783
  return calling(request, more, async (call) => {
776
784
  await writeWaiting(now());
777
785
  const when = call.when ?? now();
786
+ // In the words of the check before acting, which "act" and "before" give.
787
+ if (!Number.isFinite(when)) throw new Refusal('bad-field', 'when: does not give a time.');
778
788
  if (aheadOfClock(when)) throw notWritten('The stub was not written: it is dated ahead of the clock.');
779
789
  const made = await composeStub(call, when);
780
790
  const tried = await fitsBook(made, 'The stub was not written: with it, the book would not pass its check.');
package/src/signatures.js CHANGED
@@ -16,6 +16,10 @@
16
16
  import { importPublicKey, weakEd25519 } from './keys.js';
17
17
 
18
18
  const SLH = 'SLH-DSA-SHA2-256s';
19
+
20
+ // The length of a signature, in bytes, by method (RFC 8032, FIPS 204,
21
+ // FIPS 205).
22
+ const SIGNATURE_BYTES = { Ed25519: 64, 'ML-DSA-87': 4627, [SLH]: 29792 };
19
23
  const nodeCrypto = globalThis.process && typeof globalThis.process.getBuiltinModule === 'function' ? globalThis.process.getBuiltinModule('node:crypto') : null;
20
24
 
21
25
  function slhKey(pub) {
@@ -80,6 +84,9 @@ export function methodAvailable(alg) {
80
84
  * @returns {Promise<SignatureState>}
81
85
  */
82
86
  export async function verifySignature(jwk, signature, data, without = []) {
87
+ // A signature of another length than its method's can never check, so it
88
+ // is refused on every device, whether or not this one has the method.
89
+ if (Object.hasOwn(SIGNATURE_BYTES, jwk.alg) && signature.length !== SIGNATURE_BYTES[jwk.alg]) return 'invalid';
83
90
  if (jwk.alg === SLH) {
84
91
  if (without.includes(SLH) || !(await methodAvailable(SLH))) return 'unavailable';
85
92
  try {
package/src/types.d.ts CHANGED
@@ -431,6 +431,8 @@ export interface BeforeAnswer {
431
431
  problems: Finding[];
432
432
  breaches: Finding[];
433
433
  needs: ('approval' | 'countersignature')[];
434
+ /** Present only where the device went on without checking a seal's third signature (SLH-DSA-SHA2-256s): the methods not checked. */
435
+ notChecked?: string[];
434
436
  }
435
437
  /** "issuerKeys" must be given: a slip signed by any other key allows nothing. */
436
438
  export function checkBefore(book: string, proposal: Proposal, options: CheckOptions & { issuerKeys: string[] }): Promise<BeforeAnswer>;