@humanagencyp/hap-core 0.9.0 → 0.10.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
@@ -610,7 +610,21 @@ interface ExecutionLogQuery {
610
610
  * Sum a field's values within a time window for a given profile.
611
611
  * Use field="_count" to count executions instead of summing a field.
612
612
  */
613
- sumByWindow(profileId: string, path: string, field: string, window: CumulativeWindow, now?: number): number;
613
+ sumByWindow(profileId: string, path: string, field: string, window: CumulativeWindow, now?: number,
614
+ /**
615
+ * Only count executions whose `execution.action_type` is one of these.
616
+ * Omitted → every execution counts (a bound that governs all action types).
617
+ *
618
+ * Consumption is partitioned by action type (*Cumulative Tracking* rule 4):
619
+ * a bound counts only the action types it governs. Without the filter a
620
+ * profile with several action types feeds every bound the combined total —
621
+ * sales' "orders per day" counted quotes and sends too, and its daily order
622
+ * value summed quote values. Use {@link boundActionTypes} to derive it.
623
+ *
624
+ * Implementations SHOULD count an entry that carries no `action_type`
625
+ * against every filter: an unknown action is not evidence of headroom.
626
+ */
627
+ actionTypes?: readonly string[]): number;
614
628
  }
615
629
  /**
616
630
  * Agent frame parameters — mixed types (strings and numbers).
@@ -656,7 +670,7 @@ interface GatekeeperRequest {
656
670
  * Structured error from Gatekeeper verification.
657
671
  */
658
672
  interface GatekeeperError {
659
- code: 'BOUND_EXCEEDED' | 'CUMULATIVE_LIMIT_EXCEEDED' | 'INVALID_SIGNATURE' | 'TTL_EXPIRED' | 'FRAME_MISMATCH' | 'BOUNDS_MISMATCH' | 'CONTEXT_MISMATCH' | 'DOMAIN_NOT_COVERED' | 'INVALID_PROFILE' | 'MALFORMED_ATTESTATION' | 'INVALID_ACTION_TYPE' | 'ATTESTATION_REVOKED' | 'APPROVAL_REQUIRED' | 'IDEMPOTENCY_MISMATCH' | 'MANDATE_SIGNATURE_REQUIRED' | 'MANDATE_SIGNATURE_INVALID' | 'APPROVAL_SIGNATURE_REQUIRED' | 'APPROVAL_SIGNATURE_INVALID';
673
+ code: 'BOUND_EXCEEDED' | 'CUMULATIVE_LIMIT_EXCEEDED' | 'INVALID_SIGNATURE' | 'TTL_EXPIRED' | 'FRAME_MISMATCH' | 'BOUNDS_MISMATCH' | 'CONTEXT_MISMATCH' | 'DOMAIN_NOT_COVERED' | 'INVALID_PROFILE' | 'MALFORMED_ATTESTATION' | 'INVALID_ACTION_TYPE' | 'ATTESTATION_REVOKED' | 'APPROVAL_REQUIRED' | 'IDEMPOTENCY_MISMATCH' | 'MANDATE_SIGNATURE_REQUIRED' | 'MANDATE_SIGNATURE_INVALID' | 'APPROVAL_SIGNATURE_REQUIRED' | 'APPROVAL_SIGNATURE_INVALID' | 'BOUNDS_INVALID_VALUE' | 'CONTEXT_INVALID_VALUE';
660
674
  field?: string;
661
675
  message: string;
662
676
  bound?: string | number;
@@ -921,8 +935,11 @@ declare function deriveIdentityLine(subject: Subject | undefined | null, { opera
921
935
  * Frame Canonicalization for Agent Profiles
922
936
  *
923
937
  * Agent profiles support mixed-type fields (strings and numbers).
924
- * Canonical form: all values are converted to strings via String(value).
925
- * Keys are ordered according to the profile's keyOrder.
938
+ * Canonical form: `key=value` records joined with LF, keys in the profile's
939
+ * keyOrder. Values are stringified with String(value) — the shortest
940
+ * round-trippable form for numbers — then percent-encoded per protocol.md
941
+ * (`=`, `%`, and every byte outside printable ASCII); a value carrying a raw
942
+ * LF/CR is refused, never normalized. See `canonicalRecords` below.
926
943
  *
927
944
  * v0.3: frameSchema
928
945
  * v0.4: boundsSchema + contextSchema (separate hashes)
@@ -966,19 +983,39 @@ declare function validateContextParams(params: AgentContextParams, profile: Agen
966
983
  valid: boolean;
967
984
  errors: string[];
968
985
  };
986
+ /**
987
+ * Thrown when a value cannot be canonicalized at all — currently only for raw
988
+ * LF/CR inside a value. Carries the protocol error code so callers can map it
989
+ * straight onto the wire without string-matching a message.
990
+ *
991
+ * protocol.md → *Bounds & Scope Canonicalization* → Value encoding:
992
+ * "Values MUST NOT contain raw newline (\n) or carriage-return (\r)
993
+ * characters. Implementations MUST reject input containing them; silent
994
+ * stripping or normalization is a violation because it produces a hash that
995
+ * does not faithfully represent the input."
996
+ */
997
+ declare class CanonicalValueError extends Error {
998
+ readonly code: 'BOUNDS_INVALID_VALUE' | 'CONTEXT_INVALID_VALUE';
999
+ readonly field: string;
1000
+ constructor(code: 'BOUNDS_INVALID_VALUE' | 'CONTEXT_INVALID_VALUE', field: string, message: string);
1001
+ }
969
1002
  /**
970
1003
  * Builds the canonical bounds string from parameters.
971
- * All values are converted to strings. Keys are ordered per profile's boundsSchema.keyOrder.
1004
+ * Keys are ordered per profile's boundsSchema.keyOrder; values are encoded per
1005
+ * protocol.md → *Value encoding* (see `canonicalRecords`).
972
1006
  *
973
1007
  * @throws Error if any field fails validation
1008
+ * @throws CanonicalValueError (code BOUNDS_INVALID_VALUE) if a value carries a raw LF/CR
974
1009
  */
975
1010
  declare function canonicalBounds(params: AgentBoundsParams, profile: AgentProfile): string;
976
1011
  /**
977
1012
  * Builds the canonical context string from parameters.
978
- * All values are converted to strings. Keys are ordered per profile's contextSchema.keyOrder.
1013
+ * Keys are ordered per profile's contextSchema.keyOrder; values are encoded per
1014
+ * protocol.md → *Value encoding* (see `canonicalRecords`).
979
1015
  * For empty context (no contextSchema or no fields), returns "".
980
1016
  *
981
1017
  * @throws Error if any field fails validation
1018
+ * @throws CanonicalValueError (code CONTEXT_INVALID_VALUE) if a value carries a raw LF/CR
982
1019
  */
983
1020
  declare function canonicalContext(params: AgentContextParams, profile: AgentProfile): string;
984
1021
  /**
@@ -1103,6 +1140,23 @@ declare function verifyAttestationV4(blob: string, publicKeyHex: string, expecte
1103
1140
  * @param executionLog - Optional execution log for resolving cumulative fields
1104
1141
  */
1105
1142
  declare function verify(request: GatekeeperRequest, publicKeyHex: string, now?: number, executionLog?: ExecutionLogQuery): Promise<GatekeeperResult>;
1143
+ /**
1144
+ * Which action types' executions count toward this cumulative bound's running
1145
+ * total. `undefined` means all of them.
1146
+ *
1147
+ * `boundGovernsAction` decides whether a bound applies to the current call; this
1148
+ * decides what the bound's total is made of. Both are needed: selecting the right
1149
+ * bound and then feeding it the profile's combined total is still wrong — sales'
1150
+ * "orders per day" was checked only on orders, but counted every quote and send.
1151
+ * Consumption is partitioned by action type (*Cumulative Tracking* rule 4), as
1152
+ * the Authority Server already does.
1153
+ *
1154
+ * Same answer, same order of preference as `boundGovernsAction`: the declared
1155
+ * `appliesTo`; else, for a count bound named for an action (`delete_daily_max`),
1156
+ * that action; else every action type. Exported so display surfaces report the
1157
+ * same totals the gate enforces.
1158
+ */
1159
+ declare function boundActionTypes(fieldName: string, fieldDef: ProfileBoundsField): readonly string[] | undefined;
1106
1160
 
1107
1161
  /**
1108
1162
  * Profile Registry — dynamically populated from git-hosted profile sources.
@@ -1299,4 +1353,4 @@ interface ReceiptPayload {
1299
1353
  */
1300
1354
  declare function verifyReceiptSignature(receipt: ReceiptPayload, publicKeyHex: string): Promise<void>;
1301
1355
 
1302
- export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type ApprovalObject, 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 MandateBinding, MandateError, type MandateProjection, type OwnerMandate, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ReceiptPayload, type ResolvedDomain, type SigningSurface, type Subject, type SubjectValidation, approvalSigningBytes, attestationId, bindingAppliesTo, buildMandateProjection, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFieldsContentHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, decodeDidKey, deriveIdentityLine, encodeAttestationBlob, encodeDidKey, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isFieldBinding, isKeyBearingDid, isV4Attestation, listProfiles, mandateSigningBytes, registerProfile, selectBoundFields, signApproval, signMandateProjection, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyApproval, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash, verifyOwnerMandate, verifyOwnerMandates, verifyReceiptSignature };
1356
+ export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type ApprovalObject, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, CanonicalValueError, 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 MandateBinding, MandateError, type MandateProjection, type OwnerMandate, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ReceiptPayload, type ResolvedDomain, type SigningSurface, type Subject, type SubjectValidation, approvalSigningBytes, attestationId, bindingAppliesTo, boundActionTypes, buildMandateProjection, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFieldsContentHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, decodeDidKey, deriveIdentityLine, encodeAttestationBlob, encodeDidKey, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isFieldBinding, isKeyBearingDid, isV4Attestation, listProfiles, mandateSigningBytes, registerProfile, selectBoundFields, signApproval, signMandateProjection, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyApproval, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash, verifyOwnerMandate, verifyOwnerMandates, verifyReceiptSignature };
package/dist/index.d.ts CHANGED
@@ -610,7 +610,21 @@ interface ExecutionLogQuery {
610
610
  * Sum a field's values within a time window for a given profile.
611
611
  * Use field="_count" to count executions instead of summing a field.
612
612
  */
613
- sumByWindow(profileId: string, path: string, field: string, window: CumulativeWindow, now?: number): number;
613
+ sumByWindow(profileId: string, path: string, field: string, window: CumulativeWindow, now?: number,
614
+ /**
615
+ * Only count executions whose `execution.action_type` is one of these.
616
+ * Omitted → every execution counts (a bound that governs all action types).
617
+ *
618
+ * Consumption is partitioned by action type (*Cumulative Tracking* rule 4):
619
+ * a bound counts only the action types it governs. Without the filter a
620
+ * profile with several action types feeds every bound the combined total —
621
+ * sales' "orders per day" counted quotes and sends too, and its daily order
622
+ * value summed quote values. Use {@link boundActionTypes} to derive it.
623
+ *
624
+ * Implementations SHOULD count an entry that carries no `action_type`
625
+ * against every filter: an unknown action is not evidence of headroom.
626
+ */
627
+ actionTypes?: readonly string[]): number;
614
628
  }
615
629
  /**
616
630
  * Agent frame parameters — mixed types (strings and numbers).
@@ -656,7 +670,7 @@ interface GatekeeperRequest {
656
670
  * Structured error from Gatekeeper verification.
657
671
  */
658
672
  interface GatekeeperError {
659
- code: 'BOUND_EXCEEDED' | 'CUMULATIVE_LIMIT_EXCEEDED' | 'INVALID_SIGNATURE' | 'TTL_EXPIRED' | 'FRAME_MISMATCH' | 'BOUNDS_MISMATCH' | 'CONTEXT_MISMATCH' | 'DOMAIN_NOT_COVERED' | 'INVALID_PROFILE' | 'MALFORMED_ATTESTATION' | 'INVALID_ACTION_TYPE' | 'ATTESTATION_REVOKED' | 'APPROVAL_REQUIRED' | 'IDEMPOTENCY_MISMATCH' | 'MANDATE_SIGNATURE_REQUIRED' | 'MANDATE_SIGNATURE_INVALID' | 'APPROVAL_SIGNATURE_REQUIRED' | 'APPROVAL_SIGNATURE_INVALID';
673
+ code: 'BOUND_EXCEEDED' | 'CUMULATIVE_LIMIT_EXCEEDED' | 'INVALID_SIGNATURE' | 'TTL_EXPIRED' | 'FRAME_MISMATCH' | 'BOUNDS_MISMATCH' | 'CONTEXT_MISMATCH' | 'DOMAIN_NOT_COVERED' | 'INVALID_PROFILE' | 'MALFORMED_ATTESTATION' | 'INVALID_ACTION_TYPE' | 'ATTESTATION_REVOKED' | 'APPROVAL_REQUIRED' | 'IDEMPOTENCY_MISMATCH' | 'MANDATE_SIGNATURE_REQUIRED' | 'MANDATE_SIGNATURE_INVALID' | 'APPROVAL_SIGNATURE_REQUIRED' | 'APPROVAL_SIGNATURE_INVALID' | 'BOUNDS_INVALID_VALUE' | 'CONTEXT_INVALID_VALUE';
660
674
  field?: string;
661
675
  message: string;
662
676
  bound?: string | number;
@@ -921,8 +935,11 @@ declare function deriveIdentityLine(subject: Subject | undefined | null, { opera
921
935
  * Frame Canonicalization for Agent Profiles
922
936
  *
923
937
  * Agent profiles support mixed-type fields (strings and numbers).
924
- * Canonical form: all values are converted to strings via String(value).
925
- * Keys are ordered according to the profile's keyOrder.
938
+ * Canonical form: `key=value` records joined with LF, keys in the profile's
939
+ * keyOrder. Values are stringified with String(value) — the shortest
940
+ * round-trippable form for numbers — then percent-encoded per protocol.md
941
+ * (`=`, `%`, and every byte outside printable ASCII); a value carrying a raw
942
+ * LF/CR is refused, never normalized. See `canonicalRecords` below.
926
943
  *
927
944
  * v0.3: frameSchema
928
945
  * v0.4: boundsSchema + contextSchema (separate hashes)
@@ -966,19 +983,39 @@ declare function validateContextParams(params: AgentContextParams, profile: Agen
966
983
  valid: boolean;
967
984
  errors: string[];
968
985
  };
986
+ /**
987
+ * Thrown when a value cannot be canonicalized at all — currently only for raw
988
+ * LF/CR inside a value. Carries the protocol error code so callers can map it
989
+ * straight onto the wire without string-matching a message.
990
+ *
991
+ * protocol.md → *Bounds & Scope Canonicalization* → Value encoding:
992
+ * "Values MUST NOT contain raw newline (\n) or carriage-return (\r)
993
+ * characters. Implementations MUST reject input containing them; silent
994
+ * stripping or normalization is a violation because it produces a hash that
995
+ * does not faithfully represent the input."
996
+ */
997
+ declare class CanonicalValueError extends Error {
998
+ readonly code: 'BOUNDS_INVALID_VALUE' | 'CONTEXT_INVALID_VALUE';
999
+ readonly field: string;
1000
+ constructor(code: 'BOUNDS_INVALID_VALUE' | 'CONTEXT_INVALID_VALUE', field: string, message: string);
1001
+ }
969
1002
  /**
970
1003
  * Builds the canonical bounds string from parameters.
971
- * All values are converted to strings. Keys are ordered per profile's boundsSchema.keyOrder.
1004
+ * Keys are ordered per profile's boundsSchema.keyOrder; values are encoded per
1005
+ * protocol.md → *Value encoding* (see `canonicalRecords`).
972
1006
  *
973
1007
  * @throws Error if any field fails validation
1008
+ * @throws CanonicalValueError (code BOUNDS_INVALID_VALUE) if a value carries a raw LF/CR
974
1009
  */
975
1010
  declare function canonicalBounds(params: AgentBoundsParams, profile: AgentProfile): string;
976
1011
  /**
977
1012
  * Builds the canonical context string from parameters.
978
- * All values are converted to strings. Keys are ordered per profile's contextSchema.keyOrder.
1013
+ * Keys are ordered per profile's contextSchema.keyOrder; values are encoded per
1014
+ * protocol.md → *Value encoding* (see `canonicalRecords`).
979
1015
  * For empty context (no contextSchema or no fields), returns "".
980
1016
  *
981
1017
  * @throws Error if any field fails validation
1018
+ * @throws CanonicalValueError (code CONTEXT_INVALID_VALUE) if a value carries a raw LF/CR
982
1019
  */
983
1020
  declare function canonicalContext(params: AgentContextParams, profile: AgentProfile): string;
984
1021
  /**
@@ -1103,6 +1140,23 @@ declare function verifyAttestationV4(blob: string, publicKeyHex: string, expecte
1103
1140
  * @param executionLog - Optional execution log for resolving cumulative fields
1104
1141
  */
1105
1142
  declare function verify(request: GatekeeperRequest, publicKeyHex: string, now?: number, executionLog?: ExecutionLogQuery): Promise<GatekeeperResult>;
1143
+ /**
1144
+ * Which action types' executions count toward this cumulative bound's running
1145
+ * total. `undefined` means all of them.
1146
+ *
1147
+ * `boundGovernsAction` decides whether a bound applies to the current call; this
1148
+ * decides what the bound's total is made of. Both are needed: selecting the right
1149
+ * bound and then feeding it the profile's combined total is still wrong — sales'
1150
+ * "orders per day" was checked only on orders, but counted every quote and send.
1151
+ * Consumption is partitioned by action type (*Cumulative Tracking* rule 4), as
1152
+ * the Authority Server already does.
1153
+ *
1154
+ * Same answer, same order of preference as `boundGovernsAction`: the declared
1155
+ * `appliesTo`; else, for a count bound named for an action (`delete_daily_max`),
1156
+ * that action; else every action type. Exported so display surfaces report the
1157
+ * same totals the gate enforces.
1158
+ */
1159
+ declare function boundActionTypes(fieldName: string, fieldDef: ProfileBoundsField): readonly string[] | undefined;
1106
1160
 
1107
1161
  /**
1108
1162
  * Profile Registry — dynamically populated from git-hosted profile sources.
@@ -1299,4 +1353,4 @@ interface ReceiptPayload {
1299
1353
  */
1300
1354
  declare function verifyReceiptSignature(receipt: ReceiptPayload, publicKeyHex: string): Promise<void>;
1301
1355
 
1302
- export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type ApprovalObject, 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 MandateBinding, MandateError, type MandateProjection, type OwnerMandate, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ReceiptPayload, type ResolvedDomain, type SigningSurface, type Subject, type SubjectValidation, approvalSigningBytes, attestationId, bindingAppliesTo, buildMandateProjection, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFieldsContentHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, decodeDidKey, deriveIdentityLine, encodeAttestationBlob, encodeDidKey, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isFieldBinding, isKeyBearingDid, isV4Attestation, listProfiles, mandateSigningBytes, registerProfile, selectBoundFields, signApproval, signMandateProjection, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyApproval, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash, verifyOwnerMandate, verifyOwnerMandates, verifyReceiptSignature };
1356
+ export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type ApprovalObject, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, CanonicalValueError, 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 MandateBinding, MandateError, type MandateProjection, type OwnerMandate, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ReceiptPayload, type ResolvedDomain, type SigningSurface, type Subject, type SubjectValidation, approvalSigningBytes, attestationId, bindingAppliesTo, boundActionTypes, buildMandateProjection, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, canonicalizeText, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContentHash, computeContextHash, computeFieldsContentHash, computeFrameHash, computeIntentDisclosureHash, computeIntentHash, contentCanonicalBytes, decodeAttestationBlob, decodeDidKey, deriveIdentityLine, encodeAttestationBlob, encodeDidKey, frameHash, getAllProfiles, getProfile, intentDisclosureCanonicalBytes, isFieldBinding, isKeyBearingDid, isV4Attestation, listProfiles, mandateSigningBytes, registerProfile, selectBoundFields, signApproval, signMandateProjection, validateBoundsParams, validateContextParams, validateFrameParams, validateSubject, verify, verifyApproval, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash, verifyOwnerMandate, verifyOwnerMandates, verifyReceiptSignature };
package/dist/index.js CHANGED
@@ -30,11 +30,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ CanonicalValueError: () => CanonicalValueError,
33
34
  ContentBindingError: () => ContentBindingError,
34
35
  MandateError: () => MandateError,
35
36
  approvalSigningBytes: () => approvalSigningBytes,
36
37
  attestationId: () => attestationId,
37
38
  bindingAppliesTo: () => bindingAppliesTo,
39
+ boundActionTypes: () => boundActionTypes,
38
40
  buildMandateProjection: () => buildMandateProjection,
39
41
  canonicalBounds: () => canonicalBounds,
40
42
  canonicalContext: () => canonicalContext,
@@ -376,15 +378,51 @@ function validateContextParams(params, profile) {
376
378
  }
377
379
  return { valid: errors.length === 0, errors };
378
380
  }
381
+ var CanonicalValueError = class extends Error {
382
+ code;
383
+ field;
384
+ constructor(code, field, message) {
385
+ super(message);
386
+ this.name = "CanonicalValueError";
387
+ this.code = code;
388
+ this.field = field;
389
+ }
390
+ };
391
+ function percentEncodeCanonicalValue(raw) {
392
+ const bytes = new TextEncoder().encode(raw);
393
+ let out = "";
394
+ for (const b of bytes) {
395
+ if (b === 61 || b === 37 || b < 32 || b > 126) {
396
+ out += "%" + b.toString(16).toUpperCase().padStart(2, "0");
397
+ } else {
398
+ out += String.fromCharCode(b);
399
+ }
400
+ }
401
+ return out;
402
+ }
403
+ function canonicalRecords(params, keyOrder, code) {
404
+ const lines = [];
405
+ for (const key of keyOrder) {
406
+ const value = params[key];
407
+ if (value === void 0 || value === null) continue;
408
+ const raw = String(value);
409
+ if (raw.includes("\n") || raw.includes("\r")) {
410
+ throw new CanonicalValueError(
411
+ code,
412
+ key,
413
+ `Value for "${key}" contains a raw newline or carriage return. Refusing: a hash over stripped or normalized input would not represent what was authorized.`
414
+ );
415
+ }
416
+ lines.push(`${key}=${percentEncodeCanonicalValue(raw)}`);
417
+ }
418
+ return lines.join("\n");
419
+ }
379
420
  function canonicalBounds(params, profile) {
380
421
  const validation = validateBoundsParams(params, profile);
381
422
  if (!validation.valid) {
382
423
  throw new Error(`Invalid bounds parameters: ${validation.errors.join("; ")}`);
383
424
  }
384
- const lines = profile.boundsSchema.keyOrder.map(
385
- (key) => `${key}=${String(params[key])}`
386
- );
387
- return lines.join("\n");
425
+ return canonicalRecords(params, profile.boundsSchema.keyOrder, "BOUNDS_INVALID_VALUE");
388
426
  }
389
427
  function canonicalContext(params, profile) {
390
428
  if (!profile.contextSchema || Object.keys(profile.contextSchema.fields).length === 0) {
@@ -394,10 +432,7 @@ function canonicalContext(params, profile) {
394
432
  if (!validation.valid) {
395
433
  throw new Error(`Invalid context parameters: ${validation.errors.join("; ")}`);
396
434
  }
397
- const lines = profile.contextSchema.keyOrder.map(
398
- (key) => `${key}=${String(params[key])}`
399
- );
400
- return lines.join("\n");
435
+ return canonicalRecords(params, profile.contextSchema.keyOrder, "CONTEXT_INVALID_VALUE");
401
436
  }
402
437
  function computeBoundsHash(params, profile) {
403
438
  const canonical = canonicalBounds(params, profile);
@@ -527,7 +562,17 @@ async function verify(request, publicKeyHex, now = Math.floor(Date.now() / 1e3),
527
562
  return verifyV3(request, profile, publicKeyHex, now, executionLog, errors);
528
563
  }
529
564
  }
565
+ function noAttestationSupplied(request) {
566
+ return !Array.isArray(request.attestations) || request.attestations.length === 0;
567
+ }
568
+ function noAttestationResult() {
569
+ return {
570
+ approved: false,
571
+ errors: [{ code: "MALFORMED_ATTESTATION", message: "No attestation supplied \u2014 nothing to verify" }]
572
+ };
573
+ }
530
574
  async function verifyV3(request, profile, publicKeyHex, now, executionLog, errors) {
575
+ if (noAttestationSupplied(request)) return noAttestationResult();
531
576
  let expectedFrameHash;
532
577
  try {
533
578
  expectedFrameHash = computeFrameHash(request.frame, profile);
@@ -588,6 +633,7 @@ async function verifyV3(request, profile, publicKeyHex, now, executionLog, error
588
633
  return { approved: true };
589
634
  }
590
635
  async function verifyV4(request, profile, publicKeyHex, now, executionLog) {
636
+ if (noAttestationSupplied(request)) return noAttestationResult();
591
637
  const errors = [];
592
638
  const bounds = request.frame;
593
639
  const context = request.context;
@@ -740,6 +786,14 @@ function boundGovernsAction(fieldName, fieldDef, bt, actionType) {
740
786
  const matches = prefix === actionType || prefix.startsWith("transaction");
741
787
  return !namedForAnAction || matches;
742
788
  }
789
+ function boundActionTypes(fieldName, fieldDef) {
790
+ if (fieldDef.appliesTo) return fieldDef.appliesTo;
791
+ const bt = fieldDef.boundType;
792
+ if (!bt || bt.kind !== "cumulative_count") return void 0;
793
+ const prefix = fieldName.replace(/_(?:daily|monthly|weekly)_max$/, "");
794
+ if (prefix === fieldName || prefix.startsWith("transaction")) return void 0;
795
+ return [prefix];
796
+ }
743
797
  function checkBoundsV4(request, profile, executionLog, now) {
744
798
  const errors = [];
745
799
  if (!profile.boundsSchema) return errors;
@@ -800,7 +854,14 @@ function checkBoundsV4(request, profile, executionLog, now) {
800
854
  break;
801
855
  }
802
856
  if (!executionLog) break;
803
- const runningTotal = executionLog.sumByWindow(profileId, path, bt.of, bt.window, now);
857
+ const runningTotal = executionLog.sumByWindow(
858
+ profileId,
859
+ path,
860
+ bt.of,
861
+ bt.window,
862
+ now,
863
+ boundActionTypes(fieldName, fieldDef)
864
+ );
804
865
  const currentRaw = request.execution[bt.of];
805
866
  const current = typeof currentRaw === "number" ? currentRaw : currentRaw !== void 0 ? Number(currentRaw) : 0;
806
867
  const total = runningTotal + current;
@@ -827,7 +888,14 @@ function checkBoundsV4(request, profile, executionLog, now) {
827
888
  break;
828
889
  }
829
890
  if (!executionLog) break;
830
- const runningCount = executionLog.sumByWindow(profileId, path, "_count", bt.window, now);
891
+ const runningCount = executionLog.sumByWindow(
892
+ profileId,
893
+ path,
894
+ "_count",
895
+ bt.window,
896
+ now,
897
+ boundActionTypes(fieldName, fieldDef)
898
+ );
831
899
  const total = runningCount + 1;
832
900
  if (total > boundValue) {
833
901
  errors.push({
@@ -1156,11 +1224,13 @@ async function verifyReceiptSignature(receipt, publicKeyHex) {
1156
1224
  }
1157
1225
  // Annotate the CommonJS export names for ESM import in node:
1158
1226
  0 && (module.exports = {
1227
+ CanonicalValueError,
1159
1228
  ContentBindingError,
1160
1229
  MandateError,
1161
1230
  approvalSigningBytes,
1162
1231
  attestationId,
1163
1232
  bindingAppliesTo,
1233
+ boundActionTypes,
1164
1234
  buildMandateProjection,
1165
1235
  canonicalBounds,
1166
1236
  canonicalContext,
package/dist/index.mjs CHANGED
@@ -287,15 +287,51 @@ function validateContextParams(params, profile) {
287
287
  }
288
288
  return { valid: errors.length === 0, errors };
289
289
  }
290
+ var CanonicalValueError = class extends Error {
291
+ code;
292
+ field;
293
+ constructor(code, field, message) {
294
+ super(message);
295
+ this.name = "CanonicalValueError";
296
+ this.code = code;
297
+ this.field = field;
298
+ }
299
+ };
300
+ function percentEncodeCanonicalValue(raw) {
301
+ const bytes = new TextEncoder().encode(raw);
302
+ let out = "";
303
+ for (const b of bytes) {
304
+ if (b === 61 || b === 37 || b < 32 || b > 126) {
305
+ out += "%" + b.toString(16).toUpperCase().padStart(2, "0");
306
+ } else {
307
+ out += String.fromCharCode(b);
308
+ }
309
+ }
310
+ return out;
311
+ }
312
+ function canonicalRecords(params, keyOrder, code) {
313
+ const lines = [];
314
+ for (const key of keyOrder) {
315
+ const value = params[key];
316
+ if (value === void 0 || value === null) continue;
317
+ const raw = String(value);
318
+ if (raw.includes("\n") || raw.includes("\r")) {
319
+ throw new CanonicalValueError(
320
+ code,
321
+ key,
322
+ `Value for "${key}" contains a raw newline or carriage return. Refusing: a hash over stripped or normalized input would not represent what was authorized.`
323
+ );
324
+ }
325
+ lines.push(`${key}=${percentEncodeCanonicalValue(raw)}`);
326
+ }
327
+ return lines.join("\n");
328
+ }
290
329
  function canonicalBounds(params, profile) {
291
330
  const validation = validateBoundsParams(params, profile);
292
331
  if (!validation.valid) {
293
332
  throw new Error(`Invalid bounds parameters: ${validation.errors.join("; ")}`);
294
333
  }
295
- const lines = profile.boundsSchema.keyOrder.map(
296
- (key) => `${key}=${String(params[key])}`
297
- );
298
- return lines.join("\n");
334
+ return canonicalRecords(params, profile.boundsSchema.keyOrder, "BOUNDS_INVALID_VALUE");
299
335
  }
300
336
  function canonicalContext(params, profile) {
301
337
  if (!profile.contextSchema || Object.keys(profile.contextSchema.fields).length === 0) {
@@ -305,10 +341,7 @@ function canonicalContext(params, profile) {
305
341
  if (!validation.valid) {
306
342
  throw new Error(`Invalid context parameters: ${validation.errors.join("; ")}`);
307
343
  }
308
- const lines = profile.contextSchema.keyOrder.map(
309
- (key) => `${key}=${String(params[key])}`
310
- );
311
- return lines.join("\n");
344
+ return canonicalRecords(params, profile.contextSchema.keyOrder, "CONTEXT_INVALID_VALUE");
312
345
  }
313
346
  function computeBoundsHash(params, profile) {
314
347
  const canonical = canonicalBounds(params, profile);
@@ -438,7 +471,17 @@ async function verify(request, publicKeyHex, now = Math.floor(Date.now() / 1e3),
438
471
  return verifyV3(request, profile, publicKeyHex, now, executionLog, errors);
439
472
  }
440
473
  }
474
+ function noAttestationSupplied(request) {
475
+ return !Array.isArray(request.attestations) || request.attestations.length === 0;
476
+ }
477
+ function noAttestationResult() {
478
+ return {
479
+ approved: false,
480
+ errors: [{ code: "MALFORMED_ATTESTATION", message: "No attestation supplied \u2014 nothing to verify" }]
481
+ };
482
+ }
441
483
  async function verifyV3(request, profile, publicKeyHex, now, executionLog, errors) {
484
+ if (noAttestationSupplied(request)) return noAttestationResult();
442
485
  let expectedFrameHash;
443
486
  try {
444
487
  expectedFrameHash = computeFrameHash(request.frame, profile);
@@ -499,6 +542,7 @@ async function verifyV3(request, profile, publicKeyHex, now, executionLog, error
499
542
  return { approved: true };
500
543
  }
501
544
  async function verifyV4(request, profile, publicKeyHex, now, executionLog) {
545
+ if (noAttestationSupplied(request)) return noAttestationResult();
502
546
  const errors = [];
503
547
  const bounds = request.frame;
504
548
  const context = request.context;
@@ -651,6 +695,14 @@ function boundGovernsAction(fieldName, fieldDef, bt, actionType) {
651
695
  const matches = prefix === actionType || prefix.startsWith("transaction");
652
696
  return !namedForAnAction || matches;
653
697
  }
698
+ function boundActionTypes(fieldName, fieldDef) {
699
+ if (fieldDef.appliesTo) return fieldDef.appliesTo;
700
+ const bt = fieldDef.boundType;
701
+ if (!bt || bt.kind !== "cumulative_count") return void 0;
702
+ const prefix = fieldName.replace(/_(?:daily|monthly|weekly)_max$/, "");
703
+ if (prefix === fieldName || prefix.startsWith("transaction")) return void 0;
704
+ return [prefix];
705
+ }
654
706
  function checkBoundsV4(request, profile, executionLog, now) {
655
707
  const errors = [];
656
708
  if (!profile.boundsSchema) return errors;
@@ -711,7 +763,14 @@ function checkBoundsV4(request, profile, executionLog, now) {
711
763
  break;
712
764
  }
713
765
  if (!executionLog) break;
714
- const runningTotal = executionLog.sumByWindow(profileId, path, bt.of, bt.window, now);
766
+ const runningTotal = executionLog.sumByWindow(
767
+ profileId,
768
+ path,
769
+ bt.of,
770
+ bt.window,
771
+ now,
772
+ boundActionTypes(fieldName, fieldDef)
773
+ );
715
774
  const currentRaw = request.execution[bt.of];
716
775
  const current = typeof currentRaw === "number" ? currentRaw : currentRaw !== void 0 ? Number(currentRaw) : 0;
717
776
  const total = runningTotal + current;
@@ -738,7 +797,14 @@ function checkBoundsV4(request, profile, executionLog, now) {
738
797
  break;
739
798
  }
740
799
  if (!executionLog) break;
741
- const runningCount = executionLog.sumByWindow(profileId, path, "_count", bt.window, now);
800
+ const runningCount = executionLog.sumByWindow(
801
+ profileId,
802
+ path,
803
+ "_count",
804
+ bt.window,
805
+ now,
806
+ boundActionTypes(fieldName, fieldDef)
807
+ );
742
808
  const total = runningCount + 1;
743
809
  if (total > boundValue) {
744
810
  errors.push({
@@ -1066,11 +1132,13 @@ async function verifyReceiptSignature(receipt, publicKeyHex) {
1066
1132
  }
1067
1133
  }
1068
1134
  export {
1135
+ CanonicalValueError,
1069
1136
  ContentBindingError,
1070
1137
  MandateError,
1071
1138
  approvalSigningBytes,
1072
1139
  attestationId,
1073
1140
  bindingAppliesTo,
1141
+ boundActionTypes,
1074
1142
  buildMandateProjection,
1075
1143
  canonicalBounds,
1076
1144
  canonicalContext,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanagencyp/hap-core",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Core types, cryptographic primitives, and verification logic for the Human Agency Protocol",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,6 +24,7 @@
24
24
  "scripts": {
25
25
  "build": "tsup src/index.ts --format cjs,esm --dts",
26
26
  "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
27
+ "typecheck": "tsc --noEmit",
27
28
  "test": "vitest run",
28
29
  "test:watch": "vitest",
29
30
  "prepublishOnly": "pnpm build",
package/src/frame.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  * Frame Canonicalization for Agent Profiles
3
3
  *
4
4
  * Agent profiles support mixed-type fields (strings and numbers).
5
- * Canonical form: all values are converted to strings via String(value).
6
- * Keys are ordered according to the profile's keyOrder.
5
+ * Canonical form: `key=value` records joined with LF, keys in the profile's
6
+ * keyOrder. Values are stringified with String(value) — the shortest
7
+ * round-trippable form for numbers — then percent-encoded per protocol.md
8
+ * (`=`, `%`, and every byte outside printable ASCII); a value carrying a raw
9
+ * LF/CR is refused, never normalized. See `canonicalRecords` below.
7
10
  *
8
11
  * v0.3: frameSchema
9
12
  * v0.4: boundsSchema + contextSchema (separate hashes)
@@ -176,11 +179,115 @@ export function validateContextParams(
176
179
  return { valid: errors.length === 0, errors };
177
180
  }
178
181
 
182
+ // ─── Value Encoding (normative, v0.5+) ───────────────────────────────────────
183
+
184
+ /**
185
+ * Thrown when a value cannot be canonicalized at all — currently only for raw
186
+ * LF/CR inside a value. Carries the protocol error code so callers can map it
187
+ * straight onto the wire without string-matching a message.
188
+ *
189
+ * protocol.md → *Bounds & Scope Canonicalization* → Value encoding:
190
+ * "Values MUST NOT contain raw newline (\n) or carriage-return (\r)
191
+ * characters. Implementations MUST reject input containing them; silent
192
+ * stripping or normalization is a violation because it produces a hash that
193
+ * does not faithfully represent the input."
194
+ */
195
+ export class CanonicalValueError extends Error {
196
+ readonly code: 'BOUNDS_INVALID_VALUE' | 'CONTEXT_INVALID_VALUE';
197
+ readonly field: string;
198
+
199
+ constructor(code: 'BOUNDS_INVALID_VALUE' | 'CONTEXT_INVALID_VALUE', field: string, message: string) {
200
+ super(message);
201
+ this.name = 'CanonicalValueError';
202
+ this.code = code;
203
+ this.field = field;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Percent-encode a value per protocol.md → *Value encoding*.
209
+ *
210
+ * Encoded, over the value's UTF-8 bytes, as `%` + two UPPERCASE hex digits:
211
+ * - `=` (0x3D) — otherwise it would be read as the key/value separator
212
+ * - `%` (0x25) — so the encoding is self-inverse
213
+ * - every byte outside printable ASCII 0x20–0x7E
214
+ *
215
+ * LF and CR are deliberately NOT in this list: they are refused upstream, so
216
+ * encoding them is unreachable (v0.7 removed the spec's contradiction here).
217
+ *
218
+ * This runs at canonicalization time only. Stored values keep the human's
219
+ * original bytes.
220
+ */
221
+ function percentEncodeCanonicalValue(raw: string): string {
222
+ const bytes = new TextEncoder().encode(raw);
223
+ let out = '';
224
+ for (const b of bytes) {
225
+ if (b === 0x3d || b === 0x25 || b < 0x20 || b > 0x7e) {
226
+ out += '%' + b.toString(16).toUpperCase().padStart(2, '0');
227
+ } else {
228
+ out += String.fromCharCode(b);
229
+ }
230
+ }
231
+ return out;
232
+ }
233
+
234
+ /**
235
+ * The one place `key=value` records are built for bounds and context.
236
+ *
237
+ * Rules applied here (all normative, protocol.md → *Bounds & Scope
238
+ * Canonicalization*):
239
+ * - keys in the schema's keyOrder, never alphabetical
240
+ * - a value carrying a raw LF/CR is REFUSED (never stripped or encoded)
241
+ * - `=`, `%`, and any byte outside 0x20–0x7E are percent-encoded (UPPERCASE)
242
+ * - numbers use JS `String()`, which is the shortest round-trippable form
243
+ * (`String(20.0)` === "20")
244
+ * - a key with no value is OMITTED entirely — it emits no record
245
+ *
246
+ * On the omission rule: required keys are guaranteed present by the caller's
247
+ * validation (`validateBoundsParams` / `validateContextParams` reject a missing
248
+ * required field), so "explicit inclusion of all required keys" still holds.
249
+ * What remains are *optional* keys the human never set. Rendering those as the
250
+ * literal string "undefined" — the pre-fix behaviour — hashed a JavaScript
251
+ * artifact that no other language would produce and that collides with a real
252
+ * value of "undefined". Omitting them (rather than emitting `key=`) also keeps
253
+ * "the human set no limit" distinct from "the human set an empty value", and
254
+ * matches this package's JSON canonicalization, which drops undefined-valued
255
+ * properties.
256
+ */
257
+ function canonicalRecords(
258
+ params: Record<string, string | number | undefined>,
259
+ keyOrder: string[],
260
+ code: 'BOUNDS_INVALID_VALUE' | 'CONTEXT_INVALID_VALUE',
261
+ ): string {
262
+ const lines: string[] = [];
263
+
264
+ for (const key of keyOrder) {
265
+ const value = params[key];
266
+ if (value === undefined || value === null) continue;
267
+
268
+ const raw = String(value);
269
+ if (raw.includes('\n') || raw.includes('\r')) {
270
+ throw new CanonicalValueError(
271
+ code,
272
+ key,
273
+ `Value for "${key}" contains a raw newline or carriage return. ` +
274
+ 'Refusing: a hash over stripped or normalized input would not represent what was authorized.',
275
+ );
276
+ }
277
+
278
+ lines.push(`${key}=${percentEncodeCanonicalValue(raw)}`);
279
+ }
280
+
281
+ return lines.join('\n');
282
+ }
283
+
179
284
  /**
180
285
  * Builds the canonical bounds string from parameters.
181
- * All values are converted to strings. Keys are ordered per profile's boundsSchema.keyOrder.
286
+ * Keys are ordered per profile's boundsSchema.keyOrder; values are encoded per
287
+ * protocol.md → *Value encoding* (see `canonicalRecords`).
182
288
  *
183
289
  * @throws Error if any field fails validation
290
+ * @throws CanonicalValueError (code BOUNDS_INVALID_VALUE) if a value carries a raw LF/CR
184
291
  */
185
292
  export function canonicalBounds(params: AgentBoundsParams, profile: AgentProfile): string {
186
293
  const validation = validateBoundsParams(params, profile);
@@ -188,19 +295,17 @@ export function canonicalBounds(params: AgentBoundsParams, profile: AgentProfile
188
295
  throw new Error(`Invalid bounds parameters: ${validation.errors.join('; ')}`);
189
296
  }
190
297
 
191
- const lines = profile.boundsSchema!.keyOrder.map(
192
- (key) => `${key}=${String(params[key])}`
193
- );
194
-
195
- return lines.join('\n');
298
+ return canonicalRecords(params, profile.boundsSchema!.keyOrder, 'BOUNDS_INVALID_VALUE');
196
299
  }
197
300
 
198
301
  /**
199
302
  * Builds the canonical context string from parameters.
200
- * All values are converted to strings. Keys are ordered per profile's contextSchema.keyOrder.
303
+ * Keys are ordered per profile's contextSchema.keyOrder; values are encoded per
304
+ * protocol.md → *Value encoding* (see `canonicalRecords`).
201
305
  * For empty context (no contextSchema or no fields), returns "".
202
306
  *
203
307
  * @throws Error if any field fails validation
308
+ * @throws CanonicalValueError (code CONTEXT_INVALID_VALUE) if a value carries a raw LF/CR
204
309
  */
205
310
  export function canonicalContext(params: AgentContextParams, profile: AgentProfile): string {
206
311
  // No contextSchema or no fields → empty context
@@ -213,11 +318,7 @@ export function canonicalContext(params: AgentContextParams, profile: AgentProfi
213
318
  throw new Error(`Invalid context parameters: ${validation.errors.join('; ')}`);
214
319
  }
215
320
 
216
- const lines = profile.contextSchema.keyOrder.map(
217
- (key) => `${key}=${String(params[key])}`
218
- );
219
-
220
- return lines.join('\n');
321
+ return canonicalRecords(params, profile.contextSchema.keyOrder, 'CONTEXT_INVALID_VALUE');
221
322
  }
222
323
 
223
324
  /**
package/src/gatekeeper.ts CHANGED
@@ -86,6 +86,28 @@ export async function verify(
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * Fail closed when there is nothing to verify.
91
+ *
92
+ * The attestation loop below is a `for` over `request.attestations`. With an
93
+ * empty (or missing) array it runs zero times, pushes zero errors, and control
94
+ * falls through to the bounds check — which happily approves a call whose
95
+ * signature, bounds_hash, and TTL were never checked. "No errors" is not the
96
+ * same as "verified"; an absent attestation is the one case where silence must
97
+ * read as refusal.
98
+ */
99
+ function noAttestationSupplied(request: GatekeeperRequest): boolean {
100
+ return !Array.isArray(request.attestations) || request.attestations.length === 0;
101
+ }
102
+
103
+ /** Built fresh per call so a caller cannot mutate a shared error array. */
104
+ function noAttestationResult(): GatekeeperResult {
105
+ return {
106
+ approved: false,
107
+ errors: [{ code: 'MALFORMED_ATTESTATION', message: 'No attestation supplied — nothing to verify' }],
108
+ };
109
+ }
110
+
89
111
  // ─── v0.3 Verification ────────────────────────────────────────────────────────
90
112
 
91
113
  async function verifyV3(
@@ -96,6 +118,8 @@ async function verifyV3(
96
118
  executionLog: ExecutionLogQuery | undefined,
97
119
  errors: GatekeeperError[],
98
120
  ): Promise<GatekeeperResult> {
121
+ if (noAttestationSupplied(request)) return noAttestationResult();
122
+
99
123
  let expectedFrameHash: string;
100
124
  try {
101
125
  expectedFrameHash = computeFrameHash(request.frame, profile);
@@ -179,6 +203,8 @@ async function verifyV4(
179
203
  now: number,
180
204
  executionLog: ExecutionLogQuery | undefined,
181
205
  ): Promise<GatekeeperResult> {
206
+ if (noAttestationSupplied(request)) return noAttestationResult();
207
+
182
208
  const errors: GatekeeperError[] = [];
183
209
 
184
210
  // In v0.4 the `frame` param carries bounds; `context` carries context params
@@ -443,6 +469,36 @@ function boundGovernsAction(
443
469
  return !namedForAnAction || matches;
444
470
  }
445
471
 
472
+ /**
473
+ * Which action types' executions count toward this cumulative bound's running
474
+ * total. `undefined` means all of them.
475
+ *
476
+ * `boundGovernsAction` decides whether a bound applies to the current call; this
477
+ * decides what the bound's total is made of. Both are needed: selecting the right
478
+ * bound and then feeding it the profile's combined total is still wrong — sales'
479
+ * "orders per day" was checked only on orders, but counted every quote and send.
480
+ * Consumption is partitioned by action type (*Cumulative Tracking* rule 4), as
481
+ * the Authority Server already does.
482
+ *
483
+ * Same answer, same order of preference as `boundGovernsAction`: the declared
484
+ * `appliesTo`; else, for a count bound named for an action (`delete_daily_max`),
485
+ * that action; else every action type. Exported so display surfaces report the
486
+ * same totals the gate enforces.
487
+ */
488
+ export function boundActionTypes(
489
+ fieldName: string,
490
+ fieldDef: ProfileBoundsField,
491
+ ): readonly string[] | undefined {
492
+ if (fieldDef.appliesTo) return fieldDef.appliesTo;
493
+
494
+ const bt = fieldDef.boundType;
495
+ if (!bt || bt.kind !== 'cumulative_count') return undefined;
496
+
497
+ const prefix = fieldName.replace(/_(?:daily|monthly|weekly)_max$/, '');
498
+ if (prefix === fieldName || prefix.startsWith('transaction')) return undefined;
499
+ return [prefix];
500
+ }
501
+
446
502
  function checkBoundsV4(
447
503
  request: GatekeeperRequest,
448
504
  profile: AgentProfile,
@@ -526,7 +582,9 @@ function checkBoundsV4(
526
582
  }
527
583
  if (!executionLog) break;
528
584
 
529
- const runningTotal = executionLog.sumByWindow(profileId, path, bt.of, bt.window, now);
585
+ const runningTotal = executionLog.sumByWindow(
586
+ profileId, path, bt.of, bt.window, now, boundActionTypes(fieldName, fieldDef),
587
+ );
530
588
  const currentRaw = request.execution[bt.of];
531
589
  const current = typeof currentRaw === 'number'
532
590
  ? currentRaw
@@ -558,7 +616,9 @@ function checkBoundsV4(
558
616
  }
559
617
  if (!executionLog) break;
560
618
 
561
- const runningCount = executionLog.sumByWindow(profileId, path, '_count', bt.window, now);
619
+ const runningCount = executionLog.sumByWindow(
620
+ profileId, path, '_count', bt.window, now, boundActionTypes(fieldName, fieldDef),
621
+ );
562
622
  const total = runningCount + 1;
563
623
 
564
624
  if (total > boundValue) {
package/src/types.ts CHANGED
@@ -636,7 +636,27 @@ export interface ExecutionLogQuery {
636
636
  * Sum a field's values within a time window for a given profile.
637
637
  * Use field="_count" to count executions instead of summing a field.
638
638
  */
639
- sumByWindow(profileId: string, path: string, field: string, window: CumulativeWindow, now?: number): number;
639
+ sumByWindow(
640
+ profileId: string,
641
+ path: string,
642
+ field: string,
643
+ window: CumulativeWindow,
644
+ now?: number,
645
+ /**
646
+ * Only count executions whose `execution.action_type` is one of these.
647
+ * Omitted → every execution counts (a bound that governs all action types).
648
+ *
649
+ * Consumption is partitioned by action type (*Cumulative Tracking* rule 4):
650
+ * a bound counts only the action types it governs. Without the filter a
651
+ * profile with several action types feeds every bound the combined total —
652
+ * sales' "orders per day" counted quotes and sends too, and its daily order
653
+ * value summed quote values. Use {@link boundActionTypes} to derive it.
654
+ *
655
+ * Implementations SHOULD count an entry that carries no `action_type`
656
+ * against every filter: an unknown action is not evidence of headroom.
657
+ */
658
+ actionTypes?: readonly string[],
659
+ ): number;
640
660
  }
641
661
 
642
662
  // ─── Frame Types ─────────────────────────────────────────────────────────────
@@ -712,7 +732,13 @@ export interface GatekeeperError {
712
732
  | 'MANDATE_SIGNATURE_REQUIRED'
713
733
  | 'MANDATE_SIGNATURE_INVALID'
714
734
  | 'APPROVAL_SIGNATURE_REQUIRED'
715
- | 'APPROVAL_SIGNATURE_INVALID';
735
+ | 'APPROVAL_SIGNATURE_INVALID'
736
+ // Canonicalization refusals (protocol.md → Bounds & Scope Canonicalization
737
+ // → Value encoding). Raised when a value cannot be canonicalized at all —
738
+ // a raw LF/CR, or a profile `constraint.pattern` violation. Distinct from
739
+ // BOUNDS_MISMATCH / CONTEXT_MISMATCH, which mean the hashes disagree.
740
+ | 'BOUNDS_INVALID_VALUE'
741
+ | 'CONTEXT_INVALID_VALUE';
716
742
  field?: string;
717
743
  message: string;
718
744
  bound?: string | number;