@decionis/agent-safe-pipeline 0.4.1 → 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 (75) hide show
  1. package/README.md +10 -0
  2. package/dist/Index.d.ts +6 -0
  3. package/dist/Index.d.ts.map +1 -1
  4. package/dist/Index.js +6 -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 +8 -0
  11. package/dist/execution/GovernedExecutionRunner.d.ts.map +1 -1
  12. package/dist/execution/GovernedExecutionRunner.js +28 -0
  13. package/dist/execution/GovernedExecutionRunner.js.map +1 -1
  14. package/dist/execution/NativeExecutor.d.ts +13 -0
  15. package/dist/execution/NativeExecutor.d.ts.map +1 -1
  16. package/dist/execution/NativeExecutor.js.map +1 -1
  17. package/dist/intent/ExecutionIntent.d.ts +11 -0
  18. package/dist/intent/ExecutionIntent.d.ts.map +1 -1
  19. package/dist/intent/ExecutionIntent.js +15 -0
  20. package/dist/intent/ExecutionIntent.js.map +1 -1
  21. package/dist/intent/ExecutionSignals.d.ts +9 -1
  22. package/dist/intent/ExecutionSignals.d.ts.map +1 -1
  23. package/dist/intent/ExecutionSignals.js +9 -0
  24. package/dist/intent/ExecutionSignals.js.map +1 -1
  25. package/dist/intent/IntentCapture.d.ts.map +1 -1
  26. package/dist/intent/IntentCapture.js +15 -4
  27. package/dist/intent/IntentCapture.js.map +1 -1
  28. package/dist/intent/TargetPrecondition.d.ts +36 -0
  29. package/dist/intent/TargetPrecondition.d.ts.map +1 -0
  30. package/dist/intent/TargetPrecondition.js +57 -0
  31. package/dist/intent/TargetPrecondition.js.map +1 -0
  32. package/dist/manifest/ActionManifest.d.ts +112 -0
  33. package/dist/manifest/ActionManifest.d.ts.map +1 -0
  34. package/dist/manifest/ActionManifest.js +182 -0
  35. package/dist/manifest/ActionManifest.js.map +1 -0
  36. package/dist/plan/GovernedPlan.d.ts +109 -0
  37. package/dist/plan/GovernedPlan.d.ts.map +1 -0
  38. package/dist/plan/GovernedPlan.js +187 -0
  39. package/dist/plan/GovernedPlan.js.map +1 -0
  40. package/dist/plan/PlanState.d.ts +94 -0
  41. package/dist/plan/PlanState.d.ts.map +1 -0
  42. package/dist/plan/PlanState.js +240 -0
  43. package/dist/plan/PlanState.js.map +1 -0
  44. package/dist/plan/PlanStepReference.d.ts +16 -0
  45. package/dist/plan/PlanStepReference.d.ts.map +1 -0
  46. package/dist/plan/PlanStepReference.js +25 -0
  47. package/dist/plan/PlanStepReference.js.map +1 -0
  48. package/dist/profile/ApiProfile.d.ts +202 -2
  49. package/dist/profile/ApiProfile.d.ts.map +1 -1
  50. package/dist/profile/ApiProfile.js +397 -11
  51. package/dist/profile/ApiProfile.js.map +1 -1
  52. package/dist/profile/BuiltInApiProfiles.d.ts.map +1 -1
  53. package/dist/profile/BuiltInApiProfiles.js +199 -1
  54. package/dist/profile/BuiltInApiProfiles.js.map +1 -1
  55. package/dist/profile/McpToolProfile.d.ts +129 -0
  56. package/dist/profile/McpToolProfile.d.ts.map +1 -0
  57. package/dist/profile/McpToolProfile.js +270 -0
  58. package/dist/profile/McpToolProfile.js.map +1 -0
  59. package/dist/testing/Index.d.ts +3 -1
  60. package/dist/testing/Index.d.ts.map +1 -1
  61. package/dist/testing/Index.js +2 -0
  62. package/dist/testing/Index.js.map +1 -1
  63. package/dist/testing/LocalAssumptionBreaches.d.ts +96 -0
  64. package/dist/testing/LocalAssumptionBreaches.d.ts.map +1 -0
  65. package/dist/testing/LocalAssumptionBreaches.js +109 -0
  66. package/dist/testing/LocalAssumptionBreaches.js.map +1 -0
  67. package/dist/testing/LocalAuthority.d.ts +152 -5
  68. package/dist/testing/LocalAuthority.d.ts.map +1 -1
  69. package/dist/testing/LocalAuthority.js +294 -18
  70. package/dist/testing/LocalAuthority.js.map +1 -1
  71. package/dist/testing/LocalPlans.d.ts +43 -0
  72. package/dist/testing/LocalPlans.d.ts.map +1 -0
  73. package/dist/testing/LocalPlans.js +124 -0
  74. package/dist/testing/LocalPlans.js.map +1 -0
  75. package/package.json +2 -2
@@ -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. */
@@ -316,6 +361,13 @@ export class LocalAuthority {
316
361
  legacyVerifyReceipt;
317
362
  verifyAttestation;
318
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;
319
371
  managedApproverId;
320
372
  trustedEffectObserverIds;
321
373
  clock;
@@ -329,6 +381,13 @@ export class LocalAuthority {
329
381
  * says the executor must refuse.
330
382
  */
331
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();
332
391
  overrides = {
333
392
  enforce: [],
334
393
  status: [],
@@ -344,6 +403,11 @@ export class LocalAuthority {
344
403
  escalationRouting;
345
404
  server = null;
346
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();
347
411
  constructor(options = {}) {
348
412
  if (process.env.NODE_ENV === "production")
349
413
  throw new Error("LOCAL_DOUBLE_FORBIDDEN");
@@ -354,6 +418,9 @@ export class LocalAuthority {
354
418
  this.legacyVerifyReceipt = options.verifyReceipt;
355
419
  this.verifyAttestation = options.verifyAttestation;
356
420
  this.policy = options.policy ?? defaultPolicy;
421
+ this.materialSignalsOf = options.materialSignals ?? (() => []);
422
+ this.tenantKeys = options.tenantKeys ?? {};
423
+ this.addressableGrants = options.addressableGrants === true;
357
424
  this.managedApproverId = options.managedApproverId ?? "synthetic-managed-approver";
358
425
  this.trustedEffectObserverIds = options.trustedEffectObserverIds ?? [];
359
426
  this.clock = options.clock ?? Date.now;
@@ -362,6 +429,7 @@ export class LocalAuthority {
362
429
  privateKey: keys.privateKey,
363
430
  publicJwk: keys.publicKey.export({ format: "jwk" }),
364
431
  };
432
+ this.allowanceDrawsOf = options.allowanceDraws ?? (() => []);
365
433
  }
366
434
  /** The fixture's JWKS, as `GET /.well-known/decionis-execution-grant-jwks.json` serves it. */
367
435
  get jwks() {
@@ -392,6 +460,9 @@ export class LocalAuthority {
392
460
  ...(grant.expectedEffectDigest === null
393
461
  ? {}
394
462
  : { expected_effect_digest: grant.expectedEffectDigest }),
463
+ ...(grant.expectedStateVersion === null
464
+ ? {}
465
+ : { expected_state_version: grant.expectedStateVersion }),
395
466
  execution_nonce: grant.nonce,
396
467
  execution_correlation_id: grant.correlationId ?? "",
397
468
  },
@@ -427,6 +498,51 @@ export class LocalAuthority {
427
498
  purpose: input.purpose ?? "effect_receipt",
428
499
  });
429
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
+ }
430
546
  /** The key a signed signal's `kid` names, when it was registered to sign signals. */
431
547
  signedSignalKey(kid) {
432
548
  const key = this.providerKeys.get(kid);
@@ -540,6 +656,65 @@ export class LocalAuthority {
540
656
  }
541
657
  return record("EFFECT_RECEIPT_VERIFIED", described);
542
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
+ }
543
718
  get baseUrl() {
544
719
  return `${LOOPBACK_ORIGIN}:${this.port}`;
545
720
  }
@@ -623,7 +798,14 @@ export class LocalAuthority {
623
798
  next: {},
624
799
  });
625
800
  }
626
- 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) {
627
809
  return this.send(res, record, 401, { error: "UNAUTHORIZED" });
628
810
  }
629
811
  const dossierLookup = url.pathname.match(/^\/v1\/protocol\/dossiers\/([^/]+)$/);
@@ -676,6 +858,13 @@ export class LocalAuthority {
676
858
  return this.send(res, record, 404, { error: "NOT_FOUND" });
677
859
  }
678
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
+ }
679
868
  /** A grant resource addressed by an id that is not the presented token's grant: refused, as hosted. */
680
869
  grantIdMismatch(record, grantId) {
681
870
  const token = record.body?.execution_token;
@@ -749,13 +938,17 @@ export class LocalAuthority {
749
938
  if (!parsed.success)
750
939
  return { status: 400, body: { error: "REQUEST_INVALID" } };
751
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
+ }
752
945
  if (record.headers["idempotency-key"] !== request.intent_id) {
753
946
  return { status: 400, body: { error: "IDEMPOTENCY_KEY_MISMATCH" } };
754
947
  }
755
948
  const bindingError = this.assertBinding(request, record);
756
949
  if (bindingError !== null)
757
950
  return { status: 400, body: { error: bindingError } };
758
- const evaluation = this.evaluate(request);
951
+ const evaluation = this.heldByBreach(request, this.evaluate(request));
759
952
  if (evaluation.error !== undefined)
760
953
  return { status: 409, body: { error: evaluation.error } };
761
954
  this.decisions += 1;
@@ -766,10 +959,26 @@ export class LocalAuthority {
766
959
  if (evaluation.status !== "ALLOW" || request.mode !== "ENFORCEMENT") {
767
960
  return { status: 200, body: this.remember(request, decision) };
768
961
  }
769
- return {
770
- status: 200,
771
- body: this.remember(request, this.grantDecision(request, decision, evaluation.approval)),
772
- };
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 };
773
982
  }
774
983
  /** Keeps a minted decision for its dossier route, with the envelope when links are on. */
775
984
  remember(request, decision) {
@@ -840,7 +1049,7 @@ export class LocalAuthority {
840
1049
  if ("refused" in signed) {
841
1050
  return { status: "BLOCK", reasonCode: signed.refused, approval: null, error: signed.refused };
842
1051
  }
843
- const verdict = this.policy(signed.facts === undefined ? request : { ...request, signed_signals: signed.facts });
1052
+ const verdict = this.policy(this.policyInput(request, signed.facts));
844
1053
  if (verdict === "BLOCK") {
845
1054
  return { status: "BLOCK", reasonCode: "POLICY_HARD_LIMIT_EXCEEDED", approval: null };
846
1055
  }
@@ -877,6 +1086,15 @@ export class LocalAuthority {
877
1086
  }
878
1087
  return { status: "ALLOW", reasonCode: "PRESENCE_RECEIPT_VERIFIED", approval: humanApproval };
879
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
+ }
880
1098
  verifyEvidence(approval, request, requiredRole) {
881
1099
  if (this.presence !== undefined) {
882
1100
  return this.presence.verifyReceiptDetailed(approval, {
@@ -898,7 +1116,7 @@ export class LocalAuthority {
898
1116
  should_execute: false,
899
1117
  reason_codes: [...reasonCodes],
900
1118
  action_hash: request.intent_hash,
901
- policy_version: "synthetic-policy-v1",
1119
+ policy_version: this.policyVersion,
902
1120
  mode: request.mode,
903
1121
  execution_token: null,
904
1122
  execution_token_expires_at: null,
@@ -931,9 +1149,18 @@ export class LocalAuthority {
931
1149
  grantDecision(request, decision, approval) {
932
1150
  const issuedAt = Math.floor(this.clock() / 1_000);
933
1151
  const expiresAt = Math.min(Math.floor((this.clock() + GRANT_TTL_MS) / 1_000), Math.floor(Date.parse(request.expires_at) / 1_000));
934
- const jti = `synthetic-jti-${randomUUID()}`;
935
- 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}`;
936
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}`);
937
1164
  this.grants.set(token, {
938
1165
  jti,
939
1166
  tenantId: request.tenant_id,
@@ -949,7 +1176,11 @@ export class LocalAuthority {
949
1176
  bindingDigest,
950
1177
  payloadDigest: hashBinding(request.action.parameters),
951
1178
  expectedEffectDigest: request.expected_effect_digest ?? null,
1179
+ expectedStateVersion: preconditionIn(request.context)?.version ?? null,
952
1180
  receiptDossierId: approval?.receiptDossierId ?? null,
1181
+ policyVersion: this.policyVersion,
1182
+ materialSignals,
1183
+ scopes,
953
1184
  claimed: false,
954
1185
  claimToken: null,
955
1186
  receipt: null,
@@ -1078,7 +1309,7 @@ export class LocalAuthority {
1078
1309
  outcome = "ALLOW";
1079
1310
  if (managed.finalDecision === null) {
1080
1311
  this.decisions += 1;
1081
- 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);
1082
1313
  }
1083
1314
  decision = managed.finalDecision;
1084
1315
  }
@@ -1091,17 +1322,21 @@ export class LocalAuthority {
1091
1322
  else if (state.status === "FAILED") {
1092
1323
  outcome = "ERROR";
1093
1324
  }
1325
+ // A reauthorization whose allowance cannot be reserved ends BLOCKED, as hosted.
1326
+ const refused = decision?.status === "BLOCK" ? decision : null;
1094
1327
  return {
1095
1328
  status: 200,
1096
1329
  body: {
1097
1330
  escalation_id: managed.escalationId,
1098
1331
  intent_id: managed.request.intent_id,
1099
1332
  action_hash: managed.request.intent_hash,
1100
- status: state.status,
1101
- outcome,
1333
+ status: refused === null ? state.status : "BLOCKED",
1334
+ outcome: refused === null ? outcome : "BLOCK",
1102
1335
  expires_at: managed.request.expires_at,
1103
- reason_codes: state.reasonCodes,
1104
- decision,
1336
+ reason_codes: refused === null
1337
+ ? state.reasonCodes
1338
+ : [...refused.reason_codes, "REAUTHORIZATION_BLOCKED"],
1339
+ decision: refused === null ? decision : null,
1105
1340
  },
1106
1341
  };
1107
1342
  }
@@ -1176,9 +1411,7 @@ export class LocalAuthority {
1176
1411
  return done("FAILED", ["PRESENCE_VERIFICATION_FAILED", verification.reasonCode]);
1177
1412
  // PRESENCE_VERIFIED and REAUTHORIZING are passed through within one lookup, as Decionis does.
1178
1413
  const signed = this.signedSignalsOf(managed.request);
1179
- const reevaluated = this.policy("refused" in signed || signed.facts === undefined
1180
- ? managed.request
1181
- : { ...managed.request, signed_signals: signed.facts });
1414
+ const reevaluated = this.policy(this.policyInput(managed.request, "refused" in signed ? undefined : signed.facts));
1182
1415
  if (reevaluated === "BLOCK")
1183
1416
  return done("BLOCKED", ["REAUTHORIZATION_BLOCKED"]);
1184
1417
  return done("GRANT_READY", ["PRESENCE_RECEIPT_VERIFIED"]);
@@ -1211,6 +1444,9 @@ export class LocalAuthority {
1211
1444
  status,
1212
1445
  body: { valid: false, reason_codes: [reasonCode], claims: null },
1213
1446
  });
1447
+ const tenant = this.tenantOf(record);
1448
+ if (tenant !== null && tenant !== intent.data.tenant_id)
1449
+ return rejected("TENANT_MISMATCH", 403);
1214
1450
  const bindingError = this.assertBinding({ ...intent.data, intent_hash: parsed.data.intent_hash }, record);
1215
1451
  if (bindingError !== null)
1216
1452
  return rejected(bindingError);
@@ -1232,8 +1468,32 @@ export class LocalAuthority {
1232
1468
  parsed.data.evidence?.humanApproval?.receiptDossierId !== grant.receiptDossierId) {
1233
1469
  return rejected("PRESENCE_APPROVAL_STALE");
1234
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
+ }
1235
1484
  if (grant.claimed)
1236
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");
1237
1497
  grant.claimed = true;
1238
1498
  grant.claimToken = randomBytes(32).toString("base64url");
1239
1499
  grant.correlationId = parsed.data.commit_correlation_id ?? intent.data.intent_id;
@@ -1276,6 +1536,9 @@ export class LocalAuthority {
1276
1536
  ...(grant.expectedEffectDigest === null
1277
1537
  ? {}
1278
1538
  : { expected_effect_digest: grant.expectedEffectDigest }),
1539
+ ...(grant.expectedStateVersion === null
1540
+ ? {}
1541
+ : { expected_state_version: grant.expectedStateVersion }),
1279
1542
  },
1280
1543
  jti: grant.jti,
1281
1544
  iat: grant.issuedAt,
@@ -1376,6 +1639,16 @@ export class LocalAuthority {
1376
1639
  }
1377
1640
  grant.finalized = parsed.data.outcome;
1378
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
+ });
1379
1652
  // Byte-for-byte the hosted success body: the commit's own decision-chain
1380
1653
  // evidence is queued, not yet recorded, so a successful finalization still
1381
1654
  // carries `COMMIT_EVIDENCE_PENDING`.
@@ -1399,6 +1672,7 @@ export class LocalAuthority {
1399
1672
  recorded: true,
1400
1673
  },
1401
1674
  }),
1675
+ ...(assumptionBreach === null ? {} : { assumption_breach: assumptionBreach }),
1402
1676
  reason_codes: ["COMMIT_EVIDENCE_PENDING"],
1403
1677
  },
1404
1678
  };
@@ -1429,6 +1703,8 @@ export class LocalAuthority {
1429
1703
  return "INTENT_TIME_ORDER_INVALID";
1430
1704
  if (expiresAt - capturedAt > 300_000)
1431
1705
  return "INTENT_LIFETIME_TOO_LONG";
1706
+ if (preconditionIn(request.context) === undefined)
1707
+ return "INTENT_PRECONDITION_INVALID";
1432
1708
  return null;
1433
1709
  }
1434
1710
  async respond(res, record, route, computed) {