@intentius/chant 0.124.11 → 0.124.13

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.
Files changed (50) hide show
  1. package/dist/cli/commands/lint.d.ts.map +1 -1
  2. package/dist/gate-decision.d.ts +36 -4
  3. package/dist/gate-decision.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +8 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/lifecycle/git.d.ts +30 -14
  7. package/dist/lifecycle/git.d.ts.map +1 -1
  8. package/dist/lifecycle/ledger-port.d.ts +18 -0
  9. package/dist/lifecycle/ledger-port.d.ts.map +1 -0
  10. package/dist/lifecycle/release-ledger.d.ts +10 -9
  11. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  12. package/dist/lifecycle/release-read.d.ts +23 -0
  13. package/dist/lifecycle/release-read.d.ts.map +1 -0
  14. package/dist/lifecycle/wave-plans.d.ts +51 -0
  15. package/dist/lifecycle/wave-plans.d.ts.map +1 -0
  16. package/dist/lint/typescript.d.ts +18 -0
  17. package/dist/lint/typescript.d.ts.map +1 -0
  18. package/dist/op/activities/activity-contracts.d.ts +4 -4
  19. package/dist/op/gate-review.d.ts +1 -20
  20. package/dist/op/gate-review.d.ts.map +1 -1
  21. package/dist/op/op-waves-run.d.ts +1 -1
  22. package/dist/op/op-waves-run.d.ts.map +1 -1
  23. package/dist/workspace/identity-gate-rule.d.ts +65 -0
  24. package/dist/workspace/identity-gate-rule.d.ts.map +1 -0
  25. package/dist/workspace/identity.d.ts +4 -2
  26. package/dist/workspace/identity.d.ts.map +1 -1
  27. package/dist/workspace/principal-classes.d.ts +3 -14
  28. package/dist/workspace/principal-classes.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli/commands/lint.ts +33 -6
  31. package/src/gate-decision-bundle.test.ts +3 -2
  32. package/src/gate-decision-identity.test.ts +176 -0
  33. package/src/gate-decision.ts +100 -22
  34. package/src/lifecycle/gate-ledger.ts +8 -0
  35. package/src/lifecycle/git.ts +61 -18
  36. package/src/lifecycle/ledger-port.ts +17 -0
  37. package/src/lifecycle/ledger-readers-bundle.test.ts +47 -0
  38. package/src/lifecycle/ledger-readers.test.ts +139 -0
  39. package/src/lifecycle/release-ledger.ts +15 -42
  40. package/src/lifecycle/release-read.ts +69 -0
  41. package/src/lifecycle/wave-plans.ts +93 -0
  42. package/src/lint/typescript.ts +18 -0
  43. package/src/meta/lexicon-typescript-import.test.ts +42 -0
  44. package/src/op/gate-review.ts +2 -16
  45. package/src/op/op-waves-point.test.ts +1 -0
  46. package/src/op/op-waves-run.ts +8 -14
  47. package/src/op/op-waves.test.ts +14 -0
  48. package/src/workspace/identity-gate-rule.ts +109 -0
  49. package/src/workspace/identity.ts +8 -18
  50. package/src/workspace/principal-classes.ts +4 -29
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The `identity.gates` rule as data and one pure function (#3862).
3
+ *
4
+ * A gate the declaration names under `identity.gates` passes only on an
5
+ * approval sealed by a signer at base, from the class the gate names when it
6
+ * names one (#3163, ws-080). chant's `gateAdmissionFrom` (`./identity.ts`)
7
+ * builds a {@link GateAdmissionRule} from what `scopeSource` reads and judges
8
+ * every approval with {@link admitApprover}. `@intentius/chant/gate-decision`
9
+ * builds the same rule from a bare clone at a base commit
10
+ * (`identityGateAdmissionAt`) and judges with the same function, so a tool
11
+ * counts approvals as chant's wave gate does.
12
+ *
13
+ * The module imports node:crypto through `./trust/gate-seal.ts` and nothing
14
+ * else: no zod, no ajv, no package loading, so it bundles small.
15
+ */
16
+ import { type SealedGateApproval } from "./trust/gate-seal.js";
17
+ import type { Signer } from "./trust/trust-files.js";
18
+ /** The core classes, in the order a principal's roles are tried; `human` holds no role and is the rest. */
19
+ export declare const CORE_PRINCIPAL_CLASSES: readonly ["human", "agent", "runner", "service"];
20
+ export type CorePrincipalClass = (typeof CORE_PRINCIPAL_CLASSES)[number];
21
+ export interface PrincipalClass {
22
+ name: string;
23
+ /** One line saying who is in the class. */
24
+ description: string;
25
+ /** The role whose grant at base puts a principal in the class, or null for `human`, the rest. */
26
+ role: string | null;
27
+ /** `core`, or the pin (package name or path) that supplies the class. */
28
+ source: string;
29
+ }
30
+ export declare const CORE_CLASSES: readonly PrincipalClass[];
31
+ /** A class as the rule reads it: its name, and the role whose grant puts a principal in it (null for `human`). */
32
+ export type ClassGrant = Pick<PrincipalClass, "name" | "role">;
33
+ /** What an `identity.gates` gate is judged by, all read at base. */
34
+ export interface GateAdmissionRule {
35
+ gate: string;
36
+ /** The class the approver must be in, or null for any signer. */
37
+ class: string | null;
38
+ /** The signers file at base: its path, whether it exists, and its signers. */
39
+ signers: {
40
+ signersPath: string;
41
+ active: boolean;
42
+ signers: readonly Signer[];
43
+ };
44
+ /** The role grants at base (`.chant/trust.json` `roles`). */
45
+ roles: Readonly<Record<string, readonly string[]>>;
46
+ /** Every class the rule knows, core first, then domain classes in pin order. */
47
+ classes: readonly ClassGrant[];
48
+ /**
49
+ * Why a class the rule does not know is unknown, for the refusal. Default
50
+ * "no pinned package supplies", which is what chant says once it has read
51
+ * every pin.
52
+ */
53
+ unknownClass?: string;
54
+ }
55
+ /** Every class `principal`'s role grants put it in, in `classes` order: what `classesOf` in `./principal-classes.ts` answers. */
56
+ export declare function grantedClasses(classes: readonly ClassGrant[], roles: Readonly<Record<string, readonly string[]>>, principal: string | null): string[];
57
+ /**
58
+ * Why `a` does not count toward the gate, or null when it does. An approval
59
+ * counts only when its seal verifies for its approver against the signers at
60
+ * base and, when the rule names a class, the role grants at base put the
61
+ * approver in it (`human`: in no other class). A class the rule does not
62
+ * know lets nothing through.
63
+ */
64
+ export declare function admitApprover(rule: GateAdmissionRule, a: SealedGateApproval): string | null;
65
+ //# sourceMappingURL=identity-gate-rule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity-gate-rule.d.ts","sourceRoot":"","sources":["../../src/workspace/identity-gate-rule.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAkF,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC5I,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAElD,2GAA2G;AAC3G,eAAO,MAAM,sBAAsB,YAAI,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,CAAU,CAAC;AACvF,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzE,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,iGAAiG;IACjG,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,YAAY,EAAE,SAAS,cAAc,EAKjD,CAAC;AAEF,kHAAkH;AAClH,MAAM,MAAM,UAAU,GAAG,IAAI,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;AAE/D,oEAAoE;AACpE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,iEAAiE;IACjE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,8EAA8E;IAC9E,OAAO,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,CAAC;IAC9E,6DAA6D;IAC7D,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;IACnD,gFAAgF;IAChF,OAAO,EAAE,SAAS,UAAU,EAAE,CAAC;IAC/B;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAID,iIAAiI;AACjI,wBAAgB,cAAc,CAAC,OAAO,EAAE,SAAS,UAAU,EAAE,EAAE,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,EAAE,CAIrJ;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,iBAAiB,EAAE,CAAC,EAAE,kBAAkB,GAAG,MAAM,GAAG,IAAI,CAa3F"}
@@ -27,7 +27,7 @@
27
27
  import type { Declaration, SignedGate } from "./declaration.js";
28
28
  import { type ClassRegistry } from "./principal-classes.js";
29
29
  import type { TrustPolicy } from "./trust/policy.js";
30
- import { type SealedGateApproval } from "./trust/seal.js";
30
+ import type { SealedGateApproval } from "./trust/gate-seal.js";
31
31
  /** The forges a forge identity can name. */
32
32
  export declare const FORGES: readonly ["github", "gitlab", "forgejo", "gitea"];
33
33
  export type Forge = (typeof FORGES)[number];
@@ -98,7 +98,9 @@ export interface GateRuleSource extends IdentitySource {
98
98
  * approval counts only when its seal verifies for its approver against the
99
99
  * signers at base and, when the rule names a class, the approver's role
100
100
  * grants at base put it in that class. A class no pinned package supplies
101
- * lets nothing through.
101
+ * lets nothing through. The judge is {@link admitApprover}, which
102
+ * `@intentius/chant/gate-decision` also judges with (#3862). The policy is
103
+ * the one at base, never a returned work's copy.
102
104
  */
103
105
  export declare function gateAdmissionFrom(source: GateRuleSource, gate: string): GateAdmission | null;
104
106
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../../src/workspace/identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,EAAgD,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGvG,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAiB,KAAK,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAGtE,4CAA4C;AAC5C,eAAO,MAAM,MAAM,YAAI,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAU,CAAC;AACxE,MAAM,MAAM,KAAK,GAAG,CAAC,OAAO,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AAK5C,qCAAqC;AACrC,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,KAAK,CAAC;IACb,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAC;CACf;AAKD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAO1E;AAED,8HAA8H;AAC9H,wBAAgB,cAAc,CAAC,EAAE,EAAE,aAAa,GAAG,MAAM,CAExD;AAED,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhE,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,YAAY,CAAC;IACnB,+EAA+E;IAC/E,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;CAC7B;AAID;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,GAAE,WAAW,GAAG,IAAW,GAAG,UAAU,CAQnH;AAED,uDAAuD;AACvD,eAAO,MAAM,cAAc,qCAAsE,CAAC;AAClG,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE3D,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAA4B;IACvD,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED,8GAA8G;AAC9G,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,WAAW,GAAG,IAAI,CAAC;IAChC,MAAM,EAAE,WAAW,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,cAAc,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAO,GAAG,IAAI,CAS7I;AAED,+EAA+E;AAC/E,MAAM,MAAM,gBAAgB,GAAG,kBAAkB,CAAC;AAElD,yFAAyF;AACzF,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,UAAU,CAAC;IACxB,0EAA0E;IAC1E,OAAO,CAAC,QAAQ,EAAE,gBAAgB,GAAG,MAAM,GAAG,IAAI,CAAC;CACpD;AAED,sHAAsH;AACtH,MAAM,WAAW,cAAe,SAAQ,cAAc;IACpD,OAAO,EAAE,aAAa,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAmB5F;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAE5F"}
1
+ {"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../../src/workspace/identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAE7D,OAAO,EAA4C,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGnG,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAG5D,4CAA4C;AAC5C,eAAO,MAAM,MAAM,YAAI,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAU,CAAC;AACxE,MAAM,MAAM,KAAK,GAAG,CAAC,OAAO,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AAK5C,qCAAqC;AACrC,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,KAAK,CAAC;IACb,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAC;CACf;AAKD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAO1E;AAED,8HAA8H;AAC9H,wBAAgB,cAAc,CAAC,EAAE,EAAE,aAAa,GAAG,MAAM,CAExD;AAED,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhE,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,YAAY,CAAC;IACnB,+EAA+E;IAC/E,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;CAC7B;AAID;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,GAAE,WAAW,GAAG,IAAW,GAAG,UAAU,CAQnH;AAED,uDAAuD;AACvD,eAAO,MAAM,cAAc,qCAAsE,CAAC;AAClG,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE3D,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAA4B;IACvD,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED,8GAA8G;AAC9G,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,WAAW,GAAG,IAAI,CAAC;IAChC,MAAM,EAAE,WAAW,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,cAAc,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAO,GAAG,IAAI,CAS7I;AAED,+EAA+E;AAC/E,MAAM,MAAM,gBAAgB,GAAG,kBAAkB,CAAC;AAElD,yFAAyF;AACzF,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,UAAU,CAAC;IACxB,0EAA0E;IAC1E,OAAO,CAAC,QAAQ,EAAE,gBAAgB,GAAG,MAAM,GAAG,IAAI,CAAC;CACpD;AAED,sHAAsH;AACtH,MAAM,WAAW,cAAe,SAAQ,cAAc;IACpD,OAAO,EAAE,aAAa,CAAC;CACxB;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAM5F;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAE5F"}
@@ -24,26 +24,15 @@
24
24
  import { type KindLoadProblem, type PinLike } from "./kinds.js";
25
25
  import type { TrustPolicy } from "./trust/policy.js";
26
26
  import { type WorkspaceTree } from "./tree.js";
27
+ import { type PrincipalClass } from "./identity-gate-rule.js";
27
28
  export declare const PRINCIPALS_SCHEMA_ID: string;
28
29
  /** The subpath a package publishes its principal classes at. */
29
30
  export declare const PRINCIPALS_SUBPATH = "./workspace-principals";
30
- /** The core classes, in the order a principal's roles are tried; `human` holds no role and is the rest. */
31
- export declare const CORE_PRINCIPAL_CLASSES: readonly ["human", "agent", "runner", "service"];
32
- export type CorePrincipalClass = (typeof CORE_PRINCIPAL_CLASSES)[number];
31
+ export { CORE_CLASSES, CORE_PRINCIPAL_CLASSES, type CorePrincipalClass, type PrincipalClass } from "./identity-gate-rule.js";
33
32
  /** A class name: a core class, or a domain class a pinned package supplies. */
34
33
  export type PrincipalClassName = string;
35
34
  /** The grammar of a class name, as `workspace-principals.schema.json` and the declaration's writeScope keys say it. */
36
35
  export declare const CLASS_NAME: RegExp;
37
- export interface PrincipalClass {
38
- name: string;
39
- /** One line saying who is in the class. */
40
- description: string;
41
- /** The role whose grant at base puts a principal in the class, or null for `human`, the rest. */
42
- role: string | null;
43
- /** `core`, or the pin (package name or path) that supplies the class. */
44
- source: string;
45
- }
46
- export declare const CORE_CLASSES: readonly PrincipalClass[];
47
36
  export interface ClassRegistry {
48
37
  get(name: string): PrincipalClass | undefined;
49
38
  /** Every class name, core first, for "unknown class" messages. */
@@ -63,7 +52,7 @@ export declare function roleGrants(policy: TrustPolicy): Record<string, string[]
63
52
  /**
64
53
  * Every class `principal`'s role grants at base put it in, in registry order
65
54
  * (core first). Empty for a principal in no class but `human`, and for null.
66
- * Names compare as {@link normalisePrincipal} folds them.
55
+ * Names compare as `normalisePrincipal` (`./records.ts`) folds them.
67
56
  */
68
57
  export declare function classesOf(classes: ClassRegistry, policy: TrustPolicy, principal: string | null): string[];
69
58
  /** The class write scope judges `principal` as: the first class its grants put it in, or `human`. */
@@ -1 +1 @@
1
- {"version":3,"file":"principal-classes.d.ts","sourceRoot":"","sources":["../../src/workspace/principal-classes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"principal-classes.d.ts","sourceRoot":"","sources":["../../src/workspace/principal-classes.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.124.11",
3
+ "version": "0.124.13",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -32,6 +32,7 @@ import { applyConfiguredPreset, applyConfiguredSeverity, resolvePresetIds } from
32
32
  import { applyInlineSuppressions } from "../../lint/suppressions";
33
33
  import { getProvenance } from "../../provenance";
34
34
  import { runLexiconPostSynthChecks } from "./build";
35
+ import { armSandboxPolicyExecution, runProjectPolicies } from "../../lint/policy-sandbox";
35
36
 
36
37
  // Import config loader
37
38
  import { loadConfig, resolveRulesForFile, resolveConfiguredSeverity, findProjectRoot } from "../../lint/config";
@@ -665,9 +666,15 @@ async function runOpCheckDiagnostics(
665
666
  * builds `infraPath`, the target the caller asked to lint, never the project
666
667
  * root above it, so `chant lint <dir>` costs a build of `<dir>` and nothing
667
668
  * more (the same scoping {@link runOpCheckDiagnostics} documents). It is
668
- * skipped outright when no loaded lexicon ships post-synth checks, which keeps
669
- * a project with no lexicon, and every core-only fixture, from building at
670
- * all.
669
+ * skipped outright when no loaded lexicon ships post-synth checks and the
670
+ * project declares no `lint.policies`, which keeps a project with no lexicon,
671
+ * and every core-only fixture, from building at all.
672
+ *
673
+ * The project's `lint.policies` run over the same build after the lexicons'
674
+ * checks (#3870), with every lexicon's output and the env, as `chant build`
675
+ * runs them, and sandboxed when the build is. `lint.rules` applies to their
676
+ * ids; a policy that does not load is reported under
677
+ * {@link BUILD_FAILURE_RULE_ID}.
671
678
  *
672
679
  * A target that does not build reports each build error under
673
680
  * {@link BUILD_FAILURE_RULE_ID} and skips the checks. Findings go through
@@ -686,10 +693,10 @@ async function runLexiconPostSynthDiagnostics(
686
693
  const diagnostics: LintDiagnostic[] = [];
687
694
  const suppressed: Array<LintDiagnostic & { reason?: string }> = [];
688
695
  const withChecks = plugins.filter((plugin) => (plugin.postSynthChecks?.() ?? []).length > 0);
689
- if (withChecks.length === 0) return { diagnostics, suppressed };
690
-
691
696
  const projectRoot = findProjectRoot(infraPath);
692
697
  const lintConfig = loadConfig(projectRoot);
698
+ if (withChecks.length === 0 && (lintConfig.policies ?? []).length === 0) return { diagnostics, suppressed };
699
+
693
700
  // Where a finding that names no declared entity is reported: the config the
694
701
  // build loads once it is known (past a lint-only fragment), else the nearest.
695
702
  let fallbackFile = findProjectConfig(projectRoot).configPath ?? projectRoot;
@@ -707,6 +714,7 @@ async function runLexiconPostSynthDiagnostics(
707
714
  let telemetry: Awaited<ReturnType<typeof resolveTelemetryAttribution>>;
708
715
  let configDir = infraPath;
709
716
  let loadedConfig: Record<string, unknown> | undefined;
717
+ let policies: readonly string[] = [];
710
718
  try {
711
719
  const loaded = await loadChantConfigUpward(infraPath);
712
720
  const config = loaded.config;
@@ -720,6 +728,10 @@ async function runLexiconPostSynthDiagnostics(
720
728
  // fragment with no `buildParams` (a `src/chant.config.json`).
721
729
  const params = buildParams ?? resolveBuildParams(config.buildParams, { env: process.env }).provenance;
722
730
  env = resolveOwnershipEnv(config, params);
731
+ policies = config.lint?.policies ?? [];
732
+ // #3870: a sandboxed build sandboxes the policies too, as `chant build` arms it.
733
+ const modes = resolveBuildModes(config, { sandbox });
734
+ if (modes.sandbox && policies.length > 0) armSandboxPolicyExecution();
723
735
  telemetry = await resolveTelemetryAttribution(configDir, config as unknown as Record<string, unknown>, env);
724
736
  // Every lexicon the lint loaded, as `chant build` serializes with every
725
737
  // lexicon it loaded: a check may read another lexicon's entities, and the
@@ -732,7 +744,7 @@ async function runLexiconPostSynthDiagnostics(
732
744
  config,
733
745
  configDir,
734
746
  plugins,
735
- modes: resolveBuildModes(config, { sandbox }),
747
+ modes,
736
748
  ownership: resolveOwnershipMarker(config, params),
737
749
  buildParams: params,
738
750
  telemetry,
@@ -781,6 +793,21 @@ async function runLexiconPostSynthDiagnostics(
781
793
  }
782
794
  }
783
795
 
796
+ // #3870: the project's `lint.policies`, over the same build, as `chant
797
+ // build` runs them: every lexicon's output at once, with the env, and
798
+ // `lint.rules` applied to their ids.
799
+ if (policies.length > 0) {
800
+ let raw: Awaited<ReturnType<typeof runProjectPolicies>>;
801
+ try {
802
+ raw = await runProjectPolicies({ policies, configDir, buildResult: result, env });
803
+ } catch (err) {
804
+ diagnostics.push(buildFailure(err instanceof Error ? err.message : String(err)));
805
+ return { diagnostics, suppressed };
806
+ }
807
+ const configured = applyConfiguredSeverity(raw, lintConfig.rules);
808
+ for (const d of configured.diagnostics) diagnostics.push(toLint(d));
809
+ }
810
+
784
811
  return { diagnostics, suppressed };
785
812
  }
786
813
 
@@ -5,8 +5,8 @@
5
5
  * toolchain, and checked sealed approvals with its own copy of chant's seal
6
6
  * rule because `@intentius/chant/op/gate` pulls in zod and the git ledger
7
7
  * code. `@intentius/chant/gate-decision` holds the decision for a plan digest
8
- * under `ledger`, `pr-review` and `sealed`, and the seal check over
9
- * node:crypto. This bundles it the way terragucci would and reads esbuild's
8
+ * under `ledger`, `pr-review` and `sealed`, the seal check over
9
+ * node:crypto, and the `identity.gates` rule with its class check (#3862). This bundles it the way terragucci would and reads esbuild's
10
10
  * own list of what went in.
11
11
  */
12
12
 
@@ -46,6 +46,7 @@ describe("the gate-decision bundle", () => {
46
46
  "packages/core/src/notify.ts",
47
47
  "packages/core/src/op/gate-point.ts",
48
48
  "packages/core/src/op/op-waves.ts",
49
+ "packages/core/src/workspace/identity-gate-rule.ts",
49
50
  "packages/core/src/workspace/jsonc.ts",
50
51
  "packages/core/src/workspace/trust/gate-seal.ts",
51
52
  "packages/core/src/workspace/trust/trust-files.ts",
@@ -0,0 +1,176 @@
1
+ /**
2
+ * #3862: `identityGateAdmissionAt` judges an `identity.gates` approval from a
3
+ * bare clone at a base commit as chant's `gateAdmission(cwd, gate, base)`
4
+ * does: the seal against the signers at base, and the class against the role
5
+ * grants at base. Each fixture is judged both ways and the answers compared,
6
+ * reasons included.
7
+ */
8
+
9
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
10
+ import {
11
+ admitApprover,
12
+ CORE_CLASSES,
13
+ decideGateApproval,
14
+ GATE_SEAL_NAMESPACE,
15
+ gateSealPayload,
16
+ identityGateAdmissionAt,
17
+ type GateResolutionRecord,
18
+ } from "./gate-decision";
19
+ import { gateAdmission } from "./workspace/identity";
20
+ import { hasSshKeygen, TestRepo, type Key } from "./workspace/trust/test-repo";
21
+ import { evaluateGate, memoryGateLedgerPort } from "./op/gate";
22
+
23
+ const D1 = "sha256:" + "a".repeat(64);
24
+ const GATES = { ship: {}, deploy: { class: "human" }, release: { class: "runner" }, ops: { class: "operator" } };
25
+ const NAMES = [...Object.keys(GATES), "undeclared"];
26
+
27
+ describe.skipIf(!hasSshKeygen)("identity.gates read at base, as chant reads it", () => {
28
+ const repos: TestRepo[] = [];
29
+ afterEach(() => {
30
+ while (repos.length) repos.pop()!.cleanup();
31
+ });
32
+ // Keys and seals are made once, by ssh-keygen, and shared by every fixture.
33
+ let signer: TestRepo;
34
+ let k: Record<"alice" | "bob" | "carol" | "mallory", Key>;
35
+ const sigs = new Map<string, string>();
36
+ beforeAll(() => {
37
+ signer = new TestRepo("3862-keys");
38
+ k = { alice: signer.key("alice"), bob: signer.key("bob"), carol: signer.key("carol"), mallory: signer.key("mallory") };
39
+ });
40
+ afterAll(() => signer.cleanup());
41
+ const sign = (key: Key, payload: Buffer): string => {
42
+ const id = `${key.name}\n${payload.toString("utf-8")}`;
43
+ if (!sigs.has(id)) sigs.set(id, signer.sshSign(key, payload, GATE_SEAL_NAMESPACE));
44
+ return sigs.get(id)!;
45
+ };
46
+
47
+ function setup(files: (k: Record<string, Key>) => Record<string, string>, declaration: Record<string, unknown> = {}) {
48
+ const r = new TestRepo("3862-identity");
49
+ repos.push(r);
50
+ r.write("chant.workspace.json", JSON.stringify({ name: "acme", schema: 1, members: [], identity: { gates: GATES }, ...declaration }));
51
+ for (const [path, text] of Object.entries(files(k))) r.write(path, text);
52
+ const base = r.commit("base");
53
+ // The change being applied lists mallory and makes alice a runner; neither counts at base.
54
+ r.write(".chant/allowed_signers", `alice ${k.alice.pub}\nbob ${k.bob.pub}\nmallory ${k.mallory.pub}\n`);
55
+ r.write(".chant/trust.json", JSON.stringify({ schema: 1, roles: { runner: ["alice", "bob"] } }));
56
+ r.commit("widens the policy");
57
+ const approval = (gate: string, resolvedBy: string, extra: Partial<GateResolutionRecord> = {}): GateResolutionRecord => ({
58
+ version: 1, op: "web", gate, resolvedBy, timestamp: "2026-10-11T11:00:00.000Z", planDigest: D1, ...extra,
59
+ });
60
+ const seal = (a: GateResolutionRecord, key: Key, signer = a.resolvedBy): GateResolutionRecord => ({
61
+ ...a,
62
+ seal: { signer, key: "k", signature: sign(key, gateSealPayload(a)) },
63
+ });
64
+ const made = new Map<string, GateResolutionRecord[]>();
65
+ const approvals = (gate: string): GateResolutionRecord[] => made.get(gate) ?? (made.set(gate, approvalsOf(gate)), made.get(gate)!);
66
+ const approvalsOf = (gate: string): GateResolutionRecord[] => [
67
+ approval(gate, "alice"),
68
+ seal(approval(gate, "alice"), k.alice),
69
+ seal(approval(gate, "BOB "), k.bob, "bob"),
70
+ seal(approval(gate, "mallory"), k.mallory),
71
+ seal(approval(gate, "carol"), k.carol),
72
+ seal(approval(gate, "alice"), k.bob),
73
+ seal(approval(gate, "alice"), k.alice, "bob"),
74
+ { ...approval(gate, "alice"), seal: { signer: "alice" } as unknown as GateResolutionRecord["seal"] },
75
+ { ...seal(approval(gate, "alice"), k.alice), planDigest: "sha256:" + "b".repeat(64) },
76
+ ];
77
+ return { r, base, approvals };
78
+ }
79
+
80
+ /** Every approval of every gate, judged by both readers: the pairs, and the reasons gate-decision gave. */
81
+ function judgeBoth(r: TestRepo, base: string, approvals: (gate: string) => GateResolutionRecord[]) {
82
+ const ours: Record<string, (string | null)[] | null> = {};
83
+ for (const gate of NAMES) {
84
+ const here = identityGateAdmissionAt(r.dir, base, gate);
85
+ const chant = gateAdmission(r.dir, gate, base);
86
+ expect(here === null, gate).toBe(chant === null);
87
+ if (here === null || chant === null) {
88
+ ours[gate] = null;
89
+ continue;
90
+ }
91
+ expect(here.requirement).toEqual({ gate, class: chant.requirement.class });
92
+ const reasons = approvals(gate).map((a) => here.refuses(a));
93
+ expect(reasons, gate).toEqual(approvals(gate).map((a) => chant.refuses(a)));
94
+ ours[gate] = reasons;
95
+ }
96
+ return ours;
97
+ }
98
+
99
+ test("signers, role grants and classes at base, approval by approval", () => {
100
+ const { r, base, approvals } = setup((k) => ({
101
+ ".chant/allowed_signers": `alice ${k.alice.pub}\nbob ${k.bob.pub}\ncarol namespaces="git" ${k.carol.pub}\n`,
102
+ ".chant/trust.json": JSON.stringify({ schema: 1, roles: { runner: ["Bob"] } }),
103
+ }));
104
+ const ours = judgeBoth(r, base, approvals);
105
+ expect(ours.undeclared).toBeNull();
106
+ const passed = (gate: string) => ours[gate]!.flatMap((reason, i) => (reason === null ? [i] : []));
107
+ // Any signer: alice and bob, each with their own key. Not mallory (listed after base), not carol (no chant-gate namespace).
108
+ expect(passed("ship")).toEqual([1, 2]);
109
+ // human: alice; bob holds the runner role at base.
110
+ expect(passed("deploy")).toEqual([1]);
111
+ expect(ours.deploy![2]).toMatch(/^BOB is not in the class human that gate deploy needs/);
112
+ // runner: bob, by the grant at base; alice's grant comes after base.
113
+ expect(passed("release")).toEqual([2]);
114
+ expect(ours.release![1]).toBe("alice is not in the class runner that gate release needs: no role grant at base puts them there");
115
+ // operator: no pinned package supplies it.
116
+ expect(passed("ops")).toEqual([]);
117
+ expect(ours.ops![1]).toMatch(/names the class operator, which no pinned package supplies, .*known classes: human, agent, runner, service/);
118
+ expect(ours.ship![0]).toBe("the approval by alice is not signed; gate ship needs chant approve --sign");
119
+ expect(ours.ship![3]).toBe("mallory has no key in .chant/allowed_signers at base, so the seal can't count");
120
+ expect(ours.ship![4]).toMatch(/^the seal does not verify for carol against \.chant\/allowed_signers at base/);
121
+ });
122
+
123
+ test("a signers path trust.json names", () => {
124
+ const named = setup((k) => ({
125
+ ".chant/trust.json": JSON.stringify({ schema: 1, signers: "ops/signers", roles: { runner: ["alice"] } }),
126
+ "ops/signers": `alice ${k.alice.pub}\n`,
127
+ }));
128
+ expect(judgeBoth(named.r, named.base, named.approvals).release![1]).toBeNull();
129
+ });
130
+
131
+ test("no signers file at base, and a trust.json chant refuses", () => {
132
+ const none = setup(() => ({ "README.md": "x\n" }));
133
+ expect(judgeBoth(none.r, none.base, none.approvals).ship![1]).toBe("gate ship needs a signed approval, and there is no signers file (.chant/allowed_signers) at base to check one against");
134
+
135
+ const invalid = setup((k) => ({
136
+ ".chant/trust.json": JSON.stringify({ schema: 1, roles: { Admin: ["alice"] } }),
137
+ ".chant/allowed_signers": `alice ${k.alice.pub}\n`,
138
+ }));
139
+ expect(judgeBoth(invalid.r, invalid.base, invalid.approvals).ship![1]).toMatch(/no signers file/);
140
+ });
141
+
142
+ test("a domain class the caller read from a pin, and a pin this reader does not load", () => {
143
+ const { r, base, approvals } = setup(
144
+ (k) => ({ ".chant/allowed_signers": `alice ${k.alice.pub}\n`, ".chant/trust.json": JSON.stringify({ schema: 1, roles: { operator: ["alice"] } }) }),
145
+ { pins: [{ package: "@acme/ops-classes", version: "1.0.0" }] },
146
+ );
147
+ const unread = identityGateAdmissionAt(r.dir, base, "ops")!;
148
+ expect(unread.refuses(approvals("ops")[1])).toMatch(/is not a core class, and this reader does not load the classes the pinned packages in chant\.workspace\.json at base supply/);
149
+ const read = identityGateAdmissionAt(r.dir, base, "ops", { classes: [{ name: "operator", role: "operator" }] })!;
150
+ expect(read.refuses(approvals("ops")[1])).toBeNull();
151
+ expect(read.refuses(approvals("ops")[0])).toMatch(/not signed/);
152
+ });
153
+
154
+ test("passed to decideGateApproval as refuses, it counts what chant's wave gate counts", async () => {
155
+ const { r, base, approvals } = setup((k) => ({
156
+ ".chant/allowed_signers": `alice ${k.alice.pub}\nbob ${k.bob.pub}\n`,
157
+ ".chant/trust.json": JSON.stringify({ schema: 1, roles: { runner: ["bob"] } }),
158
+ }));
159
+ const here = identityGateAdmissionAt(r.dir, base, "release")!;
160
+ const chant = gateAdmission(r.dir, "release", base)!;
161
+ for (const resolutions of [approvals("release").slice(0, 2), approvals("release")]) {
162
+ const decided = decideGateApproval({ ledger: { pending: [], resolutions }, gate: "release", planDigest: D1, refuses: here.refuses, now: "2026-10-11T12:00:00.000Z" });
163
+ const port = { ...memoryGateLedgerPort({ pending: [], resolutions }), approvalRule: async () => chant };
164
+ const check = await evaluateGate(port, { op: "web", gate: "release", planDigest: D1, now: "2026-10-11T12:00:00.000Z" });
165
+ expect(decided.status === "approved" && decided.via === "ledger" ? decided.resolution : null).toEqual(check.satisfied ? check.resolution : null);
166
+ }
167
+ });
168
+ });
169
+
170
+ describe("admitApprover", () => {
171
+ test("is the rule gateAdmission judges by: a class it does not know refuses before the seal is read", () => {
172
+ const rule = { gate: "ship", class: "operator", signers: { signersPath: ".chant/allowed_signers", active: false, signers: [] }, roles: {}, classes: CORE_CLASSES };
173
+ expect(admitApprover(rule, { op: "web", gate: "ship", resolvedBy: "alice", timestamp: "t" })).toMatch(/no pinned package supplies/);
174
+ expect(admitApprover({ ...rule, class: null }, { op: "web", gate: "ship", resolvedBy: "alice", timestamp: "t" })).toMatch(/no signers file/);
175
+ });
176
+ });
@@ -16,6 +16,12 @@
16
16
  * approving review, when its head planned this digest
17
17
  * ({@link reviewApproval}).
18
18
  *
19
+ * A gate the declaration at base names under `identity.gates` counts only a
20
+ * sealed approval from its class: {@link identityGateAdmissionAt} reads that
21
+ * rule from a bare clone at base and judges by `admitApprover`, the function
22
+ * chant's `gateAdmission` judges by (#3862); pass its `refuses` to
23
+ * {@link decideGateApproval}.
24
+ *
19
25
  * With `spend` (#3784), an approval a wave already applied under is spent and
20
26
  * counts for nothing, as terragucci's wave gate has it: the caller hands in
21
27
  * the lines of `_gates/<op>/applied.jsonl` ({@link parseAppliedGateLines}).
@@ -36,7 +42,7 @@ import {
36
42
  } from "./lifecycle/gate-ledger-select";
37
43
  import { samePlanDigest } from "./lifecycle/plan-digest";
38
44
  import { isApprovalSpent, type AppliedGateRecord } from "./lifecycle/gate-applied";
39
- import { gateSealRefusal, type GateSigners } from "./workspace/trust/gate-seal";
45
+ import { gateSealRefusal, type GateSigners, type SealedGateApproval } from "./workspace/trust/gate-seal";
40
46
  import {
41
47
  DEFAULT_SIGNERS_PATH,
42
48
  TRUST_CONFIG_PATH,
@@ -44,6 +50,7 @@ import {
44
50
  trustConfigProblems,
45
51
  } from "./workspace/trust/trust-files";
46
52
  import { parseJsonText } from "./workspace/jsonc";
53
+ import { CORE_CLASSES, admitApprover, type ClassGrant, type GateAdmissionRule } from "./workspace/identity-gate-rule";
47
54
  import type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
48
55
  import type { MergedReview } from "./op/gate-review";
49
56
  import type { GateApprovalSource } from "./op/op-waves";
@@ -75,6 +82,14 @@ export {
75
82
  type SignerSet,
76
83
  } from "./workspace/trust/trust-files";
77
84
  export type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
85
+ export {
86
+ CORE_CLASSES,
87
+ admitApprover,
88
+ grantedClasses,
89
+ type ClassGrant,
90
+ type GateAdmissionRule,
91
+ type PrincipalClass,
92
+ } from "./workspace/identity-gate-rule";
78
93
  export {
79
94
  appliedGateApplies,
80
95
  appliedGatePath,
@@ -117,17 +132,12 @@ export function baseCommit(repo: string): string {
117
132
  return parent;
118
133
  }
119
134
 
120
- /**
121
- * The signers at `base`: the file `.chant/trust.json` names there, else
122
- * `.chant/allowed_signers`, read as chant's trust policy reads it. A
123
- * trust.json that is not valid (`trustConfigProblems`) turns attestation
124
- * off, as it does in chant, so a sealed gate passes nothing. `base` is a
125
- * commit the repository holds, such as {@link baseCommit}'s answer.
126
- */
127
- export function gateSignersAt(repo: string, base: string): GateSigners {
128
- const off = (problems: string[]): GateSigners => ({ base, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], problems });
135
+ /** The signers and role grants at `base`, as chant's trust policy reads them. */
136
+ function trustAt(repo: string, base: string): { signers: GateSigners; roles: Record<string, string[]> } {
137
+ const off = (problems: string[]) => ({ signers: { base, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], problems }, roles: {} });
129
138
  const configText = showAt(repo, base, TRUST_CONFIG_PATH);
130
139
  let signersPath = DEFAULT_SIGNERS_PATH;
140
+ let roles: Record<string, string[]> = {};
131
141
  if (configText !== undefined) {
132
142
  let raw: unknown;
133
143
  try {
@@ -138,9 +148,21 @@ export function gateSignersAt(repo: string, base: string): GateSigners {
138
148
  const problems = trustConfigProblems(raw);
139
149
  if (problems.length > 0) return off([`${TRUST_CONFIG_PATH} is invalid: ${problems.join("; ")}`]);
140
150
  signersPath = (raw as { signers?: string }).signers ?? DEFAULT_SIGNERS_PATH;
151
+ roles = (raw as { roles?: Record<string, string[]> }).roles ?? {};
141
152
  }
142
153
  const text = showAt(repo, base, signersPath);
143
- return { base, signersPath, active: text !== undefined, signers: text === undefined ? [] : parseAllowedSigners(text).signers, problems: [] };
154
+ return { signers: { base, signersPath, active: text !== undefined, signers: text === undefined ? [] : parseAllowedSigners(text).signers, problems: [] }, roles };
155
+ }
156
+
157
+ /**
158
+ * The signers at `base`: the file `.chant/trust.json` names there, else
159
+ * `.chant/allowed_signers`, read as chant's trust policy reads it. A
160
+ * trust.json that is not valid (`trustConfigProblems`) turns attestation
161
+ * off, as it does in chant, so a sealed gate passes nothing. `base` is a
162
+ * commit the repository holds, such as {@link baseCommit}'s answer.
163
+ */
164
+ export function gateSignersAt(repo: string, base: string): GateSigners {
165
+ return trustAt(repo, base).signers;
144
166
  }
145
167
 
146
168
  /** A gate the declaration at base names under `identity.gates`, and the principal class its signer must be in, if any. */
@@ -149,23 +171,79 @@ export interface IdentityGate {
149
171
  class: string | null;
150
172
  }
151
173
 
152
- /**
153
- * The gates `chant.workspace.json` (or `.jsonc`) at `base` names under
154
- * `identity.gates`: each counts only a sealed approval, from its class when
155
- * it names one. chant checks the class against the role grants at base; this
156
- * reads the names. Empty when there is no declaration at base. Throws when
157
- * the declaration there cannot be parsed, since the gate cannot be decided.
158
- */
159
- export function identityGatesAt(repo: string, base: string): IdentityGate[] {
174
+ /** The declaration at `base`, parsed, or null when there is none. Throws when it cannot be parsed. */
175
+ function declarationAt(repo: string, base: string): { file: string; value: unknown } | null {
160
176
  for (const file of ["chant.workspace.json", "chant.workspace.jsonc"]) {
161
177
  const text = showAt(repo, base, file);
162
178
  if (text === undefined) continue;
163
179
  const parsed = parseJsonText(text, { jsonc: file.endsWith(".jsonc") });
164
180
  if (!parsed.ok) throw new Error(`${file} at base is not valid JSON (${parsed.message}), so the gate cannot be decided`);
165
- const gates = (parsed.value as { identity?: { gates?: Record<string, { class?: unknown } | null> } } | null)?.identity?.gates ?? {};
166
- return Object.entries(gates).map(([gate, entry]) => ({ gate, class: typeof entry?.class === "string" ? entry.class : null }));
181
+ return { file, value: parsed.value };
167
182
  }
168
- return [];
183
+ return null;
184
+ }
185
+
186
+ function identityGatesOf(declaration: unknown): IdentityGate[] {
187
+ const gates = (declaration as { identity?: { gates?: Record<string, { class?: unknown } | null> } } | null)?.identity?.gates ?? {};
188
+ return Object.entries(gates).map(([gate, entry]) => ({ gate, class: typeof entry?.class === "string" ? entry.class : null }));
189
+ }
190
+
191
+ /**
192
+ * The gates `chant.workspace.json` (or `.jsonc`) at `base` names under
193
+ * `identity.gates`: each counts only a sealed approval, from its class when
194
+ * it names one. {@link identityGateAdmissionAt} judges an approval by one.
195
+ * Empty when there is no declaration at base. Throws when the declaration
196
+ * there cannot be parsed, since the gate cannot be decided.
197
+ */
198
+ export function identityGatesAt(repo: string, base: string): IdentityGate[] {
199
+ const declaration = declarationAt(repo, base);
200
+ return declaration === null ? [] : identityGatesOf(declaration.value);
201
+ }
202
+
203
+ /** An `identity.gates` gate at base, and the judge for its approvals. */
204
+ export interface IdentityGateAdmission {
205
+ requirement: IdentityGate;
206
+ /** Why `approval` does not count toward the gate, or null when it does. Pass it to {@link decideGateApproval} as `refuses`. */
207
+ refuses(approval: SealedGateApproval): string | null;
208
+ }
209
+
210
+ /**
211
+ * The `identity.gates` rule for `gate` at `base`, or null when the
212
+ * declaration there names none: what chant's `gateAdmission(cwd, gate, base)`
213
+ * answers, read from a bare clone. An approval counts when its seal verifies
214
+ * for its approver against the signers at base and, when the gate names a
215
+ * class, the role grants in `.chant/trust.json` at base put the approver in
216
+ * it. Both judge with the same function ({@link admitApprover}).
217
+ *
218
+ * The classes are the four core ones, then `classes`, the domain classes the
219
+ * caller read from the pinned packages (`./workspace-principals`), in pin
220
+ * order. This reader loads no package. A gate whose class is in neither
221
+ * refuses every approval, and when the declaration at base pins anything the
222
+ * reason says the pins were not read, since one of them may supply it.
223
+ * Throws when the declaration at base cannot be parsed.
224
+ */
225
+ export function identityGateAdmissionAt(
226
+ repo: string,
227
+ base: string,
228
+ gate: string,
229
+ opts: { classes?: readonly ClassGrant[] } = {},
230
+ ): IdentityGateAdmission | null {
231
+ const declaration = declarationAt(repo, base);
232
+ const requirement = declaration === null ? undefined : identityGatesOf(declaration.value).find((g) => g.gate === gate);
233
+ if (!requirement) return null;
234
+ const { signers, roles } = trustAt(repo, base);
235
+ const pins = (declaration!.value as { pins?: unknown }).pins;
236
+ const rule: GateAdmissionRule = {
237
+ gate,
238
+ class: requirement.class,
239
+ signers,
240
+ roles,
241
+ classes: [...CORE_CLASSES, ...(opts.classes ?? [])],
242
+ ...(Array.isArray(pins) && pins.length > 0
243
+ ? { unknownClass: `is not a core class, and this reader does not load the classes the pinned packages in ${declaration!.file} at base supply` }
244
+ : {}),
245
+ };
246
+ return { requirement, refuses: (a) => admitApprover(rule, a) };
169
247
  }
170
248
 
171
249
  // ── pr-review ────────────────────────────────────────────────────────────