@decionis/agent-safe-pipeline 0.1.4 → 0.3.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.
Files changed (85) hide show
  1. package/README.md +170 -10
  2. package/dist/Index.d.ts +8 -0
  3. package/dist/Index.d.ts.map +1 -1
  4. package/dist/Index.js +7 -0
  5. package/dist/Index.js.map +1 -1
  6. package/dist/audit/AuditRecorder.d.ts +1 -1
  7. package/dist/audit/AuditRecorder.d.ts.map +1 -1
  8. package/dist/audit/AuditRecorder.js.map +1 -1
  9. package/dist/decision/CreateGate.d.ts +123 -0
  10. package/dist/decision/CreateGate.d.ts.map +1 -0
  11. package/dist/decision/CreateGate.js +211 -0
  12. package/dist/decision/CreateGate.js.map +1 -0
  13. package/dist/decision/DecionisGate.d.ts +18 -1
  14. package/dist/decision/DecionisGate.d.ts.map +1 -1
  15. package/dist/decision/DecionisGate.js +31 -3
  16. package/dist/decision/DecionisGate.js.map +1 -1
  17. package/dist/decision/DecisionAuthority.d.ts +26 -0
  18. package/dist/decision/DecisionAuthority.d.ts.map +1 -1
  19. package/dist/decision/DecisionAuthority.js.map +1 -1
  20. package/dist/decision/ImmutableGateDecision.d.ts.map +1 -1
  21. package/dist/decision/ImmutableGateDecision.js +8 -0
  22. package/dist/decision/ImmutableGateDecision.js.map +1 -1
  23. package/dist/decision/Provision.d.ts +33 -0
  24. package/dist/decision/Provision.d.ts.map +1 -0
  25. package/dist/decision/Provision.js +116 -0
  26. package/dist/decision/Provision.js.map +1 -0
  27. package/dist/decision/ShadowGate.d.ts +31 -0
  28. package/dist/decision/ShadowGate.d.ts.map +1 -0
  29. package/dist/decision/ShadowGate.js +79 -0
  30. package/dist/decision/ShadowGate.js.map +1 -0
  31. package/dist/execution/ActionRegistry.d.ts +40 -0
  32. package/dist/execution/ActionRegistry.d.ts.map +1 -1
  33. package/dist/execution/ActionRegistry.js +47 -2
  34. package/dist/execution/ActionRegistry.js.map +1 -1
  35. package/dist/execution/AuthorizationVerifier.d.ts +147 -1
  36. package/dist/execution/AuthorizationVerifier.d.ts.map +1 -1
  37. package/dist/execution/AuthorizationVerifier.js +209 -6
  38. package/dist/execution/AuthorizationVerifier.js.map +1 -1
  39. package/dist/execution/SafeExecutor.d.ts +34 -3
  40. package/dist/execution/SafeExecutor.d.ts.map +1 -1
  41. package/dist/execution/SafeExecutor.js +42 -5
  42. package/dist/execution/SafeExecutor.js.map +1 -1
  43. package/dist/http/ClientIdentification.d.ts +22 -0
  44. package/dist/http/ClientIdentification.d.ts.map +1 -0
  45. package/dist/http/ClientIdentification.js +44 -0
  46. package/dist/http/ClientIdentification.js.map +1 -0
  47. package/dist/http/Credential.d.ts +17 -0
  48. package/dist/http/Credential.d.ts.map +1 -0
  49. package/dist/http/Credential.js +5 -0
  50. package/dist/http/Credential.js.map +1 -0
  51. package/dist/http/StoredCredentials.d.ts +34 -0
  52. package/dist/http/StoredCredentials.d.ts.map +1 -0
  53. package/dist/http/StoredCredentials.js +85 -0
  54. package/dist/http/StoredCredentials.js.map +1 -0
  55. package/dist/intent/CanonicalIntentHasher.d.ts.map +1 -1
  56. package/dist/intent/CanonicalIntentHasher.js +3 -0
  57. package/dist/intent/CanonicalIntentHasher.js.map +1 -1
  58. package/dist/intent/ExecutionIntent.d.ts +13 -1
  59. package/dist/intent/ExecutionIntent.d.ts.map +1 -1
  60. package/dist/intent/ExecutionIntent.js +4 -0
  61. package/dist/intent/ExecutionIntent.js.map +1 -1
  62. package/dist/intent/IntentCapture.d.ts.map +1 -1
  63. package/dist/intent/IntentCapture.js +3 -0
  64. package/dist/intent/IntentCapture.js.map +1 -1
  65. package/dist/report/DecisionReport.d.ts +22 -0
  66. package/dist/report/DecisionReport.d.ts.map +1 -0
  67. package/dist/report/DecisionReport.js +37 -0
  68. package/dist/report/DecisionReport.js.map +1 -0
  69. package/dist/report/DossierReport.d.ts +43 -0
  70. package/dist/report/DossierReport.d.ts.map +1 -0
  71. package/dist/report/DossierReport.js +130 -0
  72. package/dist/report/DossierReport.js.map +1 -0
  73. package/dist/report/HostedOutcome.d.ts +18 -0
  74. package/dist/report/HostedOutcome.d.ts.map +1 -0
  75. package/dist/report/HostedOutcome.js +24 -0
  76. package/dist/report/HostedOutcome.js.map +1 -0
  77. package/dist/testing/Index.d.ts +1 -1
  78. package/dist/testing/Index.d.ts.map +1 -1
  79. package/dist/testing/Index.js +1 -1
  80. package/dist/testing/Index.js.map +1 -1
  81. package/dist/testing/LocalAuthority.d.ts +156 -0
  82. package/dist/testing/LocalAuthority.d.ts.map +1 -1
  83. package/dist/testing/LocalAuthority.js +531 -9
  84. package/dist/testing/LocalAuthority.js.map +1 -1
  85. package/package.json +1 -1
@@ -15,7 +15,7 @@
15
15
  * Grants, claims, escalations, and commit outcomes are synthetic in-memory
16
16
  * state. Nothing here is a real policy, tenant, or credential.
17
17
  */
18
- import { createHash, randomBytes, randomUUID } from "node:crypto";
18
+ import { createHash, createPublicKey, generateKeyPairSync, randomBytes, randomUUID, sign, verify, } from "node:crypto";
19
19
  import { createServer } from "node:http";
20
20
  import { z } from "zod";
21
21
  import { readBody } from "./LocalPresence.js";
@@ -28,6 +28,16 @@ const MAX_RECORDED_BODY_CHARS = 4_096;
28
28
  const SOCKET_TIMEOUT_MS = 10_000;
29
29
  const GRANT_TTL_MS = 60_000;
30
30
  const CLAIM_LEASE_MS = 30_000;
31
+ /** The issuer the fixture's grants and attestations name; a provider double checks it. */
32
+ export const LOCAL_AUTHORITY_ISSUER = "synthetic-authority";
33
+ /** Where the fixture publishes its attestation key, at the path Decionis documents. */
34
+ export const LOCAL_AUTHORITY_JWKS_PATH = "/.well-known/decionis-execution-grant-jwks.json";
35
+ export const CLAIM_ATTESTATION_TYPE = "decionis-claim-attestation+jwt";
36
+ /** The protected header `typ` of a verifying provider's effect receipt (VP-3). */
37
+ export const EFFECT_RECEIPT_TYPE = "decionis-effect-receipt+jwt";
38
+ const COMPACT_JWS = /^[\w-]+\.[\w-]+\.[\w-]+$/;
39
+ /** The profile the fixture digests parameters under; the only one the pipeline reproduces. */
40
+ const JCS_PROFILE = "RFC8785/JCS";
31
41
  const boundedId = z.string().trim().min(1).max(200);
32
42
  const sha256Digest = z.string().regex(/^sha256:[0-9a-f]{64}$/);
33
43
  const roleId = z.string().regex(/^[A-Z][A-Z0-9_]{1,63}$/);
@@ -60,6 +70,7 @@ const IntentBindingSchema = z.strictObject({
60
70
  operation: boundedId,
61
71
  endpoint: z.string().min(1).max(500).optional(),
62
72
  }),
73
+ expected_effect_digest: sha256Digest.optional(),
63
74
  });
64
75
  const EvidenceSchema = z.strictObject({
65
76
  humanApproval: z
@@ -70,6 +81,61 @@ const EvidenceSchema = z.strictObject({
70
81
  })
71
82
  .optional(),
72
83
  });
84
+ /** `boundedIdentifier` from the Decionis `EffectEvidence` contract, verbatim. */
85
+ const boundedEffectIdentifier = z.string().trim().min(1).max(500);
86
+ /**
87
+ * Protocol 1.1 `EffectEvidence`, mirrored from the Decionis shared contract
88
+ * including its `CONFIRMED` refinements: a confirmed observation may not rest
89
+ * on a bare downstream acknowledgement, must name an observer with a version,
90
+ * must have observed exactly the expected effect at a stated time, and must
91
+ * carry a digest or a reference for the observation itself.
92
+ */
93
+ const EffectEvidenceSchema = z
94
+ .strictObject({
95
+ version: z.literal("1.0"),
96
+ status: z.enum(["CONFIRMED", "UNCONFIRMED"]),
97
+ observation_method: z.enum([
98
+ "DOWNSTREAM_ACK",
99
+ "READ_AFTER_WRITE",
100
+ "EVENT_CONFIRMATION",
101
+ "STATE_RECONCILIATION",
102
+ "SIGNED_RECEIPT",
103
+ "EXTERNAL_ATTESTATION",
104
+ "HUMAN_VALIDATION",
105
+ ]),
106
+ observer: z
107
+ .strictObject({
108
+ id: boundedEffectIdentifier,
109
+ version: boundedEffectIdentifier.nullable(),
110
+ })
111
+ .nullable(),
112
+ expected_effect_digest: sha256Digest,
113
+ observed_effect_digest: sha256Digest.nullable(),
114
+ observed_at: z.iso.datetime().nullable(),
115
+ evidence_digest: sha256Digest.nullable(),
116
+ evidence_reference: boundedEffectIdentifier.nullable(),
117
+ execution_correlation_id: boundedEffectIdentifier,
118
+ })
119
+ .superRefine((evidence, context) => {
120
+ if (evidence.status !== "CONFIRMED")
121
+ return;
122
+ const fail = (message) => {
123
+ context.addIssue({ code: "custom", message });
124
+ };
125
+ if (evidence.observation_method === "DOWNSTREAM_ACK")
126
+ fail("EFFECT_METHOD_INSUFFICIENT");
127
+ if (evidence.observer === null || evidence.observer.version === null) {
128
+ fail("EFFECT_OBSERVER_REQUIRED");
129
+ }
130
+ if (evidence.observed_effect_digest !== evidence.expected_effect_digest) {
131
+ fail("EFFECT_OBSERVED_DIGEST_MISMATCH");
132
+ }
133
+ if (evidence.observed_at === null)
134
+ fail("EFFECT_OBSERVED_AT_REQUIRED");
135
+ if (evidence.evidence_digest === null && evidence.evidence_reference === null) {
136
+ fail("EFFECT_EVIDENCE_ARTIFACT_REQUIRED");
137
+ }
138
+ });
73
139
  const ManagedEscalationRequestSchema = z.strictObject({
74
140
  mode: z.literal("MANAGED"),
75
141
  approver: z
@@ -112,8 +178,36 @@ const FinalizeRequestSchema = z.strictObject({
112
178
  outcome: z.enum(["COMMITTED", "FAILED", "INDETERMINATE"]),
113
179
  commit_correlation_id: boundedId,
114
180
  downstream_evidence: z.record(z.string(), z.unknown()).optional(),
181
+ // Deliberately unvalidated here: the hosted route answers malformed effect
182
+ // evidence with a 409 and a reason code, not with a 400 request rejection,
183
+ // so the shape is checked where that answer is produced.
184
+ effect_evidence: z.unknown().optional(),
185
+ // The provider's receipt: only its shape is a request error. What it says
186
+ // is verified and recorded, never a reason to refuse the finalization.
187
+ effect_receipt: z.string().min(1).max(20_000).regex(COMPACT_JWS).optional(),
188
+ });
189
+ /** `ExecutionProviderKeyRegistrationRequest`: the public half of an Ed25519 key. */
190
+ const ProviderKeyRegistrationSchema = z.strictObject({
191
+ org_id: z.uuid().optional(),
192
+ kid: boundedId,
193
+ issuer: z.string().trim().min(1).max(500),
194
+ algorithm: z.literal("EdDSA"),
195
+ public_jwk: z.strictObject({
196
+ kty: z.literal("OKP"),
197
+ crv: z.literal("Ed25519"),
198
+ x: z.string().regex(/^[\w-]{43}$/),
199
+ kid: z.string().optional(),
200
+ alg: z.literal("EdDSA").optional(),
201
+ use: z.literal("sig").optional(),
202
+ }),
203
+ label: z.string().max(200).optional(),
115
204
  });
116
205
  const BINDING_KEYS = Object.keys(IntentBindingSchema.shape);
206
+ function effectStatus(value) {
207
+ return value === "EFFECTED" || value === "REFUSED" || value === "INDETERMINATE" ? value : null;
208
+ }
209
+ /** The organization a provisioned synthetic workspace belongs to. */
210
+ export const LOCAL_AUTHORITY_PROVISIONAL_ORG_ID = "00000000-0000-4000-8000-000000000008";
117
211
  /**
118
212
  * Independent canonicalization: keys sorted by UTF-16 code unit, values encoded
119
213
  * with JSON.stringify semantics. This deliberately does not import the
@@ -163,12 +257,25 @@ export class LocalAuthority {
163
257
  grants = new Map();
164
258
  /** Managed escalations keyed by their opaque identifier. */
165
259
  escalations = new Map();
260
+ /** The verifying providers' receipt keys, by `kid`, as `/v1/execution/provider-keys` registers them. */
261
+ providerKeys = new Map();
166
262
  apiKey;
167
263
  presence;
168
264
  legacyVerifyReceipt;
169
265
  policy;
170
266
  managedApproverId;
267
+ trustedEffectObserverIds;
171
268
  clock;
269
+ attestationKey;
270
+ /** The `kid` the fixture's attestations name; a provider double looks it up in the JWKS. */
271
+ attestationKeyId = "synthetic-exec-grant-1";
272
+ /**
273
+ * A drill knob: when set, the next claim binds a digest over parameters
274
+ * other than the ones captured, which is what a tampered or mismatched
275
+ * authority looks like from the executor's side, and what BEAP-L3-BND-01
276
+ * says the executor must refuse.
277
+ */
278
+ misbindNextPayloadDigest = false;
172
279
  overrides = {
173
280
  enforce: [],
174
281
  status: [],
@@ -178,17 +285,174 @@ export class LocalAuthority {
178
285
  managedByIntent = new Map();
179
286
  nextManagedLifecycle = null;
180
287
  decisions = 0;
288
+ /** Every decision minted, by dossier id, for `GET /v1/protocol/dossiers/{id}`. */
289
+ dossiers = new Map();
290
+ verificationLinks;
181
291
  server = null;
182
292
  port = 0;
183
293
  constructor(options = {}) {
184
294
  if (process.env.NODE_ENV === "production")
185
295
  throw new Error("LOCAL_DOUBLE_FORBIDDEN");
186
296
  this.apiKey = options.apiKey ?? LOCAL_AUTHORITY_API_KEY;
297
+ this.verificationLinks = options.verificationLinks === true;
187
298
  this.presence = options.presence;
188
299
  this.legacyVerifyReceipt = options.verifyReceipt;
189
300
  this.policy = options.policy ?? defaultPolicy;
190
301
  this.managedApproverId = options.managedApproverId ?? "synthetic-managed-approver";
302
+ this.trustedEffectObserverIds = options.trustedEffectObserverIds ?? [];
191
303
  this.clock = options.clock ?? Date.now;
304
+ const keys = generateKeyPairSync("ed25519");
305
+ this.attestationKey = {
306
+ privateKey: keys.privateKey,
307
+ publicJwk: keys.publicKey.export({ format: "jwk" }),
308
+ };
309
+ }
310
+ /** The fixture's JWKS, as `GET /.well-known/decionis-execution-grant-jwks.json` serves it. */
311
+ get jwks() {
312
+ return {
313
+ keys: [
314
+ { ...this.attestationKey.publicJwk, kid: this.attestationKeyId, alg: "EdDSA", use: "sig" },
315
+ ],
316
+ };
317
+ }
318
+ /**
319
+ * A compact JWS over the claim, signed the way Decionis signs one: EdDSA,
320
+ * the grant key, the attestation type in the protected header. Built with
321
+ * `node:crypto` alone so the fixture stays an independent implementation.
322
+ */
323
+ attest(grant, claimValidatedAt, leaseExpiresAt) {
324
+ const encode = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
325
+ const now = Math.floor(this.clock() / 1_000);
326
+ const claims = {
327
+ iss: LOCAL_AUTHORITY_ISSUER,
328
+ sub: grant.jti,
329
+ org_id: grant.tenantId,
330
+ dossier_id: grant.dossierId,
331
+ decision_id: grant.decisionId,
332
+ binding: {
333
+ intent_hash: grant.intentHash,
334
+ execution_payload_digest: grant.payloadDigest,
335
+ execution_payload_canonicalization_profile: JCS_PROFILE,
336
+ ...(grant.expectedEffectDigest === null
337
+ ? {}
338
+ : { expected_effect_digest: grant.expectedEffectDigest }),
339
+ execution_nonce: grant.nonce,
340
+ execution_correlation_id: grant.correlationId ?? "",
341
+ },
342
+ claim_token_digest: `sha256:${createHash("sha256")
343
+ .update(grant.claimToken ?? "", "utf8")
344
+ .digest("hex")}`,
345
+ claim_validated_at: claimValidatedAt,
346
+ jti: randomUUID(),
347
+ iat: now,
348
+ nbf: now,
349
+ exp: Math.floor(Date.parse(leaseExpiresAt) / 1_000),
350
+ };
351
+ const header = encode({
352
+ alg: "EdDSA",
353
+ kid: this.attestationKeyId,
354
+ typ: CLAIM_ATTESTATION_TYPE,
355
+ });
356
+ const payload = encode(claims);
357
+ const signature = sign(null, Buffer.from(`${header}.${payload}`, "ascii"), this.attestationKey.privateKey);
358
+ return `${header}.${payload}.${signature.toString("base64url")}`;
359
+ }
360
+ /**
361
+ * Registers the public half of a provider's receipt key, as the hosted
362
+ * `POST /v1/execution/provider-keys` does; a test or a demo provider calls
363
+ * it directly rather than over HTTP.
364
+ */
365
+ registerProviderKey(input) {
366
+ this.providerKeys.set(input.kid, {
367
+ kid: input.kid,
368
+ issuer: input.issuer,
369
+ publicKey: createPublicKey({ key: input.publicJwk, format: "jwk" }),
370
+ label: input.label ?? null,
371
+ });
372
+ }
373
+ /**
374
+ * Reads a receipt the way the hosted authority does: the protected header
375
+ * names an Ed25519 key the organisation registered; the signature verifies
376
+ * under it; the issuer is the one registered with the key and the audience
377
+ * is this authority; and the claims describe this grant and this claim,
378
+ * through `sub`, the decision and dossier ids and the digest of the claim
379
+ * token being finalized. Anything else is recorded with its code, and the
380
+ * finalization proceeds regardless.
381
+ */
382
+ readEffectReceipt(token, grant, claimToken) {
383
+ const record = (code, partial = {}) => ({
384
+ token,
385
+ verified: code === "EFFECT_RECEIPT_VERIFIED",
386
+ verification_code: code,
387
+ provider_key_id: null,
388
+ issuer: null,
389
+ effect_status: null,
390
+ effect_digest: null,
391
+ effect_reference: null,
392
+ effected_at: null,
393
+ ...partial,
394
+ });
395
+ const [encodedHeader = "", encodedPayload = "", encodedSignature = ""] = token.split(".");
396
+ let header;
397
+ let payload;
398
+ try {
399
+ header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8"));
400
+ payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8"));
401
+ }
402
+ catch {
403
+ return record("EFFECT_RECEIPT_MALFORMED");
404
+ }
405
+ if (header.alg !== "EdDSA" ||
406
+ header.typ !== EFFECT_RECEIPT_TYPE ||
407
+ typeof header.kid !== "string" ||
408
+ header.kid.length === 0) {
409
+ return record("EFFECT_RECEIPT_MALFORMED");
410
+ }
411
+ const key = this.providerKeys.get(header.kid);
412
+ if (key === undefined) {
413
+ return record("EFFECT_RECEIPT_KEY_UNKNOWN", { provider_key_id: header.kid });
414
+ }
415
+ const known = { provider_key_id: key.kid, issuer: key.issuer };
416
+ const signed = Buffer.from(`${encodedHeader}.${encodedPayload}`, "ascii");
417
+ if (!verify(null, signed, key.publicKey, Buffer.from(encodedSignature, "base64url")) ||
418
+ payload.iss !== key.issuer ||
419
+ payload.aud !== LOCAL_AUTHORITY_ISSUER) {
420
+ return record("EFFECT_RECEIPT_SIGNATURE_INVALID", known);
421
+ }
422
+ const effect = typeof payload.effect === "object" && payload.effect !== null
423
+ ? payload.effect
424
+ : null;
425
+ const status = effectStatus(effect?.status);
426
+ const digest = effect?.digest;
427
+ const effectedAt = effect?.effected_at;
428
+ if (typeof payload.sub !== "string" ||
429
+ typeof payload.decision_id !== "string" ||
430
+ typeof payload.dossier_id !== "string" ||
431
+ typeof payload.claim_token_digest !== "string" ||
432
+ typeof payload.jti !== "string" ||
433
+ typeof payload.iat !== "number" ||
434
+ status === null ||
435
+ (digest !== undefined && !sha256Digest.safeParse(digest).success) ||
436
+ typeof effectedAt !== "string" ||
437
+ Number.isNaN(Date.parse(effectedAt))) {
438
+ return record("EFFECT_RECEIPT_MALFORMED", known);
439
+ }
440
+ const described = {
441
+ ...known,
442
+ effect_status: status,
443
+ effect_digest: typeof digest === "string" ? digest : null,
444
+ effect_reference: typeof effect?.reference === "string" ? effect.reference : null,
445
+ effected_at: new Date(effectedAt).toISOString(),
446
+ };
447
+ const claimTokenDigest = `sha256:${createHash("sha256").update(claimToken, "utf8").digest("hex")}`;
448
+ if (payload.sub !== grant.jti ||
449
+ payload.decision_id !== grant.decisionId ||
450
+ payload.dossier_id !== grant.dossierId ||
451
+ payload.claim_token_digest !== claimTokenDigest ||
452
+ (typeof payload.intent_hash === "string" && payload.intent_hash !== grant.intentHash)) {
453
+ return record("EFFECT_RECEIPT_BINDING_MISMATCH", described);
454
+ }
455
+ return record("EFFECT_RECEIPT_VERIFIED", described);
192
456
  }
193
457
  get baseUrl() {
194
458
  return `${LOOPBACK_ORIGIN}:${this.port}`;
@@ -249,9 +513,38 @@ export class LocalAuthority {
249
513
  record.body = truncate(raw);
250
514
  return this.send(res, record, 400, { error: "REQUEST_MALFORMED" });
251
515
  }
516
+ // The verification keys are public, as Decionis's are: a provider double
517
+ // fetches them with no credential to the authority at all.
518
+ if (req.method === "GET" && url.pathname === LOCAL_AUTHORITY_JWKS_PATH) {
519
+ return this.send(res, record, 200, this.jwks);
520
+ }
521
+ // The public lane: a workspace minted with no account, as the hosted
522
+ // authority mints one, with this double's own key as the raw key.
523
+ if (req.method === "POST" && url.pathname === "/v1/public/agents/provision") {
524
+ return this.send(res, record, 201, {
525
+ org_id: LOCAL_AUTHORITY_PROVISIONAL_ORG_ID,
526
+ raw_key: this.apiKey,
527
+ provisional: true,
528
+ limits: {
529
+ requests_per_minute: 10,
530
+ workspaces_per_network_per_day: 5,
531
+ governed_decisions_per_month: 50,
532
+ },
533
+ claim: { note: "synthetic workspace on loopback; there is nothing to claim" },
534
+ next: {},
535
+ });
536
+ }
252
537
  if (req.headers.authorization !== `Bearer ${this.apiKey}`) {
253
538
  return this.send(res, record, 401, { error: "UNAUTHORIZED" });
254
539
  }
540
+ const dossierLookup = url.pathname.match(/^\/v1\/protocol\/dossiers\/([^/]+)$/);
541
+ if (req.method === "GET" && dossierLookup !== null) {
542
+ const minted = this.dossiers.get(decodeURIComponent(dossierLookup[1] ?? ""));
543
+ if (minted === undefined || url.searchParams.get("org_id") !== minted.request.tenant_id) {
544
+ return this.send(res, record, 404, { error: "DOSSIER_NOT_FOUND" });
545
+ }
546
+ return this.send(res, record, 200, this.dossierRecord(minted.request, minted.decision));
547
+ }
255
548
  const escalationLookup = url.pathname.match(/^\/v1\/authority\/escalations\/([^/]+)$/);
256
549
  if (req.method === "GET" && escalationLookup !== null) {
257
550
  return this.respond(res, record, "status", this.managedStatus(decodeURIComponent(escalationLookup[1] ?? "")));
@@ -265,6 +558,8 @@ export class LocalAuthority {
265
558
  return this.send(res, record, 415, { error: "UNSUPPORTED_MEDIA_TYPE" });
266
559
  }
267
560
  switch (url.pathname) {
561
+ case "/v1/execution/provider-keys":
562
+ return this.send(res, record, ...this.registerProviderKeyRoute(record));
268
563
  case "/v1/authority/enforce-and-bind":
269
564
  return this.respond(res, record, "enforce", this.enforceAndBind(record));
270
565
  case "/v1/execution/claim-token":
@@ -276,6 +571,31 @@ export class LocalAuthority {
276
571
  return this.send(res, record, 404, { error: "NOT_FOUND" });
277
572
  }
278
573
  }
574
+ /** `POST /v1/execution/provider-keys`: the public half only, or a 400 naming why. */
575
+ registerProviderKeyRoute(record) {
576
+ const parsed = ProviderKeyRegistrationSchema.safeParse(record.body);
577
+ if (!parsed.success)
578
+ return [400, { error: "INVALID_BODY" }];
579
+ const body = parsed.data;
580
+ this.registerProviderKey({
581
+ kid: body.kid,
582
+ issuer: body.issuer,
583
+ publicJwk: { kty: body.public_jwk.kty, crv: body.public_jwk.crv, x: body.public_jwk.x },
584
+ ...(body.label === undefined ? {} : { label: body.label }),
585
+ });
586
+ return [
587
+ 201,
588
+ {
589
+ service: "decionis",
590
+ kid: body.kid,
591
+ issuer: body.issuer,
592
+ algorithm: "EdDSA",
593
+ label: body.label ?? null,
594
+ created_at: new Date(this.clock()).toISOString(),
595
+ revoked_at: null,
596
+ },
597
+ ];
598
+ }
279
599
  enforceAndBind(record) {
280
600
  const parsed = AuthorityRequestSchema.safeParse(record.body);
281
601
  if (!parsed.success)
@@ -293,12 +613,78 @@ export class LocalAuthority {
293
613
  this.decisions += 1;
294
614
  const decision = this.baseDecision(request, evaluation.status, [evaluation.reasonCode]);
295
615
  if (evaluation.status === "ESCALATE" && request.escalation?.mode === "MANAGED") {
296
- return { status: 200, body: this.openManaged(request, decision) };
616
+ return { status: 200, body: this.remember(request, this.openManaged(request, decision)) };
297
617
  }
298
618
  if (evaluation.status !== "ALLOW" || request.mode !== "ENFORCEMENT") {
299
- return { status: 200, body: decision };
619
+ return { status: 200, body: this.remember(request, decision) };
300
620
  }
301
- return { status: 200, body: this.grantDecision(request, decision, evaluation.approval) };
621
+ return {
622
+ status: 200,
623
+ body: this.remember(request, this.grantDecision(request, decision, evaluation.approval)),
624
+ };
625
+ }
626
+ /** Keeps a minted decision for its dossier route, with the envelope when links are on. */
627
+ remember(request, decision) {
628
+ const dossierId = decision["dossier_id"];
629
+ if (typeof dossierId !== "string")
630
+ return decision;
631
+ const linked = this.verificationLinks
632
+ ? {
633
+ ...decision,
634
+ verification: {
635
+ verification_page_url: `${this.baseUrl}/verify/${dossierId}?sig=synthetic`,
636
+ verification_url: `${this.baseUrl}/v1/public/decision-dossiers/${dossierId}/verify?sig=synthetic`,
637
+ link_expires_at: new Date(this.clock() + 24 * 60 * 60 * 1_000).toISOString(),
638
+ signature_scheme: "synthetic",
639
+ },
640
+ }
641
+ : decision;
642
+ this.dossiers.set(dossierId, { request, decision: linked });
643
+ return linked;
644
+ }
645
+ /**
646
+ * The persisted record, shaped as `GET /v1/protocol/dossiers/{id}` returns
647
+ * it: a payload with the routing decision, the inputs and a proof bundle
648
+ * naming this double's key. The artifacts are not signed; the record says
649
+ * so in its issuer tier, and nothing here claims otherwise.
650
+ */
651
+ dossierRecord(request, decision) {
652
+ const generatedAt = new Date(this.clock()).toISOString();
653
+ return {
654
+ service: "synthetic-authority",
655
+ protocol_version: "synthetic",
656
+ dossier: {
657
+ dossier_payload: {
658
+ schema_version: "decionis.decision_dossier/2.0",
659
+ dossier_id: decision["dossier_id"],
660
+ generated_at: generatedAt,
661
+ routing_decision: {
662
+ decision_id: decision["decision_id"],
663
+ outcome: decision["status"],
664
+ authority: decision["authority_classification"],
665
+ policy_version: decision["policy_version"],
666
+ reason_codes: decision["reason_codes"],
667
+ },
668
+ inputs_snapshot: {
669
+ tenant_id: request.tenant_id,
670
+ actor_id: request.actor.id,
671
+ action: request.action.type,
672
+ target: request.action.resource,
673
+ },
674
+ portable_artifact: { issuer_context: { tier: "synthetic_loopback" } },
675
+ integrity: {
676
+ proof_bundle: {
677
+ bundle_type: "decionis.decision_dossier.proof_bundle",
678
+ version: "2.0",
679
+ issued_at: generatedAt,
680
+ algorithm: "Ed25519",
681
+ key_id: this.attestationKeyId,
682
+ artifacts: [],
683
+ },
684
+ },
685
+ },
686
+ },
687
+ };
302
688
  }
303
689
  /** Policy plus evidence handling: a valid receipt turns an escalation into an allow. */
304
690
  evaluate(request) {
@@ -380,9 +766,12 @@ export class LocalAuthority {
380
766
  expiresAt,
381
767
  nonce: randomBytes(32).toString("base64url"),
382
768
  bindingDigest,
769
+ payloadDigest: hashBinding(request.action.parameters),
770
+ expectedEffectDigest: request.expected_effect_digest ?? null,
383
771
  receiptDossierId: approval?.receiptDossierId ?? null,
384
772
  claimed: false,
385
773
  claimToken: null,
774
+ receipt: null,
386
775
  correlationId: null,
387
776
  consumedBy: null,
388
777
  finalized: null,
@@ -648,6 +1037,13 @@ export class LocalAuthority {
648
1037
  return rejected("GRANT_EXPIRED");
649
1038
  if (grant.intentHash !== parsed.data.intent_hash)
650
1039
  return rejected("GRANT_BINDING_MISMATCH");
1040
+ // Unreachable while the commitment stays inside the intent hash — a
1041
+ // divergence is already an `INTENT_HASH_MISMATCH` from the independent
1042
+ // re-hash above. Kept because the hosted route checks the committed value
1043
+ // itself, and a double that only checked the hash would stop proving that.
1044
+ if ((intent.data.expected_effect_digest ?? null) !== grant.expectedEffectDigest) {
1045
+ return rejected("GRANT_BINDING_MISMATCH");
1046
+ }
651
1047
  if (grant.receiptDossierId !== null &&
652
1048
  parsed.data.evidence?.humanApproval?.receiptDossierId !== grant.receiptDossierId) {
653
1049
  return rejected("PRESENCE_APPROVAL_STALE");
@@ -658,6 +1054,15 @@ export class LocalAuthority {
658
1054
  grant.claimToken = randomBytes(32).toString("base64url");
659
1055
  grant.correlationId = parsed.data.commit_correlation_id ?? intent.data.intent_id;
660
1056
  grant.consumedBy = parsed.data.consumed_by ?? null;
1057
+ const claimValidatedAt = new Date(this.clock()).toISOString();
1058
+ const leaseExpiresAt = new Date(this.clock() + CLAIM_LEASE_MS).toISOString();
1059
+ // The drill knob: an authority that bound a digest over other parameters.
1060
+ // It is consumed by one claim, so the executor's refusal is the one
1061
+ // observation and the next proposal sees an honest authority again.
1062
+ const payloadDigest = this.misbindNextPayloadDigest
1063
+ ? hashBinding({ ...intent.data.action.parameters, synthetic_tamper: true })
1064
+ : grant.payloadDigest;
1065
+ this.misbindNextPayloadDigest = false;
661
1066
  return {
662
1067
  status: 200,
663
1068
  body: {
@@ -667,7 +1072,7 @@ export class LocalAuthority {
667
1072
  verdict: "ALLOW",
668
1073
  reason_codes: [],
669
1074
  claims: {
670
- iss: "synthetic-authority",
1075
+ iss: LOCAL_AUTHORITY_ISSUER,
671
1076
  sub: grant.actorId,
672
1077
  aud: grant.audience,
673
1078
  org_id: grant.tenantId,
@@ -680,8 +1085,13 @@ export class LocalAuthority {
680
1085
  binding: {
681
1086
  intent_hash: grant.intentHash,
682
1087
  execution_binding_digest: grant.bindingDigest,
1088
+ execution_payload_digest: payloadDigest,
1089
+ execution_payload_canonicalization_profile: JCS_PROFILE,
683
1090
  execution_nonce: grant.nonce,
684
1091
  execution_correlation_id: grant.correlationId,
1092
+ ...(grant.expectedEffectDigest === null
1093
+ ? {}
1094
+ : { expected_effect_digest: grant.expectedEffectDigest }),
685
1095
  },
686
1096
  jti: grant.jti,
687
1097
  iat: grant.issuedAt,
@@ -689,7 +1099,9 @@ export class LocalAuthority {
689
1099
  exp: grant.expiresAt,
690
1100
  },
691
1101
  claim_token: grant.claimToken,
692
- claim_lease_expires_at: new Date(this.clock() + CLAIM_LEASE_MS).toISOString(),
1102
+ claim_validated_at: claimValidatedAt,
1103
+ claim_lease_expires_at: leaseExpiresAt,
1104
+ claim_attestation: this.attest({ ...grant, payloadDigest }, claimValidatedAt, leaseExpiresAt),
693
1105
  evidence: { nonce_claim_state: "CLAIMED", commit_correlation_id: grant.correlationId },
694
1106
  },
695
1107
  };
@@ -713,18 +1125,109 @@ export class LocalAuthority {
713
1125
  }
714
1126
  if (grant.finalized !== null)
715
1127
  return rejected("NONCE_REPLAY_DETECTED");
1128
+ // The receipt is read first and recorded whatever it says; when the grant
1129
+ // named the effect it expected and the executor supplied no observation of
1130
+ // its own, a verified receipt with a digest stands in as SIGNED_RECEIPT
1131
+ // evidence by the provider's key, exactly as the hosted route reads it.
1132
+ const receipt = parsed.data.effect_receipt === undefined
1133
+ ? null
1134
+ : this.readEffectReceipt(parsed.data.effect_receipt, grant, parsed.data.claim_token);
1135
+ let supplied = parsed.data.effect_evidence;
1136
+ if (supplied === undefined &&
1137
+ receipt?.verified === true &&
1138
+ grant.expectedEffectDigest !== null &&
1139
+ receipt.effect_digest !== null) {
1140
+ supplied = {
1141
+ version: "1.0",
1142
+ status: receipt.effect_status === "EFFECTED" &&
1143
+ receipt.effect_digest === grant.expectedEffectDigest &&
1144
+ parsed.data.outcome === "COMMITTED"
1145
+ ? "CONFIRMED"
1146
+ : "UNCONFIRMED",
1147
+ observation_method: "SIGNED_RECEIPT",
1148
+ observer: { id: receipt.provider_key_id, version: EFFECT_RECEIPT_TYPE },
1149
+ expected_effect_digest: grant.expectedEffectDigest,
1150
+ observed_effect_digest: receipt.effect_digest,
1151
+ observed_at: receipt.effected_at,
1152
+ evidence_digest: `sha256:${createHash("sha256").update(receipt.token, "utf8").digest("hex")}`,
1153
+ evidence_reference: receipt.effect_reference,
1154
+ execution_correlation_id: parsed.data.commit_correlation_id,
1155
+ };
1156
+ }
1157
+ // The hosted authority refuses the whole finalization for evidence it
1158
+ // cannot bind, in exactly this order, before the commit transition.
1159
+ let evidence;
1160
+ if (supplied !== undefined) {
1161
+ const parsedEvidence = EffectEvidenceSchema.safeParse(supplied);
1162
+ if (!parsedEvidence.success)
1163
+ return rejected(malformedEffectEvidenceReason(supplied));
1164
+ evidence = parsedEvidence.data;
1165
+ if (evidence.execution_correlation_id !== parsed.data.commit_correlation_id) {
1166
+ return rejected("EXECUTION_CORRELATION_MISMATCH");
1167
+ }
1168
+ if (grant.expectedEffectDigest === null) {
1169
+ return rejected("EFFECT_EXPECTED_BINDING_UNAVAILABLE");
1170
+ }
1171
+ if (evidence.expected_effect_digest !== grant.expectedEffectDigest) {
1172
+ return rejected("EFFECT_EXPECTED_DIGEST_MISMATCH");
1173
+ }
1174
+ if (evidence.status === "CONFIRMED" && parsed.data.outcome !== "COMMITTED") {
1175
+ return rejected("EFFECT_EVIDENCE_OUTCOME_MISMATCH");
1176
+ }
1177
+ if (evidence.status === "CONFIRMED") {
1178
+ // A verified receipt's key is an observer for this finalization only.
1179
+ const trusted = [
1180
+ ...this.effectObserverIds(),
1181
+ ...(receipt?.verified === true && receipt.provider_key_id !== null
1182
+ ? [receipt.provider_key_id]
1183
+ : []),
1184
+ ];
1185
+ if (trusted.length === 0) {
1186
+ return rejected("EFFECT_OBSERVER_PROVENANCE_UNAVAILABLE");
1187
+ }
1188
+ if (evidence.observer === null || !trusted.includes(evidence.observer.id)) {
1189
+ return rejected("EFFECT_OBSERVER_PROVENANCE_MISMATCH");
1190
+ }
1191
+ }
1192
+ }
716
1193
  grant.finalized = parsed.data.outcome;
1194
+ grant.receipt = receipt;
1195
+ // Byte-for-byte the hosted success body: the commit's own decision-chain
1196
+ // evidence is queued, not yet recorded, so a successful finalization still
1197
+ // carries `COMMIT_EVIDENCE_PENDING`.
717
1198
  return {
718
1199
  status: 200,
719
1200
  body: {
720
1201
  finalized: true,
721
1202
  outcome: parsed.data.outcome,
722
- evidence_recorded: true,
723
- effect_status: "NOT_OBSERVED",
724
- reason_codes: [],
1203
+ evidence_durably_queued: true,
1204
+ decision_chain_evidence_recorded: false,
1205
+ evidence_recorded: false,
1206
+ effect_evidence_recorded: evidence !== undefined,
1207
+ effect_confirmation: evidence?.status === "CONFIRMED" ? "CONFIRMED" : "UNCONFIRMED",
1208
+ ...(receipt === null
1209
+ ? {}
1210
+ : {
1211
+ effect_receipt: {
1212
+ verified: receipt.verified,
1213
+ verification_code: receipt.verification_code,
1214
+ provider_key_id: receipt.provider_key_id,
1215
+ recorded: true,
1216
+ },
1217
+ }),
1218
+ reason_codes: ["COMMIT_EVIDENCE_PENDING"],
725
1219
  },
726
1220
  };
727
1221
  }
1222
+ /**
1223
+ * The hosted route never trusts the allowlist alone: it returns at most the
1224
+ * caller's own authenticated API-key identity, so a confirmed observation
1225
+ * must name the identity that presented the credential. This double has one
1226
+ * credential, so that credential is its identity.
1227
+ */
1228
+ effectObserverIds() {
1229
+ return this.trustedEffectObserverIds.includes(this.apiKey) ? [this.apiKey] : [];
1230
+ }
728
1231
  /** Recomputes the hash independently and applies the contract's time rules. */
729
1232
  assertBinding(request, record) {
730
1233
  const recomputed = hashBinding(bindingOf(request));
@@ -789,6 +1292,25 @@ export class LocalAuthority {
789
1292
  status === "FAILED");
790
1293
  }
791
1294
  }
1295
+ /**
1296
+ * The hosted route maps an unparseable observation to a 409 reason code rather
1297
+ * than a request rejection, and singles out a `CONFIRMED` observation whose
1298
+ * observer version is missing, because that is a provenance failure rather than
1299
+ * a malformed body.
1300
+ */
1301
+ function malformedEffectEvidenceReason(supplied) {
1302
+ const candidate = asRecord(supplied);
1303
+ const observer = asRecord(candidate.observer);
1304
+ if (candidate.version === "1.0" &&
1305
+ candidate.status === "CONFIRMED" &&
1306
+ (typeof observer.version !== "string" || observer.version.trim().length === 0)) {
1307
+ return "EFFECT_OBSERVER_PROVENANCE_UNAVAILABLE";
1308
+ }
1309
+ return "EFFECT_EVIDENCE_INVALID";
1310
+ }
1311
+ function asRecord(value) {
1312
+ return typeof value === "object" && value !== null ? value : {};
1313
+ }
792
1314
  function headerString(value) {
793
1315
  if (typeof value === "string")
794
1316
  return value;