@humanagencyp/hap-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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanagencyp/hap-core",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Core types, cryptographic primitives, and verification logic for the Human Agency Protocol",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,6 +33,7 @@
33
33
  "release:major": "pnpm version major && git push --follow-tags"
34
34
  },
35
35
  "dependencies": {
36
+ "@hpke/core": "1.9.0",
36
37
  "@noble/ed25519": "^2.1.0"
37
38
  },
38
39
  "devDependencies": {
@@ -31,6 +31,9 @@
31
31
  * conformance test pins a (payload → canonical bytes) vector that any other
32
32
  * implementation can check against.
33
33
  */
34
+ /** A high surrogate not followed by a low one, or a low one not preceded by a high one. */
35
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
36
+
34
37
  export function canonicalize(value: unknown): string {
35
38
  if (value === undefined) {
36
39
  throw new Error('canonicalize: undefined is not serializable');
@@ -41,6 +44,12 @@ export function canonicalize(value: unknown): string {
41
44
 
42
45
  // Primitives: string, finite number, boolean, null — JSON.stringify is already
43
46
  // canonical (RFC 8259 string escaping, ECMAScript number formatting).
47
+ if (typeof value === 'string' && LONE_SURROGATE.test(value)) {
48
+ // RFC 8785 requires I-JSON, which forbids a lone surrogate. JSON.stringify
49
+ // would escape it as \udXXX and sign bytes no other serializer produces;
50
+ // the protocol says refuse, never sign (Signing Canonicalization, rule 5).
51
+ throw new Error('canonicalize: string contains a lone surrogate and cannot be signed');
52
+ }
44
53
  if (value === null || typeof value !== 'object') {
45
54
  return JSON.stringify(value);
46
55
  }
@@ -1,21 +1,26 @@
1
1
  /**
2
2
  * Which commitment modes a profile allows its mandates to be signed with
3
- * (`AgentProfile.commitment_modes`). One source for the Authority Server's
4
- * refusal and the mandate screen's choice, so the two cannot disagree.
3
+ * (`AgentProfile.commitment_modes`; protocol.md → *Commitment Modes*, rule
4
+ * 8). One source for the Authority Server's refusal and the mandate screen's
5
+ * choice, so the two cannot disagree.
5
6
  */
6
- import type { AgentProfile, SignableCommitmentMode } from './types';
7
+ import type { AgentProfile, CommitmentMode, SignableCommitmentMode } from './types';
7
8
 
8
9
  /** Every mode a signer can choose, in the order a mandate screen shows them. */
9
10
  export const SIGNABLE_COMMITMENT_MODES: readonly SignableCommitmentMode[] = ['review', 'automatic'];
10
11
 
12
+ /** Every mode a signed mandate can carry. */
13
+ export const COMMITMENT_MODES: readonly CommitmentMode[] = ['automatic', 'review', 'review_above_cap'];
14
+
11
15
  const isSignable = (v: unknown): v is SignableCommitmentMode =>
12
16
  typeof v === 'string' && (SIGNABLE_COMMITMENT_MODES as readonly string[]).includes(v);
13
17
 
14
18
  /**
15
- * The modes a mandate under this profile may carry, in display order.
16
- * No declaration → all of them. A declaration is read fail-closed: entries
17
- * that are not a signable mode are dropped, so a malformed list can only
18
- * narrow — `[]`, `"review"` (not a list) or `["reveiw"]` allow nothing.
19
+ * The signable modes a mandate under this profile may carry, in display
20
+ * order. No declaration → all of them. A declaration is read fail-closed:
21
+ * entries that are not a signable mode are dropped (`review_above_cap` is
22
+ * ignored by rule 8 — it follows from `automatic`), so a malformed list can
23
+ * only narrow — `[]`, `"review"` (not a list) or `["reveiw"]` allow nothing.
19
24
  */
20
25
  export function allowedCommitmentModes(profile: AgentProfile): readonly SignableCommitmentMode[] {
21
26
  const declared = (profile as { commitment_modes?: unknown }).commitment_modes;
@@ -24,7 +29,14 @@ export function allowedCommitmentModes(profile: AgentProfile): readonly Signable
24
29
  return SIGNABLE_COMMITMENT_MODES.filter((m) => declared.includes(m));
25
30
  }
26
31
 
27
- /** Whether a mandate under this profile may be signed with `mode`. */
32
+ /**
33
+ * Whether a mandate under this profile may be SIGNED with `mode` (rule 8):
34
+ * `automatic` and `review` where listed; `review_above_cap` if and only if
35
+ * `automatic` is admitted. The Authority Server refuses any other with
36
+ * `COMMITMENT_MODE_NOT_ALLOWED`.
37
+ */
28
38
  export function isCommitmentModeAllowed(profile: AgentProfile, mode: string): boolean {
29
- return isSignable(mode) && allowedCommitmentModes(profile).includes(mode);
39
+ const allowed = allowedCommitmentModes(profile);
40
+ if (mode === 'review_above_cap') return allowed.includes('automatic');
41
+ return isSignable(mode) && allowed.includes(mode);
30
42
  }
@@ -31,7 +31,7 @@
31
31
 
32
32
  import { createHash } from 'crypto';
33
33
  import { canonicalize } from './canonicalize';
34
- import type { ContentBinding } from './types';
34
+ import type { ContentBinding, ContentFieldKind } from './types';
35
35
 
36
36
  /**
37
37
  * Canonicalize free text per the v0.5 'text' rule. Idempotent.
@@ -249,3 +249,28 @@ export function computeFieldsContentHash(
249
249
  ): string {
250
250
  return computeContentHash(binding, selectBoundFields(binding, args));
251
251
  }
252
+
253
+ // ─── Field kinds (display / verification hint) ───────────────────────────────
254
+
255
+ const FIELD_KINDS = new Set<ContentFieldKind>(['text', 'list', 'files']);
256
+
257
+ /**
258
+ * Why a binding's `field_kinds` is malformed, or null when it is fine (or
259
+ * absent). For profile validation — a malformed hint is a profile error, not
260
+ * a call-time refusal, because it never changes what is hashed.
261
+ */
262
+ export function invalidFieldKindsReason(binding: ContentBinding): string | null {
263
+ const kinds = binding.field_kinds;
264
+ if (kinds === undefined) return null;
265
+ if (!kinds || typeof kinds !== 'object' || Array.isArray(kinds)) return 'field_kinds must be an object';
266
+ for (const [field, kind] of Object.entries(kinds)) {
267
+ if (!(binding.fields ?? []).includes(field)) return `field_kinds names "${field}", which is not in fields`;
268
+ if (!FIELD_KINDS.has(kind)) return `field_kinds.${field} must be one of text, list, files`;
269
+ }
270
+ return null;
271
+ }
272
+
273
+ /** The bound fields declared as `files` — sealed document fingerprints. */
274
+ export function filesFields(binding: ContentBinding): string[] {
275
+ return Object.entries(binding.field_kinds ?? {}).filter(([, k]) => k === 'files').map(([f]) => f);
276
+ }
package/src/errors.ts CHANGED
@@ -1,16 +1,12 @@
1
1
  /**
2
- * Canonical v0.7 error codes — protocol.md → *Error Codes*.
2
+ * Canonical v0.8 error codes — protocol.md → *Error Codes*.
3
3
  *
4
4
  * "Error codes are canonical across the protocol. Implementations MUST emit
5
5
  * exactly these codes and MUST NOT alias them under different names." This
6
6
  * module is the single list both the Gatekeeper (local checks) and any AS
7
7
  * client built on this library read from, so the two cannot drift.
8
8
  *
9
- * v0.7 renamed every code that carried a retired word (attestation → mandate,
10
- * context → scope, receipt → ticket) — see protocol.md → *Migration from
11
- * v0.6* → "Error-code renames" for the old→new map. There is no alias: a v0.6
12
- * code name is simply gone from this list, by design (no backward
13
- * compatibility — CLAUDE.md "Decided by the owner").
9
+ * v0.8 adds codes and retires none; every v0.7 code keeps its name.
14
10
  */
15
11
 
16
12
  /** Codes an Authority Server (or a local Gatekeeper check) emits refusing a mandate request. */
@@ -30,11 +26,14 @@ export const MANDATE_ERROR_CODES = [
30
26
  'BOUNDS_INVALID_VALUE',
31
27
  'SCOPE_INVALID_VALUE',
32
28
  'ABOVE_CAP_CONFIG_INVALID',
29
+ 'COMMITMENT_MODE_NOT_ALLOWED', // v0.8
30
+ 'GROUP_CAP_EXCEEDED', // v0.8
33
31
  'OWNER_SIGNATURE_REQUIRED',
34
32
  'OWNER_SIGNATURE_INVALID',
35
33
  ] as const;
36
34
 
37
- /** Codes an Authority Server emits refusing a ticket request. */
35
+ /** Codes an Authority Server emits refusing a ticket request, plus the
36
+ * Gatekeeper-local and verifier codes of the same table. */
38
37
  export const TICKET_ERROR_CODES = [
39
38
  'MANDATE_NOT_FOUND',
40
39
  'MANDATE_EXPIRED',
@@ -45,6 +44,8 @@ export const TICKET_ERROR_CODES = [
45
44
  'PROFILE_NOT_FOUND',
46
45
  'INVALID_ACTION_TYPE',
47
46
  'APPROVAL_REQUIRED',
47
+ 'APPROVAL_IMPOSSIBLE', // v0.8
48
+ 'PROPOSAL_CONTENT_MISMATCH', // v0.8
48
49
  'PROPOSAL_REQUIRED',
49
50
  'PROPOSAL_NOT_FOUND',
50
51
  'PROPOSAL_NOT_APPROVED',
@@ -53,15 +54,30 @@ export const TICKET_ERROR_CODES = [
53
54
  'PROPOSAL_MISMATCH',
54
55
  'PROPOSAL_MANDATE_MISMATCH',
55
56
  'PROPOSAL_ALREADY_EXECUTED',
57
+ 'PROPOSAL_WITHDRAWN', // v0.8
56
58
  'MALFORMED_TICKET_REQUEST',
59
+ 'CONTENT_BINDING_INCOMPLETE', // v0.8, Gatekeeper · local
57
60
  'IDEMPOTENCY_KEY_REQUIRED',
58
61
  'IDEMPOTENCY_MISMATCH',
59
62
  'APPROVAL_SIGNATURE_REQUIRED',
60
63
  'APPROVAL_SIGNATURE_INVALID',
64
+ 'TICKET_REQUEST_MISMATCH', // v0.8, Gatekeeper · local
65
+ 'POLICY_UNMET', // v0.8, Verifier
66
+ ] as const;
67
+
68
+ /** Codes an Authority Server emits refusing a proposal submission or a decision (v0.8). */
69
+ export const PROPOSAL_ERROR_CODES = [
70
+ 'PROPOSAL_NOT_ENCRYPTED',
71
+ 'DECIDER_INVALID',
72
+ 'PROPOSAL_ALREADY_DECIDED',
73
+ 'PROPOSAL_APPROVERS_INVALID',
61
74
  ] as const;
62
75
 
63
76
  /** The full canonical surface — every code a conformant implementation may emit. */
64
- export type HapErrorCode = (typeof MANDATE_ERROR_CODES)[number] | (typeof TICKET_ERROR_CODES)[number];
77
+ export type HapErrorCode =
78
+ | (typeof MANDATE_ERROR_CODES)[number]
79
+ | (typeof TICKET_ERROR_CODES)[number]
80
+ | (typeof PROPOSAL_ERROR_CODES)[number];
65
81
 
66
82
  /**
67
83
  * A refusal carrying one of the canonical codes above. Thrown by local
package/src/gatekeeper.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Gatekeeper — local (Phase 1) verification for bounded execution (v0.7).
2
+ * Gatekeeper — local (Phase 1) verification for bounded execution (v0.8;
3
+ * v0.7 mandates verified under their own rules).
3
4
  *
4
5
  * protocol.md → *Validation Steps* (Phase 1, never reaches the wire):
5
6
  * 1. Resolve profile from bounds.profile
@@ -24,12 +25,13 @@
24
25
  * frame-based path is retired entirely (CLAUDE.md "Decided by the owner").
25
26
  */
26
27
 
27
- import { decodeMandateBlob, verifyMandateSignature, checkMandateExpiry, validateMandateOwners } from './mandate';
28
+ import { decodeMandateBlob, verifyMandateSignature, checkMandateExpiry, validateMandateOwners, validateAboveCapFields } from './mandate';
29
+ import { checkExecutionValues, missingSumFields } from './issuance';
28
30
  import { canonicalBounds, canonicalScope, computeBoundsHash, computeScopeHash, CanonicalValueError } from './frame';
29
31
  import { computeProfileHash } from './profile';
30
32
  import { getProfile } from './profiles';
31
33
  import { HapError, type HapErrorCode } from './errors';
32
- import { PROTOCOL_VERSION } from './versions';
34
+ import { isSupportedVersion, SUPPORTED_VERSIONS } from './versions';
33
35
  import type {
34
36
  GatekeeperRequest,
35
37
  GatekeeperResult,
@@ -112,8 +114,9 @@ export async function verify(request: GatekeeperRequest, opts: VerifyOptions = {
112
114
 
113
115
  // 3. Verify every mandate blob.
114
116
  const errors: GatekeeperError[] = [];
117
+ const verifiedPayloads: MandatePayload[] = [];
115
118
  for (const blob of request.mandates) {
116
- const mandateErrors = await verifyOneMandate(blob, {
119
+ const { errors: mandateErrors, payload } = await verifyOneMandate(blob, {
117
120
  expectedBoundsHash,
118
121
  expectedScopeHash,
119
122
  expectedProfileHash,
@@ -121,6 +124,7 @@ export async function verify(request: GatekeeperRequest, opts: VerifyOptions = {
121
124
  now,
122
125
  });
123
126
  errors.push(...mandateErrors);
127
+ if (payload) verifiedPayloads.push(payload);
124
128
  }
125
129
  if (errors.length > 0) {
126
130
  return { approved: false, errors };
@@ -144,7 +148,37 @@ export async function verify(request: GatekeeperRequest, opts: VerifyOptions = {
144
148
  }
145
149
  }
146
150
 
147
- // 5. per_transaction bounds and enum capability flags, locally.
151
+ // 5. Every value a bound reads is a finite, non-negative number
152
+ // (Validation Steps, step 8; v0.8) — before any bound is compared.
153
+ try {
154
+ checkExecutionValues(request.execution, profile);
155
+ } catch (err) {
156
+ return { approved: false, errors: [{ code: codeFromThrown(err), message: (err as Error).message }] };
157
+ }
158
+ // A cumulative_sum bound that governs this action type and reads an absent
159
+ // field is refused locally too — unless an above-cap approval is in play:
160
+ // where a mandate caps that field, an absent value is "above the cap" and
161
+ // the AS routes it to approval (Ticket Issuance, stage 7).
162
+ if (actionType !== undefined) {
163
+ const capped = new Set<string>();
164
+ for (const p of verifiedPayloads) {
165
+ for (const name of Object.keys(p.above_cap_caps ?? {})) {
166
+ const bt = profile.boundsSchema?.fields[name]?.boundType;
167
+ if (bt && bt.kind === 'per_transaction') capped.add(bt.of);
168
+ }
169
+ }
170
+ const missing = missingSumFields(request.execution, profile, actionType).filter((m) => !capped.has(m.field));
171
+ if (missing.length > 0) {
172
+ return {
173
+ approved: false,
174
+ errors: missing.map(({ bound, field }) => ({
175
+ code: 'BOUND_EXCEEDED' as const,
176
+ field,
177
+ message: `Bound "${bound}" governs "${actionType}" and reads "${field}", but this call exposes no ${field}. Refusing: an action without an amount would consume nothing of the limit (v0.8: a missing amount fails safe).`,
178
+ })),
179
+ };
180
+ }
181
+ }
148
182
  const boundsErrors = checkBounds(request, profile, actionType);
149
183
  if (boundsErrors.length > 0) {
150
184
  return { approved: false, errors: boundsErrors };
@@ -197,7 +231,16 @@ function codeFromThrown(err: unknown): HapErrorCode {
197
231
  return 'MALFORMED_MANDATE';
198
232
  }
199
233
 
200
- async function verifyOneMandate(blob: string, opts: MandateCheckOptions): Promise<GatekeeperError[]> {
234
+ async function verifyOneMandate(
235
+ blob: string,
236
+ opts: MandateCheckOptions,
237
+ ): Promise<{ errors: GatekeeperError[]; payload?: MandatePayload }> {
238
+ const errors = await verifyOneMandateErrors(blob, opts);
239
+ if (errors.length > 0) return { errors };
240
+ return { errors, payload: decodeMandateBlob(blob).payload };
241
+ }
242
+
243
+ async function verifyOneMandateErrors(blob: string, opts: MandateCheckOptions): Promise<GatekeeperError[]> {
201
244
  let mandate: Mandate;
202
245
  try {
203
246
  mandate = decodeMandateBlob(blob);
@@ -205,10 +248,10 @@ async function verifyOneMandate(blob: string, opts: MandateCheckOptions): Promis
205
248
  return [{ code: 'MALFORMED_MANDATE', message: (err as Error).message }];
206
249
  }
207
250
 
208
- if (mandate.payload.version !== PROTOCOL_VERSION) {
251
+ if (!isSupportedVersion(mandate.payload.version)) {
209
252
  return [{
210
253
  code: 'VERSION_UNSUPPORTED',
211
- message: `mandate version ${JSON.stringify(mandate.payload.version)} is not verifiable by this Gatekeeper (verifies ${PROTOCOL_VERSION} only)`,
254
+ message: `mandate version ${JSON.stringify(mandate.payload.version)} is not verifiable by this Gatekeeper (verifies [${SUPPORTED_VERSIONS.join(', ')}])`,
212
255
  }];
213
256
  }
214
257
 
@@ -226,6 +269,7 @@ async function verifyOneMandate(blob: string, opts: MandateCheckOptions): Promis
226
269
 
227
270
  try {
228
271
  validateMandateOwners(mandate.payload);
272
+ validateAboveCapFields(mandate.payload);
229
273
  } catch (err) {
230
274
  return [{ code: codeFromThrown(err), message: (err as Error).message }];
231
275
  }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * HAP Core — v0.7 ("mandate" / "ticket" / "scope" / "Mandate Owner")
2
+ * HAP Core — v0.8 (issues 0.8; verifies 0.8 and 0.7, each under its own rules)
3
3
  *
4
4
  * Shared protocol logic for agent-oriented bounded execution.
5
5
  */
@@ -18,6 +18,10 @@ export * from './profile';
18
18
  export * from './mandate';
19
19
  export * from './owner-signature';
20
20
  export * from './ticket';
21
+ export * from './ticket-projection';
22
+ export * from './verifier-policy';
23
+ export * from './proposal-content';
24
+ export * from './issuance';
21
25
  export * from './gatekeeper';
22
26
  export * from './profiles';
23
27
  export * from './did-key';
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Pure checks both enforcement points apply around issuance and execution
3
+ * (v0.8). An Authority Server composes them into its stage order
4
+ * (protocol.md → *Mandate Issuance & Validation*, *Ticket Issuance*); a
5
+ * Gatekeeper applies the execution-side ones locally (*Validation Steps*).
6
+ * They live here so the two cannot disagree about what a value means.
7
+ */
8
+
9
+ import { createHash } from 'crypto';
10
+ import { canonicalize } from './canonicalize';
11
+ import { HapError } from './errors';
12
+ import { isKeyBearingDid } from './did-key';
13
+ import type { AgentProfile, AgentBoundsParams, MandatePayload, ProfileBoundsField } from './types';
14
+
15
+ /**
16
+ * `execution_context_hash` — sha256 over the JCS of the profile's
17
+ * `executionContextSchema`, or of `{}` when the profile has none (protocol.md
18
+ * → *Execution Context Schema*, stated in v0.8).
19
+ */
20
+ export function computeExecutionContextHash(profile: Pick<AgentProfile, 'executionContextSchema'>): string {
21
+ const schema = profile.executionContextSchema ?? {};
22
+ return `sha256:${createHash('sha256').update(canonicalize(schema), 'utf8').digest('hex')}`;
23
+ }
24
+
25
+ /** The execution-context fields some bound of the profile reads
26
+ * (`boundType.of` of every `per_transaction` and `cumulative_sum` bound). */
27
+ export function boundReadFields(profile: AgentProfile): Set<string> {
28
+ const fields = new Set<string>();
29
+ for (const def of Object.values(profile.boundsSchema?.fields ?? {})) {
30
+ const bt = def.boundType;
31
+ if (bt && (bt.kind === 'per_transaction' || bt.kind === 'cumulative_sum')) fields.add(bt.of);
32
+ }
33
+ return fields;
34
+ }
35
+
36
+ /**
37
+ * Build the wire execution context (*Validation Steps*, step 11; *Ticket
38
+ * Request Schema*, rules 7–8): only what a bound reads travels. `action_type`
39
+ * travels as the request's own `actionType`, scope values never travel,
40
+ * `_`-prefixed keys are advisory metadata. A `null` value is no value.
41
+ */
42
+ export function wireExecutionContext(
43
+ execution: Record<string, unknown>,
44
+ profile: AgentProfile,
45
+ ): Record<string, unknown> {
46
+ const allowed = boundReadFields(profile);
47
+ const out: Record<string, unknown> = {};
48
+ for (const [k, v] of Object.entries(execution)) {
49
+ if (!allowed.has(k) || k.startsWith('_') || v === undefined || v === null) continue;
50
+ out[k] = v;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Every value present for a field some bound reads MUST be a finite,
57
+ * non-negative number (*Ticket Issuance*, stage 7; *Validation Steps*, step
58
+ * 8). `null` counts as absent. A `_`-prefixed key is refused.
59
+ * @throws HapError `MALFORMED_TICKET_REQUEST`
60
+ */
61
+ export function checkExecutionValues(execution: Record<string, unknown>, profile: AgentProfile): void {
62
+ for (const k of Object.keys(execution)) {
63
+ if (k.startsWith('_')) {
64
+ throw new HapError('MALFORMED_TICKET_REQUEST', `executionContext key "${k}" is reserved and MUST NOT travel`);
65
+ }
66
+ }
67
+ for (const field of boundReadFields(profile)) {
68
+ const v = execution[field];
69
+ if (v === undefined || v === null) continue;
70
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
71
+ throw new HapError(
72
+ 'MALFORMED_TICKET_REQUEST',
73
+ `executionContext.${field} MUST be a finite, non-negative number, got ${JSON.stringify(v)}`,
74
+ );
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Whether a cumulative bound governs this action type: its `appliesTo`
80
+ * names it, or it has none (Bounds Schema rule 7). */
81
+ export function boundGoverns(def: ProfileBoundsField, actionType: string): boolean {
82
+ return !def.appliesTo || def.appliesTo.includes(actionType);
83
+ }
84
+
85
+ /**
86
+ * The `cumulative_sum` bounds that govern this action type and whose field is
87
+ * absent from the execution context (*Ticket Issuance*, stage 7): each is a
88
+ * `BOUND_EXCEEDED`, unless a committed above-cap approval covers the action.
89
+ */
90
+ export function missingSumFields(
91
+ execution: Record<string, unknown>,
92
+ profile: AgentProfile,
93
+ actionType: string,
94
+ ): Array<{ bound: string; field: string }> {
95
+ const missing: Array<{ bound: string; field: string }> = [];
96
+ for (const [name, def] of Object.entries(profile.boundsSchema?.fields ?? {})) {
97
+ const bt = def.boundType;
98
+ if (!bt || bt.kind !== 'cumulative_sum' || !boundGoverns(def, actionType)) continue;
99
+ const v = execution[bt.of];
100
+ if (v === undefined || v === null) missing.push({ bound: name, field: bt.of });
101
+ }
102
+ return missing;
103
+ }
104
+
105
+ /**
106
+ * Bounds values against the profile at issuance (*Mandate Issuance*, stage
107
+ * 4): a number where the field is numeric (`"100"` is refused), no higher
108
+ * than a declared `maximum`, and an `enum` value in its `values`.
109
+ * @throws HapError `BOUNDS_INVALID_VALUE`
110
+ */
111
+ export function checkBoundsValues(bounds: AgentBoundsParams, profile: AgentProfile): void {
112
+ for (const [name, def] of Object.entries(profile.boundsSchema?.fields ?? {})) {
113
+ if (name === 'profile') continue;
114
+ const v = bounds[name];
115
+ if (v === undefined || v === null) continue;
116
+ if (def.type === 'number' || def.maximum !== undefined) {
117
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
118
+ throw new HapError('BOUNDS_INVALID_VALUE', `bound "${name}" MUST be a number, got ${JSON.stringify(v)}`);
119
+ }
120
+ if (def.maximum !== undefined && v > def.maximum) {
121
+ throw new HapError('BOUNDS_INVALID_VALUE', `bound "${name}" = ${v} exceeds the profile's maximum ${def.maximum}`);
122
+ }
123
+ }
124
+ const bt = def.boundType;
125
+ if (bt?.kind === 'enum' && !bt.values.includes(String(v))) {
126
+ throw new HapError('BOUNDS_INVALID_VALUE', `bound "${name}" = ${JSON.stringify(v)} is not in [${bt.values.join(', ')}]`);
127
+ }
128
+ }
129
+ }
130
+
131
+ /** A group's caps for one profile, as the AS holds them. */
132
+ export interface GroupCaps {
133
+ /** Caps a proposal may lift (`approval` kind) — numeric per_transaction fields only. */
134
+ approval?: Record<string, number>;
135
+ /** Hard caps: refused at signing, never routed. Numeric or enum values. */
136
+ hard?: Record<string, number | string>;
137
+ }
138
+
139
+ /**
140
+ * Whether a bound value exceeds a cap. For an `enum` bound the cap is one of
141
+ * its values, and a value that comes EARLIER in the profile's `values` order
142
+ * (more permissive) exceeds it (Bounds Schema rule 10; *Group ceilings*).
143
+ */
144
+ export function exceedsCap(value: string | number, cap: string | number, def: ProfileBoundsField): boolean {
145
+ const bt = def.boundType;
146
+ if (bt?.kind === 'enum') {
147
+ const order = bt.values;
148
+ const vi = order.indexOf(String(value));
149
+ const ci = order.indexOf(String(cap));
150
+ if (vi < 0 || ci < 0) return true; // an unknown value or cap fails closed
151
+ return vi < ci;
152
+ }
153
+ return typeof value !== 'number' || typeof cap !== 'number' || value > cap;
154
+ }
155
+
156
+ /**
157
+ * Hard group ceilings at signing (*Group ceilings*; *Group roles*, rule 5):
158
+ * a cap on a bound the Gatekeeper alone enforces (`enforcedBy: "gatekeeper"`)
159
+ * or on an `enum` bound is always hard, as is every cap in `hard`; a bound
160
+ * above one, or an optional bound omitted where a cap exists, is refused.
161
+ * Caps in `approval` on a cumulative bound are hard too (Commitment Modes,
162
+ * rule 7). An omitted REQUIRED bound is `MALFORMED_MANDATE`, checked
163
+ * elsewhere.
164
+ * @throws HapError `GROUP_CAP_EXCEEDED`
165
+ */
166
+ export function checkGroupCeilings(bounds: AgentBoundsParams, profile: AgentProfile, caps: GroupCaps): void {
167
+ const fields = profile.boundsSchema?.fields ?? {};
168
+ const hard: Record<string, number | string> = { ...(caps.hard ?? {}) };
169
+ for (const [name, cap] of Object.entries(caps.approval ?? {})) {
170
+ const def = fields[name];
171
+ const bt = def?.boundType;
172
+ if (!def || !bt) continue;
173
+ if (bt.kind !== 'per_transaction' || def.enforcedBy === 'gatekeeper') hard[name] = cap;
174
+ }
175
+ for (const [name, def] of Object.entries(fields)) {
176
+ if (name === 'profile') continue;
177
+ const cap = hard[name];
178
+ if (cap === undefined) continue;
179
+ const v = bounds[name];
180
+ if (v === undefined || v === null) {
181
+ if (!def.required) {
182
+ throw new HapError('GROUP_CAP_EXCEEDED', `bound "${name}" is omitted under a group ceiling of ${JSON.stringify(cap)} — an omitted bound is unlimited`);
183
+ }
184
+ continue;
185
+ }
186
+ if (exceedsCap(v, cap, def)) {
187
+ throw new HapError('GROUP_CAP_EXCEEDED', `bound "${name}" = ${JSON.stringify(v)} exceeds the group's hard cap ${JSON.stringify(cap)}`);
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * The above-cap configuration a mandate request or a group supplies
194
+ * (*Commitment Modes*, rule 7; *Group roles*, rules 2 and 4; Mandate rule
195
+ * 10): caps name numeric `per_transaction` bounds of the profile and never
196
+ * `profile`; approvers are key-bearing DIDs; outside a group the count is an
197
+ * integer ≥ 1 and no greater than the approvers other than the owner.
198
+ * @throws HapError `ABOVE_CAP_CONFIG_INVALID`
199
+ */
200
+ export function validateAboveCapConfig(
201
+ config: { caps?: Record<string, number>; approvers?: readonly string[]; approvals?: number },
202
+ profile: AgentProfile,
203
+ opts: { ownerDid: string; inGroup: boolean },
204
+ ): void {
205
+ const fields = profile.boundsSchema?.fields ?? {};
206
+ for (const name of Object.keys(config.caps ?? {})) {
207
+ if (name === 'profile') throw new HapError('ABOVE_CAP_CONFIG_INVALID', 'above_cap_caps MUST NOT name profile');
208
+ const def = fields[name];
209
+ if (!def || def.boundType?.kind !== 'per_transaction' || def.type !== 'number') {
210
+ throw new HapError('ABOVE_CAP_CONFIG_INVALID', `above_cap_caps names "${name}", which is not a numeric per_transaction bound of ${profile.id}`);
211
+ }
212
+ }
213
+ for (const did of config.approvers ?? []) {
214
+ if (!isKeyBearingDid(did)) {
215
+ throw new HapError('ABOVE_CAP_CONFIG_INVALID', `above-cap approver ${did} is not a key-bearing DID`);
216
+ }
217
+ }
218
+ if (config.approvers !== undefined && !opts.inGroup) {
219
+ const others = config.approvers.filter((d) => d !== opts.ownerDid).length;
220
+ const n = config.approvals;
221
+ if (n === undefined || !Number.isInteger(n) || n < 1 || n > others) {
222
+ throw new HapError(
223
+ 'ABOVE_CAP_CONFIG_INVALID',
224
+ `above_cap_approvals MUST be an integer between 1 and the ${others} approver(s) other than the owner, got ${JSON.stringify(n)}`,
225
+ );
226
+ }
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Whether a request exceeds an approval-kind cap of the mandate, or — v0.8 —
232
+ * the field the cap reads is absent (an action of unknown amount is above
233
+ * the cap). Returns the offending caps.
234
+ */
235
+ export function capsExceeded(
236
+ execution: Record<string, unknown>,
237
+ mandate: Pick<MandatePayload, 'above_cap_caps'>,
238
+ profile: AgentProfile,
239
+ ): Array<{ bound: string; field: string; cap: number; value: unknown }> {
240
+ const out: Array<{ bound: string; field: string; cap: number; value: unknown }> = [];
241
+ for (const [name, cap] of Object.entries(mandate.above_cap_caps ?? {})) {
242
+ const bt = profile.boundsSchema?.fields[name]?.boundType;
243
+ if (!bt || bt.kind !== 'per_transaction') continue;
244
+ const v = execution[bt.of];
245
+ if (v === undefined || v === null || typeof v !== 'number' || v > cap) {
246
+ out.push({ bound: name, field: bt.of, cap, value: v });
247
+ }
248
+ }
249
+ return out;
250
+ }