@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,42 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Lexicon source takes the TypeScript compiler API from
8
+ * `@intentius/chant/lint/typescript`, never from `typescript` (chant #3864).
9
+ *
10
+ * A bare `typescript` import in a published lexicon resolves to the project's
11
+ * copy. On TypeScript 7 that copy has no JS compiler API, so every sql lint
12
+ * rule threw `ts.isImportDeclaration is not a function` and `chant graph`
13
+ * refused the project. Core parses with its own 5.x, and a rule has to test
14
+ * nodes with that same module. Tests are exempt: they run in this repo, where
15
+ * `typescript` is 5.x.
16
+ */
17
+ const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
18
+ const lexiconsDir = join(repoRoot, "lexicons");
19
+
20
+ function sourceFiles(dir: string): string[] {
21
+ const out: string[] = [];
22
+ for (const name of readdirSync(dir)) {
23
+ const path = join(dir, name);
24
+ if (statSync(path).isDirectory()) out.push(...sourceFiles(path));
25
+ else if (/\.(ts|mts|js|mjs)$/.test(name) && !/\.test\.ts$/.test(name)) out.push(path);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ const BARE_TYPESCRIPT = /^\s*(import|export)\b[^;]*?\bfrom\s+["']typescript["']|\b(require|import)\(\s*["']typescript["']\s*\)/m;
31
+
32
+ describe("lexicon source imports TypeScript through chant (#3864)", () => {
33
+ test("no lexicon src file imports `typescript` directly", () => {
34
+ const offenders = readdirSync(lexiconsDir)
35
+ .map((name) => join(lexiconsDir, name, "src"))
36
+ .filter((dir) => statSync(dir, { throwIfNoEntry: false })?.isDirectory())
37
+ .flatMap(sourceFiles)
38
+ .filter((file) => BARE_TYPESCRIPT.test(readFileSync(file, "utf-8")))
39
+ .map((file) => relative(repoRoot, file));
40
+ expect(offenders, 'import { ts } from "@intentius/chant/lint/typescript" instead').toEqual([]);
41
+ });
42
+ });
@@ -70,23 +70,9 @@ export function bothRules(a: GateApprovalRule | null, b: GateApprovalRule | null
70
70
 
71
71
  // ── pr-review ─────────────────────────────────────────────────────────────
72
72
 
73
- /** The digests each wave planned at one commit, recorded for a review of it to approve. */
74
- export interface HeadPlans {
75
- version: 1;
76
- /** The Op waves spec's name. */
77
- name: string;
78
- /** The commit the waves were planned at: a pull request's head. */
79
- head: string;
80
- timestamp: string;
81
- waves: Array<{ wave: number; name: string; digest: string }>;
82
- }
73
+ // The record and its reader live apart, so a bundle can import them without git (#3865).
74
+ export { HEAD_PLANS_DIR, headPlansPath, type HeadPlans } from "../lifecycle/wave-plans";
83
75
 
84
- /** Where {@link HeadPlans} for `head` live on `chant/lifecycle`: `_wave-plans/<name>/<head>.json`. */
85
- export const HEAD_PLANS_DIR = "_wave-plans";
86
-
87
- export function headPlansPath(name: string, head: string): { dir: string; file: string } {
88
- return { dir: `${HEAD_PLANS_DIR}/${name}`, file: `${head}.json` };
89
- }
90
76
 
91
77
  /** The merged pull request's review, as a `pr-review` gate reads it. */
92
78
  export interface MergedReview {
@@ -254,6 +254,7 @@ describe("a wave gate that asks a decision point (#3836)", () => {
254
254
  members,
255
255
  description: "wave 1 of 1: vpc, nat",
256
256
  neverOverMcp: true,
257
+ approvalMode: "ledger",
257
258
  answer: { point: "prod-apply" },
258
259
  });
259
260
  expect(waiting.decision?.pendingFact).toMatchObject({ commit: "c".repeat(40), neverOverMcp: true });
@@ -130,7 +130,7 @@ export interface OpWavePlansFile {
130
130
  /** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
131
131
  export const OP_WAVE_PENDING_RESERVED = [
132
132
  "version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
133
- "environment", "origin", "neverOverMcp", "approval", "answer",
133
+ "environment", "origin", "neverOverMcp", "approval", "approvalMode", "answer",
134
134
  ] as const;
135
135
 
136
136
  /** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
@@ -1068,7 +1068,7 @@ async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<O
1068
1068
  description: hold.request.description ?? `wave ${k} (${wave.name}) of ${spec.name} is held on it by ${hold.label}`,
1069
1069
  now,
1070
1070
  // #3839: the wave a hold belongs to, as a field, whatever the hook's own description says.
1071
- fields: { ...hold.request.pending, wave: { number: k, name: wave.name, op: spec.name } },
1071
+ fields: { ...hold.request.pending, approvalMode: rules.approval, wave: { number: k, name: wave.name, op: spec.name } },
1072
1072
  ...(neverOverMcp ? { neverOverMcp: true } : {}),
1073
1073
  });
1074
1074
  const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
@@ -1633,7 +1633,8 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
1633
1633
  description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, approval ${approval}${held}`,
1634
1634
  now,
1635
1635
  ...(spent ? { spent } : {}),
1636
- ...(decision.pending ? { fields: decision.pending } : {}),
1636
+ // #3861: the approval mode the wave asked under, for readers deciding which approvals count.
1637
+ fields: { ...decision.pending, approvalMode: approval },
1637
1638
  ...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
1638
1639
  });
1639
1640
  if (check.satisfied) {
@@ -1697,7 +1698,7 @@ async function settlePointGate(options: RunOpWaveOptions, ctx: WaveContext, deci
1697
1698
  planDigest: digest,
1698
1699
  ...(options.now ? { now: options.now } : {}),
1699
1700
  // #3863: the plans file's pending fields and the spec's neverOverMcp, as settleGate passes them.
1700
- ...(decision.pending ? { fields: decision.pending } : {}),
1701
+ fields: { ...decision.pending, approvalMode: decision.approval },
1701
1702
  ...(decision.neverOverMcp ? { neverOverMcp: true } : {}),
1702
1703
  });
1703
1704
  if (check.satisfied) {
@@ -2088,17 +2089,10 @@ async function approveByReview(
2088
2089
  }
2089
2090
 
2090
2091
  async function readHeadPlans(name: string, head: string, cwd: string): Promise<HeadPlans | null> {
2091
- const { readBlobFromPath, requireLifecycleLedger } = await import("../lifecycle/git");
2092
- const { headPlansPath } = await import("./gate-review");
2092
+ const { gitLedgerReadPort, requireLifecycleLedger } = await import("../lifecycle/git");
2093
+ const { readHeadPlans: read } = await import("../lifecycle/wave-plans");
2093
2094
  await requireLifecycleLedger({ cwd });
2094
- const { dir, file } = headPlansPath(name, head);
2095
- const text = await readBlobFromPath(dir, file, { cwd });
2096
- if (!text) return null;
2097
- try {
2098
- return JSON.parse(text) as HeadPlans;
2099
- } catch {
2100
- return null;
2101
- }
2095
+ return read(gitLedgerReadPort({ cwd }), name, head);
2102
2096
  }
2103
2097
 
2104
2098
  export interface RecordHeadPlansOptions {
@@ -739,8 +739,22 @@ describe("a product's wave gate moved onto the waves (#3821)", () => {
739
739
  const result = await runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: () => ({ sha: "b".repeat(40), text: JSON.stringify({ ...tg, neverOverMcp: true }) }), gates });
740
740
  expect(result.exitCode).toBe(3);
741
741
  expect(gates.appended[0]).toMatchObject({ commit: "c".repeat(40), members, description: "wave 1 of 3: vpc, dns, nat", neverOverMcp: true, gate: "wave-1", op: "tf-apply" });
742
+ // #3861: the approval mode the wave asked under, and a plans file may not set it.
743
+ expect(gates.appended[0]).toMatchObject({ approvalMode: "ledger" });
742
744
  h.done();
743
745
 
746
+ const mode = harness(plans, { pending: { approvalMode: "sealed" } });
747
+ await expect(runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: mode.cwd, exec: mode.exec, show: mode.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/sets approvalMode, which chant writes itself/);
748
+ mode.done();
749
+
750
+ // A sealed spec at base records "sealed".
751
+ const sealedSpec: OpWavesSpec = { ...tg, waves: [{ ...tg.waves[0]!, approval: "sealed" }] };
752
+ const sh = harness(plans, {}, sealedSpec);
753
+ const sealedGates = memoryGateLedgerPort();
754
+ await runOpWave({ spec: sealedSpec, specFile: "waves.json", wave: 1, cwd: sh.cwd, exec: sh.exec, show: () => ({ sha: "b".repeat(40), text: JSON.stringify(sealedSpec) }), gates: sealedGates });
755
+ expect(sealedGates.appended[0]).toMatchObject({ approvalMode: "sealed" });
756
+ sh.done();
757
+
744
758
  const bad = harness(plans, { pending: { gate: "other" } });
745
759
  await expect(runOpWave({ spec: tg, specFile: "waves.json", wave: 1, cwd: bad.cwd, exec: bad.exec, show: bad.show, gates: memoryGateLedgerPort() })).rejects.toThrow(/sets gate, which chant writes itself/);
746
760
  bad.done();
@@ -0,0 +1,109 @@
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
+
17
+ import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, verifySshSignatureWith, type SealedGateApproval } from "./trust/gate-seal";
18
+ import type { Signer } from "./trust/trust-files";
19
+
20
+ /** The core classes, in the order a principal's roles are tried; `human` holds no role and is the rest. */
21
+ export const CORE_PRINCIPAL_CLASSES = ["human", "agent", "runner", "service"] as const;
22
+ export type CorePrincipalClass = (typeof CORE_PRINCIPAL_CLASSES)[number];
23
+
24
+ export interface PrincipalClass {
25
+ name: string;
26
+ /** One line saying who is in the class. */
27
+ description: string;
28
+ /** The role whose grant at base puts a principal in the class, or null for `human`, the rest. */
29
+ role: string | null;
30
+ /** `core`, or the pin (package name or path) that supplies the class. */
31
+ source: string;
32
+ }
33
+
34
+ export const CORE_CLASSES: readonly PrincipalClass[] = [
35
+ { name: "human", description: "a principal holding none of the roles of another class", role: null, source: "core" },
36
+ { name: "agent", description: "a principal holding the agent role, or a write naming an agent session", role: "agent", source: "core" },
37
+ { name: "runner", description: "a principal holding the runner role, such as a CI job's signing identity", role: "runner", source: "core" },
38
+ { name: "service", description: "a principal holding the service role", role: "service", source: "core" },
39
+ ];
40
+
41
+ /** A class as the rule reads it: its name, and the role whose grant puts a principal in it (null for `human`). */
42
+ export type ClassGrant = Pick<PrincipalClass, "name" | "role">;
43
+
44
+ /** What an `identity.gates` gate is judged by, all read at base. */
45
+ export interface GateAdmissionRule {
46
+ gate: string;
47
+ /** The class the approver must be in, or null for any signer. */
48
+ class: string | null;
49
+ /** The signers file at base: its path, whether it exists, and its signers. */
50
+ signers: { signersPath: string; active: boolean; signers: readonly Signer[] };
51
+ /** The role grants at base (`.chant/trust.json` `roles`). */
52
+ roles: Readonly<Record<string, readonly string[]>>;
53
+ /** Every class the rule knows, core first, then domain classes in pin order. */
54
+ classes: readonly ClassGrant[];
55
+ /**
56
+ * Why a class the rule does not know is unknown, for the refusal. Default
57
+ * "no pinned package supplies", which is what chant says once it has read
58
+ * every pin.
59
+ */
60
+ unknownClass?: string;
61
+ }
62
+
63
+ const normalise = (name: string): string => name.normalize("NFKC").trim().toLowerCase();
64
+
65
+ /** Every class `principal`'s role grants put it in, in `classes` order: what `classesOf` in `./principal-classes.ts` answers. */
66
+ export function grantedClasses(classes: readonly ClassGrant[], roles: Readonly<Record<string, readonly string[]>>, principal: string | null): string[] {
67
+ if (principal === null) return [];
68
+ const name = normalise(principal);
69
+ return classes.filter((c) => c.role !== null && (roles[c.role] ?? []).some((p) => normalise(p) === name)).map((c) => c.name);
70
+ }
71
+
72
+ /**
73
+ * Why `a` does not count toward the gate, or null when it does. An approval
74
+ * counts only when its seal verifies for its approver against the signers at
75
+ * base and, when the rule names a class, the role grants at base put the
76
+ * approver in it (`human`: in no other class). A class the rule does not
77
+ * know lets nothing through.
78
+ */
79
+ export function admitApprover(rule: GateAdmissionRule, a: SealedGateApproval): string | null {
80
+ const { gate, signers } = rule;
81
+ const want = rule.class;
82
+ if (want !== null && !rule.classes.some((c) => c.name === want)) {
83
+ return `identity.gates.${gate} names the class ${want}, which ${rule.unknownClass ?? "no pinned package supplies"}, so no approval can show it is in that class (known classes: ${rule.classes.map((c) => c.name).join(", ")})`;
84
+ }
85
+ if (!signers.active) return `gate ${gate} needs a signed approval, and there is no signers file (${signers.signersPath}) at base to check one against`;
86
+ const seal = sealRefusal(gate, signers, a);
87
+ if (seal !== null) return seal;
88
+ if (want === null) return null;
89
+ const granted = grantedClasses(rule.classes, rule.roles, a.resolvedBy);
90
+ const inClass = want === "human" ? granted.length === 0 : granted.includes(want);
91
+ return inClass ? null : `${a.resolvedBy} is not in the class ${want} that gate ${gate} needs: no role grant at base puts them there`;
92
+ }
93
+
94
+ /** The seal step, worded as `checkGateSeal` (`./trust/seal.ts`) words it against an active signers file at base. */
95
+ function sealRefusal(gate: string, signers: GateAdmissionRule["signers"], a: SealedGateApproval): string | null {
96
+ const what = `the approval of gate ${a.gate} by ${a.resolvedBy}`;
97
+ if (a.seal === undefined || a.seal === null) return `the approval by ${a.resolvedBy} is not signed; gate ${gate} needs chant approve --sign`;
98
+ const seal = a.seal as { signer?: unknown; signature?: unknown };
99
+ if (typeof seal !== "object" || Array.isArray(seal) || typeof seal.signer !== "string" || typeof seal.signature !== "string") {
100
+ return `the seal on ${what} is malformed: it needs signer and signature`;
101
+ }
102
+ if (normalise(seal.signer) !== normalise(a.resolvedBy)) return `the seal on ${what} is by ${seal.signer}, and its approver is ${a.resolvedBy || "empty"}`;
103
+ const where = `${signers.signersPath} at base`;
104
+ const listed = signers.signers.filter((s) => normalise(s.principal) === normalise(a.resolvedBy));
105
+ if (listed.length === 0) return `${a.resolvedBy} has no key in ${where}, so the seal can't count`;
106
+ const keys = listed.filter((s) => namespaceAllowed(s.namespaces, GATE_SEAL_NAMESPACE)).map((s) => Buffer.from(s.key.split(" ")[1] ?? "", "base64"));
107
+ const r = verifySshSignatureWith(seal.signature, gateSealPayload(a), GATE_SEAL_NAMESPACE, keys);
108
+ return r.ok ? null : `the seal does not verify for ${a.resolvedBy} against ${where}: ${r.reason}`;
109
+ }
@@ -26,11 +26,12 @@
26
26
  */
27
27
 
28
28
  import type { Declaration, SignedGate } from "./declaration";
29
- import { classesOf, coreClassRegistry, principalClass, type ClassRegistry } from "./principal-classes";
29
+ import { admitApprover } from "./identity-gate-rule";
30
+ import { classesOf, coreClassRegistry, roleGrants, type ClassRegistry } from "./principal-classes";
30
31
  import type { ReasonCode } from "./reason-codes";
31
32
  import { normalisePrincipal } from "./records";
32
33
  import type { TrustPolicy } from "./trust/policy";
33
- import { checkGateSeal, type SealedGateApproval } from "./trust/seal";
34
+ import type { SealedGateApproval } from "./trust/gate-seal";
34
35
  import { scopeSource } from "./write-scope";
35
36
 
36
37
  /** The forges a forge identity can name. */
@@ -152,27 +153,16 @@ export interface GateRuleSource extends IdentitySource {
152
153
  * approval counts only when its seal verifies for its approver against the
153
154
  * signers at base and, when the rule names a class, the approver's role
154
155
  * grants at base put it in that class. A class no pinned package supplies
155
- * lets nothing through.
156
+ * lets nothing through. The judge is {@link admitApprover}, which
157
+ * `@intentius/chant/gate-decision` also judges with (#3862). The policy is
158
+ * the one at base, never a returned work's copy.
156
159
  */
157
160
  export function gateAdmissionFrom(source: GateRuleSource, gate: string): GateAdmission | null {
158
161
  const requirement = source.declaration?.identity?.gates[gate];
159
162
  if (!requirement) return null;
160
163
  const { policy, classes } = source;
161
- return {
162
- requirement,
163
- refuses(a) {
164
- const want = requirement.class;
165
- if (want !== null && classes.get(want) === undefined) {
166
- return `identity.gates.${gate} names the class ${want}, which no pinned package supplies, so no approval can show it is in that class (known classes: ${classes.names().join(", ")})`;
167
- }
168
- if (!policy.active) return `gate ${gate} needs a signed approval, and there is no signers file (${policy.signersPath}) at base to check one against`;
169
- const seal = checkGateSeal(policy, a);
170
- if (seal.attested !== true) return a.seal === undefined || a.seal === null ? `the approval by ${a.resolvedBy} is not signed; gate ${gate} needs chant approve --sign` : seal.message;
171
- if (want === null) return null;
172
- const inClass = want === "human" ? principalClass(policy, a.resolvedBy, classes) === "human" : classesOf(classes, policy, a.resolvedBy).includes(want);
173
- return inClass ? null : `${a.resolvedBy} is not in the class ${want} that gate ${gate} needs: no role grant at base puts them there`;
174
- },
175
- };
164
+ const rule = { gate, class: requirement.class, signers: policy, roles: roleGrants(policy), classes: classes.all() };
165
+ return { requirement, refuses: (a) => admitApprover(rule, a) };
176
166
  }
177
167
 
178
168
  /**
@@ -26,19 +26,17 @@ import { readFileSync } from "node:fs";
26
26
  import { createRequire } from "node:module";
27
27
  import { join, posix } from "node:path";
28
28
  import { kindsExportTarget, pinnedPackageDir, type KindLoadProblem, type PinLike } from "./kinds";
29
- import { normalisePrincipal } from "./records";
30
29
  import schema from "./workspace-principals.schema.json";
31
30
  import type { TrustPolicy } from "./trust/policy";
32
31
  import { joinPath, type WorkspaceTree } from "./tree";
32
+ import { CORE_CLASSES, CORE_PRINCIPAL_CLASSES, grantedClasses, type PrincipalClass } from "./identity-gate-rule";
33
33
 
34
34
  export const PRINCIPALS_SCHEMA_ID = schema.$id;
35
35
 
36
36
  /** The subpath a package publishes its principal classes at. */
37
37
  export const PRINCIPALS_SUBPATH = "./workspace-principals";
38
38
 
39
- /** The core classes, in the order a principal's roles are tried; `human` holds no role and is the rest. */
40
- export const CORE_PRINCIPAL_CLASSES = ["human", "agent", "runner", "service"] as const;
41
- export type CorePrincipalClass = (typeof CORE_PRINCIPAL_CLASSES)[number];
39
+ export { CORE_CLASSES, CORE_PRINCIPAL_CLASSES, type CorePrincipalClass, type PrincipalClass } from "./identity-gate-rule";
42
40
 
43
41
  /** A class name: a core class, or a domain class a pinned package supplies. */
44
42
  export type PrincipalClassName = string;
@@ -46,23 +44,6 @@ export type PrincipalClassName = string;
46
44
  /** The grammar of a class name, as `workspace-principals.schema.json` and the declaration's writeScope keys say it. */
47
45
  export const CLASS_NAME = /^(?!x-)[a-z][a-z0-9-]{0,39}$/;
48
46
 
49
- export interface PrincipalClass {
50
- name: string;
51
- /** One line saying who is in the class. */
52
- description: string;
53
- /** The role whose grant at base puts a principal in the class, or null for `human`, the rest. */
54
- role: string | null;
55
- /** `core`, or the pin (package name or path) that supplies the class. */
56
- source: string;
57
- }
58
-
59
- export const CORE_CLASSES: readonly PrincipalClass[] = [
60
- { name: "human", description: "a principal holding none of the roles of another class", role: null, source: "core" },
61
- { name: "agent", description: "a principal holding the agent role, or a write naming an agent session", role: "agent", source: "core" },
62
- { name: "runner", description: "a principal holding the runner role, such as a CI job's signing identity", role: "runner", source: "core" },
63
- { name: "service", description: "a principal holding the service role", role: "service", source: "core" },
64
- ];
65
-
66
47
  const CORE_ROLES = new Set(CORE_CLASSES.map((c) => c.role).filter((r): r is string => r !== null));
67
48
 
68
49
  export interface ClassRegistry {
@@ -100,16 +81,10 @@ export function roleGrants(policy: TrustPolicy): Record<string, string[]> {
100
81
  /**
101
82
  * Every class `principal`'s role grants at base put it in, in registry order
102
83
  * (core first). Empty for a principal in no class but `human`, and for null.
103
- * Names compare as {@link normalisePrincipal} folds them.
84
+ * Names compare as `normalisePrincipal` (`./records.ts`) folds them.
104
85
  */
105
86
  export function classesOf(classes: ClassRegistry, policy: TrustPolicy, principal: string | null): string[] {
106
- if (principal === null) return [];
107
- const name = normalisePrincipal(principal);
108
- const grants = roleGrants(policy);
109
- return classes
110
- .all()
111
- .filter((c) => c.role !== null && (grants[c.role] ?? []).some((p) => normalisePrincipal(p) === name))
112
- .map((c) => c.name);
87
+ return grantedClasses(classes.all(), roleGrants(policy), principal);
113
88
  }
114
89
 
115
90
  /** The class write scope judges `principal` as: the first class its grants put it in, or `human`. */