@decionis/agent-safe-pipeline 0.4.0 → 0.5.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 (87) hide show
  1. package/README.md +29 -0
  2. package/dist/Index.d.ts +9 -0
  3. package/dist/Index.d.ts.map +1 -1
  4. package/dist/Index.js +9 -0
  5. package/dist/Index.js.map +1 -1
  6. package/dist/execution/ActionRegistry.d.ts +12 -0
  7. package/dist/execution/ActionRegistry.d.ts.map +1 -1
  8. package/dist/execution/ActionRegistry.js +14 -0
  9. package/dist/execution/ActionRegistry.js.map +1 -1
  10. package/dist/execution/GovernedExecutionRunner.d.ts +14 -0
  11. package/dist/execution/GovernedExecutionRunner.d.ts.map +1 -1
  12. package/dist/execution/GovernedExecutionRunner.js +46 -1
  13. package/dist/execution/GovernedExecutionRunner.js.map +1 -1
  14. package/dist/execution/NativeExecutor.d.ts +20 -1
  15. package/dist/execution/NativeExecutor.d.ts.map +1 -1
  16. package/dist/execution/NativeExecutor.js.map +1 -1
  17. package/dist/intent/CanonicalIntentHasher.d.ts.map +1 -1
  18. package/dist/intent/CanonicalIntentHasher.js +11 -0
  19. package/dist/intent/CanonicalIntentHasher.js.map +1 -1
  20. package/dist/intent/ExecutionIntent.d.ts +55 -4
  21. package/dist/intent/ExecutionIntent.d.ts.map +1 -1
  22. package/dist/intent/ExecutionIntent.js +40 -0
  23. package/dist/intent/ExecutionIntent.js.map +1 -1
  24. package/dist/intent/ExecutionSignals.d.ts +9 -1
  25. package/dist/intent/ExecutionSignals.d.ts.map +1 -1
  26. package/dist/intent/ExecutionSignals.js +9 -0
  27. package/dist/intent/ExecutionSignals.js.map +1 -1
  28. package/dist/intent/IntentBindingSchema.d.ts +6 -0
  29. package/dist/intent/IntentBindingSchema.d.ts.map +1 -1
  30. package/dist/intent/IntentBindingSchema.js +10 -0
  31. package/dist/intent/IntentBindingSchema.js.map +1 -1
  32. package/dist/intent/IntentCapture.d.ts.map +1 -1
  33. package/dist/intent/IntentCapture.js +18 -4
  34. package/dist/intent/IntentCapture.js.map +1 -1
  35. package/dist/intent/TargetPrecondition.d.ts +36 -0
  36. package/dist/intent/TargetPrecondition.d.ts.map +1 -0
  37. package/dist/intent/TargetPrecondition.js +57 -0
  38. package/dist/intent/TargetPrecondition.js.map +1 -0
  39. package/dist/manifest/ActionManifest.d.ts +112 -0
  40. package/dist/manifest/ActionManifest.d.ts.map +1 -0
  41. package/dist/manifest/ActionManifest.js +182 -0
  42. package/dist/manifest/ActionManifest.js.map +1 -0
  43. package/dist/plan/GovernedPlan.d.ts +109 -0
  44. package/dist/plan/GovernedPlan.d.ts.map +1 -0
  45. package/dist/plan/GovernedPlan.js +187 -0
  46. package/dist/plan/GovernedPlan.js.map +1 -0
  47. package/dist/plan/PlanState.d.ts +94 -0
  48. package/dist/plan/PlanState.d.ts.map +1 -0
  49. package/dist/plan/PlanState.js +240 -0
  50. package/dist/plan/PlanState.js.map +1 -0
  51. package/dist/plan/PlanStepReference.d.ts +16 -0
  52. package/dist/plan/PlanStepReference.d.ts.map +1 -0
  53. package/dist/plan/PlanStepReference.js +25 -0
  54. package/dist/plan/PlanStepReference.js.map +1 -0
  55. package/dist/profile/ApiProfile.d.ts +354 -0
  56. package/dist/profile/ApiProfile.d.ts.map +1 -0
  57. package/dist/profile/ApiProfile.js +755 -0
  58. package/dist/profile/ApiProfile.js.map +1 -0
  59. package/dist/profile/ApiProfiles.d.ts +16 -0
  60. package/dist/profile/ApiProfiles.d.ts.map +1 -0
  61. package/dist/profile/ApiProfiles.js +29 -0
  62. package/dist/profile/ApiProfiles.js.map +1 -0
  63. package/dist/profile/BuiltInApiProfiles.d.ts +9 -0
  64. package/dist/profile/BuiltInApiProfiles.d.ts.map +1 -0
  65. package/dist/profile/BuiltInApiProfiles.js +494 -0
  66. package/dist/profile/BuiltInApiProfiles.js.map +1 -0
  67. package/dist/profile/McpToolProfile.d.ts +129 -0
  68. package/dist/profile/McpToolProfile.d.ts.map +1 -0
  69. package/dist/profile/McpToolProfile.js +270 -0
  70. package/dist/profile/McpToolProfile.js.map +1 -0
  71. package/dist/testing/Index.d.ts +3 -1
  72. package/dist/testing/Index.d.ts.map +1 -1
  73. package/dist/testing/Index.js +2 -0
  74. package/dist/testing/Index.js.map +1 -1
  75. package/dist/testing/LocalAssumptionBreaches.d.ts +96 -0
  76. package/dist/testing/LocalAssumptionBreaches.d.ts.map +1 -0
  77. package/dist/testing/LocalAssumptionBreaches.js +109 -0
  78. package/dist/testing/LocalAssumptionBreaches.js.map +1 -0
  79. package/dist/testing/LocalAuthority.d.ts +158 -5
  80. package/dist/testing/LocalAuthority.d.ts.map +1 -1
  81. package/dist/testing/LocalAuthority.js +302 -18
  82. package/dist/testing/LocalAuthority.js.map +1 -1
  83. package/dist/testing/LocalPlans.d.ts +43 -0
  84. package/dist/testing/LocalPlans.d.ts.map +1 -0
  85. package/dist/testing/LocalPlans.js +124 -0
  86. package/dist/testing/LocalPlans.js.map +1 -0
  87. package/package.json +5 -4
@@ -12,6 +12,13 @@
12
12
  * create a request bound to the exact intent hash, wait for the ceremony,
13
13
  * verify the receipt, re-evaluate policy, then expose a normal grant.
14
14
  *
15
+ * A claim re-derives authority from current state before it consumes
16
+ * anything, as the hosted revalidator does: a policy released since the grant
17
+ * fails `POLICY_VERSION_STALE`, a material signal published since the grant
18
+ * fails `SIGNAL_VERSION_STALE`, and a concurrency scope another open claim
19
+ * holds fails `AUTHORIZATION_SCOPE_BUSY` (or `CURRENT_STATE_UNRESOLVED` while
20
+ * that claim's outcome is unknown). None of them burns the grant.
21
+ *
15
22
  * Grants, claims, escalations, and commit outcomes are synthetic in-memory
16
23
  * state. Nothing here is a real policy, tenant, or credential.
17
24
  */
@@ -19,6 +26,7 @@ import { createHash, createPublicKey, generateKeyPairSync, randomBytes, randomUU
19
26
  import { createServer } from "node:http";
20
27
  import { z } from "zod";
21
28
  import { readBody } from "./LocalPresence.js";
29
+ import { LocalAssumptionBreaches } from "./LocalAssumptionBreaches.js";
22
30
  import { verifySignedSignals, } from "./SignedSignals.js";
23
31
  export const LOCAL_AUTHORITY_API_KEY = "test-key";
24
32
  export const AUTONOMOUS_LIMIT_MINOR = 10_000;
@@ -29,6 +37,43 @@ const MAX_RECORDED_BODY_CHARS = 4_096;
29
37
  const SOCKET_TIMEOUT_MS = 10_000;
30
38
  const GRANT_TTL_MS = 60_000;
31
39
  const CLAIM_LEASE_MS = 30_000;
40
+ /**
41
+ * `ExecutionTargetPrecondition`, the contract's, written here again rather
42
+ * than imported, so the fixture checks the package's precondition with an
43
+ * independent reading: what a runtime states in `context.agent_safe` about
44
+ * the target's state before asking.
45
+ */
46
+ const TargetPreconditionContract = z
47
+ .object({
48
+ version: z.string().regex(/^[\x21-\x7e]{1,256}$/),
49
+ state: z
50
+ .string()
51
+ .min(1)
52
+ .max(200)
53
+ .regex(/^\P{Cc}+$/u)
54
+ .optional(),
55
+ source: z
56
+ .string()
57
+ .min(1)
58
+ .max(200)
59
+ .regex(/^[a-z0-9][\w.:/#-]*$/i),
60
+ read_at: z.string().datetime(),
61
+ })
62
+ .strict();
63
+ /**
64
+ * The precondition an intent's context states: the parsed contract, null when
65
+ * it states none, or `undefined` when it states one the contract refuses.
66
+ */
67
+ function preconditionIn(context) {
68
+ const namespace = context["agent_safe"];
69
+ if (namespace === null || typeof namespace !== "object" || Array.isArray(namespace))
70
+ return null;
71
+ const raw = namespace["precondition"];
72
+ if (raw === undefined)
73
+ return null;
74
+ const parsed = TargetPreconditionContract.safeParse(raw);
75
+ return parsed.success ? parsed.data : undefined;
76
+ }
32
77
  /** The issuer the fixture's grants and attestations name; a provider double checks it. */
33
78
  export const LOCAL_AUTHORITY_ISSUER = "synthetic-authority";
34
79
  /** Where the fixture publishes its attestation key, at the path Decionis documents. */
@@ -71,6 +116,14 @@ const IntentBindingSchema = z.strictObject({
71
116
  operation: boundedId,
72
117
  endpoint: z.string().min(1).max(500).optional(),
73
118
  }),
119
+ policy_projection: z
120
+ .strictObject({
121
+ decision_type: z.string().trim().min(1).max(120),
122
+ transaction_type: z.string().trim().min(1).max(120).nullable(),
123
+ workflow_key: z.string().trim().min(1).max(120).nullable(),
124
+ channel: z.string().trim().min(1).max(120).nullable(),
125
+ })
126
+ .optional(),
74
127
  expected_effect_digest: sha256Digest.optional(),
75
128
  });
76
129
  const EvidenceSchema = z.strictObject({
@@ -308,6 +361,13 @@ export class LocalAuthority {
308
361
  legacyVerifyReceipt;
309
362
  verifyAttestation;
310
363
  policy;
364
+ /** How many policy releases are active or behind; the first is `synthetic-policy-v1`. */
365
+ policyReleases = 1;
366
+ /** Every material signal ingested, by id: the reference and the value it carries. */
367
+ signals = new Map();
368
+ materialSignalsOf;
369
+ tenantKeys;
370
+ addressableGrants;
311
371
  managedApproverId;
312
372
  trustedEffectObserverIds;
313
373
  clock;
@@ -321,6 +381,13 @@ export class LocalAuthority {
321
381
  * says the executor must refuse.
322
382
  */
323
383
  misbindNextPayloadDigest = false;
384
+ /**
385
+ * The hosted assumption breaches: a finalization whose receipt or
386
+ * observation contradicts its grant holds the grant's scope, and
387
+ * `assumptionBreaches.clear(citations)` is the governed release or
388
+ * recovery review that cites it.
389
+ */
390
+ assumptionBreaches = new LocalAssumptionBreaches();
324
391
  overrides = {
325
392
  enforce: [],
326
393
  status: [],
@@ -336,6 +403,11 @@ export class LocalAuthority {
336
403
  escalationRouting;
337
404
  server = null;
338
405
  port = 0;
406
+ allowanceDrawsOf;
407
+ /** Each allowance's limit, by key, as the policy writer last set it. */
408
+ allowanceLimits = new Map();
409
+ /** What each issued grant drew, by jti. */
410
+ grantDraws = new Map();
339
411
  constructor(options = {}) {
340
412
  if (process.env.NODE_ENV === "production")
341
413
  throw new Error("LOCAL_DOUBLE_FORBIDDEN");
@@ -346,6 +418,9 @@ export class LocalAuthority {
346
418
  this.legacyVerifyReceipt = options.verifyReceipt;
347
419
  this.verifyAttestation = options.verifyAttestation;
348
420
  this.policy = options.policy ?? defaultPolicy;
421
+ this.materialSignalsOf = options.materialSignals ?? (() => []);
422
+ this.tenantKeys = options.tenantKeys ?? {};
423
+ this.addressableGrants = options.addressableGrants === true;
349
424
  this.managedApproverId = options.managedApproverId ?? "synthetic-managed-approver";
350
425
  this.trustedEffectObserverIds = options.trustedEffectObserverIds ?? [];
351
426
  this.clock = options.clock ?? Date.now;
@@ -354,6 +429,7 @@ export class LocalAuthority {
354
429
  privateKey: keys.privateKey,
355
430
  publicJwk: keys.publicKey.export({ format: "jwk" }),
356
431
  };
432
+ this.allowanceDrawsOf = options.allowanceDraws ?? (() => []);
357
433
  }
358
434
  /** The fixture's JWKS, as `GET /.well-known/decionis-execution-grant-jwks.json` serves it. */
359
435
  get jwks() {
@@ -384,6 +460,9 @@ export class LocalAuthority {
384
460
  ...(grant.expectedEffectDigest === null
385
461
  ? {}
386
462
  : { expected_effect_digest: grant.expectedEffectDigest }),
463
+ ...(grant.expectedStateVersion === null
464
+ ? {}
465
+ : { expected_state_version: grant.expectedStateVersion }),
387
466
  execution_nonce: grant.nonce,
388
467
  execution_correlation_id: grant.correlationId ?? "",
389
468
  },
@@ -419,6 +498,51 @@ export class LocalAuthority {
419
498
  purpose: input.purpose ?? "effect_receipt",
420
499
  });
421
500
  }
501
+ /** The policy release decisions are made under now, as `policy_version` names it. */
502
+ get policyVersion() {
503
+ return `synthetic-policy-v${this.policyReleases}`;
504
+ }
505
+ /**
506
+ * Activates the next policy release, with a new policy hook or the same
507
+ * one: the governance authority's own act, which no API key can perform
508
+ * here or hosted. Every grant decided under an earlier release then fails
509
+ * at claim with `POLICY_VERSION_STALE`, and a new decision is made under
510
+ * this one. Returns the release now active.
511
+ */
512
+ releasePolicy(policy) {
513
+ if (policy !== undefined)
514
+ this.policy = policy;
515
+ this.policyReleases += 1;
516
+ return this.policyVersion;
517
+ }
518
+ /**
519
+ * Ingests a material signal's current value, as the hosted
520
+ * `POST /v1/protocol/signals/envelopes` does for a system of record. Each
521
+ * call is a new version, so a grant that read an earlier one fails at
522
+ * claim with `SIGNAL_VERSION_STALE`.
523
+ */
524
+ publishSignal(signalId, value) {
525
+ const reference = {
526
+ signal_id: signalId,
527
+ version: String(Number(this.signals.get(signalId)?.reference.version ?? 0) + 1),
528
+ observed_at: new Date(this.clock()).toISOString(),
529
+ value_digest: hashBinding(value),
530
+ };
531
+ this.signals.set(signalId, { reference, value });
532
+ return reference;
533
+ }
534
+ /** The published material signals a request names, in id order. */
535
+ materialSignalsFor(request) {
536
+ const ids = [...new Set(this.materialSignalsOf(request))].sort();
537
+ const published = ids.flatMap((id) => {
538
+ const signal = this.signals.get(id);
539
+ return signal === undefined ? [] : [signal];
540
+ });
541
+ return {
542
+ references: published.map((signal) => signal.reference),
543
+ values: Object.fromEntries(published.map((signal) => [signal.reference.signal_id, signal.value])),
544
+ };
545
+ }
422
546
  /** The key a signed signal's `kid` names, when it was registered to sign signals. */
423
547
  signedSignalKey(kid) {
424
548
  const key = this.providerKeys.get(kid);
@@ -532,6 +656,65 @@ export class LocalAuthority {
532
656
  }
533
657
  return record("EFFECT_RECEIPT_VERIFIED", described);
534
658
  }
659
+ /**
660
+ * Defines an allowance, or changes its limit: the policy writer's act
661
+ * (hosted, `PUT /v1/authority/allowances/{key}` with `policy:write`), which
662
+ * no decision key can perform here or hosted. Raising it is a top-up.
663
+ */
664
+ setAllowance(key, limit) {
665
+ if (!Number.isSafeInteger(limit) || limit < 0)
666
+ throw new Error("ALLOWANCE_LIMIT_INVALID");
667
+ this.allowanceLimits.set(key, limit);
668
+ }
669
+ /** What an allowance holds now; null for a key no allowance is defined under. */
670
+ allowanceUsage(key) {
671
+ const limit = this.allowanceLimits.get(key);
672
+ if (limit === undefined)
673
+ return null;
674
+ let reserved = 0;
675
+ let held = 0;
676
+ let consumed = 0;
677
+ for (const grant of this.grants.values()) {
678
+ for (const draw of this.grantDraws.get(grant.jti) ?? []) {
679
+ if (draw.allowance_key !== key || draw.amount === null)
680
+ continue;
681
+ if (!grant.claimed) {
682
+ if (grant.expiresAt * 1_000 > this.clock())
683
+ reserved += draw.amount;
684
+ }
685
+ else if (grant.finalized === "COMMITTED") {
686
+ consumed += draw.amount;
687
+ }
688
+ else if (grant.finalized !== "FAILED") {
689
+ held += draw.amount;
690
+ }
691
+ }
692
+ }
693
+ return { reserved, held, consumed, available: Math.max(0, limit - reserved - held - consumed) };
694
+ }
695
+ /**
696
+ * An ALLOW's grant, once its draws are reserved, as hosted reserves them
697
+ * with the grant; or the same decision as `BLOCK` with the refusal code and
698
+ * no grant. Never ESCALATE: no approval creates capacity.
699
+ */
700
+ grantWithinAllowances(request, decision, approval) {
701
+ const draws = this.allowanceDrawsOf(request);
702
+ const refused = draws.find((draw) => draw.amount === null) !== undefined
703
+ ? "ALLOWANCE_AMOUNT_INVALID"
704
+ : draws.some((draw) => this.allowanceUsage(draw.allowance_key) === null)
705
+ ? "ALLOWANCE_UNDEFINED"
706
+ : draws.some((draw) => draw.amount > this.allowanceUsage(draw.allowance_key).available)
707
+ ? "ALLOWANCE_EXHAUSTED"
708
+ : null;
709
+ if (refused !== null) {
710
+ const reasons = Array.isArray(decision.reason_codes) ? decision.reason_codes : [];
711
+ return { ...decision, status: "BLOCK", reason_codes: [...reasons, refused] };
712
+ }
713
+ const granted = this.grantDecision(request, decision, approval);
714
+ if (draws.length > 0)
715
+ this.grantDraws.set(String(granted.execution_token_jti), draws);
716
+ return granted;
717
+ }
535
718
  get baseUrl() {
536
719
  return `${LOOPBACK_ORIGIN}:${this.port}`;
537
720
  }
@@ -615,7 +798,14 @@ export class LocalAuthority {
615
798
  next: {},
616
799
  });
617
800
  }
618
- if (req.headers.authorization !== `Bearer ${this.apiKey}`) {
801
+ // The policy lifecycle takes an account session, never an API key, as the
802
+ // hosted contract says: no key, an agent's or an executor's or this
803
+ // double's own, can propose, accept or release a policy. This double
804
+ // holds no account sessions at all; `releasePolicy` is the only way in.
805
+ if (url.pathname === "/v1/policies" || url.pathname.startsWith("/v1/policies/")) {
806
+ return this.send(res, record, 401, { error: "ACCOUNT_SESSION_REQUIRED" });
807
+ }
808
+ if (req.headers.authorization !== `Bearer ${this.apiKey}` && this.tenantOf(record) === null) {
619
809
  return this.send(res, record, 401, { error: "UNAUTHORIZED" });
620
810
  }
621
811
  const dossierLookup = url.pathname.match(/^\/v1\/protocol\/dossiers\/([^/]+)$/);
@@ -668,6 +858,13 @@ export class LocalAuthority {
668
858
  return this.send(res, record, 404, { error: "NOT_FOUND" });
669
859
  }
670
860
  }
861
+ /** The organisation a request's key is bound to, or null for the unbound `apiKey` or no key. */
862
+ tenantOf(record) {
863
+ const presented = record.headers.authorization?.match(/^Bearer (.+)$/)?.[1];
864
+ return presented !== undefined && Object.hasOwn(this.tenantKeys, presented)
865
+ ? (this.tenantKeys[presented] ?? null)
866
+ : null;
867
+ }
671
868
  /** A grant resource addressed by an id that is not the presented token's grant: refused, as hosted. */
672
869
  grantIdMismatch(record, grantId) {
673
870
  const token = record.body?.execution_token;
@@ -741,13 +938,17 @@ export class LocalAuthority {
741
938
  if (!parsed.success)
742
939
  return { status: 400, body: { error: "REQUEST_INVALID" } };
743
940
  const request = parsed.data;
941
+ const tenant = this.tenantOf(record);
942
+ if (tenant !== null && tenant !== request.tenant_id) {
943
+ return { status: 403, body: { error: "AUTHORITY_TENANT_MISMATCH" } };
944
+ }
744
945
  if (record.headers["idempotency-key"] !== request.intent_id) {
745
946
  return { status: 400, body: { error: "IDEMPOTENCY_KEY_MISMATCH" } };
746
947
  }
747
948
  const bindingError = this.assertBinding(request, record);
748
949
  if (bindingError !== null)
749
950
  return { status: 400, body: { error: bindingError } };
750
- const evaluation = this.evaluate(request);
951
+ const evaluation = this.heldByBreach(request, this.evaluate(request));
751
952
  if (evaluation.error !== undefined)
752
953
  return { status: 409, body: { error: evaluation.error } };
753
954
  this.decisions += 1;
@@ -758,10 +959,26 @@ export class LocalAuthority {
758
959
  if (evaluation.status !== "ALLOW" || request.mode !== "ENFORCEMENT") {
759
960
  return { status: 200, body: this.remember(request, decision) };
760
961
  }
761
- return {
762
- status: 200,
763
- body: this.remember(request, this.grantDecision(request, decision, evaluation.approval)),
764
- };
962
+ const granted = this.grantWithinAllowances(request, decision, evaluation.approval);
963
+ // Only a grant has a scope to hold: a refusal for want of an allowance has no token.
964
+ const token = granted.execution_token;
965
+ if (typeof token === "string") {
966
+ this.assumptionBreaches.granted(token, LocalAssumptionBreaches.scopeOf(request), evaluation.reasonCode === "POLICY_AUTONOMOUS_LIMIT");
967
+ }
968
+ return { status: 200, body: this.remember(request, granted) };
969
+ }
970
+ /**
971
+ * The starter policy's assumption-breach rule: an ALLOW the policy gave on
972
+ * its own becomes ESCALATE while a breach holds the scope. A person's
973
+ * approval is that person's decision, and stands.
974
+ */
975
+ heldByBreach(request, evaluation) {
976
+ if (evaluation.reasonCode !== "POLICY_AUTONOMOUS_LIMIT")
977
+ return evaluation;
978
+ if (this.assumptionBreaches.openIn(LocalAssumptionBreaches.scopeOf(request)).length === 0) {
979
+ return evaluation;
980
+ }
981
+ return { status: "ESCALATE", reasonCode: "ASSUMPTION_BREACH_OPEN", approval: null };
765
982
  }
766
983
  /** Keeps a minted decision for its dossier route, with the envelope when links are on. */
767
984
  remember(request, decision) {
@@ -832,7 +1049,7 @@ export class LocalAuthority {
832
1049
  if ("refused" in signed) {
833
1050
  return { status: "BLOCK", reasonCode: signed.refused, approval: null, error: signed.refused };
834
1051
  }
835
- const verdict = this.policy(signed.facts === undefined ? request : { ...request, signed_signals: signed.facts });
1052
+ const verdict = this.policy(this.policyInput(request, signed.facts));
836
1053
  if (verdict === "BLOCK") {
837
1054
  return { status: "BLOCK", reasonCode: "POLICY_HARD_LIMIT_EXCEEDED", approval: null };
838
1055
  }
@@ -869,6 +1086,15 @@ export class LocalAuthority {
869
1086
  }
870
1087
  return { status: "ALLOW", reasonCode: "PRESENCE_RECEIPT_VERIFIED", approval: humanApproval };
871
1088
  }
1089
+ /** The request as the policy hook reads it, with the signals that reach it. */
1090
+ policyInput(request, signedFacts) {
1091
+ const material = this.materialSignalsFor(request).values;
1092
+ return {
1093
+ ...request,
1094
+ ...(signedFacts === undefined ? {} : { signed_signals: signedFacts }),
1095
+ ...(Object.keys(material).length === 0 ? {} : { material_signals: material }),
1096
+ };
1097
+ }
872
1098
  verifyEvidence(approval, request, requiredRole) {
873
1099
  if (this.presence !== undefined) {
874
1100
  return this.presence.verifyReceiptDetailed(approval, {
@@ -890,7 +1116,7 @@ export class LocalAuthority {
890
1116
  should_execute: false,
891
1117
  reason_codes: [...reasonCodes],
892
1118
  action_hash: request.intent_hash,
893
- policy_version: "synthetic-policy-v1",
1119
+ policy_version: this.policyVersion,
894
1120
  mode: request.mode,
895
1121
  execution_token: null,
896
1122
  execution_token_expires_at: null,
@@ -923,9 +1149,18 @@ export class LocalAuthority {
923
1149
  grantDecision(request, decision, approval) {
924
1150
  const issuedAt = Math.floor(this.clock() / 1_000);
925
1151
  const expiresAt = Math.min(Math.floor((this.clock() + GRANT_TTL_MS) / 1_000), Math.floor(Date.parse(request.expires_at) / 1_000));
926
- const jti = `synthetic-jti-${randomUUID()}`;
927
- const token = `synthetic-grant.${jti}`;
1152
+ const jti = this.addressableGrants ? randomUUID() : `synthetic-jti-${randomUUID()}`;
1153
+ const encode = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
1154
+ const token = this.addressableGrants
1155
+ ? `${encode({ alg: "none", typ: "synthetic-grant" })}.${encode({ jti, org_id: request.tenant_id })}.unsigned`
1156
+ : `synthetic-grant.${jti}`;
928
1157
  const bindingDigest = `sha256:${createHash("sha256").update(request.intent_hash).digest("hex")}`;
1158
+ const materialSignals = this.materialSignalsFor(request).references;
1159
+ // The hosted claim store's conflict domains, one per material signal the
1160
+ // grant binds. Hosted, a grant that binds none holds its execution target
1161
+ // instead; this double does not, so that flows written before it modelled
1162
+ // scopes, such as two open card claims on one card, keep their behaviour.
1163
+ const scopes = materialSignals.map((signal) => `signal:${signal.signal_id}`);
929
1164
  this.grants.set(token, {
930
1165
  jti,
931
1166
  tenantId: request.tenant_id,
@@ -941,7 +1176,11 @@ export class LocalAuthority {
941
1176
  bindingDigest,
942
1177
  payloadDigest: hashBinding(request.action.parameters),
943
1178
  expectedEffectDigest: request.expected_effect_digest ?? null,
1179
+ expectedStateVersion: preconditionIn(request.context)?.version ?? null,
944
1180
  receiptDossierId: approval?.receiptDossierId ?? null,
1181
+ policyVersion: this.policyVersion,
1182
+ materialSignals,
1183
+ scopes,
945
1184
  claimed: false,
946
1185
  claimToken: null,
947
1186
  receipt: null,
@@ -1070,7 +1309,7 @@ export class LocalAuthority {
1070
1309
  outcome = "ALLOW";
1071
1310
  if (managed.finalDecision === null) {
1072
1311
  this.decisions += 1;
1073
- managed.finalDecision = this.grantDecision(managed.request, this.baseDecision({ ...managed.request, mode: "ENFORCEMENT" }, "ALLOW", state.reasonCodes), null);
1312
+ managed.finalDecision = this.grantWithinAllowances(managed.request, this.baseDecision({ ...managed.request, mode: "ENFORCEMENT" }, "ALLOW", state.reasonCodes), null);
1074
1313
  }
1075
1314
  decision = managed.finalDecision;
1076
1315
  }
@@ -1083,17 +1322,21 @@ export class LocalAuthority {
1083
1322
  else if (state.status === "FAILED") {
1084
1323
  outcome = "ERROR";
1085
1324
  }
1325
+ // A reauthorization whose allowance cannot be reserved ends BLOCKED, as hosted.
1326
+ const refused = decision?.status === "BLOCK" ? decision : null;
1086
1327
  return {
1087
1328
  status: 200,
1088
1329
  body: {
1089
1330
  escalation_id: managed.escalationId,
1090
1331
  intent_id: managed.request.intent_id,
1091
1332
  action_hash: managed.request.intent_hash,
1092
- status: state.status,
1093
- outcome,
1333
+ status: refused === null ? state.status : "BLOCKED",
1334
+ outcome: refused === null ? outcome : "BLOCK",
1094
1335
  expires_at: managed.request.expires_at,
1095
- reason_codes: state.reasonCodes,
1096
- decision,
1336
+ reason_codes: refused === null
1337
+ ? state.reasonCodes
1338
+ : [...refused.reason_codes, "REAUTHORIZATION_BLOCKED"],
1339
+ decision: refused === null ? decision : null,
1097
1340
  },
1098
1341
  };
1099
1342
  }
@@ -1168,9 +1411,7 @@ export class LocalAuthority {
1168
1411
  return done("FAILED", ["PRESENCE_VERIFICATION_FAILED", verification.reasonCode]);
1169
1412
  // PRESENCE_VERIFIED and REAUTHORIZING are passed through within one lookup, as Decionis does.
1170
1413
  const signed = this.signedSignalsOf(managed.request);
1171
- const reevaluated = this.policy("refused" in signed || signed.facts === undefined
1172
- ? managed.request
1173
- : { ...managed.request, signed_signals: signed.facts });
1414
+ const reevaluated = this.policy(this.policyInput(managed.request, "refused" in signed ? undefined : signed.facts));
1174
1415
  if (reevaluated === "BLOCK")
1175
1416
  return done("BLOCKED", ["REAUTHORIZATION_BLOCKED"]);
1176
1417
  return done("GRANT_READY", ["PRESENCE_RECEIPT_VERIFIED"]);
@@ -1203,6 +1444,9 @@ export class LocalAuthority {
1203
1444
  status,
1204
1445
  body: { valid: false, reason_codes: [reasonCode], claims: null },
1205
1446
  });
1447
+ const tenant = this.tenantOf(record);
1448
+ if (tenant !== null && tenant !== intent.data.tenant_id)
1449
+ return rejected("TENANT_MISMATCH", 403);
1206
1450
  const bindingError = this.assertBinding({ ...intent.data, intent_hash: parsed.data.intent_hash }, record);
1207
1451
  if (bindingError !== null)
1208
1452
  return rejected(bindingError);
@@ -1224,8 +1468,32 @@ export class LocalAuthority {
1224
1468
  parsed.data.evidence?.humanApproval?.receiptDossierId !== grant.receiptDossierId) {
1225
1469
  return rejected("PRESENCE_APPROVAL_STALE");
1226
1470
  }
1471
+ // Authority is re-derived from current state before anything is claimed,
1472
+ // in the hosted order, so a stale grant never burns its nonce: the policy
1473
+ // release first, then every material signal the decision read.
1474
+ if (grant.policyVersion !== this.policyVersion)
1475
+ return rejected("POLICY_VERSION_STALE");
1476
+ if (grant.materialSignals.some((bound) => this.signals.get(bound.signal_id)?.reference.version !== bound.version)) {
1477
+ return rejected("SIGNAL_VERSION_STALE");
1478
+ }
1479
+ // A breach opened since the grant was issued: the policy re-run no longer
1480
+ // approves it, after the release and the signals, as the hosted claim orders it.
1481
+ if (this.assumptionBreaches.holdsClaim(parsed.data.execution_token)) {
1482
+ return rejected("POLICY_STATE_CHANGED");
1483
+ }
1227
1484
  if (grant.claimed)
1228
1485
  return rejected("NONCE_REPLAY_DETECTED");
1486
+ // A scope is held from the claim until the outcome is known: a commit or
1487
+ // a failure releases it, an INDETERMINATE keeps it until reconciled.
1488
+ const holders = [...this.grants.values()].filter((other) => other.claimed &&
1489
+ other.tenantId === grant.tenantId &&
1490
+ (other.finalized === null || other.finalized === "INDETERMINATE") &&
1491
+ other.scopes.some((scope) => grant.scopes.includes(scope)));
1492
+ if (holders.some((other) => other.finalized === "INDETERMINATE")) {
1493
+ return rejected("CURRENT_STATE_UNRESOLVED");
1494
+ }
1495
+ if (holders.length > 0)
1496
+ return rejected("AUTHORIZATION_SCOPE_BUSY");
1229
1497
  grant.claimed = true;
1230
1498
  grant.claimToken = randomBytes(32).toString("base64url");
1231
1499
  grant.correlationId = parsed.data.commit_correlation_id ?? intent.data.intent_id;
@@ -1268,6 +1536,9 @@ export class LocalAuthority {
1268
1536
  ...(grant.expectedEffectDigest === null
1269
1537
  ? {}
1270
1538
  : { expected_effect_digest: grant.expectedEffectDigest }),
1539
+ ...(grant.expectedStateVersion === null
1540
+ ? {}
1541
+ : { expected_state_version: grant.expectedStateVersion }),
1271
1542
  },
1272
1543
  jti: grant.jti,
1273
1544
  iat: grant.issuedAt,
@@ -1368,6 +1639,16 @@ export class LocalAuthority {
1368
1639
  }
1369
1640
  grant.finalized = parsed.data.outcome;
1370
1641
  grant.receipt = receipt;
1642
+ const assumptionBreach = this.assumptionBreaches.finalized({
1643
+ token: parsed.data.execution_token,
1644
+ grantJti: grant.jti,
1645
+ dossierId: grant.dossierId,
1646
+ outcome: parsed.data.outcome,
1647
+ expectedEffectDigest: grant.expectedEffectDigest,
1648
+ observedEffectDigest: evidence?.observed_effect_digest ?? null,
1649
+ evidenceDigest: evidence?.evidence_digest ?? null,
1650
+ receipt,
1651
+ });
1371
1652
  // Byte-for-byte the hosted success body: the commit's own decision-chain
1372
1653
  // evidence is queued, not yet recorded, so a successful finalization still
1373
1654
  // carries `COMMIT_EVIDENCE_PENDING`.
@@ -1391,6 +1672,7 @@ export class LocalAuthority {
1391
1672
  recorded: true,
1392
1673
  },
1393
1674
  }),
1675
+ ...(assumptionBreach === null ? {} : { assumption_breach: assumptionBreach }),
1394
1676
  reason_codes: ["COMMIT_EVIDENCE_PENDING"],
1395
1677
  },
1396
1678
  };
@@ -1421,6 +1703,8 @@ export class LocalAuthority {
1421
1703
  return "INTENT_TIME_ORDER_INVALID";
1422
1704
  if (expiresAt - capturedAt > 300_000)
1423
1705
  return "INTENT_LIFETIME_TOO_LONG";
1706
+ if (preconditionIn(request.context) === undefined)
1707
+ return "INTENT_PRECONDITION_INVALID";
1424
1708
  return null;
1425
1709
  }
1426
1710
  async respond(res, record, route, computed) {