@panaversity/ksor 0.0.40 → 0.0.41

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 (103) hide show
  1. package/CHANGELOG.md +869 -0
  2. package/README.md +11 -7
  3. package/dist/checker/check-main.mjs +14049 -0
  4. package/dist/cli.mjs +11528 -5213
  5. package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
  6. package/dist/gateway.d.mts +52 -13
  7. package/dist/gateway.mjs +2 -2
  8. package/dist/index.d.mts +1 -1
  9. package/dist/index.mjs +1 -1
  10. package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
  11. package/docs/authorization.md +8 -6
  12. package/docs/deploying.md +36 -25
  13. package/docs/index.md +26 -13
  14. package/docs/ingesting.md +70 -22
  15. package/docs/tool-surface.md +69 -16
  16. package/package.json +4 -3
  17. package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
  18. package/schema/schema.sql +77 -14
  19. package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
  20. package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
  21. package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
  22. package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
  23. package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
  24. package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
  25. package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
  26. package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
  27. package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
  28. package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
  29. package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
  30. package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
  31. package/templates/scaffold/.github/workflows/validate.yml +9 -1
  32. package/templates/scaffold/.ksor/governance.yaml +17 -0
  33. package/templates/scaffold/AGENTS.md +234 -113
  34. package/templates/scaffold/Dockerfile +5 -1
  35. package/templates/scaffold/README.md +160 -42
  36. package/templates/scaffold/env.example +37 -6
  37. package/templates/scaffold/gitignore +13 -8
  38. package/templates/scaffold/instance.md +21 -17
  39. package/templates/scaffold/knowledge/governance-ladder.md +6 -2
  40. package/templates/scaffold/knowledge/index.md +9 -0
  41. package/templates/scaffold/knowledge/surfaces/for-agents.md +7 -6
  42. package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
  43. package/templates/scaffold/knowledge/surfaces/index.md +4 -20
  44. package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
  45. package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
  46. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
  47. package/templates/scaffold/package.json +3 -4
  48. package/templates/scaffold/pnpm-lock.yaml +3 -0
  49. package/templates/scaffold/system/gateways/content.ts +13 -0
  50. package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
  51. package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
  52. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +126 -91
  53. package/templates/scaffold/system/site/app/global.css +13 -5
  54. package/templates/scaffold/system/site/app/layout.tsx +8 -3
  55. package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
  56. package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
  57. package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
  58. package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
  59. package/templates/scaffold/system/site/components/governance.tsx +205 -87
  60. package/templates/scaffold/system/site/components/record-index.tsx +5 -5
  61. package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
  62. package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
  63. package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
  64. package/templates/scaffold/system/site/lib/attachments.ts +0 -28
  65. package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
  66. package/templates/scaffold/system/site/lib/audience.ts +42 -146
  67. package/templates/scaffold/system/site/lib/embed-rule.ts +9 -0
  68. package/templates/scaffold/system/site/lib/governance.ts +339 -225
  69. package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
  70. package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
  71. package/templates/scaffold/system/site/lib/lock.ts +282 -0
  72. package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
  73. package/templates/scaffold/system/site/lib/record-href.ts +68 -0
  74. package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
  75. package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
  76. package/templates/scaffold/system/site/lib/shared.ts +67 -104
  77. package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
  78. package/templates/scaffold/system/site/lib/source.ts +256 -186
  79. package/templates/scaffold/system/site/lib/stage-knowledge.ts +566 -492
  80. package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
  81. package/templates/scaffold/system/site/package.json +1 -0
  82. package/templates/scaffold/system/site/record/actor.ts +23 -0
  83. package/templates/scaffold/system/site/record/check.ts +571 -0
  84. package/templates/scaffold/system/site/record/citations.ts +312 -0
  85. package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
  86. package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
  87. package/templates/scaffold/system/site/record/hygiene.ts +320 -0
  88. package/templates/scaffold/system/site/record/index-file.ts +150 -0
  89. package/templates/scaffold/system/site/record/index.ts +103 -0
  90. package/templates/scaffold/system/site/record/instance.ts +257 -0
  91. package/templates/scaffold/system/site/record/instant.ts +43 -0
  92. package/templates/scaffold/system/site/record/ledger.ts +694 -0
  93. package/templates/scaffold/system/site/record/load.ts +129 -0
  94. package/templates/scaffold/system/site/record/lock.ts +306 -0
  95. package/templates/scaffold/system/site/record/near-miss.ts +37 -0
  96. package/templates/scaffold/system/site/record/policy.ts +414 -0
  97. package/templates/scaffold/system/site/record/profile.ts +535 -0
  98. package/templates/scaffold/system/site/record/refusal.ts +106 -0
  99. package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
  100. package/templates/scaffold/system/site/source.config.ts +77 -22
  101. package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
  102. package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
  103. package/templates/scaffold/system/site/lib/page-order.ts +0 -93
@@ -0,0 +1,414 @@
1
+ /**
2
+ * The Governance Policy, `.ksor/governance.yaml` (record spec §4; KSP-001
3
+ * §4.2.5): the audience registry and the authority sets every governance
4
+ * fact on a concept is checked against. Scope resolution is the proposal's,
5
+ * verbatim — the deepest matching path wins, an explicit type breaks the
6
+ * tie, equally specific approval rules intersect, and a less specific rule
7
+ * never widens.
8
+ */
9
+ import { z } from "zod";
10
+
11
+ import { actorKind } from "./actor";
12
+ import { nearest } from "./near-miss";
13
+ import type { Refusal } from "./refusal";
14
+ import { parseYamlFile } from "./yaml-file";
15
+
16
+ const SLUG = "ksor-policy-invalid";
17
+ const PATH = ".ksor/governance.yaml";
18
+
19
+ const anyActor = z.custom<string>(
20
+ (v) => typeof v === "string" && actorKind(v) !== null,
21
+ "an actor is `human:<id>`, `process:<id>`, `team:<id>` or `<producer>/<version>`",
22
+ );
23
+ const scope = z
24
+ .object({
25
+ paths: z.array(z.string().min(1)).optional(),
26
+ types: z.array(z.string().min(1)).optional(),
27
+ })
28
+ .optional();
29
+
30
+ const ownershipRule = z.object({ scope, owner: anyActor, escalation: anyActor.optional() });
31
+ const approvalRule = z.object({
32
+ scope,
33
+ actors: z.array(anyActor).min(1, "an approval rule needs non-empty `actors`"),
34
+ });
35
+
36
+ /**
37
+ * Every object in the policy has a CLOSED key set, checked before the shape is
38
+ * parsed so the refusal can name the key and the set it missed.
39
+ *
40
+ * zod strips an unknown key by default, and a stripped key in THIS file widens
41
+ * authority: `scope: { path: ["drafts/"] }` (one letter) left `scope: {}`,
42
+ * which `pathDepth` scores as depth 0, so an intern's drafts rule became the
43
+ * record's fallback and approved a document nobody had authority over
44
+ * (reproduced end to end, 2026-08-25). The instance's key set is closed for
45
+ * exactly this reason, and this file is the root of authority the instance is
46
+ * not. There are therefore no extension keys here: a key the policy does not
47
+ * read is a rule that is not in force, and silence about that is the failure
48
+ * mode.
49
+ */
50
+ const POLICY_KEYS: Readonly<Record<string, readonly string[]>> = {
51
+ "(root)": ["version", "audiences", "ownership", "approval_authorities", "takedown_authorities"],
52
+ scope: ["paths", "types"],
53
+ "an audience": ["description"],
54
+ "an `ownership` rule": ["scope", "owner", "escalation"],
55
+ "an `approval_authorities` rule": ["scope", "actors"],
56
+ takedown_authorities: ["actors"],
57
+ };
58
+
59
+ function isMapping(value: unknown): value is Record<string, unknown> {
60
+ return typeof value === "object" && value !== null && !Array.isArray(value);
61
+ }
62
+
63
+ function closedKeys(value: unknown, where: string, path: string, refusals: Refusal[]): void {
64
+ if (!isMapping(value)) return;
65
+ const allowed = POLICY_KEYS[where] ?? [];
66
+ for (const key of Object.keys(value)) {
67
+ if (allowed.includes(key)) continue;
68
+ const near = nearest(key, allowed, 2) ?? undefined;
69
+ refusals.push({
70
+ slug: SLUG,
71
+ path,
72
+ why: `${where === "(root)" ? "the policy" : where} declares an unknown key: \`${key}\` — the policy is the root of authority every approval and takedown is checked against, and a key it does not read is a rule that is not in force`,
73
+ fix: `${near === undefined ? `remove \`${key}:\`` : `did you mean \`${near}:\`?`} (allowed ${where === "(root)" ? "at the root" : `in ${where}`}: ${allowed.join(", ")})`,
74
+ });
75
+ }
76
+ }
77
+
78
+ /** The whole closed-key walk, in the shape `checkInstance` uses for `instance.md`. */
79
+ function checkPolicyKeys(value: unknown, path: string): Refusal[] {
80
+ const refusals: Refusal[] = [];
81
+ if (!isMapping(value)) return refusals;
82
+ closedKeys(value, "(root)", path, refusals);
83
+ const audiences = value["audiences"];
84
+ if (isMapping(audiences)) {
85
+ for (const entry of Object.values(audiences)) closedKeys(entry, "an audience", path, refusals);
86
+ }
87
+ for (const [key, where] of [
88
+ ["ownership", "an `ownership` rule"],
89
+ ["approval_authorities", "an `approval_authorities` rule"],
90
+ ] as const) {
91
+ const rules = value[key];
92
+ if (!Array.isArray(rules)) continue;
93
+ for (const rule of rules) {
94
+ closedKeys(rule, where, path, refusals);
95
+ if (isMapping(rule)) closedKeys(rule["scope"], "scope", path, refusals);
96
+ }
97
+ }
98
+ closedKeys(value["takedown_authorities"], "takedown_authorities", path, refusals);
99
+ return refusals;
100
+ }
101
+
102
+ /**
103
+ * Why this scope path can never name a concept, or null when it can.
104
+ *
105
+ * `paths` holds bundle-relative directory prefixes (KSP-001 §4.2.5), matched
106
+ * segment-wise against concept ids — and `conceptIdOf` strips both the
107
+ * `knowledge/` prefix and the `.md`. So the two forms a hand reaches for first
108
+ * are the two that match NOTHING: `hr/handbook.md`, written as the file is
109
+ * named, and `knowledge/hr/`, written as the takedown ledger's `stable_id` is.
110
+ * Neither errored. The tightly scoped rule simply never applied and resolution
111
+ * fell through to a broader one, which is the failure this whole file exists to
112
+ * prevent — a rule the policy does not read is a rule that is not in force.
113
+ * They are refused rather than repaired, because guessing which document an
114
+ * author meant is the policy choosing an authority for them.
115
+ */
116
+ function pathProblem(raw: string): { readonly why: string; readonly fix: string } | null {
117
+ const trimmed = raw.replace(/^\/+/, "").replace(/\/+$/, "");
118
+ const extension = /\.mdx?$/.exec(trimmed)?.[0];
119
+ if (extension !== undefined) {
120
+ return {
121
+ why: `which matches no concept: a path names a directory or a concept id, and neither carries a file extension — \`knowledge/hr/handbook.md\` is the concept \`hr/handbook\` — so this rule is not in force and resolution falls through to a broader one`,
122
+ fix: `write \`${trimmed.slice(0, -extension.length)}\` — a path matches the concept of exactly that id as well as everything beneath it, so one document can be scoped without its extension`,
123
+ };
124
+ }
125
+ if (trimmed === "knowledge" || trimmed.startsWith("knowledge/")) {
126
+ const rest = trimmed.slice("knowledge".length).replace(/^\//, "");
127
+ return {
128
+ why: "which matches no concept: scope paths are bundle-relative and start INSIDE `knowledge/`, unlike the takedown ledger's `stable_id`, which spells it out — so this rule is not in force and resolution falls through to a broader one",
129
+ fix:
130
+ rest === ""
131
+ ? "drop the prefix: `/` is the whole record, and so is omitting `paths` entirely"
132
+ : `drop the prefix: \`${rest}/\``,
133
+ };
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * Why this scope binds the rule to nothing, or null when it binds it to
140
+ * something.
141
+ *
142
+ * The empty LIST is `pathProblem`'s failure reached through the value instead
143
+ * of the key: `paths: []` makes `pathDepth` loop zero times and return null,
144
+ * so `mostSpecific` skips the rule entirely, and `types: []` fails every
145
+ * `includes` for the same effect. An empty list reads as "everywhere" and
146
+ * means "nowhere" — on `approval_authorities` that refuses the concept and
147
+ * fails safe, but on `ownership` it resolves to the same `null` as "no rule
148
+ * binds this", and deprecation authority then falls back to the document's own
149
+ * self-declared `owner:` (2026-08-25 review).
150
+ *
151
+ * The empty MAPPING is the opposite direction and the same silence: `scope: {}`
152
+ * scores depth 0 and matches EVERY concept, which is the state the one-letter
153
+ * `path:` typo produced before the key set was closed. It is the one route to
154
+ * that widening a closed key set cannot catch, so it is refused here — the
155
+ * record-wide fallback is written by omitting `scope`, and a rule that names a
156
+ * scope is saying it is not the fallback.
157
+ */
158
+ function emptyScopeProblem(scope: Scope | undefined): {
159
+ readonly why: string;
160
+ readonly fix: string;
161
+ } | null {
162
+ if (scope === undefined) return null;
163
+ for (const [key, list] of [
164
+ ["paths", scope.paths],
165
+ ["types", scope.types],
166
+ ] as const) {
167
+ if (list !== undefined && list.length === 0) {
168
+ return {
169
+ why: `declares an empty \`${key}\` list, which matches no concept — an empty list is not "everywhere", it is "nowhere", so this rule is not in force at all`,
170
+ fix: `list the ${key === "paths" ? "directory prefixes" : "types"} the rule covers, or omit \`scope:\` entirely to make it the record-wide fallback`,
171
+ };
172
+ }
173
+ }
174
+ if (scope.paths === undefined && scope.types === undefined) {
175
+ return {
176
+ why: "declares a `scope` that constrains nothing, so it matches EVERY concept at the widest tier — a rule that names a scope is saying it is not the record-wide fallback",
177
+ fix: "add `paths:` or `types:`, or drop the `scope:` key so the rule reads as the deliberate fallback it would otherwise silently become",
178
+ };
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /** Every scope of one rule family, in the vocabulary `POLICY_KEYS` uses. */
184
+ function checkScopes(
185
+ rules: readonly { readonly scope?: Scope }[],
186
+ where: string,
187
+ path: string,
188
+ refusals: Refusal[],
189
+ ): void {
190
+ for (const rule of rules) {
191
+ const empty = emptyScopeProblem(rule.scope);
192
+ if (empty !== null) {
193
+ refusals.push({ slug: SLUG, path, why: `${where} ${empty.why}`, fix: empty.fix });
194
+ continue;
195
+ }
196
+ for (const raw of rule.scope?.paths ?? []) {
197
+ const problem = pathProblem(raw);
198
+ if (problem === null) continue;
199
+ refusals.push({
200
+ slug: SLUG,
201
+ path,
202
+ why: `${where} scopes to \`${raw}\`, ${problem.why}`,
203
+ fix: problem.fix,
204
+ });
205
+ }
206
+ }
207
+ }
208
+
209
+ const policySchema = z.object({
210
+ version: z.string().min(1),
211
+ audiences: z
212
+ .record(
213
+ z.string().min(1),
214
+ z.object({ description: z.string().min(1, "every audience needs a `description`") }),
215
+ )
216
+ .optional(),
217
+ ownership: z.array(ownershipRule).optional(),
218
+ approval_authorities: z.array(approvalRule),
219
+ takedown_authorities: z.object({
220
+ actors: z.array(anyActor).min(1, "`takedown_authorities` needs non-empty `actors`"),
221
+ }),
222
+ });
223
+
224
+ export interface Scope {
225
+ readonly paths?: readonly string[];
226
+ readonly types?: readonly string[];
227
+ }
228
+ export interface OwnershipRule {
229
+ readonly scope?: Scope;
230
+ readonly owner: string;
231
+ readonly escalation?: string;
232
+ }
233
+ export interface ApprovalRule {
234
+ readonly scope?: Scope;
235
+ readonly actors: readonly string[];
236
+ }
237
+
238
+ export interface Policy {
239
+ /** Registered audience identifiers, sorted; `public` is reserved and never listed. */
240
+ readonly audiences: readonly string[];
241
+ readonly takedownActors: readonly string[];
242
+ readonly ownership: readonly OwnershipRule[];
243
+ readonly approvalRules: readonly ApprovalRule[];
244
+ /** The parsed document. The key set is closed, so this is `POLICY_KEYS` and nothing else. */
245
+ readonly raw: Readonly<Record<string, unknown>>;
246
+ }
247
+
248
+ export type PolicyResult =
249
+ | { readonly ok: true; readonly policy: Policy }
250
+ | { readonly ok: false; readonly refusals: readonly Refusal[] };
251
+
252
+ /** `text` is null when the file does not exist. */
253
+ export function parsePolicy(text: string | null, path: string): PolicyResult {
254
+ if (text === null) {
255
+ return {
256
+ ok: false,
257
+ refusals: [
258
+ {
259
+ slug: "ksor-policy-missing",
260
+ path,
261
+ why: "the record has no Governance Policy — the root of authority every approval and takedown is checked against",
262
+ fix: "write `.ksor/governance.yaml` with `version`, `approval_authorities: [{ actors: [human:<you>] }]` and `takedown_authorities: { actors: [human:<you>] }`",
263
+ },
264
+ ],
265
+ };
266
+ }
267
+ const loaded = parseYamlFile(text, path, SLUG);
268
+ if (!loaded.ok) return loaded;
269
+ const unknown = checkPolicyKeys(loaded.value, path);
270
+ if (unknown.length > 0) return { ok: false, refusals: unknown };
271
+ const parsed = policySchema.safeParse(loaded.value);
272
+ if (!parsed.success) {
273
+ return {
274
+ ok: false,
275
+ refusals: parsed.error.issues.map((issue) => ({
276
+ slug: SLUG,
277
+ path,
278
+ why: `\`${issue.path.map(String).join(".") || "(root)"}\`: ${issue.message}`,
279
+ fix: "the policy's shape is record spec §4: `version`, optional `audiences` and `ownership`, required `approval_authorities` and `takedown_authorities`",
280
+ })),
281
+ };
282
+ }
283
+ const fm = parsed.data;
284
+ const unmatchable: Refusal[] = [];
285
+ checkScopes(fm.ownership ?? [], "an `ownership` rule", path, unmatchable);
286
+ checkScopes(fm.approval_authorities, "an `approval_authorities` rule", path, unmatchable);
287
+ if (unmatchable.length > 0) return { ok: false, refusals: unmatchable };
288
+ if (fm.audiences !== undefined && "public" in fm.audiences) {
289
+ return {
290
+ ok: false,
291
+ refusals: [
292
+ {
293
+ slug: SLUG,
294
+ path,
295
+ why: "`audiences.public` is declared — `public` is the reserved unrestricted audience and a policy may not redefine it",
296
+ fix: "remove the `public` entry; it is implicit in every record",
297
+ },
298
+ ],
299
+ };
300
+ }
301
+ return {
302
+ ok: true,
303
+ policy: {
304
+ audiences: Object.keys(fm.audiences ?? {}).sort(),
305
+ takedownActors: fm.takedown_authorities.actors,
306
+ ownership: fm.ownership ?? [],
307
+ approvalRules: fm.approval_authorities,
308
+ raw: loaded.value,
309
+ },
310
+ };
311
+ }
312
+
313
+ export type ApproversResult =
314
+ | { readonly ok: true; readonly actors: readonly string[] }
315
+ | { readonly ok: false; readonly refusal: Refusal };
316
+
317
+ /** The effective approval authority set for the concept `id` (bundle-relative) of `type`. */
318
+ export function resolveApprovers(policy: Policy, id: string, type: string): ApproversResult {
319
+ const rules = mostSpecific(policy.approvalRules, id, type);
320
+ if (rules.length === 0) {
321
+ return {
322
+ ok: false,
323
+ refusal: {
324
+ slug: SLUG,
325
+ path: PATH,
326
+ why: `no \`approval_authorities\` rule matches \`${id}\` (${type})`,
327
+ fix: "add an unscoped rule as the fallback, or a scoped one covering this path",
328
+ },
329
+ };
330
+ }
331
+ const actors = rules
332
+ .map((r) => new Set(r.actors))
333
+ .reduce((acc, set) => new Set([...acc].filter((a) => set.has(a))));
334
+ if (actors.size === 0) {
335
+ return {
336
+ ok: false,
337
+ refusal: {
338
+ slug: SLUG,
339
+ path: PATH,
340
+ why: `the equally specific approval rules matching \`${id}\` (${type}) share no actor — their intersection is empty`,
341
+ fix: "give the rules a common actor, or make one more specific than the other",
342
+ },
343
+ };
344
+ }
345
+ return { ok: true, actors: [...actors] };
346
+ }
347
+
348
+ export type OwnerResult =
349
+ | { readonly ok: true; readonly owner: string | null }
350
+ | { readonly ok: false; readonly refusal: Refusal };
351
+
352
+ /** The resolved owner, or null when no ownership rule binds the concept. */
353
+ export function resolveOwner(policy: Policy, id: string, type: string): OwnerResult {
354
+ const rules = mostSpecific(policy.ownership, id, type);
355
+ if (rules.length === 0) return { ok: true, owner: null };
356
+ const owners = new Set(rules.map((r) => `${r.owner} ${r.escalation ?? ""}`));
357
+ if (owners.size > 1) {
358
+ return {
359
+ ok: false,
360
+ refusal: {
361
+ slug: SLUG,
362
+ path: PATH,
363
+ why: `two equally specific ownership rules match \`${id}\` (${type}) and name different owners`,
364
+ fix: "make them agree, or make one more specific than the other",
365
+ },
366
+ };
367
+ }
368
+ return { ok: true, owner: rules[0]?.owner ?? null };
369
+ }
370
+
371
+ /**
372
+ * Segment-wise: `finance/` covers `finance/x` and never `financeops/x`. A bare
373
+ * `/` normalises to the empty prefix, which matches every concept at depth 0 —
374
+ * the same tier as omitting `paths`, so any deeper rule still beats it. Forms
375
+ * that could never match are refused at parse time (`pathProblem`), and so is
376
+ * the empty list (`emptyScopeProblem`), so `null` here means one thing only:
377
+ * this rule's prefixes do not cover this concept.
378
+ */
379
+ function pathDepth(id: string, prefixes: readonly string[] | undefined): number | null {
380
+ if (prefixes === undefined) return 0;
381
+ let best: number | null = null;
382
+ for (const raw of prefixes) {
383
+ const prefix = raw.replace(/^\/+/, "").replace(/\/+$/, "");
384
+ const depth = prefix === "" ? 0 : prefix.split("/").length;
385
+ if (prefix === "" || id === prefix || id.startsWith(`${prefix}/`)) {
386
+ if (best === null || depth > best) best = depth;
387
+ }
388
+ }
389
+ return best;
390
+ }
391
+
392
+ function mostSpecific<R extends { readonly scope?: Scope }>(
393
+ rules: readonly R[],
394
+ id: string,
395
+ type: string,
396
+ ): R[] {
397
+ let top: readonly [number, number] | null = null;
398
+ let winners: R[] = [];
399
+ for (const rule of rules) {
400
+ const depth = pathDepth(id, rule.scope?.paths);
401
+ if (depth === null) continue;
402
+ const types = rule.scope?.types;
403
+ if (types !== undefined && !types.includes(type)) continue;
404
+ const key = [depth, types === undefined ? 0 : 1] as const;
405
+ const cmp = top === null ? 1 : key[0] - top[0] || key[1] - top[1];
406
+ if (cmp > 0) {
407
+ top = key;
408
+ winners = [rule];
409
+ } else if (cmp === 0) {
410
+ winners.push(rule);
411
+ }
412
+ }
413
+ return winners;
414
+ }