@kindgi/api 0.1.4-rc.0 → 0.1.4-rc.2

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 (262) hide show
  1. package/dist/agent-binding.d.ts +20 -2
  2. package/dist/agent-binding.d.ts.map +1 -1
  3. package/dist/agent-pins.d.ts.map +1 -1
  4. package/dist/agent-pins.js +4 -1
  5. package/dist/agent-pins.js.map +1 -1
  6. package/dist/app.d.ts +8 -0
  7. package/dist/app.d.ts.map +1 -1
  8. package/dist/app.js +30 -5
  9. package/dist/app.js.map +1 -1
  10. package/dist/deploy-versions.d.ts +10 -5
  11. package/dist/deploy-versions.d.ts.map +1 -1
  12. package/dist/deploy-versions.js +4 -10
  13. package/dist/deploy-versions.js.map +1 -1
  14. package/dist/derive-agent-version.d.ts +9 -1
  15. package/dist/derive-agent-version.d.ts.map +1 -1
  16. package/dist/derive-agent-version.js +11 -1
  17. package/dist/derive-agent-version.js.map +1 -1
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js +26 -0
  20. package/dist/errors.js.map +1 -1
  21. package/dist/eval-case-binding.d.ts +10 -0
  22. package/dist/eval-case-binding.d.ts.map +1 -1
  23. package/dist/eval-run-binding.d.ts +21 -3
  24. package/dist/eval-run-binding.d.ts.map +1 -1
  25. package/dist/eval-run-binding.js.map +1 -1
  26. package/dist/eval-run-dispatcher.d.ts +3 -0
  27. package/dist/eval-run-dispatcher.d.ts.map +1 -1
  28. package/dist/eval-run-dispatcher.js.map +1 -1
  29. package/dist/flow-binding.d.ts +8 -0
  30. package/dist/flow-binding.d.ts.map +1 -1
  31. package/dist/flow-pins.d.ts +20 -7
  32. package/dist/flow-pins.d.ts.map +1 -1
  33. package/dist/flow-pins.js +36 -11
  34. package/dist/flow-pins.js.map +1 -1
  35. package/dist/gate-policy-binding.d.ts +164 -0
  36. package/dist/gate-policy-binding.d.ts.map +1 -0
  37. package/dist/gate-policy-binding.js +12 -0
  38. package/dist/gate-policy-binding.js.map +1 -0
  39. package/dist/gate.d.ts +56 -0
  40. package/dist/gate.d.ts.map +1 -0
  41. package/dist/gate.js +359 -0
  42. package/dist/gate.js.map +1 -0
  43. package/dist/guardrail-binding.d.ts +8 -0
  44. package/dist/guardrail-binding.d.ts.map +1 -1
  45. package/dist/handler-binding.d.ts +17 -2
  46. package/dist/handler-binding.d.ts.map +1 -1
  47. package/dist/index.d.ts +11 -4
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/judged-dispatcher.d.ts +20 -1
  52. package/dist/judged-dispatcher.d.ts.map +1 -1
  53. package/dist/judged-dispatcher.js +57 -7
  54. package/dist/judged-dispatcher.js.map +1 -1
  55. package/dist/judgment-binding.d.ts +38 -0
  56. package/dist/judgment-binding.d.ts.map +1 -1
  57. package/dist/judgment-binding.js +19 -0
  58. package/dist/judgment-binding.js.map +1 -1
  59. package/dist/live-version-binding.d.ts +206 -0
  60. package/dist/live-version-binding.d.ts.map +1 -0
  61. package/dist/live-version-binding.js +4 -0
  62. package/dist/live-version-binding.js.map +1 -0
  63. package/dist/middleware/authorize.d.ts.map +1 -1
  64. package/dist/middleware/authorize.js +13 -2
  65. package/dist/middleware/authorize.js.map +1 -1
  66. package/dist/middleware/project-ref.d.ts +25 -0
  67. package/dist/middleware/project-ref.d.ts.map +1 -0
  68. package/dist/middleware/project-ref.js +72 -0
  69. package/dist/middleware/project-ref.js.map +1 -0
  70. package/dist/openapi/generate.d.ts.map +1 -1
  71. package/dist/openapi/generate.js +4 -0
  72. package/dist/openapi/generate.js.map +1 -1
  73. package/dist/openapi/operations.d.ts +6 -0
  74. package/dist/openapi/operations.d.ts.map +1 -1
  75. package/dist/openapi/operations.js +524 -27
  76. package/dist/openapi/operations.js.map +1 -1
  77. package/dist/openapi/schemas.d.ts +43 -0
  78. package/dist/openapi/schemas.d.ts.map +1 -1
  79. package/dist/openapi/schemas.js +1050 -7
  80. package/dist/openapi/schemas.js.map +1 -1
  81. package/dist/publish-refused.d.ts +18 -0
  82. package/dist/publish-refused.d.ts.map +1 -0
  83. package/dist/publish-refused.js +22 -0
  84. package/dist/publish-refused.js.map +1 -0
  85. package/dist/registry-read-only.d.ts +32 -0
  86. package/dist/registry-read-only.d.ts.map +1 -0
  87. package/dist/registry-read-only.js +22 -0
  88. package/dist/registry-read-only.js.map +1 -0
  89. package/dist/retention-binding.d.ts +29 -0
  90. package/dist/retention-binding.d.ts.map +1 -1
  91. package/dist/routes/agent-releases.d.ts +40 -0
  92. package/dist/routes/agent-releases.d.ts.map +1 -0
  93. package/dist/routes/agent-releases.js +462 -0
  94. package/dist/routes/agent-releases.js.map +1 -0
  95. package/dist/routes/agents.d.ts +7 -1
  96. package/dist/routes/agents.d.ts.map +1 -1
  97. package/dist/routes/agents.js +38 -4
  98. package/dist/routes/agents.js.map +1 -1
  99. package/dist/routes/approvals.d.ts.map +1 -1
  100. package/dist/routes/approvals.js +27 -9
  101. package/dist/routes/approvals.js.map +1 -1
  102. package/dist/routes/audit.d.ts.map +1 -1
  103. package/dist/routes/audit.js +7 -0
  104. package/dist/routes/audit.js.map +1 -1
  105. package/dist/routes/auth.js +1 -1
  106. package/dist/routes/auth.js.map +1 -1
  107. package/dist/routes/blocks.d.ts.map +1 -1
  108. package/dist/routes/blocks.js +37 -12
  109. package/dist/routes/blocks.js.map +1 -1
  110. package/dist/routes/conversations.d.ts +0 -7
  111. package/dist/routes/conversations.d.ts.map +1 -1
  112. package/dist/routes/conversations.js +19 -3
  113. package/dist/routes/conversations.js.map +1 -1
  114. package/dist/routes/deployments.d.ts +6 -0
  115. package/dist/routes/deployments.d.ts.map +1 -1
  116. package/dist/routes/deployments.js +49 -5
  117. package/dist/routes/deployments.js.map +1 -1
  118. package/dist/routes/env.d.ts.map +1 -1
  119. package/dist/routes/env.js +1 -0
  120. package/dist/routes/env.js.map +1 -1
  121. package/dist/routes/eval-comparison.d.ts +4 -2
  122. package/dist/routes/eval-comparison.d.ts.map +1 -1
  123. package/dist/routes/eval-comparison.js +59 -10
  124. package/dist/routes/eval-comparison.js.map +1 -1
  125. package/dist/routes/eval-runs.d.ts +7 -1
  126. package/dist/routes/eval-runs.d.ts.map +1 -1
  127. package/dist/routes/eval-runs.js +34 -3
  128. package/dist/routes/eval-runs.js.map +1 -1
  129. package/dist/routes/eval-versions.d.ts +25 -0
  130. package/dist/routes/eval-versions.d.ts.map +1 -0
  131. package/dist/routes/eval-versions.js +66 -0
  132. package/dist/routes/eval-versions.js.map +1 -0
  133. package/dist/routes/flows.d.ts +14 -7
  134. package/dist/routes/flows.d.ts.map +1 -1
  135. package/dist/routes/flows.js +14 -8
  136. package/dist/routes/flows.js.map +1 -1
  137. package/dist/routes/gate-policies.d.ts +19 -0
  138. package/dist/routes/gate-policies.d.ts.map +1 -0
  139. package/dist/routes/gate-policies.js +191 -0
  140. package/dist/routes/gate-policies.js.map +1 -0
  141. package/dist/routes/gate-policy-spec.d.ts +17 -0
  142. package/dist/routes/gate-policy-spec.d.ts.map +1 -0
  143. package/dist/routes/gate-policy-spec.js +192 -0
  144. package/dist/routes/gate-policy-spec.js.map +1 -0
  145. package/dist/routes/gate-policy-wire.d.ts +3 -0
  146. package/dist/routes/gate-policy-wire.d.ts.map +1 -0
  147. package/dist/routes/gate-policy-wire.js +18 -0
  148. package/dist/routes/gate-policy-wire.js.map +1 -0
  149. package/dist/routes/guardrails.d.ts.map +1 -1
  150. package/dist/routes/guardrails.js +4 -0
  151. package/dist/routes/guardrails.js.map +1 -1
  152. package/dist/routes/hierarchy-errors.d.ts +21 -0
  153. package/dist/routes/hierarchy-errors.d.ts.map +1 -1
  154. package/dist/routes/hierarchy-errors.js +21 -0
  155. package/dist/routes/hierarchy-errors.js.map +1 -1
  156. package/dist/routes/judged-suites.js +7 -0
  157. package/dist/routes/judged-suites.js.map +1 -1
  158. package/dist/routes/judgments.d.ts +4 -1
  159. package/dist/routes/judgments.d.ts.map +1 -1
  160. package/dist/routes/judgments.js +92 -19
  161. package/dist/routes/judgments.js.map +1 -1
  162. package/dist/routes/live-scope-wire.d.ts +32 -0
  163. package/dist/routes/live-scope-wire.d.ts.map +1 -0
  164. package/dist/routes/live-scope-wire.js +71 -0
  165. package/dist/routes/live-scope-wire.js.map +1 -0
  166. package/dist/routes/policies.d.ts +6 -3
  167. package/dist/routes/policies.d.ts.map +1 -1
  168. package/dist/routes/policies.js +55 -3
  169. package/dist/routes/policies.js.map +1 -1
  170. package/dist/routes/projects.d.ts.map +1 -1
  171. package/dist/routes/projects.js +59 -9
  172. package/dist/routes/projects.js.map +1 -1
  173. package/dist/routes/retention.d.ts.map +1 -1
  174. package/dist/routes/retention.js +18 -2
  175. package/dist/routes/retention.js.map +1 -1
  176. package/dist/routes/reviewers.d.ts +8 -1
  177. package/dist/routes/reviewers.d.ts.map +1 -1
  178. package/dist/routes/reviewers.js +20 -3
  179. package/dist/routes/reviewers.js.map +1 -1
  180. package/dist/routes/runs.d.ts.map +1 -1
  181. package/dist/routes/runs.js +23 -13
  182. package/dist/routes/runs.js.map +1 -1
  183. package/dist/routes/secrets.d.ts.map +1 -1
  184. package/dist/routes/secrets.js +2 -0
  185. package/dist/routes/secrets.js.map +1 -1
  186. package/dist/routes/segments.d.ts +17 -0
  187. package/dist/routes/segments.d.ts.map +1 -0
  188. package/dist/routes/segments.js +68 -0
  189. package/dist/routes/segments.js.map +1 -0
  190. package/dist/routes/teams.d.ts.map +1 -1
  191. package/dist/routes/teams.js +53 -8
  192. package/dist/routes/teams.js.map +1 -1
  193. package/dist/routes/tools.d.ts.map +1 -1
  194. package/dist/routes/tools.js +4 -0
  195. package/dist/routes/tools.js.map +1 -1
  196. package/dist/routes/uuid-param.d.ts +11 -0
  197. package/dist/routes/uuid-param.d.ts.map +1 -0
  198. package/dist/routes/uuid-param.js +19 -0
  199. package/dist/routes/uuid-param.js.map +1 -0
  200. package/dist/tool-binding.d.ts +8 -0
  201. package/dist/tool-binding.d.ts.map +1 -1
  202. package/openapi.json +6484 -2402
  203. package/package.json +21 -21
  204. package/src/agent-binding.ts +21 -2
  205. package/src/agent-pins.ts +3 -1
  206. package/src/app.ts +47 -4
  207. package/src/deploy-versions.ts +12 -15
  208. package/src/derive-agent-version.ts +12 -1
  209. package/src/errors.ts +26 -0
  210. package/src/eval-case-binding.ts +7 -0
  211. package/src/eval-run-binding.ts +31 -3
  212. package/src/eval-run-dispatcher.ts +3 -0
  213. package/src/flow-binding.ts +9 -0
  214. package/src/flow-pins.ts +49 -9
  215. package/src/gate-policy-binding.ts +171 -0
  216. package/src/gate.ts +467 -0
  217. package/src/guardrail-binding.ts +9 -0
  218. package/src/handler-binding.ts +17 -2
  219. package/src/index.ts +47 -1
  220. package/src/judged-dispatcher.ts +100 -9
  221. package/src/judgment-binding.ts +62 -0
  222. package/src/live-version-binding.ts +237 -0
  223. package/src/middleware/authorize.ts +18 -2
  224. package/src/middleware/project-ref.ts +88 -0
  225. package/src/openapi/generate.ts +5 -0
  226. package/src/openapi/operations.ts +664 -27
  227. package/src/openapi/schemas.ts +1175 -33
  228. package/src/publish-refused.ts +31 -0
  229. package/src/registry-read-only.ts +43 -0
  230. package/src/retention-binding.ts +30 -0
  231. package/src/routes/agent-releases.ts +581 -0
  232. package/src/routes/agents.ts +53 -3
  233. package/src/routes/approvals.ts +36 -8
  234. package/src/routes/audit.ts +10 -0
  235. package/src/routes/auth.ts +1 -1
  236. package/src/routes/blocks.ts +39 -14
  237. package/src/routes/conversations.ts +31 -3
  238. package/src/routes/deployments.ts +75 -5
  239. package/src/routes/env.ts +1 -0
  240. package/src/routes/eval-comparison.ts +59 -11
  241. package/src/routes/eval-runs.ts +46 -3
  242. package/src/routes/eval-versions.ts +110 -0
  243. package/src/routes/flows.ts +34 -11
  244. package/src/routes/gate-policies.ts +226 -0
  245. package/src/routes/gate-policy-spec.ts +229 -0
  246. package/src/routes/gate-policy-wire.ts +20 -0
  247. package/src/routes/guardrails.ts +7 -0
  248. package/src/routes/hierarchy-errors.ts +23 -0
  249. package/src/routes/judged-suites.ts +5 -0
  250. package/src/routes/judgments.ts +107 -23
  251. package/src/routes/live-scope-wire.ts +90 -0
  252. package/src/routes/policies.ts +61 -3
  253. package/src/routes/projects.ts +72 -9
  254. package/src/routes/retention.ts +21 -2
  255. package/src/routes/reviewers.ts +23 -4
  256. package/src/routes/runs.ts +34 -20
  257. package/src/routes/secrets.ts +2 -0
  258. package/src/routes/segments.ts +75 -0
  259. package/src/routes/teams.ts +66 -8
  260. package/src/routes/tools.ts +7 -0
  261. package/src/routes/uuid-param.ts +28 -0
  262. package/src/tool-binding.ts +9 -0
@@ -10,6 +10,7 @@ import type { Cursor, GuardrailId, ProjectId, TenantId, UserId } from '@kindgi/t
10
10
  import { statusFor, toWireError } from '../errors.js';
11
11
  import type { GuardrailRegistryBinding } from '../guardrail-binding.js';
12
12
  import type { Authorizer } from '../middleware/authorize.js';
13
+ import { refuseWritesWhenReadOnly } from '../registry-read-only.js';
13
14
  import type { AppEnv } from '../types.js';
14
15
  import { clampLimit } from './pagination.js';
15
16
  import { parseScopeParams } from './scope-params.js';
@@ -47,6 +48,12 @@ export function guardrailsRouter(
47
48
  onWrite?: GuardrailWriteHook,
48
49
  ): Hono<AppEnv> {
49
50
  const r = new Hono<AppEnv>();
51
+ // A read-only registry (under `kindgi dev`, the pack's files) refuses
52
+ // every write before anything else runs.
53
+ r.use(
54
+ '*',
55
+ refuseWritesWhenReadOnly(() => binding.readOnly),
56
+ );
50
57
 
51
58
  // Authorization (PEP) — mirrors agents. Cascade via `guardrail#parent@project`
52
59
  // written in-tx by register; direct check via `ref('guardrail', id)`.
@@ -42,6 +42,15 @@ export function orgDeleteSlugConflictError(slugs: readonly string[]) {
42
42
  } as const;
43
43
  }
44
44
 
45
+ /**
46
+ * `404 org-not-found`: the org a project or team would be in isn't the
47
+ * tenant's (it never existed, or it was deleted). The code and message
48
+ * `GET /v1/orgs/{id}` answers (T220).
49
+ */
50
+ export function orgNotFoundError(orgId: string) {
51
+ return { code: 'org-not-found', message: `No org with id "${orgId}"`, orgId } as const;
52
+ }
53
+
45
54
  /** `409 project-default-already-exists`: the tenant has a Default project. */
46
55
  export function projectDefaultAlreadyExistsError() {
47
56
  return {
@@ -49,3 +58,17 @@ export function projectDefaultAlreadyExistsError() {
49
58
  message: 'The tenant already has a Default project',
50
59
  } as const;
51
60
  }
61
+
62
+ /**
63
+ * `501 authz-membership-unsupported`: authorization is enforced, but the
64
+ * tenant-hierarchy binding can't change a membership together with its
65
+ * authorization tuple (it has no `method`). Nothing is changed: removing
66
+ * the row alone would leave the user's permission in place.
67
+ */
68
+ export function membershipNotKeptInStepError(method: string) {
69
+ return {
70
+ code: 'authz-membership-unsupported',
71
+ message: `This runtime can't keep permissions in step with a membership change (its tenant-hierarchy binding has no ${method}), so nothing was changed.`,
72
+ method,
73
+ } as const;
74
+ }
@@ -252,12 +252,16 @@ async function summarize(
252
252
  let no = 0;
253
253
  let yesWeight = 0;
254
254
  let totalWeight = 0;
255
+ // What judges a class was restricted to asserted, as it was when each judgment was recorded.
256
+ const restricted = { yesWeight: 0, totalWeight: 0 };
255
257
  for (const j of judgments) {
256
258
  const w = await weightOf(j.judgeClassId);
257
259
  totalWeight += w;
260
+ if (j.restricted === true) restricted.totalWeight += w;
258
261
  if (j.verdict === 'yes') {
259
262
  yes += 1;
260
263
  yesWeight += w;
264
+ if (j.restricted === true) restricted.yesWeight += w;
261
265
  } else {
262
266
  no += 1;
263
267
  }
@@ -271,6 +275,7 @@ async function summarize(
271
275
  no,
272
276
  yesWeight,
273
277
  totalWeight,
278
+ restricted,
274
279
  reasons: judgments.flatMap((j) =>
275
280
  j.reason !== undefined ? [{ verdict: j.verdict, reason: j.reason }] : [],
276
281
  ),
@@ -4,7 +4,7 @@
4
4
  import { type Context, Hono } from 'hono';
5
5
 
6
6
  import type { ConversationBinding } from '@kindgi/agents';
7
- import { ref } from '@kindgi/authz';
7
+ import { REVIEWER_ROLE_RANK, type ReviewerRole, ref } from '@kindgi/authz';
8
8
  import type { RunBinding } from '@kindgi/runtime';
9
9
  import type { Cursor, ProjectId, RunId, TenantId } from '@kindgi/types';
10
10
 
@@ -12,6 +12,7 @@ import { statusFor, toWireError } from '../errors.js';
12
12
  import type { FlowRegistryBinding } from '../flow-binding.js';
13
13
  import {
14
14
  type JudgeClass,
15
+ type JudgeClassAssertableBy,
15
16
  type JudgeClassScope,
16
17
  type JudgedItem,
17
18
  type JudgedRunContext,
@@ -23,8 +24,11 @@ import {
23
24
  VERDICTS,
24
25
  type Verdict,
25
26
  judgeClassApplies,
27
+ whyNotAssertable,
26
28
  } from '../judgment-binding.js';
27
29
  import type { Authorizer } from '../middleware/authorize.js';
30
+ import type { ReviewerBinding } from '../reviewer-binding.js';
31
+ import { callerReviewerRole } from '../reviewer-role.js';
28
32
  import type { AppEnv } from '../types.js';
29
33
  import { captureTurnContext } from './judgment-context.js';
30
34
  import { captureFlowContext } from './judgment-flow-context.js';
@@ -52,6 +56,8 @@ export function judgmentsRouter(
52
56
  authorizer?: Authorizer,
53
57
  conversations?: ConversationBinding,
54
58
  flows?: FlowRegistryBinding,
59
+ /** Whose reviewer role a restricted judge class checks (T200). */
60
+ reviewers?: ReviewerBinding,
55
61
  ): Hono<AppEnv> {
56
62
  const r = new Hono<AppEnv>();
57
63
 
@@ -72,9 +78,12 @@ export function judgmentsRouter(
72
78
  return fail('permission-denied', 'Judging needs a user or a service token.');
73
79
  }
74
80
 
75
- const prepared = await prepareJudgment(c, body, runBinding, binding, authorizer);
81
+ const prepared = await prepareJudgment(c, body, runBinding, binding, authorizer, {
82
+ asserted,
83
+ reviewers,
84
+ });
76
85
  if (prepared.kind === 'err') return fail(prepared.code, prepared.message);
77
- const { run, subject, projectId, itemValue, conversationId } = prepared;
86
+ const { run, subject, projectId, itemValue, conversationId, restricted } = prepared;
78
87
  const context = (await isFirstJudgment(binding, tenantId, body.runId))
79
88
  ? await captureContext({
80
89
  tenantId,
@@ -103,6 +112,7 @@ export function judgmentsRouter(
103
112
  verdict: body.verdict,
104
113
  ...(body.reason !== undefined && { reason: body.reason }),
105
114
  ...(body.judgeClassId !== undefined && { judgeClassId: body.judgeClassId }),
115
+ ...(restricted === true && { restricted }),
106
116
  assertedBy: asserted,
107
117
  ...(body.participantId !== undefined && { participantId: body.participantId }),
108
118
  });
@@ -239,7 +249,7 @@ export function judgeClassesRouter(
239
249
  };
240
250
  const parsed = parseClassBody(await c.req.json().catch(() => null));
241
251
  if (parsed.kind === 'err') return fail('bad-input', parsed.message);
242
- const { scope, name, weight, description } = parsed.body;
252
+ const { scope, name, weight, description, assertableBy } = parsed.body;
243
253
  if (
244
254
  authorizer !== undefined &&
245
255
  !(await authorizer.can(c, 'admin', scopeRef(tenantId, scope)))
@@ -252,6 +262,7 @@ export function judgeClassesRouter(
252
262
  name,
253
263
  weight,
254
264
  ...(description !== undefined && { description }),
265
+ ...(assertableBy !== undefined && { assertableBy }),
255
266
  });
256
267
  if (outcome.kind === 'name-taken') {
257
268
  return fail('judge-class-name-taken', `A judge class named "${name}" already exists here.`);
@@ -332,19 +343,25 @@ export function judgeClassesRouter(
332
343
  const raw = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
333
344
  const weight = raw?.weight;
334
345
  const description = raw?.description;
346
+ const assertableBy =
347
+ raw?.assertableBy === undefined ? undefined : parseAssertableBy(raw.assertableBy);
335
348
  if (
336
349
  raw === null ||
337
350
  typeof raw !== 'object' ||
338
- (weight === undefined && description === undefined) ||
351
+ (weight === undefined && description === undefined && assertableBy === undefined) ||
339
352
  (weight !== undefined && !isWeight(weight)) ||
340
- (description !== undefined && typeof description !== 'string')
353
+ (description !== undefined && typeof description !== 'string') ||
354
+ typeof assertableBy === 'string'
341
355
  ) {
342
356
  c.status(statusFor('bad-input') as never);
343
357
  return c.json(
344
358
  toWireError(
345
359
  {
346
360
  code: 'bad-input',
347
- message: 'Send weight (a number ≥ 0) and/or description (a string).',
361
+ message:
362
+ typeof assertableBy === 'string'
363
+ ? assertableBy
364
+ : 'Send weight (a number ≥ 0), description (a string) and/or assertableBy (who may assert the class, or null for anyone).',
348
365
  },
349
366
  requestId,
350
367
  ),
@@ -358,6 +375,7 @@ export function judgeClassesRouter(
358
375
  judgeClassId: found.id,
359
376
  ...(weight !== undefined && { weight: weight as number }),
360
377
  ...(description !== undefined && { description: description as string }),
378
+ ...(assertableBy !== undefined && { assertableBy }),
361
379
  });
362
380
  return updated === null ? classNotFound(c, found.id) : c.json(serializeJudgeClass(updated));
363
381
  });
@@ -382,6 +400,8 @@ type Prepared =
382
400
  readonly projectId: ProjectId;
383
401
  readonly itemValue?: unknown;
384
402
  readonly conversationId?: string;
403
+ /** The class was restricted and the caller met it. */
404
+ readonly restricted?: true;
385
405
  }
386
406
  | { readonly kind: 'err'; readonly code: string; readonly message: string };
387
407
 
@@ -396,6 +416,7 @@ async function prepareJudgment(
396
416
  runBinding: RunBinding,
397
417
  binding: JudgmentRegistryBinding,
398
418
  authorizer: Authorizer | undefined,
419
+ who: { readonly asserted: JudgmentAssertedBy; readonly reviewers: ReviewerBinding | undefined },
399
420
  ): Promise<Prepared> {
400
421
  const err = (code: string, message: string): Prepared => ({ kind: 'err', code, message });
401
422
  const tenantId = c.get('tenantId') as TenantId;
@@ -417,20 +438,37 @@ async function prepareJudgment(
417
438
  }
418
439
  const subject = subjectOf(run);
419
440
  const projectId = run.projectId as unknown as ProjectId;
420
- if (
421
- body.judgeClassId !== undefined &&
422
- !(await classApplies(binding, tenantId, body.judgeClassId, { projectId, subject }))
423
- ) {
424
- return err(
425
- 'judge-class-not-applicable',
426
- `Judge class "${body.judgeClassId}" doesn't exist or doesn't apply to this run's project or agent.`,
427
- );
441
+ let restricted = false;
442
+ if (body.judgeClassId !== undefined) {
443
+ const judgeClass = await applicableClass(binding, tenantId, body.judgeClassId, {
444
+ projectId,
445
+ subject,
446
+ });
447
+ if (judgeClass === null) {
448
+ return err(
449
+ 'judge-class-not-applicable',
450
+ `Judge class "${body.judgeClassId}" doesn't exist or doesn't apply to this run's project or agent.`,
451
+ );
452
+ }
453
+ if (judgeClass.assertableBy !== undefined) {
454
+ const reviewerRole = await callerReviewerRole(c, who.reviewers);
455
+ const why = whyNotAssertable(judgeClass.assertableBy, {
456
+ ...who.asserted,
457
+ ...(reviewerRole !== undefined && { reviewerRole }),
458
+ });
459
+ if (why !== undefined) {
460
+ return err('judge-class-not-allowed', `You can't judge as "${judgeClass.name}": ${why}.`);
461
+ }
462
+ restricted = true;
463
+ }
428
464
  }
429
465
  const copy = { input: run.input, output: run.output };
430
- const turn =
431
- run.agent !== undefined ? { conversationId: run.agent.conversationId as string } : {};
466
+ const extra = {
467
+ ...(run.agent !== undefined && { conversationId: run.agent.conversationId as string }),
468
+ ...(restricted && { restricted: true as const }),
469
+ };
432
470
  if (body.item.pointer === undefined)
433
- return { kind: 'ok', run: copy, subject, projectId, ...turn };
471
+ return { kind: 'ok', run: copy, subject, projectId, ...extra };
434
472
  const found = resolvePointer(run.output, body.item.pointer);
435
473
  if (!found.found) {
436
474
  return err(
@@ -438,7 +476,7 @@ async function prepareJudgment(
438
476
  `Nothing at "${body.item.pointer}" in run "${body.runId}"'s output.`,
439
477
  );
440
478
  }
441
- return { kind: 'ok', run: copy, subject, projectId, itemValue: found.value, ...turn };
479
+ return { kind: 'ok', run: copy, subject, projectId, itemValue: found.value, ...extra };
442
480
  }
443
481
 
444
482
  /** Whether the run has no live judgment yet (its copy is stored with the first). */
@@ -484,15 +522,15 @@ async function isFirstJudgment(
484
522
  return page.data.length === 0;
485
523
  }
486
524
 
487
- /** Whether a live class exists and its scope covers the run. */
488
- async function classApplies(
525
+ /** The live class, when it exists and its scope covers the run; else `null`. */
526
+ async function applicableClass(
489
527
  binding: JudgmentRegistryBinding,
490
528
  tenantId: TenantId,
491
529
  judgeClassId: string,
492
530
  run: { readonly projectId: ProjectId; readonly subject: JudgedSubject },
493
- ): Promise<boolean> {
531
+ ): Promise<JudgeClass | null> {
494
532
  const judgeClass = await binding.getClass({ tenantId, judgeClassId });
495
- return judgeClass !== null && judgeClassApplies(judgeClass.scope, run);
533
+ return judgeClass !== null && judgeClassApplies(judgeClass.scope, run) ? judgeClass : null;
496
534
  }
497
535
 
498
536
  // ---------- request parsing ----------
@@ -573,6 +611,47 @@ interface ClassBody {
573
611
  readonly name: string;
574
612
  readonly weight: number;
575
613
  readonly description?: string;
614
+ readonly assertableBy?: JudgeClassAssertableBy;
615
+ }
616
+
617
+ const ASSERTABLE_BY_USAGE =
618
+ 'assertableBy says who may assert the class: { minReviewerRole?: "standard" | "senior" | "admin", principalKinds?: ["user" | "service", …], principalIds?: [id, …] }, with at least one of them.';
619
+
620
+ /** `assertableBy`: a restriction, `null` (on an update: lift it), or what's wrong. */
621
+ function parseAssertableBy(raw: unknown): JudgeClassAssertableBy | null | string {
622
+ if (raw === null) return null;
623
+ if (typeof raw !== 'object' || Array.isArray(raw)) return ASSERTABLE_BY_USAGE;
624
+ const b = raw as Record<string, unknown>;
625
+ const keys = Object.keys(b);
626
+ const known = ['minReviewerRole', 'principalKinds', 'principalIds'];
627
+ if (keys.length === 0 || keys.some((k) => !known.includes(k))) return ASSERTABLE_BY_USAGE;
628
+ const role = b.minReviewerRole;
629
+ if (role !== undefined && !(typeof role === 'string' && role in REVIEWER_ROLE_RANK)) {
630
+ return ASSERTABLE_BY_USAGE;
631
+ }
632
+ const kinds = b.principalKinds;
633
+ if (
634
+ kinds !== undefined &&
635
+ !(
636
+ Array.isArray(kinds) &&
637
+ kinds.length > 0 &&
638
+ kinds.every((k) => k === 'user' || k === 'service')
639
+ )
640
+ ) {
641
+ return ASSERTABLE_BY_USAGE;
642
+ }
643
+ const ids = b.principalIds;
644
+ if (
645
+ ids !== undefined &&
646
+ !(Array.isArray(ids) && ids.length > 0 && ids.length <= 100 && ids.every(nonEmpty))
647
+ ) {
648
+ return ASSERTABLE_BY_USAGE;
649
+ }
650
+ return {
651
+ ...(role !== undefined && { minReviewerRole: role as ReviewerRole }),
652
+ ...(kinds !== undefined && { principalKinds: kinds as ('user' | 'service')[] }),
653
+ ...(ids !== undefined && { principalIds: ids as string[] }),
654
+ };
576
655
  }
577
656
 
578
657
  function parseClassBody(raw: unknown): Parsed<ClassBody> {
@@ -587,6 +666,8 @@ function parseClassBody(raw: unknown): Parsed<ClassBody> {
587
666
  }
588
667
  const scope = parseClassScope(b.scope);
589
668
  if (typeof scope === 'string') return err(scope);
669
+ const assertableBy = b.assertableBy === undefined ? undefined : parseAssertableBy(b.assertableBy);
670
+ if (typeof assertableBy === 'string') return err(assertableBy);
590
671
  return {
591
672
  kind: 'ok',
592
673
  body: {
@@ -594,6 +675,7 @@ function parseClassBody(raw: unknown): Parsed<ClassBody> {
594
675
  name: b.name,
595
676
  weight: b.weight,
596
677
  ...(typeof b.description === 'string' && { description: b.description }),
678
+ ...(assertableBy !== undefined && assertableBy !== null && { assertableBy }),
597
679
  },
598
680
  };
599
681
  }
@@ -712,6 +794,7 @@ function serializeJudgment(j: Judgment): Record<string, unknown> {
712
794
  verdict: j.verdict,
713
795
  ...(j.reason !== undefined && { reason: j.reason }),
714
796
  ...(j.judgeClassId !== undefined && { judgeClassId: j.judgeClassId }),
797
+ ...(j.restricted === true && { restricted: true }),
715
798
  assertedBy: j.assertedBy,
716
799
  ...(j.participantId !== undefined && { participantId: j.participantId }),
717
800
  createdAt: j.createdAt,
@@ -736,6 +819,7 @@ function serializeJudgeClass(k: JudgeClass): Record<string, unknown> {
736
819
  name: k.name,
737
820
  weight: k.weight,
738
821
  ...(k.description !== undefined && { description: k.description }),
822
+ ...(k.assertableBy !== undefined && { assertableBy: k.assertableBy }),
739
823
  createdAt: k.createdAt,
740
824
  updatedAt: k.updatedAt,
741
825
  ...(k.unregisteredAt !== undefined && { unregisteredAt: k.unregisteredAt }),
@@ -0,0 +1,90 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { LiveScope, OrgId, ProjectId } from '@kindgi/types';
5
+
6
+ import { parseSegmentsBody } from './segments.js';
7
+
8
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+
10
+ /** A live scope on the wire: `{kind}` plus the id or path it needs. */
11
+ export type WireLiveScope =
12
+ | { readonly kind: 'tenant' }
13
+ | { readonly kind: 'org'; readonly orgId: string }
14
+ | { readonly kind: 'project'; readonly projectId: string }
15
+ | {
16
+ readonly kind: 'segment';
17
+ readonly projectId: string;
18
+ readonly path: readonly { readonly key: string; readonly value: string }[];
19
+ };
20
+
21
+ export function liveScopeToWire(scope: LiveScope): WireLiveScope {
22
+ switch (scope.kind) {
23
+ case 'tenant':
24
+ return { kind: 'tenant' };
25
+ case 'org':
26
+ return { kind: 'org', orgId: scope.orgId as unknown as string };
27
+ case 'project':
28
+ return { kind: 'project', projectId: scope.projectId as unknown as string };
29
+ case 'segment':
30
+ return {
31
+ kind: 'segment',
32
+ projectId: scope.projectId as unknown as string,
33
+ path: scope.path.map(({ key, value }) => ({ key, value })),
34
+ };
35
+ }
36
+ }
37
+
38
+ /**
39
+ * A live scope from a request body (`scope`): `{kind: 'tenant'}`,
40
+ * `{kind: 'org', orgId}`, `{kind: 'project', projectId}` or
41
+ * `{kind: 'segment', projectId, path: [{key, value}, …]}`.
42
+ */
43
+ export function parseLiveScopeBody(
44
+ raw: unknown,
45
+ ):
46
+ | { readonly kind: 'ok'; readonly scope: LiveScope }
47
+ | { readonly kind: 'err'; readonly message: string } {
48
+ const b = (raw ?? {}) as Record<string, unknown>;
49
+ const id = (field: 'orgId' | 'projectId'): string | undefined => {
50
+ const v = b[field];
51
+ return typeof v === 'string' && UUID_RE.test(v) ? v : undefined;
52
+ };
53
+ switch (b.kind) {
54
+ case 'tenant':
55
+ return { kind: 'ok', scope: { kind: 'tenant' } };
56
+ case 'org': {
57
+ const orgId = id('orgId');
58
+ if (orgId === undefined)
59
+ return { kind: 'err', message: '`scope.orgId` must be an org id (a UUID)' };
60
+ return { kind: 'ok', scope: { kind: 'org', orgId: orgId as OrgId } };
61
+ }
62
+ case 'project': {
63
+ const projectId = id('projectId');
64
+ if (projectId === undefined) {
65
+ return { kind: 'err', message: '`scope.projectId` must be a project id (a UUID)' };
66
+ }
67
+ return { kind: 'ok', scope: { kind: 'project', projectId: projectId as ProjectId } };
68
+ }
69
+ case 'segment': {
70
+ const projectId = id('projectId');
71
+ if (projectId === undefined) {
72
+ return { kind: 'err', message: '`scope.projectId` must be a project id (a UUID)' };
73
+ }
74
+ const path = parseSegmentsBody(b.path, 'scope.path');
75
+ if (path.kind === 'err') return path;
76
+ if (path.segments === undefined) {
77
+ return { kind: 'err', message: '`scope.path` needs at least one segment' };
78
+ }
79
+ return {
80
+ kind: 'ok',
81
+ scope: { kind: 'segment', projectId: projectId as ProjectId, path: path.segments },
82
+ };
83
+ }
84
+ default:
85
+ return {
86
+ kind: 'err',
87
+ message: '`scope.kind` must be one of tenant, org, project, segment',
88
+ };
89
+ }
90
+ }
@@ -11,6 +11,8 @@ import {
11
11
  type Policy,
12
12
  type PolicyKind,
13
13
  type PolicyRegistryBinding,
14
+ type PolicyScopeChanged,
15
+ type PolicyScopeTaken,
14
16
  isAppliedPolicyKind,
15
17
  validatePolicySpec,
16
18
  } from '@kindgi/policy-contract';
@@ -27,9 +29,12 @@ import { clampLimit } from './pagination.js';
27
29
  * that kind. The route validates the top-level shape (id, tenantId,
28
30
  * semver version, kind ∈ closed enum, spec is an object), and `spec`
29
31
  * against its kind's contract where `@kindgi/policy-contract` has one
30
- * (`tool-errors`, `hitl`) — a policy that couldn't be applied is refused
31
- * here, not found out on a turn. Other kinds' specs are their runtime
32
- * consumer's to validate.
32
+ * (`tool-errors`, `hitl`, `retention`) — a policy that couldn't be
33
+ * applied is refused here, not found out on a turn or a sweep. Other
34
+ * kinds' specs are their runtime consumer's to validate. A kind that
35
+ * holds a scope (`policyScope`: a retention policy, its domain) gets one
36
+ * policy per scope; the binding enforces it (`409 policy-scope-taken`,
37
+ * `409 policy-scope-changed`).
33
38
  *
34
39
  * Enforcement is out of scope. This is the *registry* surface only.
35
40
  * Runtime consumers (e.g. `@kindgi/capabilities` `route` for
@@ -255,6 +260,10 @@ export function policiesRouter(binding: PolicyRegistryBinding): Hono<AppEnv> {
255
260
  ),
256
261
  );
257
262
  }
263
+ if (outcome.kind === 'scope-taken' || outcome.kind === 'scope-changed') {
264
+ c.status(statusFor(`policy-${outcome.kind}`) as never);
265
+ return c.json(toWireError(scopeError(outcome, 'publish'), requestId));
266
+ }
258
267
  c.status(201);
259
268
  return c.json({ policyId: outcome.policyId, version: outcome.version });
260
269
  });
@@ -305,6 +314,10 @@ export function policiesRouter(binding: PolicyRegistryBinding): Hono<AppEnv> {
305
314
  ),
306
315
  );
307
316
  }
317
+ if (outcome.kind === 'scope-taken' || outcome.kind === 'scope-changed') {
318
+ c.status(statusFor(`policy-${outcome.kind}`) as never);
319
+ return c.json(toWireError(scopeError(outcome, 'reinstate'), requestId));
320
+ }
308
321
  return c.json({
309
322
  policyId: outcome.policyId,
310
323
  version: outcome.version,
@@ -315,6 +328,51 @@ export function policiesRouter(binding: PolicyRegistryBinding): Hono<AppEnv> {
315
328
  return r;
316
329
  }
317
330
 
331
+ /**
332
+ * The 409 for a policy whose scope (`policyScope`) another policy holds,
333
+ * or that a version would move: for `retention`, a second policy for a
334
+ * domain, which a tenant can't have.
335
+ */
336
+ function scopeError(
337
+ outcome: PolicyScopeTaken | PolicyScopeChanged,
338
+ action: 'publish' | 'reinstate',
339
+ ): { code: string; message: string } & Record<string, unknown> {
340
+ const { policyId, version, policyKind, scope } = outcome;
341
+ const retention = policyKind === 'retention';
342
+ if (outcome.kind === 'scope-taken') {
343
+ const { heldBy } = outcome;
344
+ const message = !retention
345
+ ? `Policy "${heldBy}" already holds "${scope}" for ${policyKind} policies.`
346
+ : action === 'publish'
347
+ ? `Retention domain "${scope}" is already covered by policy "${heldBy}": a tenant has one retention policy per domain, and one for "*". Publish a new version of "${heldBy}" instead, or unregister it first.`
348
+ : `Retention domain "${scope}" is now covered by policy "${heldBy}", so reinstating "${policyId}" ${version} would make two: a tenant has one retention policy per domain, and one for "*". Unregister "${heldBy}" first.`;
349
+ return {
350
+ code: 'policy-scope-taken',
351
+ message,
352
+ policyId,
353
+ version,
354
+ policyKind,
355
+ scope,
356
+ heldBy,
357
+ };
358
+ }
359
+ const { previousScope } = outcome;
360
+ const message = !retention
361
+ ? `Policy "${policyId}" holds "${previousScope}"; version ${version} can't move it to "${scope}".`
362
+ : action === 'publish'
363
+ ? `Policy "${policyId}" covers retention domain "${previousScope}", and a new version can't move it to "${scope}". Publish a policy for "${scope}" under a new id.`
364
+ : `Version ${version} of policy "${policyId}" covers retention domain "${scope}", but its other versions cover "${previousScope}": a policy keeps one domain. Publish it under a new id instead.`;
365
+ return {
366
+ code: 'policy-scope-changed',
367
+ message,
368
+ policyId,
369
+ version,
370
+ policyKind,
371
+ scope,
372
+ previousScope,
373
+ };
374
+ }
375
+
318
376
  function serializePolicy(p: Policy): Record<string, unknown> {
319
377
  return {
320
378
  id: p.id,