verdifax 1.0.0 → 1.2.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/README.md CHANGED
@@ -18,6 +18,8 @@ Requires Node 22 or newer.
18
18
 
19
19
  ## Quickstart
20
20
 
21
+ On a trial key, register a request key first and give the client that key: see [Request signing](#request-signing). Without it, a trial key refuses the run.
22
+
21
23
  ```typescript
22
24
  import { VerdifaxClient } from "verdifax";
23
25
 
@@ -80,13 +82,23 @@ if (v.verified) {
80
82
  | `client.getPublicBundleByToken(token)` | `GET /public/runs/{token}/bundle.json` | none |
81
83
  | `client.dcaeVerify(bundle)` | `POST /dcae/verify` | none |
82
84
  | `client.registerRequestKey()` | `POST /keys/request-key` | required |
85
+ | `client.rotateRequestKey(newSigningKey)` | `POST /keys/request-key` | required |
83
86
  | `client.clearRequestKey()` | `DELETE /keys/request-key` | required |
87
+ | `client.uploadPolicy(document)` | `POST /policies` | required |
88
+ | `client.listPolicies()` | `GET /policies` | required |
89
+ | `client.getPolicy(documentHash)` | `GET /policies/{document_hash}` | required |
90
+ | `client.testPolicy({ inputs, document?, documentHash? })` | `POST /policies/test` | required |
91
+ | `client.getPolicyHistory()` | `GET /policies/history` | required |
92
+ | `client.activatePolicy(documentHash)` | `POST /policies/{document_hash}/activate` | required |
84
93
  | `client.adminCreateKey(name)` | `POST /admin/keys` | admin |
85
94
  | `client.adminListKeys()` | `GET /admin/keys` | admin |
86
95
  | `client.adminRevokeKey(id)` | `DELETE /admin/keys/{id}` | admin |
96
+ | `client.adminClearRequestKey(id)` | `DELETE /admin/keys/{id}/request-key` | admin |
87
97
  | `client.adminErase(req)` | `POST /admin/erase` | admin |
88
98
 
89
- These functions make no request: `captureEnvironment`, `formatProviderPayload`, `signRequest`, `requestKeyRegistrationBody`, `requestSignaturePreimage`, `generateRequestKeyPair`, `validateHex64`, `validateRouteId`, `dseDispatchHash`, `dcaeClosureHash` and `cresReceiptHash`. `RUN_STATUSES` lists every run status the server uses.
99
+ `client.job(jobId)` returns a [job helper](#jobs) and makes no request until a step runs.
100
+
101
+ These functions make no request: `captureEnvironment`, `formatProviderPayload`, `signRequest`, `signRequestV2`, `requestKeyRegistrationBody`, `requestKeyChangeApproval`, `requestKeyChangePreimage`, `policyActivationApproval`, `policyActivationPreimage`, `requestSignaturePreimage`, `requestSignaturePreimageV2`, `generateRequestKeyPair`, `validateHex64`, `validateJobName`, `validateRouteId`, `dseDispatchHash`, `dcaeClosureHash` and `cresReceiptHash`. `RUN_STATUSES` lists every run status the server uses, and `EXECUTE_FIELDS` lists every top-level key a run request may hold.
90
102
 
91
103
  `getRunArtifacts`, `getPublicBundle` and `getPublicBundleByToken` return the exact JSON text the server sent, so you can save it and check it with `verdifax-verify`.
92
104
 
@@ -126,6 +138,100 @@ for (const row of batch.results) {
126
138
 
127
139
  The client's timeout covers the whole batch, so raise `timeoutMs` for a large one. A batch is one POST and it is never retried. After a timeout the server may have run some or all of the rows, so list your runs before you send them again.
128
140
 
141
+ ## Jobs
142
+
143
+ A job links several runs into one map: each step names the records it directly follows, so work can split into branches and join again. `client.job(jobId)` returns a helper that keeps the map for you.
144
+
145
+ ```typescript
146
+ const job = client.job("loan-1042");
147
+ const run = { program_id, route_id, registry_record_hash };
148
+
149
+ await job.step("intake", { ...run, payload_text: "application received" });
150
+
151
+ // Two branches after one step.
152
+ await job.step("credit", { ...run, payload_text: "credit check", after: ["intake"] });
153
+ await job.step("income", { ...run, payload_text: "income check", after: ["intake"] });
154
+
155
+ // A final step joining them.
156
+ await job.step("decide", {
157
+ ...run,
158
+ payload_text: "decision",
159
+ after: ["credit", "income"],
160
+ final: true,
161
+ });
162
+
163
+ console.log(job.recordHashes); // step id to record hash
164
+ console.log(job.signedRequests); // step id to signed text, when the client signs
165
+ ```
166
+
167
+ `job.step(stepId, options)` takes everything `attest` takes, plus `after`, `external_previous` and `final`, and returns what `attest` returns. `after` lists step ids in this job. Without it, a step follows the one run just before it through this helper, and the first step follows nothing. An `after` that names an unknown step throws a `VerdifaxError` before anything is sent.
168
+
169
+ A refused or halted step stays in the map, so a later step can follow it. `job.step` records its hash and signed text, then throws the same `APIError` that `attest` throws, with the hash in `err.recordHash`. A step that sealed nothing, such as a timeout or a 409, is not in the map and does not move the default `after`, so it can run again under the same id. To run branches at the same time, give each one its `after`. An empty `after` starts another root step.
170
+
171
+ A successful step records `record_hash`, or `manifest_hash` if the response has no `record_hash`. If it has neither, `job.step` throws a `VerdifaxError`, so the map never silently gets a gap. A mistyped option such as `afer` is refused before sending, so a typo cannot silently drop a link.
172
+
173
+ `execute`, `attest` and each row of `executeBatch` also take a `job` block directly: `{ job_id, step_id, previous_record_hashes, external_previous, final }`. Results carry `record_hash`. `job_id` and `step_id` are 1 to 128 letters, digits, `.`, `_`, `:` or `-`, and any other name is refused before sending. The SDK sorts both lists and removes repeats, and refuses unknown fields, a `final` that is not true or false, and hash lists that are not lists. A determinism check runs twice, so `verifyDeterminism` refuses a job.
174
+
175
+ The server refuses a step in a closed job, a repeated step id, and a final step that leaves a branch with no step after it. To check a whole job, save each record in one folder and run `verdifax-verify --job FOLDER`.
176
+
177
+ A job map covers what was sent. It cannot show a step that was never sent.
178
+
179
+ ## Policies
180
+
181
+ A policy is your own set of rules that decides whether a run is allowed or refused, from the facts the run declares. You upload its exact text, test it on sample inputs, then activate it. Each change is one more link in a chain of changes on the public log.
182
+
183
+ ```typescript
184
+ import { readFileSync, writeFileSync } from "node:fs";
185
+
186
+ // Upload the exact text. Nothing changes until it is activated.
187
+ const stored = await client.uploadPolicy(readFileSync("policy.json", "utf8"));
188
+
189
+ // See which rule would fire on sample inputs. This seals nothing.
190
+ const trial = await client.testPolicy({
191
+ documentHash: stored.document_hash,
192
+ inputs: { subject_type: "agent", verb: "approve-loan", object_type: "transaction", classification: "CONFIDENTIAL" },
193
+ });
194
+ console.log(trial.outcome, trial.fired_rule_id, trial.reason_code);
195
+
196
+ // Make it the policy in force. With a signingKey the request key signs the change.
197
+ const change = await client.activatePolicy(stored.document_hash);
198
+
199
+ // A run declares the facts the rules read, and can name the policy it expects.
200
+ await client.attest({
201
+ program_id,
202
+ route_id,
203
+ registry_record_hash,
204
+ payload_text: "approve loan 1042",
205
+ attested_context: { actor_id: "agent:loan-bot" },
206
+ policy_context: {
207
+ actor_type: "agent",
208
+ mfa_satisfied: true,
209
+ object_type: "transaction",
210
+ object_id: "loan-1042",
211
+ classification: "CONFIDENTIAL",
212
+ },
213
+ expected_policy_hash: change.policy_hash,
214
+ });
215
+
216
+ // Roll back by activating an earlier document.
217
+ await client.activatePolicy(earlierDocumentHash);
218
+
219
+ // Save the chain of changes and check it with verdifax-verify --policy-history policy-history.json.
220
+ writeFileSync("policy-history.json", JSON.stringify(await client.getPolicyHistory()));
221
+ ```
222
+
223
+ `uploadPolicy` sends the string it is given unchanged, because the server hashes the exact text, and it refuses anything that is not a string. `getPolicy` returns a stored document with its exact text. `testPolicy` takes exactly one of `document` and `documentHash`. `listPolicies` returns the documents, `chain_id`, the newest change as `newest_activation_hash` (64 zeros when there is none yet) and the document in force as `active_document_hash` (empty under the built-in policy).
224
+
225
+ `activatePolicy` reads `listPolicies` first and names the newest change as the one it follows. With the `signingKey` option it sends the request key's signature over the chain, the document and that change, which the server requires while a request key is registered. Rolling back is activating an earlier document: it is one more change, so the change it undoes stays on the record. An HTTP 409 means another change came first or the document is not yet effective. It throws the usual `APIError` and is not retried. `getPolicyHistory` returns the changes oldest first, in the form `verdifax-verify --policy-history FILE` reads.
226
+
227
+ `execute`, `attest`, each row of `executeBatch`, `verifyDeterminism`, `job.step` and the provider helpers take `policy_context` and `expected_policy_hash`. `policy_context` holds only `actor_type` (`agent`, `user`, `service` or `system`), `mfa_satisfied` (true or false), `session_age_seconds` (a whole number, not negative), `object_type` (`text`, `transaction`, `code`, `data`, `model` or `file`), `object_id` (at most 256 bytes) and `classification` (`PUBLIC`, `INTERNAL`, `CONFIDENTIAL`, `RESTRICTED`, `TOP_SECRET` or `TS_SCI`). Any other key or value is refused before sending, because a misspelt key would be dropped and a rule on it would read its zero value. `actor_type`, `mfa_satisfied` and `session_age_seconds` describe the attested actor, so a run that sets one of them must also send `attested_context` with the actor, such as its `actor_id`. `expected_policy_hash` is 64 lowercase hex characters: when another policy is active the server refuses the run with HTTP 409. Both fields are inside `signed_request` on a signed client.
228
+
229
+ Verdifax did not write, review or approve your policy. A pass means the rules ran, not that they are good rules.
230
+
231
+ A policy judges what you declared. It cannot show the declared facts are true.
232
+
233
+ A refusal stops the action only if your system waits for the answer.
234
+
129
235
  ## Evidence export
130
236
 
131
237
  `exportRuns` lists the runs the server holds for your key in a period, failures included, with up to 10,000 rows per export. `exportRunsCSV` returns the same rows as raw CSV text.
@@ -142,6 +248,8 @@ if (truncated) {
142
248
  }
143
249
  ```
144
250
 
251
+ Pass `job_id` in the period to export one job. JSON rows also carry `anchor_source` (`bundle`, `receipt` or `none`), and `job_id` and `step_id` when the run is a job step.
252
+
145
253
  The export states what the server stored, not a verdict. A call that never reached Verdifax cannot appear in it. Both forms say when a period holds more rows than the export returned: check `truncated`. `exportRunsCSV` reads `total` and `truncated` from the `X-Verdifax-Total` and `X-Verdifax-Truncated` response headers, and throws if the server does not send them. In the CSV, a `route_id` that a spreadsheet could read as a formula gets a leading `'`.
146
254
 
147
255
  ## Provider helpers
@@ -159,12 +267,16 @@ await client.attestClaudeResponse({
159
267
  });
160
268
  ```
161
269
 
270
+ Both helpers take the same optional `job` block as `attest` and return `record_hash` and `signed_request` the same way.
271
+
162
272
  `govern` is false by default, which changes earlier behavior. Set `govern: true` to also send the response text as `ai_output_text` for the AIVP review, which runs on Anthropic in production. Without it, the AI output text is not sent for review.
163
273
 
164
274
  ## Optional execute fields
165
275
 
166
276
  `execute`, `attest`, `executeBatch` and `verifyDeterminism` accept `attested_context`, `reproducibility_context`, `request_substance`, `authorization_chain`, `regulatory_scaffolding` and `causal_graph`. Each is recorded as supplied and Verdifax does not check it. The client derives `attested` and `declared` from the fields you provide. Leave out `request_substance.origin.source_ip_hash` and `user_agent_hash`: the server computes them. `execute`, `attest` and `verifyDeterminism` refuse them before sending, and a batch row that sets them fails in `results`.
167
277
 
278
+ The server ignores a key it does not know, so a misspelt field would be dropped with no error. The client refuses it instead, before sending: a run request for `execute`, `attest`, `verifyDeterminism`, `job.step` or a row of `executeBatch` with a top-level key outside `EXECUTE_FIELDS`, or an `attested_context` key outside `attested`, `actor_id`, `actor_role`, `authorization_policy`, `actor_signature`, `model_provider`, `model_name`, `model_version`, `model_temperature`, `prompt_hash`, `decision_kind`, `decision_result` and `decision_note`, throws a `VerdifaxError` naming the key. `attest` also refuses `request_signature` and `signed_request`, which it does not send. `attestClaudeResponse` and `attestOpenAIResponse` accept their own argument names and every name in `EXECUTE_FIELDS`, so a run request spread into the arguments keeps working, and refuse any other key the same way. Responses are not checked this way: a field a newer server adds is kept and does not throw.
279
+
168
280
  `execute`, `attest` and `verifyDeterminism` check that `program_id` and `registry_record_hash` are 64 lowercase hex characters and that `route_id` is 1 to 256 printable ASCII characters.
169
281
 
170
282
  ## Environment capture
@@ -214,11 +326,12 @@ try {
214
326
 
215
327
  ## Request signing
216
328
 
217
- Request signing is optional. A client with no signing key sends the same requests as before.
329
+ A trial key, which is what a pilot receives, needs a request key: it seals nothing until one is registered, and the key can then be replaced but not cleared. On a production key, request signing is optional, and a client with no signing key sends the same requests as before.
218
330
 
219
331
  You can register an Ed25519 public key on your API key, so that each run must also be signed with a private key that only you hold. The key pair is created on your machine, and Verdifax receives only the public key.
220
332
 
221
333
  ```typescript
334
+ import { writeFileSync } from "node:fs";
222
335
  import { VerdifaxClient, generateRequestKeyPair } from "verdifax";
223
336
 
224
337
  // Run once, then store privateKey as a secret.
@@ -233,21 +346,29 @@ await client.registerRequestKey(); // sends the public key with its proof
233
346
 
234
347
  // execute, attest, attestClaudeResponse, attestOpenAIResponse, executeBatch
235
348
  // and verifyDeterminism now sign.
236
- await client.execute({
349
+ const run = await client.execute({
237
350
  program_id: "1111111111111111111111111111111111111111111111111111111111111111",
238
351
  route_id: "demo-attestation",
239
352
  registry_record_hash: "2222222222222222222222222222222222222222222222222222222222222222",
240
353
  payload_text: "first signed attestation",
241
354
  });
242
355
 
243
- await client.clearRequestKey(); // stops requiring signatures on this API key
356
+ // The exact text that was signed. Keep it for verdifax-verify --request FILE.
357
+ writeFileSync("signed-request.json", run.signed_request!);
358
+
359
+ // The current key approves the change, and this client then signs with the new key.
360
+ await client.rotateRequestKey(generateRequestKeyPair().privateKey);
361
+
362
+ await client.clearRequestKey(); // stops requiring signatures, and this client stops signing
244
363
  ```
245
364
 
246
- Registering a key changes that API key for every client that uses it: the server then refuses a run without a valid signature, so every caller on the key needs the private key. Each request carries the time and a fresh random nonce. The server refuses a repeated nonce and a time more than five minutes from its own clock.
365
+ A signed call serializes the request once and signs that exact text, using signature version 2, so the signature covers the whole request. It sends `signed_request` and `request_signature` and nothing else, and a batch row has the same shape. The result carries the text as `signed_request`, on each row for a batch. A refused or halted run throws the usual `APIError`, with the text in `err.signedRequest`. It holds your payload, so it stays out of the error message and logging the error does not print it: read it by name.
366
+
367
+ Registering a key changes that API key for every client that uses it: the server then refuses a run without a valid signature, so every caller on the key needs the private key. An API key alone cannot replace or clear a registered key: `rotateRequestKey` and `clearRequestKey` send the current key's approval. After a successful `clearRequestKey` the client drops its signing key and sends unsigned requests, because the server refuses a signed run on an API key with no registered key. To sign again, create a client with a `signingKey` and register it. Each request carries the time and a fresh random nonce. The server refuses a repeated nonce and a time more than five minutes from its own clock.
247
368
 
248
369
  In a batch each row carries its own signature and nonce, and the server checks them as it reaches that row. A row refused for its signature comes back in `results`, so a repeated batch returns HTTP 200 with failed rows, and a row reached more than five minutes after it was signed fails the same way. A determinism check uses one signature for the request, and repeating it returns HTTP 403.
249
370
 
250
- The signature shows the holder of the key requested the run. It does not show the key holder accepted the outcome.
371
+ A signature shows who requested the run, not that they accepted the outcome.
251
372
 
252
373
  ## Trust model
253
374