@cotal-ai/core 0.12.0 → 0.13.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 (152) hide show
  1. package/dist/acls.d.ts +31 -7
  2. package/dist/acls.d.ts.map +1 -1
  3. package/dist/acls.js +105 -17
  4. package/dist/acls.js.map +1 -1
  5. package/dist/agent-file.js +1 -1
  6. package/dist/agent-file.js.map +1 -1
  7. package/dist/auth-provider.d.ts +8 -0
  8. package/dist/auth-provider.d.ts.map +1 -1
  9. package/dist/auth-provider.js.map +1 -1
  10. package/dist/backup.d.ts.map +1 -1
  11. package/dist/backup.js +6 -4
  12. package/dist/backup.js.map +1 -1
  13. package/dist/broker-floor.d.ts +34 -0
  14. package/dist/broker-floor.d.ts.map +1 -0
  15. package/dist/broker-floor.js +39 -0
  16. package/dist/broker-floor.js.map +1 -0
  17. package/dist/canonical.d.ts +24 -0
  18. package/dist/canonical.d.ts.map +1 -0
  19. package/dist/canonical.js +159 -0
  20. package/dist/canonical.js.map +1 -0
  21. package/dist/connector.d.ts +4 -0
  22. package/dist/connector.d.ts.map +1 -1
  23. package/dist/endpoint-action.d.ts +424 -0
  24. package/dist/endpoint-action.d.ts.map +1 -0
  25. package/dist/endpoint-action.js +910 -0
  26. package/dist/endpoint-action.js.map +1 -0
  27. package/dist/endpoint-binding.d.ts +344 -0
  28. package/dist/endpoint-binding.d.ts.map +1 -0
  29. package/dist/endpoint-binding.js +753 -0
  30. package/dist/endpoint-binding.js.map +1 -0
  31. package/dist/endpoint-checkpoint.d.ts +292 -0
  32. package/dist/endpoint-checkpoint.d.ts.map +1 -0
  33. package/dist/endpoint-checkpoint.js +818 -0
  34. package/dist/endpoint-checkpoint.js.map +1 -0
  35. package/dist/endpoint-cluster.d.ts +95 -0
  36. package/dist/endpoint-cluster.d.ts.map +1 -0
  37. package/dist/endpoint-cluster.js +194 -0
  38. package/dist/endpoint-cluster.js.map +1 -0
  39. package/dist/endpoint-contract-store.d.ts +90 -0
  40. package/dist/endpoint-contract-store.d.ts.map +1 -0
  41. package/dist/endpoint-contract-store.js +348 -0
  42. package/dist/endpoint-contract-store.js.map +1 -0
  43. package/dist/endpoint-envelope.d.ts +165 -0
  44. package/dist/endpoint-envelope.d.ts.map +1 -0
  45. package/dist/endpoint-envelope.js +346 -0
  46. package/dist/endpoint-envelope.js.map +1 -0
  47. package/dist/endpoint-grants.d.ts +123 -0
  48. package/dist/endpoint-grants.d.ts.map +1 -0
  49. package/dist/endpoint-grants.js +215 -0
  50. package/dist/endpoint-grants.js.map +1 -0
  51. package/dist/endpoint-guard.d.ts +222 -0
  52. package/dist/endpoint-guard.d.ts.map +1 -0
  53. package/dist/endpoint-guard.js +468 -0
  54. package/dist/endpoint-guard.js.map +1 -0
  55. package/dist/endpoint-handle.d.ts +176 -0
  56. package/dist/endpoint-handle.d.ts.map +1 -0
  57. package/dist/endpoint-handle.js +608 -0
  58. package/dist/endpoint-handle.js.map +1 -0
  59. package/dist/endpoint-journal.d.ts +225 -0
  60. package/dist/endpoint-journal.d.ts.map +1 -0
  61. package/dist/endpoint-journal.js +431 -0
  62. package/dist/endpoint-journal.js.map +1 -0
  63. package/dist/endpoint-receipt.d.ts +225 -0
  64. package/dist/endpoint-receipt.d.ts.map +1 -0
  65. package/dist/endpoint-receipt.js +417 -0
  66. package/dist/endpoint-receipt.js.map +1 -0
  67. package/dist/endpoint-records.d.ts +243 -0
  68. package/dist/endpoint-records.d.ts.map +1 -0
  69. package/dist/endpoint-records.js +591 -0
  70. package/dist/endpoint-records.js.map +1 -0
  71. package/dist/endpoint-serve.d.ts +176 -0
  72. package/dist/endpoint-serve.d.ts.map +1 -0
  73. package/dist/endpoint-serve.js +446 -0
  74. package/dist/endpoint-serve.js.map +1 -0
  75. package/dist/endpoint-service.d.ts +518 -0
  76. package/dist/endpoint-service.d.ts.map +1 -0
  77. package/dist/endpoint-service.js +973 -0
  78. package/dist/endpoint-service.js.map +1 -0
  79. package/dist/endpoint-session.d.ts +398 -0
  80. package/dist/endpoint-session.d.ts.map +1 -0
  81. package/dist/endpoint-session.js +834 -0
  82. package/dist/endpoint-session.js.map +1 -0
  83. package/dist/endpoint-signing.d.ts +105 -0
  84. package/dist/endpoint-signing.d.ts.map +1 -0
  85. package/dist/endpoint-signing.js +190 -0
  86. package/dist/endpoint-signing.js.map +1 -0
  87. package/dist/endpoint-subjects.d.ts +222 -0
  88. package/dist/endpoint-subjects.d.ts.map +1 -0
  89. package/dist/endpoint-subjects.js +381 -0
  90. package/dist/endpoint-subjects.js.map +1 -0
  91. package/dist/endpoint-supervisor.d.ts +19 -0
  92. package/dist/endpoint-supervisor.d.ts.map +1 -0
  93. package/dist/endpoint-supervisor.js +12 -0
  94. package/dist/endpoint-supervisor.js.map +1 -0
  95. package/dist/endpoint-traits.d.ts +225 -0
  96. package/dist/endpoint-traits.d.ts.map +1 -0
  97. package/dist/endpoint-traits.js +469 -0
  98. package/dist/endpoint-traits.js.map +1 -0
  99. package/dist/endpoint-verbs.d.ts +277 -0
  100. package/dist/endpoint-verbs.d.ts.map +1 -0
  101. package/dist/endpoint-verbs.js +576 -0
  102. package/dist/endpoint-verbs.js.map +1 -0
  103. package/dist/endpoint-virtual.d.ts +308 -0
  104. package/dist/endpoint-virtual.d.ts.map +1 -0
  105. package/dist/endpoint-virtual.js +440 -0
  106. package/dist/endpoint-virtual.js.map +1 -0
  107. package/dist/endpoint-work.d.ts +304 -0
  108. package/dist/endpoint-work.d.ts.map +1 -0
  109. package/dist/endpoint-work.js +728 -0
  110. package/dist/endpoint-work.js.map +1 -0
  111. package/dist/endpoint.d.ts +66 -22
  112. package/dist/endpoint.d.ts.map +1 -1
  113. package/dist/endpoint.js +289 -89
  114. package/dist/endpoint.js.map +1 -1
  115. package/dist/evict.d.ts +95 -32
  116. package/dist/evict.d.ts.map +1 -1
  117. package/dist/evict.js +193 -3
  118. package/dist/evict.js.map +1 -1
  119. package/dist/index.d.ts +25 -0
  120. package/dist/index.d.ts.map +1 -1
  121. package/dist/index.js +25 -0
  122. package/dist/index.js.map +1 -1
  123. package/dist/lease.d.ts +4 -0
  124. package/dist/lease.d.ts.map +1 -1
  125. package/dist/lease.js.map +1 -1
  126. package/dist/members.d.ts +4 -4
  127. package/dist/members.d.ts.map +1 -1
  128. package/dist/members.js +12 -12
  129. package/dist/members.js.map +1 -1
  130. package/dist/provision.d.ts +70 -18
  131. package/dist/provision.d.ts.map +1 -1
  132. package/dist/provision.js +194 -33
  133. package/dist/provision.js.map +1 -1
  134. package/dist/safe-pattern.d.ts +33 -0
  135. package/dist/safe-pattern.d.ts.map +1 -0
  136. package/dist/safe-pattern.js +486 -0
  137. package/dist/safe-pattern.js.map +1 -0
  138. package/dist/schema-profile.d.ts +93 -0
  139. package/dist/schema-profile.d.ts.map +1 -0
  140. package/dist/schema-profile.js +284 -0
  141. package/dist/schema-profile.js.map +1 -0
  142. package/dist/streams.d.ts +13 -8
  143. package/dist/streams.d.ts.map +1 -1
  144. package/dist/streams.js +35 -18
  145. package/dist/streams.js.map +1 -1
  146. package/dist/subjects.d.ts +100 -40
  147. package/dist/subjects.d.ts.map +1 -1
  148. package/dist/subjects.js +136 -60
  149. package/dist/subjects.js.map +1 -1
  150. package/dist/types.d.ts +9 -0
  151. package/dist/types.d.ts.map +1 -1
  152. package/package.json +3 -1
@@ -0,0 +1,608 @@
1
+ /**
2
+ * The CAPABILITY HANDLE (SPEC §13.6): the one passable reference type — a signed JSON grant,
3
+ * RFC 8785 canonical, Ed25519-signed by a key in the trust-anchor registry (§13.10).
4
+ *
5
+ * This module owns the transport-thin CORE of the handle: the artifact shape + closed-tuple
6
+ * validation, the NORMATIVE COMPILER (a grant entry → exactly the subjects the equivalent
7
+ * minted capability would receive, never wider — reads and instance rails included, every
8
+ * present signed component consumed), the ATTENUATION CONTAINMENT ORDER (a child handle MUST
9
+ * be ⊆ its parent), and CHAIN VERIFICATION (walk every parentDigest link to a registered
10
+ * anchor, fail closed on widening / unknown / revoked / expiry). Conferral — REDEMPTION
11
+ * through the trusted auth path, which fresh-checks the target triple against the current
12
+ * mapping and mints a ledgered short-lived credential — is the §9/§10 exchange's job (the D14
13
+ * auth-path slice); a handle grants NO broker reach here, only narrows.
14
+ *
15
+ * Verification discipline (§13.10 D28): the signature and the parentDigest identity are
16
+ * checked over the EXACT RAW presented artifacts, never a reconstructed projection — the
17
+ * parsed projection is for SEMANTICS only. Issuer authority is the anchor's STRUCTURED
18
+ * `handles` scope (ceiling entries in the handle-grant shape itself); coverage is the SAME
19
+ * §13.6 containment order (`handle.grants ⊆ anchor.scope`). A child link's issuer key must be
20
+ * lifecycle-bound to the parent's holder (owner text alone would let a recycled alias issue
21
+ * off its predecessor's handles). Every link — not only the leaf — is currency-checked
22
+ * (window, TTL ceilings clock-anchored at `now`, live-epoch), sturdy revocation is
23
+ * strict-`false`-only (an unreadable status is REVOKED), and the whole walk is bounded
24
+ * (chain length + ONE total await budget across the walk).
25
+ *
26
+ * Two uses, both fail-closed: ATTENUATION (presented in the `auth` slot — the handler enforces
27
+ * effective = presenter-cred ∩ handle.grants ∩ issuer-authority, never conferring reach) and
28
+ * CONFERRAL (redemption). Both rest on the same containment + chain verification here.
29
+ */
30
+ import { contractDigest, isContractDigest, canonicalJson } from "./canonical.js";
31
+ import { EpEnvelopeError } from "./endpoint-envelope.js";
32
+ import { assertBoundedOwner, assertLifecycleToken, assertCommandToken, endpointToken, EP_AUTHZ_MODES } from "./endpoint-subjects.js";
33
+ import { verifyArtifactSignature, resolveAnchorForUse, assertArtifactCurrency } from "./endpoint-signing.js";
34
+ const isRec = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
35
+ function invalid(what) { throw new EpEnvelopeError("contract-invalid", `${what} (SPEC 13.6 capability handle)`); }
36
+ /** The §13.6 default validity ceilings (space-configurable): live ≤ 24h, sturdy default 30d. */
37
+ export const HANDLE_MAX_LIVE_TTL_MS = 24 * 60 * 60 * 1000;
38
+ export const HANDLE_MAX_STURDY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
39
+ /** Verification bounds (fail loud past each; a bound reached is a refusal, never a truncation). */
40
+ export const HANDLE_MAX_CHAIN_LENGTH = 16;
41
+ export const HANDLE_MAX_BYTES = 64 * 1024;
42
+ export const HANDLE_MAX_GRANTS = 64;
43
+ export const HANDLE_MAX_COMMANDS_PER_GRANT = 64;
44
+ export const HANDLE_MAX_READS_PER_GRANT = 64;
45
+ /** Bounded canonical token grammars (§13.6 consuming boundary): id/space/keyId are bounded
46
+ * opaque tokens; the holder principal id is `owner` or `owner.actor` reverse-form. A garbled
47
+ * token is a CATALOG error (`contract-invalid`), never a raw validator throw. */
48
+ const HANDLE_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
49
+ function assertHandleToken(v, what) {
50
+ if (typeof v !== "string" || !HANDLE_TOKEN.test(v))
51
+ invalid(`${what} is not a bounded canonical token (1..128 of [A-Za-z0-9._:-], leading alnum)`);
52
+ return v;
53
+ }
54
+ function parseGrantCommand(raw) {
55
+ if (!isRec(raw))
56
+ invalid("a grant command is not an object");
57
+ const o = raw;
58
+ const allowed = new Set(["name", "authz", "targetOwner", "targetActor", "targetLifecycleUid"]);
59
+ for (const k of Object.keys(o))
60
+ if (!allowed.has(k))
61
+ invalid(`a grant command carries the unknown field "${k}" (closed schema)`);
62
+ if (typeof o.name !== "string")
63
+ invalid("a grant command has no string name");
64
+ assertCommandToken(o.name);
65
+ const hasOwner = o.targetOwner !== undefined, hasActor = o.targetActor !== undefined, hasUid = o.targetLifecycleUid !== undefined;
66
+ const hasAuthz = o.authz !== undefined;
67
+ // The closed set of three target shapes (§13.6). The parsed projection stays BYTE-FAITHFUL
68
+ // to the signed form (no default injection — `authz ?? "owner"` applies at USE time), so the
69
+ // projection can never diverge from the D28 signature input.
70
+ if (!hasOwner && !hasActor && !hasUid) {
71
+ // NO-TARGET: an `authz` field is schema-invalid.
72
+ if (hasAuthz)
73
+ invalid(`command "${o.name}" is a no-target entry but carries authz (schema-invalid: a no-target entry compiles to the untargeted/self form)`);
74
+ return { name: o.name };
75
+ }
76
+ if (hasOwner && !hasActor && !hasUid) {
77
+ // OWNER-DOMAIN: authz names owner|child|ledger (default owner); `any` is schema-invalid.
78
+ if (typeof o.targetOwner !== "string")
79
+ invalid(`command "${o.name}" targetOwner is not a string`);
80
+ assertBoundedOwner(o.targetOwner, `command "${o.name}" targetOwner`);
81
+ if (hasAuthz && o.authz !== "owner" && o.authz !== "child" && o.authz !== "ledger")
82
+ invalid(`command "${o.name}" authz "${String(o.authz)}" is not owner|child|ledger ("any" is operator-ceiling authority, never conferred through a handle)`);
83
+ return { name: o.name, ...(hasAuthz ? { authz: o.authz } : {}), targetOwner: o.targetOwner };
84
+ }
85
+ if (hasOwner && hasActor && hasUid) {
86
+ // ACTOR-PINNED FULL TRIPLE: `authz` is schema-invalid (the triple IS the mode).
87
+ if (hasAuthz)
88
+ invalid(`command "${o.name}" pins the full target triple but also carries authz (schema-invalid: the triple IS the handle mode)`);
89
+ if (typeof o.targetOwner !== "string" || typeof o.targetActor !== "string" || typeof o.targetLifecycleUid !== "string")
90
+ invalid(`command "${o.name}" target triple components must all be strings`);
91
+ assertBoundedOwner(o.targetOwner, `command "${o.name}" targetOwner`);
92
+ assertBoundedOwner(o.targetActor, `command "${o.name}" targetActor`);
93
+ assertLifecycleToken(o.targetLifecycleUid, `command "${o.name}" targetLifecycleUid`);
94
+ return { name: o.name, targetOwner: o.targetOwner, targetActor: o.targetActor, targetLifecycleUid: o.targetLifecycleUid };
95
+ }
96
+ // Every other combination is schema-invalid — in particular a partial tuple never weakens
97
+ // into a broader one (§13.6): targetActor without targetLifecycleUid, or targetLifecycleUid
98
+ // without targetActor, or owner+actor without uid.
99
+ invalid(`command "${o.name}" has a partial target tuple (${[hasOwner && "owner", hasActor && "actor", hasUid && "uid"].filter(Boolean).join("+")}); the legal shapes are no components, targetOwner alone, or the full triple; a partial tuple never weakens into a broader grant`);
100
+ }
101
+ /** A read scope names an exact record-key or event-topic SUBTREE (§13.6): dot-separated
102
+ * literal tokens in the ONE token grammar record keys are built from (the §3 subject charset,
103
+ * which the §13.7 key qualifiers use: [A-Za-z0-9_-], 1..64 per token), never a wildcard — the
104
+ * subtree semantics live in the containment order (dot-prefix), not in the entry. The grammar
105
+ * is exactly the key grammar on purpose: a wider charset would mint inert grants naming keys
106
+ * that can never exist (and that a conforming implementation refuses — a divergence in a
107
+ * normative compiler), while a narrower one would make legal keys (a `_`-leading goalId)
108
+ * unreachable by any grant. */
109
+ const READ_TOKEN = /^[A-Za-z0-9_-]{1,64}$/;
110
+ function assertReadSubtree(r, endpoint) {
111
+ if (typeof r !== "string" || r.length === 0 || r.length > 256)
112
+ invalid(`grant entry for "${endpoint}" read scope is not a bounded string (1..256)`);
113
+ const tokens = r.split(".");
114
+ if (tokens.length > 16 || tokens.some((t) => !READ_TOKEN.test(t)))
115
+ invalid(`grant entry for "${endpoint}" read scope "${r}" is not a bounded literal dot-token subtree (at most 16 tokens of 1..64 [A-Za-z0-9_-]; no wildcards)`);
116
+ return r;
117
+ }
118
+ function parseGrant(raw) {
119
+ if (!isRec(raw))
120
+ invalid("a grant entry is not an object");
121
+ const o = raw;
122
+ for (const k of Object.keys(o))
123
+ if (!["endpoint", "instanceId", "commands", "reads"].includes(k))
124
+ invalid(`a grant entry carries the unknown field "${k}"`);
125
+ if (typeof o.endpoint !== "string")
126
+ invalid("a grant entry has no string endpoint");
127
+ endpointToken(o.endpoint);
128
+ if (o.instanceId !== undefined) {
129
+ try {
130
+ assertLifecycleToken(o.instanceId, "grant instanceId");
131
+ }
132
+ catch (e) {
133
+ invalid(e.message);
134
+ }
135
+ }
136
+ if (!Array.isArray(o.commands) || o.commands.length === 0)
137
+ invalid(`grant entry for "${o.endpoint}" has no commands`);
138
+ if (o.commands.length > HANDLE_MAX_COMMANDS_PER_GRANT)
139
+ invalid(`grant entry for "${o.endpoint}" exceeds ${HANDLE_MAX_COMMANDS_PER_GRANT} commands (bounded, never truncated)`);
140
+ const commands = o.commands.map(parseGrantCommand);
141
+ let reads;
142
+ if (o.reads !== undefined) {
143
+ if (!Array.isArray(o.reads))
144
+ invalid(`grant entry for "${o.endpoint}" has a non-array reads`);
145
+ if (o.reads.length > HANDLE_MAX_READS_PER_GRANT)
146
+ invalid(`grant entry for "${o.endpoint}" exceeds ${HANDLE_MAX_READS_PER_GRANT} read subtrees (bounded)`);
147
+ reads = o.reads.map((r) => assertReadSubtree(r, o.endpoint));
148
+ }
149
+ return { endpoint: o.endpoint, ...(o.instanceId !== undefined ? { instanceId: o.instanceId } : {}), commands, ...(reads !== undefined ? { reads } : {}) };
150
+ }
151
+ /** Validate a handle artifact's SHAPE (the closed-tuple rules + envelope), returning the typed
152
+ * frozen handle. This is the schema step; signature + chain + currency are the verify step
153
+ * ({@link verifyHandleChain}) — and those verify the RAW presented artifact, never this
154
+ * projection. A `sturdy: false` handle MUST carry `epoch`; a `sturdy: true` handle MUST NOT
155
+ * (§13.6: live binds the process epoch, sturdy binds the lifecycle UID). */
156
+ export function parseHandle(raw) {
157
+ if (!isRec(raw))
158
+ invalid("the handle is not an object");
159
+ const o = raw;
160
+ const allowed = new Set(["v", "id", "space", "issuer", "holder", "grants", "iat", "nbf", "exp", "parentDigest", "sturdy", "epoch", "sig"]);
161
+ for (const k of Object.keys(o))
162
+ if (!allowed.has(k))
163
+ invalid(`the handle carries the unknown field "${k}" (closed envelope)`);
164
+ if (o.v !== 1)
165
+ invalid("the handle version is not 1");
166
+ assertHandleToken(o.id, "the handle id");
167
+ assertHandleToken(o.space, "the handle space");
168
+ if (!isRec(o.issuer))
169
+ invalid("the handle issuer is not an object");
170
+ for (const k of Object.keys(o.issuer))
171
+ if (k !== "keyId")
172
+ invalid(`the handle issuer carries the unknown field "${k}" (closed schema)`);
173
+ assertHandleToken(o.issuer.keyId, "the handle issuer keyId");
174
+ if (!isRec(o.holder))
175
+ invalid("the handle holder is not an object");
176
+ for (const k of Object.keys(o.holder))
177
+ if (k !== "id" && k !== "lifecycleUid")
178
+ invalid(`the handle holder carries the unknown field "${k}" (closed schema)`);
179
+ assertHandleToken(o.holder.id, "the handle holder id");
180
+ {
181
+ const uid = o.holder.lifecycleUid;
182
+ if (typeof uid !== "string")
183
+ invalid("the handle holder lifecycleUid is not a string");
184
+ try {
185
+ assertLifecycleToken(uid, "the handle holder lifecycleUid");
186
+ }
187
+ catch (e) {
188
+ invalid(e.message);
189
+ }
190
+ }
191
+ if (!Array.isArray(o.grants) || o.grants.length === 0)
192
+ invalid("the handle has no grants");
193
+ if (o.grants.length > HANDLE_MAX_GRANTS)
194
+ invalid(`the handle exceeds ${HANDLE_MAX_GRANTS} grant entries (bounded, never truncated)`);
195
+ // Any token-validator throw inside a grant becomes a CATALOG contract-invalid error (§13.6:
196
+ // the consuming boundary never surfaces a raw validator throw).
197
+ let grants;
198
+ try {
199
+ grants = o.grants.map(parseGrant);
200
+ }
201
+ catch (e) {
202
+ if (e instanceof EpEnvelopeError)
203
+ throw e;
204
+ invalid(`a grant entry has an invalid token: ${e.message}`);
205
+ }
206
+ for (const f of ["iat", "exp"])
207
+ if (typeof o[f] !== "number" || !Number.isSafeInteger(o[f]) || o[f] < 0)
208
+ invalid(`the handle ${f} is not a non-negative safe integer`);
209
+ if (o.nbf !== undefined && (typeof o.nbf !== "number" || !Number.isSafeInteger(o.nbf) || o.nbf < 0))
210
+ invalid("the handle nbf is not a non-negative safe integer");
211
+ if (o.exp <= o.iat)
212
+ invalid("the handle exp is not after iat");
213
+ if (o.nbf !== undefined && o.nbf > o.exp)
214
+ invalid("the handle nbf is after exp (an empty validity window is garbled, not a short one)");
215
+ if (o.parentDigest !== undefined && (typeof o.parentDigest !== "string" || !isContractDigest(o.parentDigest)))
216
+ invalid("the handle parentDigest is not a sha256 digest");
217
+ if (typeof o.sturdy !== "boolean")
218
+ invalid("the handle sturdy flag is not a boolean");
219
+ if (o.sturdy === false && (typeof o.epoch !== "number" || !Number.isSafeInteger(o.epoch) || o.epoch < 0))
220
+ invalid("a LIVE handle (sturdy:false) MUST carry a non-negative integer epoch (it binds the process epoch)");
221
+ if (o.sturdy === true && o.epoch !== undefined)
222
+ invalid("a STURDY handle MUST NOT carry epoch (it binds the lifecycle UID, not a process epoch)");
223
+ if (typeof o.sig !== "string")
224
+ invalid("the handle has no sig");
225
+ return Object.freeze({
226
+ v: 1, id: o.id, space: o.space, issuer: { keyId: o.issuer.keyId },
227
+ holder: { id: o.holder.id, lifecycleUid: o.holder.lifecycleUid },
228
+ grants, iat: o.iat, ...(o.nbf !== undefined ? { nbf: o.nbf } : {}), exp: o.exp,
229
+ ...(o.parentDigest !== undefined ? { parentDigest: o.parentDigest } : {}), sturdy: o.sturdy,
230
+ ...(o.epoch !== undefined ? { epoch: o.epoch } : {}), sig: o.sig,
231
+ });
232
+ }
233
+ /** The handle's content address (`sha256:<hex>` over the full artifact incl. `sig`) — the
234
+ * identity a child's `parentDigest` references. Chain verification computes this over the
235
+ * RAW presented artifact ({@link verifyHandleChain}); this export is for issuance (building
236
+ * a child's `parentDigest` from the parent artifact you hold). */
237
+ export function handleDigest(handle) {
238
+ return contractDigest(handle);
239
+ }
240
+ /** Compile ONE grant command to the {@link EpTarget} the equivalent minted capability carries
241
+ * (never wider): a no-target command → the untargeted form or `.self` PER ITS CONTRACT; an
242
+ * owner-domain command → its authz mode pinning `targetOwner`; an actor-pinned command →
243
+ * `handle`-mode with the verified triple. Every present signed component is consumed. */
244
+ function compileTarget(cmd, contract) {
245
+ if (cmd.targetOwner === undefined)
246
+ return contract.noTargetForm === "self" ? { mode: "self" } : undefined;
247
+ if (cmd.targetActor !== undefined && cmd.targetLifecycleUid !== undefined)
248
+ return { mode: "handle", tOwner: cmd.targetOwner, tActor: cmd.targetActor, tUid: cmd.targetLifecycleUid };
249
+ return { mode: (cmd.authz ?? "owner"), tOwner: cmd.targetOwner };
250
+ }
251
+ /** Compile a handle's grants to the EpCapability set + read subtrees the equivalent minted
252
+ * capability would receive (§13.6). This is what a redemption mints and what attenuation
253
+ * intersects against; it NEVER widens and consumes EVERY signed component:
254
+ * - `routes` is set EXPLICITLY: an instance entry compiles to the exact `ep.inst` rails
255
+ * ONLY (`routes: []` — an instance pin never also grants the class rail); a class entry
256
+ * compiles to `routes: ["one"]` (scatter `all` is not expressible in a handle);
257
+ * - a NO-TARGET command's untargeted-vs-`.self` form and the `journal` rail come from the
258
+ * REQUIRED command-contract seam (the compiler never guesses either);
259
+ * - a JOURNAL-class command compiles to a journal-EXCLUSIVE capability (`routes: []`, no
260
+ * request rails): journal submissions ride ONLY `epj` (§13.9), so emitting a request row
261
+ * alongside would be equivalent-mint WIDENING. The frozen `epj` grammar has no instance
262
+ * coordinate, so an instance-pinned journal command is UNREPRESENTABLE and refuses — the
263
+ * signed instance pin is never silently dropped onto a class-wide journal row;
264
+ * - `reads` are returned alongside, endpoint-bound, never dropped. */
265
+ export function compileHandleGrants(handle, opts) {
266
+ if (typeof opts?.commandContract !== "function")
267
+ throw new EpEnvelopeError("failed-precondition", "compiling handle grants requires the command-contract seam; the compiler never guesses a command's no-target form or journal class (SPEC 13.6/13.9)");
268
+ const caps = [];
269
+ const reads = [];
270
+ for (const g of handle.grants) {
271
+ for (const cmd of g.commands) {
272
+ const contract = opts.commandContract(g.endpoint, cmd.name);
273
+ if (!contract || (contract.noTargetForm !== "untargeted" && contract.noTargetForm !== "self") || typeof contract.journal !== "boolean")
274
+ throw new EpEnvelopeError("internal", `the command-contract seam returned a malformed contract for ${g.endpoint}.${cmd.name}; a garbled contract never compiles (SPEC 13.6)`);
275
+ const target = compileTarget(cmd, contract);
276
+ if (contract.journal) {
277
+ if (g.instanceId !== undefined)
278
+ throw new EpEnvelopeError("permission-denied", `grant entry for "${g.endpoint}" pins instance "${g.instanceId}" but command "${cmd.name}" is journal-class; the frozen epj grammar has no instance coordinate, so the signed instance pin cannot be honored - unrepresentable, refused rather than widened (SPEC 13.6/13.9)`);
279
+ caps.push({
280
+ endpoint: g.endpoint, command: cmd.name,
281
+ routes: [],
282
+ ...(target !== undefined ? { target } : {}),
283
+ journal: true,
284
+ });
285
+ continue;
286
+ }
287
+ caps.push({
288
+ endpoint: g.endpoint, command: cmd.name,
289
+ routes: g.instanceId !== undefined ? [] : ["one"],
290
+ ...(g.instanceId !== undefined ? { instanceId: g.instanceId } : {}),
291
+ ...(target !== undefined ? { target } : {}),
292
+ });
293
+ }
294
+ for (const r of g.reads ?? [])
295
+ if (!reads.some((x) => x.endpoint === g.endpoint && x.subtree === r))
296
+ reads.push({ endpoint: g.endpoint, subtree: r });
297
+ }
298
+ return { caps, reads };
299
+ }
300
+ // ---- the attenuation containment order (§13.6: a child MUST be ⊆ its parent) ------------------
301
+ /** The command-mode lattice `self < owner < any`; `child`/`ledger`/`handle` are grantable in a
302
+ * child ONLY where the parent names the SAME mode (they are distinct validator-primary rails,
303
+ * never a widening of `owner`). */
304
+ const MODE_RANK = { self: 0, owner: 1, child: 1, ledger: 1, handle: 1, any: 2 };
305
+ function modeOf(cmd) {
306
+ if (cmd.targetOwner === undefined)
307
+ return "self"; // no-target ~ self/untargeted floor
308
+ if (cmd.targetActor !== undefined)
309
+ return "handle";
310
+ return (cmd.authz ?? "owner");
311
+ }
312
+ /** True iff a child command mode is ⊆ a parent command mode under the §13.6 order: never higher
313
+ * in `self < owner < any`, and `child`/`ledger`/`handle` only where the parent names the SAME
314
+ * mode. */
315
+ function modeContained(childMode, parentMode) {
316
+ if (childMode === parentMode)
317
+ return true;
318
+ if (childMode === "child" || childMode === "ledger" || childMode === "handle")
319
+ return false; // distinct rails: only same-mode
320
+ if (parentMode === "child" || parentMode === "ledger" || parentMode === "handle")
321
+ return false; // a distinct-rail parent confers only itself
322
+ return MODE_RANK[childMode] <= MODE_RANK[parentMode];
323
+ }
324
+ function targetComponentsContained(child, parent) {
325
+ // Each present PARENT component must be equal in the child; the child may NEWLY PIN a
326
+ // component the parent left open, never widen a pinned one to absent.
327
+ for (const f of ["targetOwner", "targetActor", "targetLifecycleUid"])
328
+ if (parent[f] !== undefined && parent[f] !== child[f])
329
+ return false;
330
+ return true;
331
+ }
332
+ function readsContained(childReads, parentReads) {
333
+ if (childReads === undefined || childReads.length === 0)
334
+ return true;
335
+ const parents = parentReads ?? [];
336
+ // Every child subtree must be subject-prefix-contained in some parent subtree (equal, or a
337
+ // strict `.`-delimited descendant — never a token-boundary-crossing string prefix).
338
+ return childReads.every((cr) => parents.some((pr) => cr === pr || cr.startsWith(pr + ".")));
339
+ }
340
+ /** The per-grant-entry half of the §13.6 containment order, shared by attenuation (child ⊆
341
+ * parent) and issuer-scope coverage (handle.grants ⊆ anchor.scope). Returns the FIRST
342
+ * widening as a why-string, or undefined when contained. */
343
+ function grantWidens(cg, ceiling) {
344
+ const pg = ceiling.find((p) => p.endpoint === cg.endpoint);
345
+ if (pg === undefined)
346
+ return `endpoint "${cg.endpoint}" is not granted`;
347
+ if (pg.instanceId !== undefined && pg.instanceId !== cg.instanceId)
348
+ return `endpoint "${cg.endpoint}" instance ${cg.instanceId ?? "(absent)"} != the pinned ${pg.instanceId}`;
349
+ if (!readsContained(cg.reads, pg.reads))
350
+ return `endpoint "${cg.endpoint}" reads are not subtree-contained`;
351
+ for (const cc of cg.commands) {
352
+ const pc = pg.commands.find((p) => p.name === cc.name);
353
+ if (pc === undefined)
354
+ return `command "${cg.endpoint}.${cc.name}" is not granted`;
355
+ if (!modeContained(modeOf(cc), modeOf(pc)))
356
+ return `command "${cg.endpoint}.${cc.name}" mode ${modeOf(cc)} is not ⊆ ${modeOf(pc)}`;
357
+ if (!targetComponentsContained(cc, pc))
358
+ return `command "${cg.endpoint}.${cc.name}" target components widen the granted ones`;
359
+ }
360
+ return undefined;
361
+ }
362
+ /** Assert a CHILD handle is ⊆ its PARENT under the §13.6 normative containment order. Per grant
363
+ * entry: endpoint equal (domain patterns are a future extension; this revision pins exact
364
+ * endpoints); `instanceId` equal or newly pinned (never widened to absent); commands a
365
+ * name-subset with per-command mode contained and target components equal-or-newly-pinned;
366
+ * reads subject-prefix-contained. Per envelope: same space; validity window within the
367
+ * parent's; `sturdy` only if the parent is sturdy. Throws `permission-denied` on any widening. */
368
+ export function assertHandleContainedIn(child, parent) {
369
+ const deny = (why) => { throw new EpEnvelopeError("permission-denied", `handle "${child.id}" widens its parent "${parent.id}": ${why} (SPEC 13.6 containment: a child MUST be ⊆ its parent)`); };
370
+ if (child.space !== parent.space)
371
+ deny(`space ${child.space} != ${parent.space}`);
372
+ if (child.exp > parent.exp)
373
+ deny(`exp ${child.exp} exceeds the parent's ${parent.exp}`);
374
+ const childNbf = child.nbf ?? child.iat, parentNbf = parent.nbf ?? parent.iat;
375
+ if (childNbf < parentNbf)
376
+ deny(`validity starts (${childNbf}) before the parent's (${parentNbf})`);
377
+ if (child.sturdy && !parent.sturdy)
378
+ deny("a sturdy child cannot descend from a live parent");
379
+ for (const cg of child.grants) {
380
+ const widens = grantWidens(cg, parent.grants);
381
+ if (widens !== undefined)
382
+ deny(widens);
383
+ }
384
+ }
385
+ // ---- issuer authority: the anchor's STRUCTURED handles scope (§13.10) -------------------------
386
+ /** Parse an anchor's `handles`-role scope: each entry is a canonical-JSON-encoded
387
+ * {@link HandleGrant} ceiling — "the full grant dimensions, in the handle-grant shape itself"
388
+ * (§13.10). An absent/empty dimension is CLOSED; a garbled entry never widens into an open
389
+ * ceiling. */
390
+ function parseAnchorHandleScope(anchor) {
391
+ const entries = anchor.scope?.handles;
392
+ if (entries === undefined || entries.length === 0)
393
+ throw new EpEnvelopeError("permission-denied", `signing key ${anchor.keyId} carries the "handles" role with no scope ceiling; an absent dimension is closed, not open (SPEC 13.10)`);
394
+ return entries.map((e) => {
395
+ let raw;
396
+ try {
397
+ raw = JSON.parse(e);
398
+ }
399
+ catch {
400
+ throw new EpEnvelopeError("permission-denied", `signing key ${anchor.keyId} has a garbled handles-scope entry (not JSON); an unreadable ceiling never authorizes (SPEC 13.10)`);
401
+ }
402
+ try {
403
+ return parseGrant(raw);
404
+ }
405
+ catch (err) {
406
+ throw new EpEnvelopeError("permission-denied", `signing key ${anchor.keyId} has a garbled handles-scope entry (${err.message}); an unreadable ceiling never authorizes (SPEC 13.10)`);
407
+ }
408
+ });
409
+ }
410
+ /** Enforce §13.10 issuer authority for one handle: `handle.grants ⊆ anchor.scope` under the
411
+ * SAME §13.6 containment order — endpoints, per-command modes, target components, instance
412
+ * pins, and read subtrees are all ceiling dimensions; a flat endpoint/command list cannot
413
+ * express them and is exactly the laundering this refuses. */
414
+ export function assertIssuerScopeCoversHandle(anchor, handle) {
415
+ const ceiling = parseAnchorHandleScope(anchor);
416
+ for (const g of handle.grants) {
417
+ const widens = grantWidens(g, ceiling);
418
+ if (widens !== undefined)
419
+ throw new EpEnvelopeError("permission-denied", `signing key ${anchor.keyId}'s handles scope does not cover handle "${handle.id}": ${widens} (SPEC 13.10/13.6: handle.grants ⊆ anchor.scope, containment never widening)`);
420
+ }
421
+ }
422
+ /** Race an authority read against the REMAINING verification budget: a stuck registry/revocation
423
+ * authority is a bounded `unavailable` refusal, never a hung verification. The caller passes the
424
+ * remaining milliseconds of ONE total walk budget (never a per-await reset: sixteen links each
425
+ * burning a full fresh budget would multiply the ceiling); an exhausted budget refuses
426
+ * IMMEDIATELY, WITHOUT invoking the authority seam (the work is a THUNK, checked against the
427
+ * budget before it is started). Races `Promise.resolve(p)` unconditionally (a non-native
428
+ * thenable must not bypass the deadline), and NORMALIZES any lookup failure (a raw seam
429
+ * rejection) to a catalog `unavailable`, so retry classification is deterministic.
430
+ *
431
+ * Deferred (a shared-seam API change, tracked separately): on timeout the underlying in-flight
432
+ * read is not aborted (the {@link AnchorResolver} seam shared with signing/receipt/guard takes
433
+ * no AbortSignal). The verification still fails closed and bounds the CALLER; the residual is
434
+ * orphaned backend work, a resource concern, not an authorization one. */
435
+ async function withVerifyBudget(work, remainingMs, what) {
436
+ if (remainingMs <= 0)
437
+ throw new EpEnvelopeError("unavailable", `${what} found the verification budget already exhausted; the whole chain walk is ONE bounded operation and fails closed WITHOUT starting the read (SPEC 13.10)`);
438
+ let timer;
439
+ const deadline = new Promise((_, reject) => {
440
+ timer = setTimeout(() => reject(new EpEnvelopeError("unavailable", `${what} did not answer within the remaining ${remainingMs}ms verification budget; verification is bounded and fails closed (SPEC 13.10)`)), remainingMs);
441
+ });
442
+ try {
443
+ return await Promise.race([Promise.resolve(work()), deadline]); // work() invoked ONLY after the budget check
444
+ }
445
+ catch (e) {
446
+ if (e instanceof EpEnvelopeError)
447
+ throw e;
448
+ throw new EpEnvelopeError("unavailable", `${what} failed: ${e?.message ?? String(e)}; a lookup failure is a bounded unavailable, never a raw error (SPEC 13.10)`);
449
+ }
450
+ finally {
451
+ clearTimeout(timer);
452
+ }
453
+ }
454
+ /** Verify a presented handle CHAIN inline (§13.6): the leaf plus every `parentDigest`-linked
455
+ * ancestor, presented together (no ambient fetch). Signature and digest identity are checked
456
+ * over the EXACT RAW presented artifacts (D28), never the parsed projection. For each link,
457
+ * leaf to root:
458
+ * - its `parentDigest` (if any) equals the digest of the NEXT presented RAW artifact (the
459
+ * chain is the one presented, not a forgeable claim);
460
+ * - each child is ⊆ its parent ({@link assertHandleContainedIn});
461
+ * - the ISSUER of a child is the PARENT's holder — anchor-registered with a `handles` role
462
+ * whose STRUCTURED scope covers the link ({@link assertIssuerScopeCoversHandle}) AND
463
+ * lifecycle-bound to the parent's holder (`ownerLifecycleUid`; owner text alone would let
464
+ * a recycled alias issue off its predecessor's handles — absent binding fails closed);
465
+ * - the signature verifies against the resolved anchor, within the anchor's window;
466
+ * - EVERY link is currency-checked: window (`nbf ≤ now ≤ exp`), no future `iat`, TTL span
467
+ * within the live/sturdy ceiling AND `exp ≤ now + ceiling` (clock-anchored: a backdated
468
+ * `nbf`/forward-dated `iat` cannot manufacture validity beyond the ceiling), space match;
469
+ * - EVERY sturdy link's revocation status is strict-`false`-checked (unreadable = revoked);
470
+ * - a LIVE leaf binds `presenterEpoch`; a LIVE ANCESTOR requires `resolveHolderEpoch` to
471
+ * fresh-check ITS holder's current epoch (a restarted intermediate kills the chain).
472
+ * The walk is bounded: chain length ≤ {@link HANDLE_MAX_CHAIN_LENGTH}, every await within
473
+ * `verifyBudgetMs` (default 5000). A ROOT handle (no parentDigest) is issued by an anchor
474
+ * whose owner is the handle's issuer principal. Returns the leaf and its compiled
475
+ * equivalent-mint bundle (never wider than any ancestor). */
476
+ export async function verifyHandleChain(chain, opts) {
477
+ if (!Array.isArray(chain) || chain.length === 0)
478
+ throw new EpEnvelopeError("permission-denied", "a handle chain is empty (SPEC 13.6: present the leaf plus every ancestor inline)");
479
+ if (chain.length > HANDLE_MAX_CHAIN_LENGTH)
480
+ throw new EpEnvelopeError("permission-denied", `the presented chain has ${chain.length} links; verification is bounded at ${HANDLE_MAX_CHAIN_LENGTH} (SPEC 13.6)`);
481
+ if (!Number.isSafeInteger(opts.now) || opts.now < 0)
482
+ throw new EpEnvelopeError("failed-precondition", `now must be a non-negative safe integer; got ${JSON.stringify(opts.now)}`);
483
+ const budget = opts.verifyBudgetMs ?? 5_000;
484
+ if (!Number.isSafeInteger(budget) || budget <= 0)
485
+ throw new EpEnvelopeError("failed-precondition", `verifyBudgetMs must be a positive integer; got ${JSON.stringify(opts.verifyBudgetMs)}`);
486
+ // Snapshot the FULL authority context into detached, frozen locals at entry, BEFORE the first
487
+ // await: a caller mutating opts.readRevocation / resolveAnchor / resolveHolderEpoch (or any
488
+ // scalar/presenter field) between links would otherwise swap the authority a single
489
+ // verification decides against. Every consuming read below uses ONLY these locals.
490
+ if (!isRec(opts.presenter) || typeof opts.presenter.id !== "string" || typeof opts.presenter.lifecycleUid !== "string")
491
+ throw new EpEnvelopeError("failed-precondition", "the presenter must carry a string id and lifecycleUid");
492
+ const A = Object.freeze({
493
+ now: opts.now,
494
+ space: opts.space,
495
+ presenter: Object.freeze({ id: opts.presenter.id, lifecycleUid: opts.presenter.lifecycleUid, epoch: opts.presenter.epoch }),
496
+ maxSturdyTtlMs: opts.maxSturdyTtlMs ?? HANDLE_MAX_STURDY_TTL_MS,
497
+ maxLiveTtlMs: opts.maxLiveTtlMs ?? HANDLE_MAX_LIVE_TTL_MS,
498
+ resolveHolderEpoch: opts.resolveHolderEpoch,
499
+ resolveAnchor: opts.resolveAnchor,
500
+ readRevocation: opts.readRevocation,
501
+ commandContract: opts.commandContract,
502
+ });
503
+ // ONE total walk budget on a MONOTONIC clock: every read races the REMAINING time, so sixteen
504
+ // links cannot each burn a fresh full budget (bounded as one operation, SPEC 13.10), and a
505
+ // wall-clock adjustment cannot extend the budget backward or exhaust it forward.
506
+ const startedAt = performance.now();
507
+ const remainingBudget = () => budget - (performance.now() - startedAt);
508
+ // Snapshot each RAW artifact to a detached, byte-bounded, deep-frozen copy at entry (D28: the
509
+ // signature and digest identity are checked over EXACTLY this snapshot; the parsed projection
510
+ // derives from the same bytes, so a projection/default can never be the signed value, and a
511
+ // mid-flight mutation of the caller's object cannot split verification from projection).
512
+ const raws = chain.map((r) => {
513
+ if (!isRec(r))
514
+ invalid("a chain element is not an object");
515
+ const bytes = new TextEncoder().encode(canonicalJson(r)); // throws on non-interchangeable I-JSON
516
+ if (bytes.length > HANDLE_MAX_BYTES)
517
+ invalid(`a chain artifact is ${bytes.length} bytes, over the ${HANDLE_MAX_BYTES} bound (bounded verification)`);
518
+ return Object.freeze(JSON.parse(new TextDecoder().decode(bytes)));
519
+ });
520
+ const handles = raws.map(parseHandle);
521
+ const leaf = handles[0];
522
+ // The presented chain must be leaf → … → root by parentDigest identity over the RAW
523
+ // artifacts (no ambient fetch, no forgeable parent claim, no reconstructed projection).
524
+ for (let i = 0; i < handles.length; i++) {
525
+ const h = handles[i];
526
+ const rawParent = raws[i + 1];
527
+ if (h.parentDigest === undefined) {
528
+ if (rawParent !== undefined)
529
+ throw new EpEnvelopeError("permission-denied", `handle "${h.id}" is a root (no parentDigest) but the chain presents further ancestors; a chain must terminate at its root (SPEC 13.6)`);
530
+ }
531
+ else {
532
+ if (rawParent === undefined)
533
+ throw new EpEnvelopeError("permission-denied", `handle "${h.id}" names a parentDigest but no parent is presented; chains are presented inline in full (SPEC 13.6)`);
534
+ if (contractDigest(rawParent) !== h.parentDigest)
535
+ throw new EpEnvelopeError("permission-denied", `handle "${h.id}" parentDigest does not match the presented parent "${handles[i + 1].id}" (SPEC 13.6: the chain is the one presented, not a claim)`);
536
+ }
537
+ }
538
+ // Leaf holder binding (the presenter IS the leaf's holder).
539
+ if (leaf.holder.id !== A.presenter.id || leaf.holder.lifecycleUid !== A.presenter.lifecycleUid)
540
+ throw new EpEnvelopeError("permission-denied", `handle "${leaf.id}" is holder-bound to ${leaf.holder.id}/${leaf.holder.lifecycleUid}; the presenter ${A.presenter.id}/${A.presenter.lifecycleUid} is not the holder; a recycled alias cannot present its predecessor's handles (SPEC 13.6)`);
541
+ // Walk every link: currency, containment, issuer authority, signature, revocation.
542
+ for (let i = 0; i < handles.length; i++) {
543
+ const h = handles[i];
544
+ const parent = handles[i + 1];
545
+ // Per-link currency (§13.6: expiry/ceilings fail closed on EVERY link, not only the leaf).
546
+ if (h.space !== A.space)
547
+ throw new EpEnvelopeError("permission-denied", `handle "${h.id}" is bound to space ${h.space}, not ${A.space} (SPEC 13.6)`);
548
+ // The shared §13.6/§13.10 window rules (assertArtifactCurrency); "opaque" because this runs
549
+ // BEFORE the link's signature check, so every refusal stays permission-denied.
550
+ assertArtifactCurrency({ iat: h.iat, ...(h.nbf !== undefined ? { nbf: h.nbf } : {}), exp: h.exp }, { now: A.now, ceilingMs: h.sturdy ? A.maxSturdyTtlMs : A.maxLiveTtlMs, what: `handle "${h.id}"`, ceilingName: h.sturdy ? "sturdy" : "live", refusals: "opaque" });
551
+ if (!h.sturdy) {
552
+ if (i === 0) {
553
+ if (h.epoch !== A.presenter.epoch)
554
+ throw new EpEnvelopeError("permission-denied", `live handle "${h.id}" binds process epoch ${h.epoch}; the presenter epoch is ${A.presenter.epoch} (live authority dies on restart, SPEC 13.1/13.6)`);
555
+ }
556
+ else {
557
+ // A LIVE ANCESTOR binds ITS holder's process epoch: fresh-check it (a restarted
558
+ // intermediate kills every descendant, exactly like a revoked sturdy ancestor).
559
+ if (typeof A.resolveHolderEpoch !== "function")
560
+ throw new EpEnvelopeError("failed-precondition", `the chain contains a LIVE ancestor ("${h.id}") but no resolveHolderEpoch seam was supplied; a live link's epoch currency is never assumed (SPEC 13.6)`);
561
+ const resolveHolderEpoch = A.resolveHolderEpoch;
562
+ const current = await withVerifyBudget(() => resolveHolderEpoch(h.holder), remainingBudget(), `the holder-epoch resolver for "${h.id}"`);
563
+ if (current !== null && (typeof current !== "number" || !Number.isSafeInteger(current) || current < 0))
564
+ throw new EpEnvelopeError("internal", `the holder-epoch resolver returned ${JSON.stringify(current)}; a non-integer epoch never authorizes (SPEC 13.6)`);
565
+ if (current === null || current !== h.epoch)
566
+ throw new EpEnvelopeError("permission-denied", `live ancestor "${h.id}" binds its holder's process epoch ${h.epoch} but the current epoch is ${current === null ? "retired/unknown" : current}; live authority dies on restart, on every link (SPEC 13.6)`);
567
+ }
568
+ }
569
+ if (parent !== undefined)
570
+ assertHandleContainedIn(h, parent);
571
+ const anchor = await withVerifyBudget(() => resolveAnchorForUse(A.resolveAnchor, { keyId: h.issuer.keyId, role: "handles", at: A.now }), remainingBudget(), `the anchor-registry read for ${h.issuer.keyId}`);
572
+ // The issuer of a CHILD is the PARENT's holder — bound by LIFECYCLE, not owner text: a
573
+ // recycled alias (same id, new lifecycleUid) re-registering a key must never issue off its
574
+ // predecessor's handles. An anchor without the lifecycle binding fails closed here.
575
+ if (parent !== undefined) {
576
+ if (anchor.owner !== parent.holder.id)
577
+ throw new EpEnvelopeError("permission-denied", `handle "${h.id}" issuer key ${h.issuer.keyId} belongs to ${anchor.owner}, not the parent's holder ${parent.holder.id} (SPEC 13.6/13.10)`);
578
+ if (anchor.ownerLifecycleUid === undefined)
579
+ throw new EpEnvelopeError("permission-denied", `handle "${h.id}" issuer key ${h.issuer.keyId} is not lifecycle-bound in the anchor registry; child issuance requires the key's owning lifecycle so a recycled alias cannot issue off its predecessor's handles (SPEC 13.6/13.10: fail closed)`);
580
+ if (anchor.ownerLifecycleUid !== parent.holder.lifecycleUid)
581
+ throw new EpEnvelopeError("permission-denied", `handle "${h.id}" issuer key ${h.issuer.keyId} belongs to lifecycle ${anchor.ownerLifecycleUid}, not the parent holder's ${parent.holder.lifecycleUid}; a recycled alias cannot issue off its predecessor's handles (SPEC 13.6/13.10)`);
582
+ }
583
+ // A ROOT's issuer is the anchor's own owner (the root of trust): the handle carries no
584
+ // separate issuer-principal claim, so the root binding IS the resolved key — handles-roled,
585
+ // in-window, scope-covering; registry ownership semantics live in D18.
586
+ // The issuer key's STRUCTURED handles scope must cover this link's full grant dimensions.
587
+ assertIssuerScopeCoversHandle(anchor, h);
588
+ // D28: the signature verifies over the EXACT RAW presented artifact, never a projection.
589
+ verifyArtifactSignature(raws[i], anchor);
590
+ if (h.sturdy) {
591
+ const readRevocation = A.readRevocation;
592
+ const revoked = await withVerifyBudget(() => readRevocation(h.issuer.keyId, h.id), remainingBudget(), `the revocation read for "${h.id}"`);
593
+ if (revoked !== false)
594
+ throw new EpEnvelopeError("permission-denied", `a sturdy link in the chain is not provably unrevoked (handle "${h.id}", issuer ${h.issuer.keyId}, status ${JSON.stringify(revoked)}); ONLY a literal false is "not revoked"; an unreadable/undefined status fails closed, and every sturdy link is checked, not only the leaf (SPEC 13.6)`);
595
+ }
596
+ }
597
+ return { leaf, compiled: compileHandleGrants(leaf, { commandContract: A.commandContract }) };
598
+ }
599
+ /** Serialize a handle to its canonical bytes (the wire/store form). Throws if the artifact is
600
+ * not interchangeable I-JSON (the strict canonical path), so a non-canonicalizable handle can
601
+ * never be persisted or presented. The parsed projection is byte-faithful to the signed form
602
+ * (parse injects nothing and drops nothing), so these bytes re-verify. */
603
+ export function serializeHandle(handle) {
604
+ return new TextEncoder().encode(canonicalJson(handle));
605
+ }
606
+ // re-export so a caller can spell the modes without reaching into subjects
607
+ export { EP_AUTHZ_MODES };
608
+ //# sourceMappingURL=endpoint-handle.js.map