@humanagencyp/hap-core 0.12.1 → 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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Public ticket projection — `HAP-ticket-projection` (protocol.md → *Public
3
+ * ticket projection*, new in v0.8, specification-led).
4
+ *
5
+ * A ticket is one signature over every field, so a redacted view cannot be
6
+ * re-verified. Where a ticket is shown to parties other than its holder —
7
+ * a public page, a machine verifier at an effector, an auditor — the AS
8
+ * serves a public-only subset, separately signed by the same key, so the
9
+ * public view verifies on its own. It never widens what the ticket
10
+ * discloses: `executionContext` carries only the governing
11
+ * `disclose_fields`, and `userId`, `cumulativeState`, `limits` and `action`
12
+ * never appear.
13
+ */
14
+
15
+ import * as ed from '@noble/ed25519';
16
+ import { canonicalize } from './canonicalize';
17
+ import { toBase64Url, fromBase64Url } from './base64url';
18
+ import { decodeDidKey } from './did-key';
19
+ import { HapError } from './errors';
20
+ import type { Mandate, MandateOwnerEntry, Subject } from './types';
21
+ import type { TicketPayload } from './ticket';
22
+ import { verifyOwnerSignature, OwnerSignatureError } from './owner-signature';
23
+
24
+ /** The AS may not let a projection be accepted as authorization later than
25
+ * this after the ticket's timestamp (rule 3). */
26
+ export const PROJECTION_MAX_VALIDITY_SECONDS = 86_400;
27
+
28
+ export interface TicketProjection {
29
+ typ: 'HAP-ticket-projection';
30
+ version: string;
31
+ id: string;
32
+ mandateId: string;
33
+ issuer: string;
34
+ timestamp: number;
35
+ /** Set by the AS at projection time; governs acceptance as authorization, not evidence. */
36
+ valid_until: number;
37
+ profileId: string;
38
+ actionType: string;
39
+ boundsHash: string;
40
+ contentHash?: string;
41
+ contentBinding?: TicketPayload['contentBinding'];
42
+ subjects?: Subject[];
43
+ /** Exactly the governing `disclose_fields` the ticket carries; absent when none. */
44
+ executionContext?: Record<string, unknown>;
45
+ /** The mandate's `mandate_owners` entry, present exactly when it carries a signature. */
46
+ owner?: MandateOwnerEntry;
47
+ signature: string;
48
+ }
49
+
50
+ /**
51
+ * Build the projection of a ticket (rule 1). `discloseFields` is the
52
+ * governing list — the mandate's `disclose_fields` where present, else the
53
+ * profile's; nothing when neither declares one.
54
+ */
55
+ export function buildTicketProjection(
56
+ ticket: TicketPayload,
57
+ mandate: Mandate,
58
+ opts: { validUntil: number; discloseFields?: readonly string[] },
59
+ ): Omit<TicketProjection, 'signature'> {
60
+ if (ticket.mandateId !== mandate.payload.mandate_id) {
61
+ throw new HapError('MALFORMED_TICKET_REQUEST', 'the mandate supplied is not the one the ticket names');
62
+ }
63
+ if (opts.validUntil > ticket.timestamp + PROJECTION_MAX_VALIDITY_SECONDS) {
64
+ throw new HapError('MALFORMED_TICKET_REQUEST', `valid_until MUST NOT end later than ${PROJECTION_MAX_VALIDITY_SECONDS}s after the ticket's timestamp`);
65
+ }
66
+ const projection: Omit<TicketProjection, 'signature'> = {
67
+ typ: 'HAP-ticket-projection',
68
+ version: ticket.version,
69
+ id: ticket.id,
70
+ mandateId: ticket.mandateId,
71
+ issuer: ticket.issuer,
72
+ timestamp: ticket.timestamp,
73
+ valid_until: opts.validUntil,
74
+ profileId: ticket.profileId,
75
+ actionType: ticket.actionType,
76
+ boundsHash: ticket.boundsHash,
77
+ };
78
+ if (ticket.contentHash !== undefined) projection.contentHash = ticket.contentHash;
79
+ if (ticket.contentBinding !== undefined) projection.contentBinding = ticket.contentBinding;
80
+ if (ticket.subjects !== undefined) projection.subjects = ticket.subjects;
81
+ const disclosed: Record<string, unknown> = {};
82
+ for (const field of opts.discloseFields ?? []) {
83
+ if (ticket.executionContext[field] !== undefined) disclosed[field] = ticket.executionContext[field];
84
+ }
85
+ if (Object.keys(disclosed).length > 0) projection.executionContext = disclosed;
86
+ const owner = mandate.payload.mandate_owners[0];
87
+ if (owner?.signature !== undefined) projection.owner = owner;
88
+ return projection;
89
+ }
90
+
91
+ /** Sign a projection with the key that signed its ticket (rule 2). */
92
+ export async function signTicketProjection(
93
+ projection: Omit<TicketProjection, 'signature'>,
94
+ privateKey: Uint8Array,
95
+ ): Promise<TicketProjection> {
96
+ const signature = toBase64Url(await ed.signAsync(new TextEncoder().encode(canonicalize(projection)), privateKey));
97
+ return { ...projection, signature };
98
+ }
99
+
100
+ /**
101
+ * Verify a projection on its own: `typ` required, signature under the key
102
+ * `issuer` names. With `asAuthorization`, a projection past `valid_until`
103
+ * is refused (rule 3); as evidence it is not.
104
+ * @throws HapError `INVALID_SIGNATURE`, `POLICY_UNMET` (validity window)
105
+ */
106
+ export async function verifyTicketProjection(
107
+ projection: TicketProjection,
108
+ opts: { trustedIssuers?: readonly string[]; now?: number; asAuthorization?: boolean } = {},
109
+ ): Promise<void> {
110
+ if (projection.typ !== 'HAP-ticket-projection') {
111
+ throw new HapError('INVALID_SIGNATURE', 'a projection verifier requires typ "HAP-ticket-projection"');
112
+ }
113
+ for (const k of ['userId', 'cumulativeState', 'limits', 'action'] as const) {
114
+ if (k in projection) throw new HapError('INVALID_SIGNATURE', `a projection MUST NOT carry ${k}`);
115
+ }
116
+ if (opts.trustedIssuers && !opts.trustedIssuers.includes(projection.issuer)) {
117
+ throw new HapError('INVALID_SIGNATURE', `issuer ${JSON.stringify(projection.issuer)} is not a trusted Authority Server`);
118
+ }
119
+ const { signature, ...unsigned } = projection;
120
+ if (!signature) throw new HapError('INVALID_SIGNATURE', 'projection carries no signature');
121
+ let publicKey: Uint8Array;
122
+ try {
123
+ publicKey = decodeDidKey(projection.issuer);
124
+ } catch (err) {
125
+ throw new HapError('INVALID_SIGNATURE', `cannot resolve a key from issuer ${JSON.stringify(projection.issuer)}: ${(err as Error).message}`);
126
+ }
127
+ let sigBytes: Uint8Array;
128
+ try {
129
+ sigBytes = fromBase64Url(signature);
130
+ } catch (err) {
131
+ throw new HapError('INVALID_SIGNATURE', (err as Error).message);
132
+ }
133
+ const ok = await ed.verifyAsync(sigBytes, new TextEncoder().encode(canonicalize(unsigned)), publicKey).catch(() => false);
134
+ if (!ok) throw new HapError('INVALID_SIGNATURE', 'projection signature verification failed');
135
+ if (opts.asAuthorization) {
136
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
137
+ if (now > projection.valid_until) {
138
+ throw new HapError('POLICY_UNMET', `projection presented after valid_until ${projection.valid_until}`);
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Verify the projection's `owner` entry against the mandate obtained through
145
+ * the third-party verification API (rule 4): the entry is the mandate's
146
+ * own, and its signature verifies over the projection rebuilt from the
147
+ * mandate. The projection alone cannot establish this.
148
+ * @throws HapError `OWNER_SIGNATURE_INVALID`
149
+ */
150
+ export async function verifyProjectionOwner(projection: TicketProjection, mandate: Mandate): Promise<void> {
151
+ if (!projection.owner) {
152
+ throw new HapError('OWNER_SIGNATURE_INVALID', 'projection carries no owner entry');
153
+ }
154
+ if (projection.mandateId !== mandate.payload.mandate_id) {
155
+ throw new HapError('OWNER_SIGNATURE_INVALID', 'the mandate supplied is not the one the projection names');
156
+ }
157
+ const own = mandate.payload.mandate_owners[0];
158
+ if (!own || canonicalize(own) !== canonicalize(projection.owner)) {
159
+ throw new HapError('OWNER_SIGNATURE_INVALID', 'projection owner entry differs from the mandate\'s mandate_owners entry');
160
+ }
161
+ try {
162
+ await verifyOwnerSignature(mandate, projection.owner);
163
+ } catch (err) {
164
+ if (err instanceof OwnerSignatureError) throw new HapError(err.code, err.message);
165
+ throw err;
166
+ }
167
+ }
package/src/ticket.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  /**
2
- * Mandate tickets (v0.7) — the wire type, signing, and holder-side
3
- * verification. Renamed from `receipt.ts` (protocol.md → *Mandate Tickets*:
4
- * "v0.4 introduced the object under the name execution receipt; v0.7 renames
5
- * it because the tense was wrong — a receipt follows payment, and the ticket
6
- * must come first").
2
+ * Mandate tickets — the wire type, signing, holder-side verification, and
3
+ * (v0.8) the Gatekeeper's check that a ticket answers the request it sent.
7
4
  *
8
5
  * Ticket payloads are camelCase on the wire (unlike the snake_case mandate
9
6
  * payload) — a shipped inconsistency the spec documents rather than breaks
@@ -12,10 +9,28 @@
12
9
 
13
10
  import { HapError } from './errors';
14
11
  import * as ed from '@noble/ed25519';
15
- import type { ContentBinding, Subject } from './types';
12
+ import type { ContentBinding, Mandate, Subject } from './types';
16
13
  import { canonicalize } from './canonicalize';
17
14
  import { toBase64Url, fromBase64Url } from './base64url';
18
15
  import { decodeDidKey } from './did-key';
16
+ import { assertSupportedVersion } from './versions';
17
+ import { verifyApproval, OwnerSignatureError, type ApprovalObject } from './owner-signature';
18
+
19
+ /** Clock-skew tolerance and freshness window (Mandate rule 9; Ticket rule 4). */
20
+ export const TICKET_FRESHNESS_SECONDS = 300;
21
+
22
+ /** One verified approval carried by a ticket. */
23
+ export interface ApprovalSignatureEntry {
24
+ alg: 'EdDSA' | 'ES256';
25
+ signature: string;
26
+ nonce: string;
27
+ decided_at: number;
28
+ }
29
+
30
+ /** v0.8 — an above-cap approver's entry in `approvalSignatures`. */
31
+ export interface ApproverSignatureEntry extends ApprovalSignatureEntry {
32
+ approver_did: string;
33
+ }
19
34
 
20
35
  /** Signed mandate ticket — protocol.md → *Ticket Payload Schema*. */
21
36
  export interface TicketPayload {
@@ -42,6 +57,10 @@ export interface TicketPayload {
42
57
  /** The AS DID, as in the mandate (new in v0.7). */
43
58
  issuer: string;
44
59
  timestamp: number;
60
+ /** v0.8 — copied verbatim from the request, so the ticket names the single
61
+ * request it answers. Present exactly when the request carried one
62
+ * (synchronous path). */
63
+ idempotencyKey?: string;
45
64
  /** Hash of the bound content, computed by the Gatekeeper, copied
46
65
  * verbatim by the AS. The AS never sees the content. */
47
66
  contentHash?: string;
@@ -52,10 +71,15 @@ export interface TicketPayload {
52
71
  subjects?: Subject[];
53
72
  /** Review path: the proposal this ticket executed. */
54
73
  proposalId?: string;
74
+ /** v0.8, review path: the proposal's content fingerprint, copied verbatim
75
+ * from the proposal the AS compared it against. */
76
+ proposalContentHash?: string;
55
77
  /** Review path: the owner's HAP-approval signature for the executed
56
78
  * proposal, as the object itself — never a hash of it, which a holder
57
79
  * could check only by asking the AS. */
58
- approvalSignature?: { alg: 'EdDSA' | 'ES256'; signature: string; nonce: string; decided_at: number };
80
+ approvalSignature?: ApprovalSignatureEntry;
81
+ /** v0.8, above-cap path: one entry per approver signature the AS verified. */
82
+ approvalSignatures?: ApproverSignatureEntry[];
59
83
  /** Ed25519 over the JCS-canonical payload (this field excluded), base64url. */
60
84
  signature: string;
61
85
  }
@@ -72,25 +96,50 @@ export async function signTicket(
72
96
  return { ...payload, signature };
73
97
  }
74
98
 
99
+ /**
100
+ * The structural rules that keep a ticket, a projection and a mandate
101
+ * payload apart (protocol.md → *Ticket Payload Schema*, "Signature"): a
102
+ * ticket verifier MUST require `id`, `mandateId`, `actionType`, `timestamp`
103
+ * and `issuer`, MUST reject an object carrying `typ` (a projection can never
104
+ * be accepted as a ticket), and MUST reject `mandate_id` or `issued_at`.
105
+ * @throws HapError `INVALID_SIGNATURE`
106
+ */
107
+ export function assertTicketShape(ticket: Record<string, unknown>): void {
108
+ if ('typ' in ticket) {
109
+ throw new HapError('INVALID_SIGNATURE', 'a ticket carries no typ — an object with one is a projection, not a ticket');
110
+ }
111
+ if ('mandate_id' in ticket || 'issued_at' in ticket) {
112
+ throw new HapError('INVALID_SIGNATURE', 'a ticket carries no mandate_id or issued_at — this object is a mandate payload');
113
+ }
114
+ for (const required of ['id', 'mandateId', 'actionType', 'timestamp', 'issuer'] as const) {
115
+ if (ticket[required] === undefined) {
116
+ throw new HapError('INVALID_SIGNATURE', `ticket is missing required field ${required}`);
117
+ }
118
+ }
119
+ }
120
+
75
121
  /**
76
122
  * Verify a ticket's AS signature: strip `signature`, JCS-canonicalize the
77
123
  * rest, Ed25519-verify against the key resolved from `issuer` — never
78
124
  * against a key offered alongside the ticket (protocol.md → *Ticket
79
125
  * Verification* step 1). This is holder-side verification — it needs the
80
126
  * COMPLETE ticket; a redacted public view cannot be re-verified this way
81
- * (see {@link publicTicketView}).
127
+ * (see `ticket-projection.ts` for the view that can).
82
128
  *
83
129
  * Content is checked separately: where `contentHash` is present, recompute
84
130
  * it from the held artifact via the content-binding module and compare.
85
131
  *
86
132
  * @param opts.trustedIssuers When supplied, an `issuer` not in this list is
87
133
  * rejected before any cryptography runs.
88
- * @throws Error prefixed `INVALID_SIGNATURE:` when verification fails.
134
+ * @throws HapError `INVALID_SIGNATURE` when verification fails;
135
+ * `VERSION_UNSUPPORTED` for a version this package cannot read.
89
136
  */
90
137
  export async function verifyTicketSignature(
91
138
  ticket: TicketPayload,
92
139
  opts?: { trustedIssuers?: readonly string[] },
93
140
  ): Promise<void> {
141
+ assertTicketShape(ticket as unknown as Record<string, unknown>);
142
+ assertSupportedVersion(ticket.version, 'ticket');
94
143
  const { signature, ...unsigned } = ticket;
95
144
  if (!signature) {
96
145
  throw new HapError('INVALID_SIGNATURE', 'ticket carries no signature');
@@ -120,19 +169,204 @@ export async function verifyTicketSignature(
120
169
  }
121
170
  }
122
171
 
172
+ // ─── The request a ticket answers (v0.8) ─────────────────────────────────────
173
+
174
+ /** The fields of a ticket request a Gatekeeper compares with the ticket it
175
+ * receives (protocol.md → *Ticket Payload Schema*, rule 4). */
176
+ export interface TicketRequestEcho {
177
+ profileId: string;
178
+ actionType: string;
179
+ boundsHash: string;
180
+ executionContext: Record<string, unknown>;
181
+ mandateId?: string;
182
+ idempotencyKey?: string;
183
+ contentHash?: string;
184
+ contentBinding?: TicketPayload['contentBinding'];
185
+ proposalId?: string;
186
+ proposalContentHash?: string;
187
+ }
188
+
189
+ const ECHOED_FIELDS = [
190
+ 'profileId', 'actionType', 'boundsHash', 'executionContext',
191
+ 'contentHash', 'contentBinding', 'proposalId', 'proposalContentHash',
192
+ ] as const;
193
+
123
194
  /**
124
- * A redacted public view of a ticket (new in v0.7 — protocol.md → *Ticket
125
- * Disclosure Is Declared*, *Ticket Verification*). Hides `userId`,
126
- * `cumulativeState`, and `limits` unconditionally, and narrows
127
- * `executionContext` to exactly the keys named by `governingDiscloseFields`
128
- * (the mandate's `disclose_fields` where present, otherwise the profile's).
195
+ * v0.8 — the Gatekeeper's half of "a ticket names the request it answers":
196
+ * every echoed field equals what was sent, the `idempotencyKey` it sent is
197
+ * the one the ticket carries, `mandateId` and `version` are the verified
198
+ * mandate's, and a fresh ticket's `timestamp` lies within
199
+ * {@link TICKET_FRESHNESS_SECONDS}. A replay (`idempotent: true`, unsigned)
200
+ * is exempt from freshness only; the caller passes `replay: true` and then
201
+ * applies *One execution per ticket*, rule 2.
129
202
  *
130
- * Disclose NOTHING by default: an empty or absent `governingDiscloseFields`
131
- * strips `executionContext` entirely. "The holder is the verifier" — this
132
- * view is NOT independently re-verifiable against `signature` (it is missing
133
- * signed fields); a "signature valid" indicator on a page showing only this
134
- * view is the AS re-verifying its own signature, and implementations MUST
135
- * NOT present it as independently verified.
203
+ * Does NOT verify the signature — call {@link verifyTicketSignature} first.
204
+ * @throws HapError `TICKET_REQUEST_MISMATCH`
205
+ */
206
+ export function verifyTicketAgainstRequest(
207
+ ticket: TicketPayload,
208
+ request: TicketRequestEcho,
209
+ opts: { mandate?: Mandate; now?: number; replay?: boolean } = {},
210
+ ): void {
211
+ for (const field of ECHOED_FIELDS) {
212
+ const sent = request[field];
213
+ const got = ticket[field];
214
+ if (sent === undefined && got === undefined) continue;
215
+ if (sent === undefined || got === undefined || canonicalize(sent) !== canonicalize(got)) {
216
+ throw new HapError('TICKET_REQUEST_MISMATCH', `ticket ${field} differs from the request's`);
217
+ }
218
+ }
219
+ if (request.idempotencyKey !== undefined && ticket.idempotencyKey !== request.idempotencyKey) {
220
+ throw new HapError(
221
+ 'TICKET_REQUEST_MISMATCH',
222
+ ticket.idempotencyKey === undefined
223
+ ? 'ticket carries no idempotencyKey although the request sent one'
224
+ : 'ticket idempotencyKey differs from the one the request sent',
225
+ );
226
+ }
227
+ const expectedMandateId = opts.mandate?.payload.mandate_id ?? request.mandateId;
228
+ if (expectedMandateId !== undefined && ticket.mandateId !== expectedMandateId) {
229
+ throw new HapError('TICKET_REQUEST_MISMATCH', `ticket mandateId ${ticket.mandateId} is not the mandate this request was made under`);
230
+ }
231
+ if (opts.mandate && ticket.version !== opts.mandate.payload.version) {
232
+ throw new HapError('TICKET_REQUEST_MISMATCH', `ticket version ${ticket.version} differs from its mandate's ${opts.mandate.payload.version}`);
233
+ }
234
+ if (!opts.replay) {
235
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
236
+ if (Math.abs(now - ticket.timestamp) > TICKET_FRESHNESS_SECONDS) {
237
+ throw new HapError('TICKET_REQUEST_MISMATCH', `ticket timestamp ${ticket.timestamp} is outside the freshness window at ${now}`);
238
+ }
239
+ }
240
+ }
241
+
242
+ // ─── Approvals carried by a ticket ───────────────────────────────────────────
243
+
244
+ /**
245
+ * Rebuild the `HAP-approval` a decider signed, from the ticket and the
246
+ * mandate it names (protocol.md → *Ticket Payload Schema*, `approvalSignature`):
247
+ * `typ` and `version`; `proposal_id` and `mandate_id` from `proposalId` and
248
+ * `mandateId`; `action` and `action_type` (v0.8) from `action` and
249
+ * `actionType`; `proposal_content_hash` from `proposalContentHash`;
250
+ * `content_hash` from `contentHash` where a binding applies to this action
251
+ * (the AS copies one only then), else `proposalContentHash`; `decision`
252
+ * `"commit"`; `approver_did` as given; `decided_at` and `nonce` from the
253
+ * entry.
254
+ */
255
+ export function reconstructApproval(
256
+ ticket: TicketPayload,
257
+ entry: ApprovalSignatureEntry,
258
+ approverDid: string,
259
+ ): ApprovalObject {
260
+ if (!ticket.proposalId) {
261
+ throw new HapError('APPROVAL_SIGNATURE_INVALID', 'ticket carries an approval signature but no proposalId');
262
+ }
263
+ if (ticket.version === '0.7') {
264
+ if (!ticket.contentHash) {
265
+ throw new HapError('APPROVAL_SIGNATURE_INVALID', 'a v0.7 approval without a content binding is not reconstructible from the ticket alone');
266
+ }
267
+ return {
268
+ typ: 'HAP-approval',
269
+ version: '0.7',
270
+ proposal_id: ticket.proposalId,
271
+ mandate_id: ticket.mandateId,
272
+ decision: 'commit',
273
+ content_hash: ticket.contentHash,
274
+ decided_at: entry.decided_at,
275
+ nonce: entry.nonce,
276
+ };
277
+ }
278
+ if (!ticket.proposalContentHash) {
279
+ throw new HapError('APPROVAL_SIGNATURE_INVALID', 'a v0.8 review-path ticket carries proposalContentHash');
280
+ }
281
+ return {
282
+ typ: 'HAP-approval',
283
+ version: '0.8',
284
+ approver_did: approverDid,
285
+ proposal_id: ticket.proposalId,
286
+ mandate_id: ticket.mandateId,
287
+ action: ticket.action,
288
+ action_type: ticket.actionType,
289
+ decision: 'commit',
290
+ content_hash: ticket.contentHash ?? ticket.proposalContentHash,
291
+ proposal_content_hash: ticket.proposalContentHash,
292
+ decided_at: entry.decided_at,
293
+ nonce: entry.nonce,
294
+ };
295
+ }
296
+
297
+ /** What a ticket's approvals, once verified, establish. */
298
+ export interface VerifiedApprovals {
299
+ /** The Mandate Owner's DID, when `approvalSignature` verified. */
300
+ owner?: string;
301
+ /** Distinct above-cap approver DIDs whose `approvalSignatures` entries verified. */
302
+ approvers: string[];
303
+ }
304
+
305
+ /**
306
+ * Verify every approval a ticket carries against the mandate it names
307
+ * (protocol.md → *Ticket Payload Schema*, rule 4): `approvalSignature`
308
+ * under the Mandate Owner's DID; each `approvalSignatures` entry under its
309
+ * `approver_did`, which MUST be in the signed `above_cap_approvers` and
310
+ * MUST NOT be the Mandate Owner (the proposer never counts). Whether the
311
+ * verified set is ENOUGH is {@link approvalsSufficient}, so a verifier can
312
+ * report what it found and a policy can decide.
313
+ *
314
+ * @throws HapError `APPROVAL_SIGNATURE_INVALID`
315
+ */
316
+ export async function verifyTicketApprovals(ticket: TicketPayload, mandate: Mandate): Promise<VerifiedApprovals> {
317
+ if (ticket.mandateId !== mandate.payload.mandate_id) {
318
+ throw new HapError('APPROVAL_SIGNATURE_INVALID', 'the mandate supplied is not the one the ticket names');
319
+ }
320
+ const ownerDid = mandate.payload.mandate_owners[0]?.did;
321
+ const result: VerifiedApprovals = { approvers: [] };
322
+ try {
323
+ if (ticket.approvalSignature) {
324
+ const approval = reconstructApproval(ticket, ticket.approvalSignature, ownerDid);
325
+ await verifyApproval(approval, ticket.approvalSignature.signature, ownerDid);
326
+ result.owner = ownerDid;
327
+ }
328
+ const signed = new Set(mandate.payload.above_cap_approvers ?? []);
329
+ for (const entry of ticket.approvalSignatures ?? []) {
330
+ if (!signed.has(entry.approver_did)) {
331
+ throw new HapError('APPROVAL_SIGNATURE_INVALID', `approver ${entry.approver_did} is not in the mandate's signed above_cap_approvers`);
332
+ }
333
+ if (entry.approver_did === ownerDid) {
334
+ throw new HapError('APPROVAL_SIGNATURE_INVALID', 'the proposer never counts as an above-cap approver');
335
+ }
336
+ const approval = reconstructApproval(ticket, entry, entry.approver_did);
337
+ await verifyApproval(approval, entry.signature, entry.approver_did);
338
+ if (!result.approvers.includes(entry.approver_did)) result.approvers.push(entry.approver_did);
339
+ }
340
+ } catch (err) {
341
+ if (err instanceof OwnerSignatureError) throw new HapError(err.code, err.message);
342
+ throw err;
343
+ }
344
+ return result;
345
+ }
346
+
347
+ /**
348
+ * Whether the verified approvals satisfy what the mandate requires for a
349
+ * decision above a cap: at least `above_cap_approvals` distinct approvers
350
+ * (v0.7: every signed approver), plus the owner's own approval in `review`.
351
+ */
352
+ export function approvalsSufficient(mandate: Mandate, verified: VerifiedApprovals, aboveCap: boolean): boolean {
353
+ const p = mandate.payload;
354
+ const ownerNeeded = p.commitment_mode === 'review';
355
+ if (ownerNeeded && !verified.owner) return false;
356
+ if (!aboveCap) return true;
357
+ const required = p.version === '0.7'
358
+ ? (p.above_cap_approvers ?? []).length
359
+ : (p.above_cap_approvals ?? 1);
360
+ return verified.approvers.length >= required;
361
+ }
362
+
363
+ /**
364
+ * A redacted public view of a ticket (protocol.md → *Ticket Disclosure Is
365
+ * Declared*). Hides `userId`, `cumulativeState`, and `limits`, and narrows
366
+ * `executionContext` to the governing `disclose_fields`. NOT independently
367
+ * re-verifiable — since v0.8 a verifiable public view is the separately
368
+ * signed `HAP-ticket-projection` (`ticket-projection.ts`); a view that is
369
+ * not one MUST NOT be presented as independently verified.
136
370
  */
137
371
  export function publicTicketView(
138
372
  ticket: TicketPayload,
package/src/types.ts CHANGED
@@ -84,10 +84,15 @@ export interface Subject {
84
84
  verified_at?: number;
85
85
  }
86
86
 
87
- /** protocol.md → *Mandate Payload (v0.7)*. */
87
+ /** The protocol version an artifact was issued under. This package verifies
88
+ * each under its own rules (protocol.md → *Version negotiation*, rule 4). */
89
+ export type ProtocolVersion = '0.8' | '0.7';
90
+
91
+ /** protocol.md → *Mandate Payload (v0.8)*; a `"0.7"` payload is the same shape
92
+ * without `above_cap_approvals` and without the v0.8 owner-signed fields. */
88
93
  export interface MandatePayload {
89
94
  mandate_id: string;
90
- version: '0.7';
95
+ version: ProtocolVersion;
91
96
  profile_id: string;
92
97
  /** Hash of the canonical bounds string. */
93
98
  bounds_hash: string;
@@ -104,10 +109,16 @@ export interface MandatePayload {
104
109
  /** Exactly one entry in v0.7 (Mandate rule 7). */
105
110
  mandate_owners: MandateOwnerEntry[];
106
111
  gate_content_hashes: Record<string, string>;
107
- commitment_mode: 'automatic' | 'review' | 'review_above_cap';
108
- /** Required, together with `above_cap_approvers`, iff `commitment_mode === "review_above_cap"`. */
112
+ commitment_mode: CommitmentMode;
113
+ /** Present together with `above_cap_approvers` and (v0.8) `above_cap_approvals`:
114
+ * under `review_above_cap`, or `review` in a group with approval-kind caps
115
+ * the owner's mandate can reach (protocol.md → *Group roles*, rule 7). */
109
116
  above_cap_caps?: Record<string, number>;
110
117
  above_cap_approvers?: string[];
118
+ /** v0.8 — how many distinct `above_cap_approvers`, other than the Mandate
119
+ * Owner, must approve. Integer ≥ 1. Present exactly when
120
+ * `above_cap_approvers` is (Mandate rule 10). A v0.7 mandate carries none. */
121
+ above_cap_approvals?: number;
111
122
  /**
112
123
  * Present iff the mandate carries an encrypted-intent disclosure object
113
124
  * (companion spec `intent-disclosure@0.1`) — `sha256:`-prefixed hash
@@ -290,6 +301,15 @@ export interface ProfileBoundsField {
290
301
  * min(value, maximum). Absent → no ceiling.
291
302
  */
292
303
  maximum?: number;
304
+ /**
305
+ * v0.8 (Bounds Schema rule 11) — a bound only the Gatekeeper enforces, such
306
+ * as a read window whose actions produce no ticket. The Authority Server
307
+ * still verifies the value at issuance (`maximum`, enum membership, any
308
+ * group ceiling) and never reads it at ticket time. A group cap on such a
309
+ * bound is always hard, and the signed value is the ceiling (*Group
310
+ * ceilings*).
311
+ */
312
+ enforcedBy?: 'gatekeeper';
293
313
  }
294
314
 
295
315
  /**
@@ -446,6 +466,9 @@ export type ExecutionContextFieldDef = DeclaredFieldDef | CumulativeFieldDef;
446
466
  */
447
467
  export type SignableCommitmentMode = 'automatic' | 'review';
448
468
 
469
+ /** Every commitment mode a signed mandate can carry. */
470
+ export type CommitmentMode = SignableCommitmentMode | 'review_above_cap';
471
+
449
472
  /** protocol.md → *Owner Signatures* → "Where the requirement lives" — the
450
473
  * profile-floor tier: `minBinding` names the weakest `OwnerSignatureBinding`
451
474
  * this profile accepts for a co-signing owner. */
@@ -504,8 +527,13 @@ export interface AgentProfile {
504
527
  * a mandate screen offers only the listed modes. Fail-closed: a declared
505
528
  * list with no valid entry allows nothing — a malformed declaration never
506
529
  * widens what a profile allows. See {@link allowedCommitmentModes}.
530
+ *
531
+ * v0.8 (Commitment Modes, rule 8): `review_above_cap` is admitted if and
532
+ * only if `automatic` is listed; an entry `review_above_cap` in the list is
533
+ * ignored, and a list whose only entry is it admits nothing and is
534
+ * malformed (`PROFILE_INVALID`).
507
535
  */
508
- commitment_modes?: readonly SignableCommitmentMode[];
536
+ commitment_modes?: readonly CommitmentMode[];
509
537
 
510
538
  /**
511
539
  * The bounds schema — the enforceable parameters (protocol.md → *Bounds