@humanagencyp/hap-core 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -210,6 +210,21 @@ interface ProfileBoundsField {
210
210
  * treat a missing boundType as an error when running in v0.4 mode.
211
211
  */
212
212
  boundType?: BoundType;
213
+ /**
214
+ * Which execution action types this bound governs, e.g. `["write"]`.
215
+ *
216
+ * When present, this is authoritative. When absent, enforcement falls back to
217
+ * inferring the action type from the FIELD NAME (`send_daily_max` → `send`),
218
+ * which is a convention rather than a contract: a profile that names a bound
219
+ * after the domain concept instead of the action — calendar's
220
+ * `booking_daily_max` against an action type of `write` — matches nothing and
221
+ * is skipped, so a limit the user set is never applied and nothing says so.
222
+ *
223
+ * Declaring it removes the guess. Deliberately additive: profiles migrate one
224
+ * at a time, and every remaining fallback is logged, so the cases still
225
+ * relying on the naming convention become visible instead of staying silent.
226
+ */
227
+ appliesTo?: string[];
213
228
  /**
214
229
  * The measurement dimension of the bound's value. UI renders the unit
215
230
  * inline next to the input (e.g. `4 min`, `100 EUR`). Independent from
@@ -474,6 +489,18 @@ interface GatekeeperRequest {
474
489
  execution: Record<string, string | number>;
475
490
  /** v0.4: context parameters (currency, action_type, etc.) */
476
491
  context?: AgentContextParams;
492
+ /**
493
+ * Authorization path used to scope cumulative lookups in the execution log.
494
+ *
495
+ * Deliberately OUTSIDE `frame`: the frame is hashed and validated against the
496
+ * profile's boundsSchema, so an extra key there is rejected as an unknown
497
+ * field and breaks attestation verification outright. Cumulative checks
498
+ * previously read the path from the frame, where callers cannot legally put
499
+ * it — so it resolved to "" while the log stored real paths, no entry ever
500
+ * matched, and every running total read zero. The local gate existed but
501
+ * could never fire.
502
+ */
503
+ path?: string;
477
504
  }
478
505
  /**
479
506
  * Structured error from Gatekeeper verification.
@@ -652,8 +679,13 @@ declare function validateSubject(subject: Subject): SubjectValidation;
652
679
  * The name appears only at `high` with a disclosed name; otherwise no name.
653
680
  *
654
681
  * - low / no disclosure → "Sent by an AI agent via «operator»"
655
- * - high / as_vouched → "Sent by an AI agent of «name», verified by «operator»"
656
- * - high / eudi → "Sent by an AI agent of «name», identity verified (EUDI)"
682
+ * - high / as_vouched → "Sent by «name»'s AI agent, verified by «operator»"
683
+ * - high / eudi → "Sent by «name»'s AI agent, identity verified (EUDI)"
684
+ *
685
+ * Footer wording v1.1 (July 2026): the named forms read "«name»'s AI agent"
686
+ * (more natural than the v1 "an AI agent of «name»"). The v1 wording is frozen
687
+ * for already-sent content; verifiers strip BOTH forms (see the gateway's
688
+ * FOOTER_RE and the AS ContentVerifier).
657
689
  *
658
690
  * Punctuation is ASCII (comma, not em-dash) so the line survives header and
659
691
  * plain-text encoding intact across email, calendar, and publishing channels.
package/dist/index.d.ts CHANGED
@@ -210,6 +210,21 @@ interface ProfileBoundsField {
210
210
  * treat a missing boundType as an error when running in v0.4 mode.
211
211
  */
212
212
  boundType?: BoundType;
213
+ /**
214
+ * Which execution action types this bound governs, e.g. `["write"]`.
215
+ *
216
+ * When present, this is authoritative. When absent, enforcement falls back to
217
+ * inferring the action type from the FIELD NAME (`send_daily_max` → `send`),
218
+ * which is a convention rather than a contract: a profile that names a bound
219
+ * after the domain concept instead of the action — calendar's
220
+ * `booking_daily_max` against an action type of `write` — matches nothing and
221
+ * is skipped, so a limit the user set is never applied and nothing says so.
222
+ *
223
+ * Declaring it removes the guess. Deliberately additive: profiles migrate one
224
+ * at a time, and every remaining fallback is logged, so the cases still
225
+ * relying on the naming convention become visible instead of staying silent.
226
+ */
227
+ appliesTo?: string[];
213
228
  /**
214
229
  * The measurement dimension of the bound's value. UI renders the unit
215
230
  * inline next to the input (e.g. `4 min`, `100 EUR`). Independent from
@@ -474,6 +489,18 @@ interface GatekeeperRequest {
474
489
  execution: Record<string, string | number>;
475
490
  /** v0.4: context parameters (currency, action_type, etc.) */
476
491
  context?: AgentContextParams;
492
+ /**
493
+ * Authorization path used to scope cumulative lookups in the execution log.
494
+ *
495
+ * Deliberately OUTSIDE `frame`: the frame is hashed and validated against the
496
+ * profile's boundsSchema, so an extra key there is rejected as an unknown
497
+ * field and breaks attestation verification outright. Cumulative checks
498
+ * previously read the path from the frame, where callers cannot legally put
499
+ * it — so it resolved to "" while the log stored real paths, no entry ever
500
+ * matched, and every running total read zero. The local gate existed but
501
+ * could never fire.
502
+ */
503
+ path?: string;
477
504
  }
478
505
  /**
479
506
  * Structured error from Gatekeeper verification.
@@ -652,8 +679,13 @@ declare function validateSubject(subject: Subject): SubjectValidation;
652
679
  * The name appears only at `high` with a disclosed name; otherwise no name.
653
680
  *
654
681
  * - low / no disclosure → "Sent by an AI agent via «operator»"
655
- * - high / as_vouched → "Sent by an AI agent of «name», verified by «operator»"
656
- * - high / eudi → "Sent by an AI agent of «name», identity verified (EUDI)"
682
+ * - high / as_vouched → "Sent by «name»'s AI agent, verified by «operator»"
683
+ * - high / eudi → "Sent by «name»'s AI agent, identity verified (EUDI)"
684
+ *
685
+ * Footer wording v1.1 (July 2026): the named forms read "«name»'s AI agent"
686
+ * (more natural than the v1 "an AI agent of «name»"). The v1 wording is frozen
687
+ * for already-sent content; verifiers strip BOTH forms (see the gateway's
688
+ * FOOTER_RE and the AS ContentVerifier).
657
689
  *
658
690
  * Punctuation is ASCII (comma, not em-dash) so the line survives header and
659
691
  * plain-text encoding intact across email, calendar, and publishing channels.
package/dist/index.js CHANGED
@@ -170,9 +170,9 @@ function deriveIdentityLine(subject, { operatorName }) {
170
170
  return `Sent by an AI agent via ${operatorName}`;
171
171
  }
172
172
  if (subject.method === "eudi") {
173
- return `Sent by an AI agent of ${name}, identity verified (EUDI)`;
173
+ return `Sent by ${name}'s AI agent, identity verified (EUDI)`;
174
174
  }
175
- return `Sent by an AI agent of ${name}, verified by ${operatorName}`;
175
+ return `Sent by ${name}'s AI agent, verified by ${operatorName}`;
176
176
  }
177
177
 
178
178
  // src/frame.ts
@@ -626,12 +626,23 @@ function checkBoundsFromFrameSchema(request, profile) {
626
626
  }
627
627
  return errors;
628
628
  }
629
+ function boundGovernsAction(fieldName, fieldDef, bt, actionType) {
630
+ if (fieldDef.appliesTo) {
631
+ return actionType ? fieldDef.appliesTo.includes(actionType) : true;
632
+ }
633
+ if (bt.kind !== "cumulative_count" || !actionType) return true;
634
+ const prefix = fieldName.replace(/_(?:daily|monthly|weekly)_max$/, "");
635
+ const namedForAnAction = prefix !== fieldName;
636
+ const matches = prefix === actionType || prefix.startsWith("transaction");
637
+ return !namedForAnAction || matches;
638
+ }
629
639
  function checkBoundsV4(request, profile, executionLog, now) {
630
640
  const errors = [];
631
641
  if (!profile.boundsSchema) return errors;
632
642
  const bounds = request.frame;
633
643
  const profileId = String(bounds.profile ?? profile.id);
634
- const path = bounds.path ? String(bounds.path) : "";
644
+ const actionType = typeof request.execution.action_type === "string" ? request.execution.action_type : void 0;
645
+ const path = request.path ?? (bounds.path ? String(bounds.path) : "");
635
646
  for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
636
647
  if (fieldName === "profile" || fieldName === "path") continue;
637
648
  const boundValue = bounds[fieldName];
@@ -647,6 +658,7 @@ function checkBoundsV4(request, profile, executionLog, now) {
647
658
  });
648
659
  continue;
649
660
  }
661
+ if (!boundGovernsAction(fieldName, fieldDef, bt, actionType)) continue;
650
662
  switch (bt.kind) {
651
663
  case "per_transaction": {
652
664
  const actual = request.execution[bt.of];
package/dist/index.mjs CHANGED
@@ -99,9 +99,9 @@ function deriveIdentityLine(subject, { operatorName }) {
99
99
  return `Sent by an AI agent via ${operatorName}`;
100
100
  }
101
101
  if (subject.method === "eudi") {
102
- return `Sent by an AI agent of ${name}, identity verified (EUDI)`;
102
+ return `Sent by ${name}'s AI agent, identity verified (EUDI)`;
103
103
  }
104
- return `Sent by an AI agent of ${name}, verified by ${operatorName}`;
104
+ return `Sent by ${name}'s AI agent, verified by ${operatorName}`;
105
105
  }
106
106
 
107
107
  // src/frame.ts
@@ -555,12 +555,23 @@ function checkBoundsFromFrameSchema(request, profile) {
555
555
  }
556
556
  return errors;
557
557
  }
558
+ function boundGovernsAction(fieldName, fieldDef, bt, actionType) {
559
+ if (fieldDef.appliesTo) {
560
+ return actionType ? fieldDef.appliesTo.includes(actionType) : true;
561
+ }
562
+ if (bt.kind !== "cumulative_count" || !actionType) return true;
563
+ const prefix = fieldName.replace(/_(?:daily|monthly|weekly)_max$/, "");
564
+ const namedForAnAction = prefix !== fieldName;
565
+ const matches = prefix === actionType || prefix.startsWith("transaction");
566
+ return !namedForAnAction || matches;
567
+ }
558
568
  function checkBoundsV4(request, profile, executionLog, now) {
559
569
  const errors = [];
560
570
  if (!profile.boundsSchema) return errors;
561
571
  const bounds = request.frame;
562
572
  const profileId = String(bounds.profile ?? profile.id);
563
- const path = bounds.path ? String(bounds.path) : "";
573
+ const actionType = typeof request.execution.action_type === "string" ? request.execution.action_type : void 0;
574
+ const path = request.path ?? (bounds.path ? String(bounds.path) : "");
564
575
  for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
565
576
  if (fieldName === "profile" || fieldName === "path") continue;
566
577
  const boundValue = bounds[fieldName];
@@ -576,6 +587,7 @@ function checkBoundsV4(request, profile, executionLog, now) {
576
587
  });
577
588
  continue;
578
589
  }
590
+ if (!boundGovernsAction(fieldName, fieldDef, bt, actionType)) continue;
579
591
  switch (bt.kind) {
580
592
  case "per_transaction": {
581
593
  const actual = request.execution[bt.of];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanagencyp/hap-core",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Core types, cryptographic primitives, and verification logic for the Human Agency Protocol",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/gatekeeper.ts CHANGED
@@ -40,6 +40,8 @@ import type {
40
40
  AgentContextParams,
41
41
  ExecutionLogQuery,
42
42
  CumulativeFieldDef,
43
+ ProfileBoundsField,
44
+ BoundType,
43
45
  } from './types';
44
46
 
45
47
  /**
@@ -406,6 +408,41 @@ function checkBoundsFromFrameSchema(request: GatekeeperRequest, profile: AgentPr
406
408
  * enum — bound value must be in the allowed set (attest-time
407
409
  * validation; not an execution-time check)
408
410
  */
411
+ /**
412
+ * Does this bound govern the action being attempted?
413
+ *
414
+ * Cumulative totals are scoped by (profileId, path) only, so without this every
415
+ * cumulative_count bound on a profile sees the same running count. On customers
416
+ * — which declares both write_daily_max and delete_daily_max — the smaller
417
+ * delete limit would count writes and block them.
418
+ *
419
+ * Preferred answer is the profile's declared `appliesTo`. Absent that, fall back
420
+ * to the field-name convention (`delete_daily_max` → `delete`), matching how the
421
+ * Authority Server selects bounds so the two enforcement points cannot disagree.
422
+ *
423
+ * Fails CLOSED: when the action type is unknown, or the bound's name implies no
424
+ * particular action, the bound is enforced.
425
+ */
426
+ function boundGovernsAction(
427
+ fieldName: string,
428
+ fieldDef: ProfileBoundsField,
429
+ bt: BoundType,
430
+ actionType: string | undefined,
431
+ ): boolean {
432
+ if (fieldDef.appliesTo) {
433
+ return actionType ? fieldDef.appliesTo.includes(actionType) : true;
434
+ }
435
+
436
+ // The name convention only ever disambiguated count bounds; sums are governed
437
+ // by their `of` field and stay unfiltered, as at the Authority Server.
438
+ if (bt.kind !== 'cumulative_count' || !actionType) return true;
439
+
440
+ const prefix = fieldName.replace(/_(?:daily|monthly|weekly)_max$/, '');
441
+ const namedForAnAction = prefix !== fieldName;
442
+ const matches = prefix === actionType || prefix.startsWith('transaction');
443
+ return !namedForAnAction || matches;
444
+ }
445
+
409
446
  function checkBoundsV4(
410
447
  request: GatekeeperRequest,
411
448
  profile: AgentProfile,
@@ -417,7 +454,14 @@ function checkBoundsV4(
417
454
 
418
455
  const bounds = request.frame as AgentBoundsParams;
419
456
  const profileId = String(bounds.profile ?? profile.id);
420
- const path = bounds.path ? String(bounds.path) : '';
457
+ const actionType = typeof request.execution.action_type === 'string'
458
+ ? request.execution.action_type
459
+ : undefined;
460
+ // Prefer the explicit request path. `frame.path` is retained only as a
461
+ // fallback for callers that legitimately declare `path` in their profile's
462
+ // boundsSchema; for every shipped profile it is absent, which is exactly why
463
+ // this silently resolved to "" and disabled the cumulative gate entirely.
464
+ const path = request.path ?? (bounds.path ? String(bounds.path) : '');
421
465
 
422
466
  for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
423
467
  if (fieldName === 'profile' || fieldName === 'path') continue;
@@ -441,6 +485,8 @@ function checkBoundsV4(
441
485
  continue;
442
486
  }
443
487
 
488
+ if (!boundGovernsAction(fieldName, fieldDef, bt, actionType)) continue;
489
+
444
490
  switch (bt.kind) {
445
491
  case 'per_transaction': {
446
492
  const actual = request.execution[bt.of];
package/src/identity.ts CHANGED
@@ -56,8 +56,13 @@ export function validateSubject(subject: Subject): SubjectValidation {
56
56
  * The name appears only at `high` with a disclosed name; otherwise no name.
57
57
  *
58
58
  * - low / no disclosure → "Sent by an AI agent via «operator»"
59
- * - high / as_vouched → "Sent by an AI agent of «name», verified by «operator»"
60
- * - high / eudi → "Sent by an AI agent of «name», identity verified (EUDI)"
59
+ * - high / as_vouched → "Sent by «name»'s AI agent, verified by «operator»"
60
+ * - high / eudi → "Sent by «name»'s AI agent, identity verified (EUDI)"
61
+ *
62
+ * Footer wording v1.1 (July 2026): the named forms read "«name»'s AI agent"
63
+ * (more natural than the v1 "an AI agent of «name»"). The v1 wording is frozen
64
+ * for already-sent content; verifiers strip BOTH forms (see the gateway's
65
+ * FOOTER_RE and the AS ContentVerifier).
61
66
  *
62
67
  * Punctuation is ASCII (comma, not em-dash) so the line survives header and
63
68
  * plain-text encoding intact across email, calendar, and publishing channels.
@@ -76,7 +81,7 @@ export function deriveIdentityLine(
76
81
  return `Sent by an AI agent via ${operatorName}`;
77
82
  }
78
83
  if (subject!.method === 'eudi') {
79
- return `Sent by an AI agent of ${name}, identity verified (EUDI)`;
84
+ return `Sent by ${name}'s AI agent, identity verified (EUDI)`;
80
85
  }
81
- return `Sent by an AI agent of ${name}, verified by ${operatorName}`;
86
+ return `Sent by ${name}'s AI agent, verified by ${operatorName}`;
82
87
  }
package/src/types.ts CHANGED
@@ -213,6 +213,21 @@ export interface ProfileBoundsField {
213
213
  * treat a missing boundType as an error when running in v0.4 mode.
214
214
  */
215
215
  boundType?: BoundType;
216
+ /**
217
+ * Which execution action types this bound governs, e.g. `["write"]`.
218
+ *
219
+ * When present, this is authoritative. When absent, enforcement falls back to
220
+ * inferring the action type from the FIELD NAME (`send_daily_max` → `send`),
221
+ * which is a convention rather than a contract: a profile that names a bound
222
+ * after the domain concept instead of the action — calendar's
223
+ * `booking_daily_max` against an action type of `write` — matches nothing and
224
+ * is skipped, so a limit the user set is never applied and nothing says so.
225
+ *
226
+ * Declaring it removes the guess. Deliberately additive: profiles migrate one
227
+ * at a time, and every remaining fallback is logged, so the cases still
228
+ * relying on the naming convention become visible instead of staying silent.
229
+ */
230
+ appliesTo?: string[];
216
231
  /**
217
232
  * The measurement dimension of the bound's value. UI renders the unit
218
233
  * inline next to the input (e.g. `4 min`, `100 EUR`). Independent from
@@ -502,6 +517,18 @@ export interface GatekeeperRequest {
502
517
  execution: Record<string, string | number>;
503
518
  /** v0.4: context parameters (currency, action_type, etc.) */
504
519
  context?: AgentContextParams;
520
+ /**
521
+ * Authorization path used to scope cumulative lookups in the execution log.
522
+ *
523
+ * Deliberately OUTSIDE `frame`: the frame is hashed and validated against the
524
+ * profile's boundsSchema, so an extra key there is rejected as an unknown
525
+ * field and breaks attestation verification outright. Cumulative checks
526
+ * previously read the path from the frame, where callers cannot legally put
527
+ * it — so it resolved to "" while the log stored real paths, no entry ever
528
+ * matched, and every running total read zero. The local gate existed but
529
+ * could never fire.
530
+ */
531
+ path?: string;
505
532
  }
506
533
 
507
534
  /**