@humanagencyp/hap-core 0.7.0 → 0.8.1

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/dist/index.d.mts CHANGED
@@ -113,6 +113,27 @@ interface Attestation {
113
113
  interface FieldConstraint {
114
114
  type: 'number' | 'string';
115
115
  enforceable: Array<'max' | 'enum' | 'subset'>;
116
+ /**
117
+ * Action types for which this dimension MUST be present in the execution
118
+ * context. When the grant constrains this field and the action is one of
119
+ * these, an absent value is a denial rather than a skipped check.
120
+ *
121
+ * Without it, a constraint is only enforced against calls that happen to
122
+ * expose the value. Gmail's `send_message` is the live case: pass `raw` and
123
+ * `to` is never populated, so `allowed_recipients` had nothing to compare and
124
+ * the check passed — a send to an unverified recipient looked authorized.
125
+ * `send_draft` has the same shape: it transmits, and the Gatekeeper cannot
126
+ * see to whom.
127
+ *
128
+ * Keyed on `action_type` because "does this action engage recipients?" is a
129
+ * property of the action, not of the field name — deleting a draft has no
130
+ * recipients and must keep passing, while sending one must not. This keeps
131
+ * the engine profile-agnostic: it compares declared strings and knows nothing
132
+ * about email, calendars or payments.
133
+ *
134
+ * Omitted → previous behaviour: absence skips the check.
135
+ */
136
+ requiredFor?: string[];
116
137
  }
117
138
  /**
118
139
  * Frame field definition within a profile.
@@ -246,22 +267,63 @@ interface ProfileBoundsField {
246
267
  * backward compatibility). The gateway computes the hash; the SP only ever
247
268
  * receives the hash, never the content, so HAP's privacy-minimal design holds.
248
269
  *
249
- * The profile declares only the *policy* — whether to bind and how to
250
- * canonicalize. It does NOT name the tool field: that is tool-specific and is
251
- * resolved at runtime (the same content-field resolver the footer uses for
252
- * `kind:"text"`; the whole record payload for `kind:"jcs"`).
270
+ * At `version:"1"` the profile declares only the *policy* — whether to bind and
271
+ * how to canonicalize. It does NOT name the tool field: that is tool-specific
272
+ * and is resolved at runtime (the same content-field resolver the footer uses
273
+ * for `kind:"text"`; the whole record payload for `kind:"jcs"`).
274
+ *
275
+ * At `version:"2"` the profile additionally declares WHICH fields are bound (see
276
+ * {@link ContentBinding.fields}). Neither v1 mode is the general case: `text`
277
+ * binds one field and leaves everything beside it unbound, while `jcs` over the
278
+ * whole payload is checkable only by a party that already knows the whole
279
+ * payload — an email recipient holds the body, the subject and their own
280
+ * address, but not `bcc`. The general case is a declared subset, chosen so the
281
+ * intended verifier can reproduce it.
253
282
  */
254
283
  interface ContentBinding {
255
284
  /** Canonicalization version. A verifier MUST pin the version named here. */
256
285
  version: string;
257
286
  /**
258
- * - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
287
+ * - 'jcs' → structured writes: RFC 8785 JCS over the record payload
288
+ * (v1) or over the object built from {@link fields} (v2).
259
289
  * - 'text' → free text: NFC + LF + trailing-whitespace strip (see
260
290
  * canonicalizeText), auto-detected content field.
261
291
  */
262
292
  kind: 'jcs' | 'text';
263
293
  /** text only: hash the content BEFORE any appended Suveren footer. */
264
294
  pre_footer?: boolean;
295
+ /**
296
+ * v2 only — the tool-argument keys this binding covers, and the complete
297
+ * statement of what a verifier must reproduce. The Gatekeeper builds an
298
+ * object from exactly these keys and canonicalizes it by `kind`.
299
+ *
300
+ * Adding or removing an entry changes every resulting hash, so it is a
301
+ * BREAKING profile change requiring a version bump, never a silent edit.
302
+ *
303
+ * Choose the subset by one rule: bind everything the approving human is
304
+ * shown, and nothing the intended verifier cannot see.
305
+ */
306
+ fields?: string[];
307
+ /**
308
+ * v2 only — the subset of {@link fields} whose absence is a fault rather than
309
+ * a fact. An absent OPTIONAL field is omitted from the hashed object (an
310
+ * email legitimately has no `cc`); an absent REQUIRED field means the call is
311
+ * not the call this profile thinks it is, and MUST refuse rather than hash a
312
+ * partial object that reads exactly like a complete one.
313
+ *
314
+ * MUST be a subset of `fields`. Absent → every field is optional, and only a
315
+ * wholly empty selection refuses.
316
+ */
317
+ required_fields?: string[];
318
+ /**
319
+ * v2 only — the action types this binding covers, using the same vocabulary
320
+ * as {@link ProfileBoundsField.appliesTo}. A profile gates more than its
321
+ * content-bearing calls: `email` also gates deletes, which carry an id and no
322
+ * content, and applying a field binding to those would refuse them.
323
+ *
324
+ * Absent → the binding applies to every gated action under the profile.
325
+ */
326
+ appliesTo?: string[];
265
327
  }
266
328
  /**
267
329
  * Context field definition within a v0.4 profile.
@@ -575,6 +637,12 @@ declare function canonicalize(value: unknown): string;
575
637
  * (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
576
638
  * trailing blank lines removed), taken pre-footer when `pre_footer` is set.
577
639
  *
640
+ * At `version:"2"` the profile also declares WHICH tool arguments are bound
641
+ * ({@link selectBoundFields}), so a receipt can commit to an email's recipients
642
+ * and not only its prose — while still omitting what the intended verifier
643
+ * cannot see. Every string entering the hashed object is canonicalized by the
644
+ * same `text` rule, so a delivered copy with CRLF endings still reproduces it.
645
+ *
578
646
  * Both Node and the browser produce byte-identical output: JCS relies only on
579
647
  * environment-independent primitives, and the text rule uses String.normalize +
580
648
  * plain string ops. The SHA-256 is computed with Node `crypto` here (the same
@@ -605,6 +673,56 @@ declare function contentCanonicalBytes(kind: ContentBinding['kind'], content: Re
605
673
  * @param content the record payload (jcs) or the resolved text field (text)
606
674
  */
607
675
  declare function computeContentHash(binding: ContentBinding, content: Record<string, unknown> | string): string;
676
+ /** Why a field binding refused. Every case is fail-closed by design. */
677
+ type ContentBindingErrorCode =
678
+ /** The profile declares version:"2" with no usable `fields` list. */
679
+ 'NO_FIELDS_DECLARED'
680
+ /** `required_fields` names something absent from `fields`. */
681
+ | 'REQUIRED_FIELD_NOT_DECLARED'
682
+ /** A required field was absent or empty at call time. */
683
+ | 'MISSING_REQUIRED_FIELD'
684
+ /** No declared field carried a value — the hash would commit to nothing. */
685
+ | 'EMPTY_BINDING';
686
+ /**
687
+ * A field binding could not be computed. ALWAYS a refusal, never a downgrade:
688
+ * the alternative is a receipt that verifies while proving less than it appears
689
+ * to, which is the failure content binding exists to prevent.
690
+ */
691
+ declare class ContentBindingError extends Error {
692
+ readonly code: ContentBindingErrorCode;
693
+ /** The offending field, when the code names one. */
694
+ readonly field?: string;
695
+ constructor(code: ContentBindingErrorCode, message: string, field?: string);
696
+ }
697
+ /** True when this binding selects a declared subset (v2) rather than v1's implicit scope. */
698
+ declare function isFieldBinding(binding: ContentBinding): boolean;
699
+ /**
700
+ * Whether a field binding covers this action type, per the profile's `appliesTo`.
701
+ *
702
+ * Read STRICTLY — an undeclared action type is NOT covered. This differs from
703
+ * how bounds read the same key (there, an unknown action type enforces the
704
+ * bound, because an extra limit is safe). Here the two directions are not
705
+ * symmetric: applying a field binding to a call that carries no content refuses
706
+ * a legitimate action, so an unknown action type must fall outside rather than
707
+ * inside. Callers are expected to warn on the undeclared case — it is a
708
+ * manifest bug either way.
709
+ */
710
+ declare function bindingAppliesTo(binding: ContentBinding, actionType: string | undefined): boolean;
711
+ /**
712
+ * Build the object a v2 binding hashes: exactly the declared `fields` that
713
+ * carry a value, canonicalized. Exported so a verifier can construct the same
714
+ * object from what they hold and see it before hashing.
715
+ *
716
+ * Throws {@link ContentBindingError} rather than returning a partial result —
717
+ * see that class for why refusing is the only safe outcome.
718
+ */
719
+ declare function selectBoundFields(binding: ContentBinding, args: Record<string, unknown>): Record<string, unknown>;
720
+ /**
721
+ * Compute a v2 field-binding hash from raw tool arguments: select the declared
722
+ * subset, then hash it by the declared `kind`. Convenience over
723
+ * {@link selectBoundFields} + {@link computeContentHash} for the common path.
724
+ */
725
+ declare function computeFieldsContentHash(binding: ContentBinding, args: Record<string, unknown>): string;
608
726
 
609
727
  /**
610
728
  * Intent hashing + intent-disclosure binding (HAP v0.5).
@@ -896,4 +1014,4 @@ declare function listProfiles(): string[];
896
1014
  declare function getAllProfiles(): AgentProfile[];
897
1015
  declare function clearProfiles(): void;
898
1016
 
899
- export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type ContentBinding, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, type Subject, type SubjectValidation, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, deriveIdentityLine, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
1017
+ export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type ContentBinding, ContentBindingError, type ContentBindingErrorCode, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, type Subject, type SubjectValidation, attestationId, bindingAppliesTo, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFieldsContentHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, deriveIdentityLine, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isFieldBinding, isV4Attestation, listProfiles, registerProfile, selectBoundFields, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
package/dist/index.d.ts CHANGED
@@ -113,6 +113,27 @@ interface Attestation {
113
113
  interface FieldConstraint {
114
114
  type: 'number' | 'string';
115
115
  enforceable: Array<'max' | 'enum' | 'subset'>;
116
+ /**
117
+ * Action types for which this dimension MUST be present in the execution
118
+ * context. When the grant constrains this field and the action is one of
119
+ * these, an absent value is a denial rather than a skipped check.
120
+ *
121
+ * Without it, a constraint is only enforced against calls that happen to
122
+ * expose the value. Gmail's `send_message` is the live case: pass `raw` and
123
+ * `to` is never populated, so `allowed_recipients` had nothing to compare and
124
+ * the check passed — a send to an unverified recipient looked authorized.
125
+ * `send_draft` has the same shape: it transmits, and the Gatekeeper cannot
126
+ * see to whom.
127
+ *
128
+ * Keyed on `action_type` because "does this action engage recipients?" is a
129
+ * property of the action, not of the field name — deleting a draft has no
130
+ * recipients and must keep passing, while sending one must not. This keeps
131
+ * the engine profile-agnostic: it compares declared strings and knows nothing
132
+ * about email, calendars or payments.
133
+ *
134
+ * Omitted → previous behaviour: absence skips the check.
135
+ */
136
+ requiredFor?: string[];
116
137
  }
117
138
  /**
118
139
  * Frame field definition within a profile.
@@ -246,22 +267,63 @@ interface ProfileBoundsField {
246
267
  * backward compatibility). The gateway computes the hash; the SP only ever
247
268
  * receives the hash, never the content, so HAP's privacy-minimal design holds.
248
269
  *
249
- * The profile declares only the *policy* — whether to bind and how to
250
- * canonicalize. It does NOT name the tool field: that is tool-specific and is
251
- * resolved at runtime (the same content-field resolver the footer uses for
252
- * `kind:"text"`; the whole record payload for `kind:"jcs"`).
270
+ * At `version:"1"` the profile declares only the *policy* — whether to bind and
271
+ * how to canonicalize. It does NOT name the tool field: that is tool-specific
272
+ * and is resolved at runtime (the same content-field resolver the footer uses
273
+ * for `kind:"text"`; the whole record payload for `kind:"jcs"`).
274
+ *
275
+ * At `version:"2"` the profile additionally declares WHICH fields are bound (see
276
+ * {@link ContentBinding.fields}). Neither v1 mode is the general case: `text`
277
+ * binds one field and leaves everything beside it unbound, while `jcs` over the
278
+ * whole payload is checkable only by a party that already knows the whole
279
+ * payload — an email recipient holds the body, the subject and their own
280
+ * address, but not `bcc`. The general case is a declared subset, chosen so the
281
+ * intended verifier can reproduce it.
253
282
  */
254
283
  interface ContentBinding {
255
284
  /** Canonicalization version. A verifier MUST pin the version named here. */
256
285
  version: string;
257
286
  /**
258
- * - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
287
+ * - 'jcs' → structured writes: RFC 8785 JCS over the record payload
288
+ * (v1) or over the object built from {@link fields} (v2).
259
289
  * - 'text' → free text: NFC + LF + trailing-whitespace strip (see
260
290
  * canonicalizeText), auto-detected content field.
261
291
  */
262
292
  kind: 'jcs' | 'text';
263
293
  /** text only: hash the content BEFORE any appended Suveren footer. */
264
294
  pre_footer?: boolean;
295
+ /**
296
+ * v2 only — the tool-argument keys this binding covers, and the complete
297
+ * statement of what a verifier must reproduce. The Gatekeeper builds an
298
+ * object from exactly these keys and canonicalizes it by `kind`.
299
+ *
300
+ * Adding or removing an entry changes every resulting hash, so it is a
301
+ * BREAKING profile change requiring a version bump, never a silent edit.
302
+ *
303
+ * Choose the subset by one rule: bind everything the approving human is
304
+ * shown, and nothing the intended verifier cannot see.
305
+ */
306
+ fields?: string[];
307
+ /**
308
+ * v2 only — the subset of {@link fields} whose absence is a fault rather than
309
+ * a fact. An absent OPTIONAL field is omitted from the hashed object (an
310
+ * email legitimately has no `cc`); an absent REQUIRED field means the call is
311
+ * not the call this profile thinks it is, and MUST refuse rather than hash a
312
+ * partial object that reads exactly like a complete one.
313
+ *
314
+ * MUST be a subset of `fields`. Absent → every field is optional, and only a
315
+ * wholly empty selection refuses.
316
+ */
317
+ required_fields?: string[];
318
+ /**
319
+ * v2 only — the action types this binding covers, using the same vocabulary
320
+ * as {@link ProfileBoundsField.appliesTo}. A profile gates more than its
321
+ * content-bearing calls: `email` also gates deletes, which carry an id and no
322
+ * content, and applying a field binding to those would refuse them.
323
+ *
324
+ * Absent → the binding applies to every gated action under the profile.
325
+ */
326
+ appliesTo?: string[];
265
327
  }
266
328
  /**
267
329
  * Context field definition within a v0.4 profile.
@@ -575,6 +637,12 @@ declare function canonicalize(value: unknown): string;
575
637
  * (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
576
638
  * trailing blank lines removed), taken pre-footer when `pre_footer` is set.
577
639
  *
640
+ * At `version:"2"` the profile also declares WHICH tool arguments are bound
641
+ * ({@link selectBoundFields}), so a receipt can commit to an email's recipients
642
+ * and not only its prose — while still omitting what the intended verifier
643
+ * cannot see. Every string entering the hashed object is canonicalized by the
644
+ * same `text` rule, so a delivered copy with CRLF endings still reproduces it.
645
+ *
578
646
  * Both Node and the browser produce byte-identical output: JCS relies only on
579
647
  * environment-independent primitives, and the text rule uses String.normalize +
580
648
  * plain string ops. The SHA-256 is computed with Node `crypto` here (the same
@@ -605,6 +673,56 @@ declare function contentCanonicalBytes(kind: ContentBinding['kind'], content: Re
605
673
  * @param content the record payload (jcs) or the resolved text field (text)
606
674
  */
607
675
  declare function computeContentHash(binding: ContentBinding, content: Record<string, unknown> | string): string;
676
+ /** Why a field binding refused. Every case is fail-closed by design. */
677
+ type ContentBindingErrorCode =
678
+ /** The profile declares version:"2" with no usable `fields` list. */
679
+ 'NO_FIELDS_DECLARED'
680
+ /** `required_fields` names something absent from `fields`. */
681
+ | 'REQUIRED_FIELD_NOT_DECLARED'
682
+ /** A required field was absent or empty at call time. */
683
+ | 'MISSING_REQUIRED_FIELD'
684
+ /** No declared field carried a value — the hash would commit to nothing. */
685
+ | 'EMPTY_BINDING';
686
+ /**
687
+ * A field binding could not be computed. ALWAYS a refusal, never a downgrade:
688
+ * the alternative is a receipt that verifies while proving less than it appears
689
+ * to, which is the failure content binding exists to prevent.
690
+ */
691
+ declare class ContentBindingError extends Error {
692
+ readonly code: ContentBindingErrorCode;
693
+ /** The offending field, when the code names one. */
694
+ readonly field?: string;
695
+ constructor(code: ContentBindingErrorCode, message: string, field?: string);
696
+ }
697
+ /** True when this binding selects a declared subset (v2) rather than v1's implicit scope. */
698
+ declare function isFieldBinding(binding: ContentBinding): boolean;
699
+ /**
700
+ * Whether a field binding covers this action type, per the profile's `appliesTo`.
701
+ *
702
+ * Read STRICTLY — an undeclared action type is NOT covered. This differs from
703
+ * how bounds read the same key (there, an unknown action type enforces the
704
+ * bound, because an extra limit is safe). Here the two directions are not
705
+ * symmetric: applying a field binding to a call that carries no content refuses
706
+ * a legitimate action, so an unknown action type must fall outside rather than
707
+ * inside. Callers are expected to warn on the undeclared case — it is a
708
+ * manifest bug either way.
709
+ */
710
+ declare function bindingAppliesTo(binding: ContentBinding, actionType: string | undefined): boolean;
711
+ /**
712
+ * Build the object a v2 binding hashes: exactly the declared `fields` that
713
+ * carry a value, canonicalized. Exported so a verifier can construct the same
714
+ * object from what they hold and see it before hashing.
715
+ *
716
+ * Throws {@link ContentBindingError} rather than returning a partial result —
717
+ * see that class for why refusing is the only safe outcome.
718
+ */
719
+ declare function selectBoundFields(binding: ContentBinding, args: Record<string, unknown>): Record<string, unknown>;
720
+ /**
721
+ * Compute a v2 field-binding hash from raw tool arguments: select the declared
722
+ * subset, then hash it by the declared `kind`. Convenience over
723
+ * {@link selectBoundFields} + {@link computeContentHash} for the common path.
724
+ */
725
+ declare function computeFieldsContentHash(binding: ContentBinding, args: Record<string, unknown>): string;
608
726
 
609
727
  /**
610
728
  * Intent hashing + intent-disclosure binding (HAP v0.5).
@@ -896,4 +1014,4 @@ declare function listProfiles(): string[];
896
1014
  declare function getAllProfiles(): AgentProfile[];
897
1015
  declare function clearProfiles(): void;
898
1016
 
899
- export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type ContentBinding, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, type Subject, type SubjectValidation, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, deriveIdentityLine, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
1017
+ export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type ContentBinding, ContentBindingError, type ContentBindingErrorCode, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, type Subject, type SubjectValidation, attestationId, bindingAppliesTo, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFieldsContentHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, deriveIdentityLine, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isFieldBinding, isV4Attestation, listProfiles, registerProfile, selectBoundFields, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
package/dist/index.js CHANGED
@@ -30,7 +30,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ ContentBindingError: () => ContentBindingError,
33
34
  attestationId: () => attestationId,
35
+ bindingAppliesTo: () => bindingAppliesTo,
34
36
  canonicalBounds: () => canonicalBounds,
35
37
  canonicalContext: () => canonicalContext,
36
38
  canonicalFrame: () => canonicalFrame,
@@ -41,6 +43,7 @@ __export(index_exports, {
41
43
  computeBoundsHash: () => computeBoundsHash,
42
44
  computeContentHash: () => computeContentHash,
43
45
  computeContextHash: () => computeContextHash,
46
+ computeFieldsContentHash: () => computeFieldsContentHash,
44
47
  computeFrameHash: () => computeFrameHash,
45
48
  computeIntentDisclosureHash: () => computeIntentDisclosureHash,
46
49
  computeIntentHash: () => computeIntentHash,
@@ -52,9 +55,11 @@ __export(index_exports, {
52
55
  getAllProfiles: () => getAllProfiles,
53
56
  getProfile: () => getProfile,
54
57
  intentDisclosureCanonicalBytes: () => intentDisclosureCanonicalBytes,
58
+ isFieldBinding: () => isFieldBinding,
55
59
  isV4Attestation: () => isV4Attestation,
56
60
  listProfiles: () => listProfiles,
57
61
  registerProfile: () => registerProfile,
62
+ selectBoundFields: () => selectBoundFields,
58
63
  validateBoundsParams: () => validateBoundsParams,
59
64
  validateContextParams: () => validateContextParams,
60
65
  validateFrameParams: () => validateFrameParams,
@@ -120,6 +125,89 @@ function contentCanonicalBytes(kind, content) {
120
125
  function computeContentHash(binding, content) {
121
126
  return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
122
127
  }
128
+ var ContentBindingError = class extends Error {
129
+ code;
130
+ /** The offending field, when the code names one. */
131
+ field;
132
+ constructor(code, message, field) {
133
+ super(message);
134
+ this.name = "ContentBindingError";
135
+ this.code = code;
136
+ this.field = field;
137
+ }
138
+ };
139
+ function isFieldBinding(binding) {
140
+ return binding.version === "2" && Array.isArray(binding.fields) && binding.fields.length > 0;
141
+ }
142
+ function bindingAppliesTo(binding, actionType) {
143
+ if (!binding.appliesTo) return true;
144
+ return actionType !== void 0 && binding.appliesTo.includes(actionType);
145
+ }
146
+ function canonicalizeValue(value) {
147
+ if (value === null || value === void 0) return void 0;
148
+ if (typeof value === "string") {
149
+ const text = canonicalizeText(value);
150
+ return text === "" ? void 0 : text;
151
+ }
152
+ if (typeof value === "number" || typeof value === "boolean") return value;
153
+ if (Array.isArray(value)) {
154
+ const items = value.map(canonicalizeValue).filter((v) => v !== void 0);
155
+ return items.length > 0 ? items : void 0;
156
+ }
157
+ if (typeof value === "object") {
158
+ const out = {};
159
+ for (const [key, v] of Object.entries(value)) {
160
+ const c = canonicalizeValue(v);
161
+ if (c !== void 0) out[key] = c;
162
+ }
163
+ return Object.keys(out).length > 0 ? out : void 0;
164
+ }
165
+ return void 0;
166
+ }
167
+ function selectBoundFields(binding, args) {
168
+ const fields = binding.fields;
169
+ if (!fields || fields.length === 0) {
170
+ throw new ContentBindingError(
171
+ "NO_FIELDS_DECLARED",
172
+ 'content_binding version "2" requires a non-empty `fields` list.'
173
+ );
174
+ }
175
+ const required = new Set(binding.required_fields ?? []);
176
+ for (const field of required) {
177
+ if (!fields.includes(field)) {
178
+ throw new ContentBindingError(
179
+ "REQUIRED_FIELD_NOT_DECLARED",
180
+ `content_binding requires "${field}" but does not bind it \u2014 required_fields must be a subset of fields.`,
181
+ field
182
+ );
183
+ }
184
+ }
185
+ const bound = {};
186
+ for (const field of fields) {
187
+ const value = canonicalizeValue(args[field]);
188
+ if (value === void 0) {
189
+ if (required.has(field)) {
190
+ throw new ContentBindingError(
191
+ "MISSING_REQUIRED_FIELD",
192
+ `content_binding requires "${field}", which is absent or empty in this call. Refusing rather than hashing a partial object.`,
193
+ field
194
+ );
195
+ }
196
+ continue;
197
+ }
198
+ bound[field] = value;
199
+ }
200
+ if (Object.keys(bound).length === 0) {
201
+ throw new ContentBindingError(
202
+ "EMPTY_BINDING",
203
+ `No declared field (${fields.join(", ")}) carried a value \u2014 the hash would commit to nothing.`
204
+ );
205
+ }
206
+ return bound;
207
+ }
208
+ function computeFieldsContentHash(binding, args) {
209
+ return computeContentHash(binding, selectBoundFields(binding, args));
210
+ }
123
211
 
124
212
  // src/intent-disclosure.ts
125
213
  var import_crypto2 = require("crypto");
@@ -757,6 +845,23 @@ function checkContextConstraints(context, execution, profile) {
757
845
  if (!profile.contextSchema) return errors;
758
846
  for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
759
847
  if (!fieldDef.constraint) continue;
848
+ const requiredFor = fieldDef.constraint.requiredFor;
849
+ if (requiredFor?.length) {
850
+ const boundValue = context[fieldName];
851
+ const actualValue = execution[fieldName];
852
+ const actionType = execution["action_type"];
853
+ const constrained = boundValue !== void 0 && boundValue !== "";
854
+ const engaged = actionType !== void 0 && requiredFor.includes(String(actionType));
855
+ if (constrained && engaged && (actualValue === void 0 || actualValue === "")) {
856
+ errors.push({
857
+ code: "BOUND_EXCEEDED",
858
+ field: fieldName,
859
+ message: `Authorization limits "${fieldName}" to [${boundValue}], but this "${String(actionType)}" call exposes no ${fieldName} to check against it. Refusing: the call cannot be shown to stay in scope.`,
860
+ bound: boundValue
861
+ });
862
+ continue;
863
+ }
864
+ }
760
865
  for (const enforceType of fieldDef.constraint.enforceable) {
761
866
  if (enforceType === "enum") {
762
867
  const boundValue = context[fieldName];
@@ -842,7 +947,9 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
842
947
  }
843
948
  // Annotate the CommonJS export names for ESM import in node:
844
949
  0 && (module.exports = {
950
+ ContentBindingError,
845
951
  attestationId,
952
+ bindingAppliesTo,
846
953
  canonicalBounds,
847
954
  canonicalContext,
848
955
  canonicalFrame,
@@ -853,6 +960,7 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
853
960
  computeBoundsHash,
854
961
  computeContentHash,
855
962
  computeContextHash,
963
+ computeFieldsContentHash,
856
964
  computeFrameHash,
857
965
  computeIntentDisclosureHash,
858
966
  computeIntentHash,
@@ -864,9 +972,11 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
864
972
  getAllProfiles,
865
973
  getProfile,
866
974
  intentDisclosureCanonicalBytes,
975
+ isFieldBinding,
867
976
  isV4Attestation,
868
977
  listProfiles,
869
978
  registerProfile,
979
+ selectBoundFields,
870
980
  validateBoundsParams,
871
981
  validateContextParams,
872
982
  validateFrameParams,
package/dist/index.mjs CHANGED
@@ -49,6 +49,89 @@ function contentCanonicalBytes(kind, content) {
49
49
  function computeContentHash(binding, content) {
50
50
  return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
51
51
  }
52
+ var ContentBindingError = class extends Error {
53
+ code;
54
+ /** The offending field, when the code names one. */
55
+ field;
56
+ constructor(code, message, field) {
57
+ super(message);
58
+ this.name = "ContentBindingError";
59
+ this.code = code;
60
+ this.field = field;
61
+ }
62
+ };
63
+ function isFieldBinding(binding) {
64
+ return binding.version === "2" && Array.isArray(binding.fields) && binding.fields.length > 0;
65
+ }
66
+ function bindingAppliesTo(binding, actionType) {
67
+ if (!binding.appliesTo) return true;
68
+ return actionType !== void 0 && binding.appliesTo.includes(actionType);
69
+ }
70
+ function canonicalizeValue(value) {
71
+ if (value === null || value === void 0) return void 0;
72
+ if (typeof value === "string") {
73
+ const text = canonicalizeText(value);
74
+ return text === "" ? void 0 : text;
75
+ }
76
+ if (typeof value === "number" || typeof value === "boolean") return value;
77
+ if (Array.isArray(value)) {
78
+ const items = value.map(canonicalizeValue).filter((v) => v !== void 0);
79
+ return items.length > 0 ? items : void 0;
80
+ }
81
+ if (typeof value === "object") {
82
+ const out = {};
83
+ for (const [key, v] of Object.entries(value)) {
84
+ const c = canonicalizeValue(v);
85
+ if (c !== void 0) out[key] = c;
86
+ }
87
+ return Object.keys(out).length > 0 ? out : void 0;
88
+ }
89
+ return void 0;
90
+ }
91
+ function selectBoundFields(binding, args) {
92
+ const fields = binding.fields;
93
+ if (!fields || fields.length === 0) {
94
+ throw new ContentBindingError(
95
+ "NO_FIELDS_DECLARED",
96
+ 'content_binding version "2" requires a non-empty `fields` list.'
97
+ );
98
+ }
99
+ const required = new Set(binding.required_fields ?? []);
100
+ for (const field of required) {
101
+ if (!fields.includes(field)) {
102
+ throw new ContentBindingError(
103
+ "REQUIRED_FIELD_NOT_DECLARED",
104
+ `content_binding requires "${field}" but does not bind it \u2014 required_fields must be a subset of fields.`,
105
+ field
106
+ );
107
+ }
108
+ }
109
+ const bound = {};
110
+ for (const field of fields) {
111
+ const value = canonicalizeValue(args[field]);
112
+ if (value === void 0) {
113
+ if (required.has(field)) {
114
+ throw new ContentBindingError(
115
+ "MISSING_REQUIRED_FIELD",
116
+ `content_binding requires "${field}", which is absent or empty in this call. Refusing rather than hashing a partial object.`,
117
+ field
118
+ );
119
+ }
120
+ continue;
121
+ }
122
+ bound[field] = value;
123
+ }
124
+ if (Object.keys(bound).length === 0) {
125
+ throw new ContentBindingError(
126
+ "EMPTY_BINDING",
127
+ `No declared field (${fields.join(", ")}) carried a value \u2014 the hash would commit to nothing.`
128
+ );
129
+ }
130
+ return bound;
131
+ }
132
+ function computeFieldsContentHash(binding, args) {
133
+ return computeContentHash(binding, selectBoundFields(binding, args));
134
+ }
52
135
 
53
136
  // src/intent-disclosure.ts
54
137
  import { createHash as createHash2 } from "crypto";
@@ -686,6 +769,23 @@ function checkContextConstraints(context, execution, profile) {
686
769
  if (!profile.contextSchema) return errors;
687
770
  for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
688
771
  if (!fieldDef.constraint) continue;
772
+ const requiredFor = fieldDef.constraint.requiredFor;
773
+ if (requiredFor?.length) {
774
+ const boundValue = context[fieldName];
775
+ const actualValue = execution[fieldName];
776
+ const actionType = execution["action_type"];
777
+ const constrained = boundValue !== void 0 && boundValue !== "";
778
+ const engaged = actionType !== void 0 && requiredFor.includes(String(actionType));
779
+ if (constrained && engaged && (actualValue === void 0 || actualValue === "")) {
780
+ errors.push({
781
+ code: "BOUND_EXCEEDED",
782
+ field: fieldName,
783
+ message: `Authorization limits "${fieldName}" to [${boundValue}], but this "${String(actionType)}" call exposes no ${fieldName} to check against it. Refusing: the call cannot be shown to stay in scope.`,
784
+ bound: boundValue
785
+ });
786
+ continue;
787
+ }
788
+ }
689
789
  for (const enforceType of fieldDef.constraint.enforceable) {
690
790
  if (enforceType === "enum") {
691
791
  const boundValue = context[fieldName];
@@ -770,7 +870,9 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
770
870
  return errors;
771
871
  }
772
872
  export {
873
+ ContentBindingError,
773
874
  attestationId,
875
+ bindingAppliesTo,
774
876
  canonicalBounds,
775
877
  canonicalContext,
776
878
  canonicalFrame,
@@ -781,6 +883,7 @@ export {
781
883
  computeBoundsHash,
782
884
  computeContentHash,
783
885
  computeContextHash,
886
+ computeFieldsContentHash,
784
887
  computeFrameHash,
785
888
  computeIntentDisclosureHash,
786
889
  computeIntentHash,
@@ -792,9 +895,11 @@ export {
792
895
  getAllProfiles,
793
896
  getProfile,
794
897
  intentDisclosureCanonicalBytes,
898
+ isFieldBinding,
795
899
  isV4Attestation,
796
900
  listProfiles,
797
901
  registerProfile,
902
+ selectBoundFields,
798
903
  validateBoundsParams,
799
904
  validateContextParams,
800
905
  validateFrameParams,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanagencyp/hap-core",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Core types, cryptographic primitives, and verification logic for the Human Agency Protocol",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,6 +16,12 @@
16
16
  * (Unicode NFC, LF line endings, trailing per-line whitespace stripped,
17
17
  * trailing blank lines removed), taken pre-footer when `pre_footer` is set.
18
18
  *
19
+ * At `version:"2"` the profile also declares WHICH tool arguments are bound
20
+ * ({@link selectBoundFields}), so a receipt can commit to an email's recipients
21
+ * and not only its prose — while still omitting what the intended verifier
22
+ * cannot see. Every string entering the hashed object is canonicalized by the
23
+ * same `text` rule, so a delivered copy with CRLF endings still reproduces it.
24
+ *
19
25
  * Both Node and the browser produce byte-identical output: JCS relies only on
20
26
  * environment-independent primitives, and the text rule uses String.normalize +
21
27
  * plain string ops. The SHA-256 is computed with Node `crypto` here (the same
@@ -81,3 +87,165 @@ export function computeContentHash(
81
87
  ): string {
82
88
  return `sha256:${sha256Hex(contentCanonicalBytes(binding.kind, content))}`;
83
89
  }
90
+
91
+ // ─── v2: binding over a declared field subset ────────────────────────────────
92
+
93
+ /** Why a field binding refused. Every case is fail-closed by design. */
94
+ export type ContentBindingErrorCode =
95
+ /** The profile declares version:"2" with no usable `fields` list. */
96
+ | 'NO_FIELDS_DECLARED'
97
+ /** `required_fields` names something absent from `fields`. */
98
+ | 'REQUIRED_FIELD_NOT_DECLARED'
99
+ /** A required field was absent or empty at call time. */
100
+ | 'MISSING_REQUIRED_FIELD'
101
+ /** No declared field carried a value — the hash would commit to nothing. */
102
+ | 'EMPTY_BINDING';
103
+
104
+ /**
105
+ * A field binding could not be computed. ALWAYS a refusal, never a downgrade:
106
+ * the alternative is a receipt that verifies while proving less than it appears
107
+ * to, which is the failure content binding exists to prevent.
108
+ */
109
+ export class ContentBindingError extends Error {
110
+ readonly code: ContentBindingErrorCode;
111
+ /** The offending field, when the code names one. */
112
+ readonly field?: string;
113
+
114
+ constructor(code: ContentBindingErrorCode, message: string, field?: string) {
115
+ super(message);
116
+ this.name = 'ContentBindingError';
117
+ this.code = code;
118
+ this.field = field;
119
+ }
120
+ }
121
+
122
+ /** True when this binding selects a declared subset (v2) rather than v1's implicit scope. */
123
+ export function isFieldBinding(binding: ContentBinding): boolean {
124
+ return binding.version === '2' && Array.isArray(binding.fields) && binding.fields.length > 0;
125
+ }
126
+
127
+ /**
128
+ * Whether a field binding covers this action type, per the profile's `appliesTo`.
129
+ *
130
+ * Read STRICTLY — an undeclared action type is NOT covered. This differs from
131
+ * how bounds read the same key (there, an unknown action type enforces the
132
+ * bound, because an extra limit is safe). Here the two directions are not
133
+ * symmetric: applying a field binding to a call that carries no content refuses
134
+ * a legitimate action, so an unknown action type must fall outside rather than
135
+ * inside. Callers are expected to warn on the undeclared case — it is a
136
+ * manifest bug either way.
137
+ */
138
+ export function bindingAppliesTo(
139
+ binding: ContentBinding,
140
+ actionType: string | undefined,
141
+ ): boolean {
142
+ if (!binding.appliesTo) return true;
143
+ return actionType !== undefined && binding.appliesTo.includes(actionType);
144
+ }
145
+
146
+ /**
147
+ * Canonicalize one value on its way into the bound object, or `undefined` when
148
+ * it carries nothing (absent / null / empty after canonicalization).
149
+ *
150
+ * Strings are run through {@link canonicalizeText} — the SAME rule `kind:"text"`
151
+ * uses, applied per string rather than to one field. This is not decoration:
152
+ * the verifier of an email holds the DELIVERED copy, whose body has CRLF line
153
+ * endings and transport-added trailing whitespace. JCS embeds strings verbatim,
154
+ * so without this the recipient could never reproduce the hash.
155
+ *
156
+ * Deliberately NOT normalized: array order (the To: header preserves what was
157
+ * sent, and reordering recipients is a change worth catching) and address case
158
+ * (the local part is case-sensitive per RFC 5321, so lowercasing would be a
159
+ * semantic claim this layer has no business making).
160
+ */
161
+ function canonicalizeValue(value: unknown): unknown {
162
+ if (value === null || value === undefined) return undefined;
163
+ if (typeof value === 'string') {
164
+ const text = canonicalizeText(value);
165
+ return text === '' ? undefined : text;
166
+ }
167
+ if (typeof value === 'number' || typeof value === 'boolean') return value;
168
+ if (Array.isArray(value)) {
169
+ const items = value.map(canonicalizeValue).filter((v) => v !== undefined);
170
+ return items.length > 0 ? items : undefined;
171
+ }
172
+ if (typeof value === 'object') {
173
+ const out: Record<string, unknown> = {};
174
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
175
+ const c = canonicalizeValue(v);
176
+ if (c !== undefined) out[key] = c;
177
+ }
178
+ return Object.keys(out).length > 0 ? out : undefined;
179
+ }
180
+ return undefined; // functions/symbols — not JSON, nothing to bind
181
+ }
182
+
183
+ /**
184
+ * Build the object a v2 binding hashes: exactly the declared `fields` that
185
+ * carry a value, canonicalized. Exported so a verifier can construct the same
186
+ * object from what they hold and see it before hashing.
187
+ *
188
+ * Throws {@link ContentBindingError} rather than returning a partial result —
189
+ * see that class for why refusing is the only safe outcome.
190
+ */
191
+ export function selectBoundFields(
192
+ binding: ContentBinding,
193
+ args: Record<string, unknown>,
194
+ ): Record<string, unknown> {
195
+ const fields = binding.fields;
196
+ if (!fields || fields.length === 0) {
197
+ throw new ContentBindingError(
198
+ 'NO_FIELDS_DECLARED',
199
+ 'content_binding version "2" requires a non-empty `fields` list.',
200
+ );
201
+ }
202
+
203
+ const required = new Set(binding.required_fields ?? []);
204
+ for (const field of required) {
205
+ if (!fields.includes(field)) {
206
+ throw new ContentBindingError(
207
+ 'REQUIRED_FIELD_NOT_DECLARED',
208
+ `content_binding requires "${field}" but does not bind it — required_fields must be a subset of fields.`,
209
+ field,
210
+ );
211
+ }
212
+ }
213
+
214
+ const bound: Record<string, unknown> = {};
215
+ for (const field of fields) {
216
+ const value = canonicalizeValue(args[field]);
217
+ if (value === undefined) {
218
+ if (required.has(field)) {
219
+ throw new ContentBindingError(
220
+ 'MISSING_REQUIRED_FIELD',
221
+ `content_binding requires "${field}", which is absent or empty in this call. ` +
222
+ `Refusing rather than hashing a partial object.`,
223
+ field,
224
+ );
225
+ }
226
+ continue; // legitimately absent (an email with no cc)
227
+ }
228
+ bound[field] = value;
229
+ }
230
+
231
+ if (Object.keys(bound).length === 0) {
232
+ throw new ContentBindingError(
233
+ 'EMPTY_BINDING',
234
+ `No declared field (${fields.join(', ')}) carried a value — the hash would commit to nothing.`,
235
+ );
236
+ }
237
+
238
+ return bound;
239
+ }
240
+
241
+ /**
242
+ * Compute a v2 field-binding hash from raw tool arguments: select the declared
243
+ * subset, then hash it by the declared `kind`. Convenience over
244
+ * {@link selectBoundFields} + {@link computeContentHash} for the common path.
245
+ */
246
+ export function computeFieldsContentHash(
247
+ binding: ContentBinding,
248
+ args: Record<string, unknown>,
249
+ ): string {
250
+ return computeContentHash(binding, selectBoundFields(binding, args));
251
+ }
package/src/gatekeeper.ts CHANGED
@@ -612,6 +612,34 @@ function checkContextConstraints(
612
612
  for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
613
613
  if (!fieldDef.constraint) continue;
614
614
 
615
+ // A constrained dimension that the call does not expose cannot be checked.
616
+ // Skipping is only safe when the action does not engage that dimension —
617
+ // deleting a draft has no recipients. When it DOES engage it, silence must
618
+ // not read as compliance: an absent value means the Gatekeeper cannot show
619
+ // the call is in scope, and an unverifiable send is exactly what the scope
620
+ // exists to prevent. The profile declares which actions engage the field.
621
+ const requiredFor = fieldDef.constraint.requiredFor;
622
+ if (requiredFor?.length) {
623
+ const boundValue = context[fieldName];
624
+ const actualValue = execution[fieldName];
625
+ const actionType = execution['action_type'];
626
+ const constrained = boundValue !== undefined && boundValue !== '';
627
+ const engaged = actionType !== undefined && requiredFor.includes(String(actionType));
628
+
629
+ if (constrained && engaged && (actualValue === undefined || actualValue === '')) {
630
+ errors.push({
631
+ code: 'BOUND_EXCEEDED',
632
+ field: fieldName,
633
+ message:
634
+ `Authorization limits "${fieldName}" to [${boundValue}], but this ` +
635
+ `"${String(actionType)}" call exposes no ${fieldName} to check against it. ` +
636
+ `Refusing: the call cannot be shown to stay in scope.`,
637
+ bound: boundValue,
638
+ });
639
+ continue; // nothing further to compare for this field
640
+ }
641
+ }
642
+
615
643
  for (const enforceType of fieldDef.constraint.enforceable) {
616
644
  if (enforceType === 'enum') {
617
645
  // The context field value is the allowed value; execution must match
package/src/types.ts CHANGED
@@ -121,6 +121,27 @@ export interface Attestation {
121
121
  export interface FieldConstraint {
122
122
  type: 'number' | 'string';
123
123
  enforceable: Array<'max' | 'enum' | 'subset'>;
124
+ /**
125
+ * Action types for which this dimension MUST be present in the execution
126
+ * context. When the grant constrains this field and the action is one of
127
+ * these, an absent value is a denial rather than a skipped check.
128
+ *
129
+ * Without it, a constraint is only enforced against calls that happen to
130
+ * expose the value. Gmail's `send_message` is the live case: pass `raw` and
131
+ * `to` is never populated, so `allowed_recipients` had nothing to compare and
132
+ * the check passed — a send to an unverified recipient looked authorized.
133
+ * `send_draft` has the same shape: it transmits, and the Gatekeeper cannot
134
+ * see to whom.
135
+ *
136
+ * Keyed on `action_type` because "does this action engage recipients?" is a
137
+ * property of the action, not of the field name — deleting a draft has no
138
+ * recipients and must keep passing, while sending one must not. This keeps
139
+ * the engine profile-agnostic: it compares declared strings and knows nothing
140
+ * about email, calendars or payments.
141
+ *
142
+ * Omitted → previous behaviour: absence skips the check.
143
+ */
144
+ requiredFor?: string[];
124
145
  }
125
146
 
126
147
  /**
@@ -250,22 +271,64 @@ export interface ProfileBoundsField {
250
271
  * backward compatibility). The gateway computes the hash; the SP only ever
251
272
  * receives the hash, never the content, so HAP's privacy-minimal design holds.
252
273
  *
253
- * The profile declares only the *policy* — whether to bind and how to
254
- * canonicalize. It does NOT name the tool field: that is tool-specific and is
255
- * resolved at runtime (the same content-field resolver the footer uses for
256
- * `kind:"text"`; the whole record payload for `kind:"jcs"`).
274
+ * At `version:"1"` the profile declares only the *policy* — whether to bind and
275
+ * how to canonicalize. It does NOT name the tool field: that is tool-specific
276
+ * and is resolved at runtime (the same content-field resolver the footer uses
277
+ * for `kind:"text"`; the whole record payload for `kind:"jcs"`).
278
+ *
279
+ * At `version:"2"` the profile additionally declares WHICH fields are bound (see
280
+ * {@link ContentBinding.fields}). Neither v1 mode is the general case: `text`
281
+ * binds one field and leaves everything beside it unbound, while `jcs` over the
282
+ * whole payload is checkable only by a party that already knows the whole
283
+ * payload — an email recipient holds the body, the subject and their own
284
+ * address, but not `bcc`. The general case is a declared subset, chosen so the
285
+ * intended verifier can reproduce it.
257
286
  */
258
287
  export interface ContentBinding {
259
288
  /** Canonicalization version. A verifier MUST pin the version named here. */
260
289
  version: string;
261
290
  /**
262
- * - 'jcs' → structured writes: RFC 8785 JCS over the record payload.
291
+ * - 'jcs' → structured writes: RFC 8785 JCS over the record payload
292
+ * (v1) or over the object built from {@link fields} (v2).
263
293
  * - 'text' → free text: NFC + LF + trailing-whitespace strip (see
264
294
  * canonicalizeText), auto-detected content field.
265
295
  */
266
296
  kind: 'jcs' | 'text';
267
297
  /** text only: hash the content BEFORE any appended Suveren footer. */
268
298
  pre_footer?: boolean;
299
+
300
+ /**
301
+ * v2 only — the tool-argument keys this binding covers, and the complete
302
+ * statement of what a verifier must reproduce. The Gatekeeper builds an
303
+ * object from exactly these keys and canonicalizes it by `kind`.
304
+ *
305
+ * Adding or removing an entry changes every resulting hash, so it is a
306
+ * BREAKING profile change requiring a version bump, never a silent edit.
307
+ *
308
+ * Choose the subset by one rule: bind everything the approving human is
309
+ * shown, and nothing the intended verifier cannot see.
310
+ */
311
+ fields?: string[];
312
+ /**
313
+ * v2 only — the subset of {@link fields} whose absence is a fault rather than
314
+ * a fact. An absent OPTIONAL field is omitted from the hashed object (an
315
+ * email legitimately has no `cc`); an absent REQUIRED field means the call is
316
+ * not the call this profile thinks it is, and MUST refuse rather than hash a
317
+ * partial object that reads exactly like a complete one.
318
+ *
319
+ * MUST be a subset of `fields`. Absent → every field is optional, and only a
320
+ * wholly empty selection refuses.
321
+ */
322
+ required_fields?: string[];
323
+ /**
324
+ * v2 only — the action types this binding covers, using the same vocabulary
325
+ * as {@link ProfileBoundsField.appliesTo}. A profile gates more than its
326
+ * content-bearing calls: `email` also gates deletes, which carry an id and no
327
+ * content, and applying a field binding to those would refuse them.
328
+ *
329
+ * Absent → the binding applies to every gated action under the profile.
330
+ */
331
+ appliesTo?: string[];
269
332
  }
270
333
 
271
334
  /**