@kindgi/api 0.1.4-rc.1 → 0.1.4-rc.3

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 (220) hide show
  1. package/dist/agent-binding.d.ts +12 -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 +23 -4
  9. package/dist/app.js.map +1 -1
  10. package/dist/deploy-versions.d.ts +8 -5
  11. package/dist/deploy-versions.d.ts.map +1 -1
  12. package/dist/deploy-versions.js +3 -9
  13. package/dist/deploy-versions.js.map +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +25 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/eval-case-binding.d.ts +10 -0
  18. package/dist/eval-case-binding.d.ts.map +1 -1
  19. package/dist/eval-run-binding.d.ts +14 -3
  20. package/dist/eval-run-binding.d.ts.map +1 -1
  21. package/dist/eval-run-binding.js.map +1 -1
  22. package/dist/flow-pins.d.ts +20 -7
  23. package/dist/flow-pins.d.ts.map +1 -1
  24. package/dist/flow-pins.js +36 -11
  25. package/dist/flow-pins.js.map +1 -1
  26. package/dist/gate-policy-binding.d.ts +164 -0
  27. package/dist/gate-policy-binding.d.ts.map +1 -0
  28. package/dist/gate-policy-binding.js +12 -0
  29. package/dist/gate-policy-binding.js.map +1 -0
  30. package/dist/gate.d.ts +56 -0
  31. package/dist/gate.d.ts.map +1 -0
  32. package/dist/gate.js +359 -0
  33. package/dist/gate.js.map +1 -0
  34. package/dist/handler-binding.d.ts +14 -2
  35. package/dist/handler-binding.d.ts.map +1 -1
  36. package/dist/hitl-binding.d.ts +5 -0
  37. package/dist/hitl-binding.d.ts.map +1 -1
  38. package/dist/index.d.ts +10 -4
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/judged-dispatcher.d.ts +16 -1
  43. package/dist/judged-dispatcher.d.ts.map +1 -1
  44. package/dist/judged-dispatcher.js +45 -6
  45. package/dist/judged-dispatcher.js.map +1 -1
  46. package/dist/judgment-binding.d.ts +38 -0
  47. package/dist/judgment-binding.d.ts.map +1 -1
  48. package/dist/judgment-binding.js +19 -0
  49. package/dist/judgment-binding.js.map +1 -1
  50. package/dist/live-version-binding.d.ts +206 -0
  51. package/dist/live-version-binding.d.ts.map +1 -0
  52. package/dist/live-version-binding.js +4 -0
  53. package/dist/live-version-binding.js.map +1 -0
  54. package/dist/middleware/authorize.d.ts.map +1 -1
  55. package/dist/middleware/authorize.js +13 -2
  56. package/dist/middleware/authorize.js.map +1 -1
  57. package/dist/middleware/project-ref.d.ts +25 -0
  58. package/dist/middleware/project-ref.d.ts.map +1 -0
  59. package/dist/middleware/project-ref.js +72 -0
  60. package/dist/middleware/project-ref.js.map +1 -0
  61. package/dist/openapi/generate.d.ts.map +1 -1
  62. package/dist/openapi/generate.js +4 -0
  63. package/dist/openapi/generate.js.map +1 -1
  64. package/dist/openapi/operations.d.ts +6 -0
  65. package/dist/openapi/operations.d.ts.map +1 -1
  66. package/dist/openapi/operations.js +512 -22
  67. package/dist/openapi/operations.js.map +1 -1
  68. package/dist/openapi/schemas.d.ts +37 -0
  69. package/dist/openapi/schemas.d.ts.map +1 -1
  70. package/dist/openapi/schemas.js +709 -5
  71. package/dist/openapi/schemas.js.map +1 -1
  72. package/dist/publish-refused.d.ts +18 -0
  73. package/dist/publish-refused.d.ts.map +1 -0
  74. package/dist/publish-refused.js +22 -0
  75. package/dist/publish-refused.js.map +1 -0
  76. package/dist/retention-binding.d.ts +29 -0
  77. package/dist/retention-binding.d.ts.map +1 -1
  78. package/dist/routes/agent-releases.d.ts +40 -0
  79. package/dist/routes/agent-releases.d.ts.map +1 -0
  80. package/dist/routes/agent-releases.js +462 -0
  81. package/dist/routes/agent-releases.js.map +1 -0
  82. package/dist/routes/agents.d.ts +7 -1
  83. package/dist/routes/agents.d.ts.map +1 -1
  84. package/dist/routes/agents.js +31 -3
  85. package/dist/routes/agents.js.map +1 -1
  86. package/dist/routes/approvals.d.ts.map +1 -1
  87. package/dist/routes/approvals.js +40 -9
  88. package/dist/routes/approvals.js.map +1 -1
  89. package/dist/routes/audit.d.ts.map +1 -1
  90. package/dist/routes/audit.js +7 -0
  91. package/dist/routes/audit.js.map +1 -1
  92. package/dist/routes/auth.js +1 -1
  93. package/dist/routes/auth.js.map +1 -1
  94. package/dist/routes/conversations.d.ts +0 -7
  95. package/dist/routes/conversations.d.ts.map +1 -1
  96. package/dist/routes/conversations.js +19 -3
  97. package/dist/routes/conversations.js.map +1 -1
  98. package/dist/routes/deployments.d.ts +6 -0
  99. package/dist/routes/deployments.d.ts.map +1 -1
  100. package/dist/routes/deployments.js +33 -5
  101. package/dist/routes/deployments.js.map +1 -1
  102. package/dist/routes/env.d.ts.map +1 -1
  103. package/dist/routes/env.js +1 -0
  104. package/dist/routes/env.js.map +1 -1
  105. package/dist/routes/eval-comparison.d.ts +2 -1
  106. package/dist/routes/eval-comparison.d.ts.map +1 -1
  107. package/dist/routes/eval-comparison.js +22 -9
  108. package/dist/routes/eval-comparison.js.map +1 -1
  109. package/dist/routes/flows.d.ts +14 -7
  110. package/dist/routes/flows.d.ts.map +1 -1
  111. package/dist/routes/flows.js +10 -8
  112. package/dist/routes/flows.js.map +1 -1
  113. package/dist/routes/gate-policies.d.ts +19 -0
  114. package/dist/routes/gate-policies.d.ts.map +1 -0
  115. package/dist/routes/gate-policies.js +191 -0
  116. package/dist/routes/gate-policies.js.map +1 -0
  117. package/dist/routes/gate-policy-spec.d.ts +17 -0
  118. package/dist/routes/gate-policy-spec.d.ts.map +1 -0
  119. package/dist/routes/gate-policy-spec.js +192 -0
  120. package/dist/routes/gate-policy-spec.js.map +1 -0
  121. package/dist/routes/gate-policy-wire.d.ts +3 -0
  122. package/dist/routes/gate-policy-wire.d.ts.map +1 -0
  123. package/dist/routes/gate-policy-wire.js +18 -0
  124. package/dist/routes/gate-policy-wire.js.map +1 -0
  125. package/dist/routes/hierarchy-errors.d.ts +11 -0
  126. package/dist/routes/hierarchy-errors.d.ts.map +1 -1
  127. package/dist/routes/hierarchy-errors.js +13 -0
  128. package/dist/routes/hierarchy-errors.js.map +1 -1
  129. package/dist/routes/judged-suites.js +7 -0
  130. package/dist/routes/judged-suites.js.map +1 -1
  131. package/dist/routes/judgments.d.ts +4 -1
  132. package/dist/routes/judgments.d.ts.map +1 -1
  133. package/dist/routes/judgments.js +92 -19
  134. package/dist/routes/judgments.js.map +1 -1
  135. package/dist/routes/live-scope-wire.d.ts +32 -0
  136. package/dist/routes/live-scope-wire.d.ts.map +1 -0
  137. package/dist/routes/live-scope-wire.js +71 -0
  138. package/dist/routes/live-scope-wire.js.map +1 -0
  139. package/dist/routes/policies.d.ts +6 -3
  140. package/dist/routes/policies.d.ts.map +1 -1
  141. package/dist/routes/policies.js +55 -3
  142. package/dist/routes/policies.js.map +1 -1
  143. package/dist/routes/projects.d.ts.map +1 -1
  144. package/dist/routes/projects.js +47 -3
  145. package/dist/routes/projects.js.map +1 -1
  146. package/dist/routes/retention.d.ts.map +1 -1
  147. package/dist/routes/retention.js +18 -2
  148. package/dist/routes/retention.js.map +1 -1
  149. package/dist/routes/reviewers.d.ts +8 -1
  150. package/dist/routes/reviewers.d.ts.map +1 -1
  151. package/dist/routes/reviewers.js +20 -3
  152. package/dist/routes/reviewers.js.map +1 -1
  153. package/dist/routes/runs.d.ts.map +1 -1
  154. package/dist/routes/runs.js +22 -13
  155. package/dist/routes/runs.js.map +1 -1
  156. package/dist/routes/secrets.d.ts.map +1 -1
  157. package/dist/routes/secrets.js +2 -0
  158. package/dist/routes/secrets.js.map +1 -1
  159. package/dist/routes/segments.d.ts +17 -0
  160. package/dist/routes/segments.d.ts.map +1 -0
  161. package/dist/routes/segments.js +68 -0
  162. package/dist/routes/segments.js.map +1 -0
  163. package/dist/routes/teams.d.ts.map +1 -1
  164. package/dist/routes/teams.js +42 -3
  165. package/dist/routes/teams.js.map +1 -1
  166. package/dist/routes/uuid-param.d.ts +11 -0
  167. package/dist/routes/uuid-param.d.ts.map +1 -0
  168. package/dist/routes/uuid-param.js +19 -0
  169. package/dist/routes/uuid-param.js.map +1 -0
  170. package/openapi.json +7008 -3624
  171. package/package.json +21 -21
  172. package/src/agent-binding.ts +12 -2
  173. package/src/agent-pins.ts +3 -1
  174. package/src/app.ts +37 -3
  175. package/src/deploy-versions.ts +11 -14
  176. package/src/errors.ts +25 -0
  177. package/src/eval-case-binding.ts +7 -0
  178. package/src/eval-run-binding.ts +24 -3
  179. package/src/flow-pins.ts +49 -9
  180. package/src/gate-policy-binding.ts +171 -0
  181. package/src/gate.ts +467 -0
  182. package/src/handler-binding.ts +14 -2
  183. package/src/hitl-binding.ts +5 -0
  184. package/src/index.ts +46 -1
  185. package/src/judged-dispatcher.ts +74 -6
  186. package/src/judgment-binding.ts +62 -0
  187. package/src/live-version-binding.ts +237 -0
  188. package/src/middleware/authorize.ts +18 -2
  189. package/src/middleware/project-ref.ts +88 -0
  190. package/src/openapi/generate.ts +5 -0
  191. package/src/openapi/operations.ts +619 -22
  192. package/src/openapi/schemas.ts +788 -5
  193. package/src/publish-refused.ts +31 -0
  194. package/src/retention-binding.ts +30 -0
  195. package/src/routes/agent-releases.ts +581 -0
  196. package/src/routes/agents.ts +43 -2
  197. package/src/routes/approvals.ts +58 -8
  198. package/src/routes/audit.ts +10 -0
  199. package/src/routes/auth.ts +1 -1
  200. package/src/routes/conversations.ts +31 -3
  201. package/src/routes/deployments.ts +50 -5
  202. package/src/routes/env.ts +1 -0
  203. package/src/routes/eval-comparison.ts +24 -10
  204. package/src/routes/flows.ts +27 -11
  205. package/src/routes/gate-policies.ts +226 -0
  206. package/src/routes/gate-policy-spec.ts +229 -0
  207. package/src/routes/gate-policy-wire.ts +20 -0
  208. package/src/routes/hierarchy-errors.ts +14 -0
  209. package/src/routes/judged-suites.ts +5 -0
  210. package/src/routes/judgments.ts +107 -23
  211. package/src/routes/live-scope-wire.ts +90 -0
  212. package/src/routes/policies.ts +61 -3
  213. package/src/routes/projects.ts +54 -2
  214. package/src/routes/retention.ts +21 -2
  215. package/src/routes/reviewers.ts +23 -4
  216. package/src/routes/runs.ts +33 -20
  217. package/src/routes/secrets.ts +2 -0
  218. package/src/routes/segments.ts +75 -0
  219. package/src/routes/teams.ts +53 -3
  220. package/src/routes/uuid-param.ts +28 -0
@@ -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,
@@ -10,6 +10,7 @@ import type {
10
10
  ProjectBinding,
11
11
  ProjectMembership,
12
12
  ProjectMembershipBinding,
13
+ ProjectMembershipUpdateRoleOutcome,
13
14
  ProjectPatch,
14
15
  ProjectRole,
15
16
  ProjectSpec,
@@ -20,6 +21,7 @@ import { statusFor, toWireError } from '../errors.js';
20
21
  import type { Authorizer } from '../middleware/authorize.js';
21
22
  import type { AppEnv } from '../types.js';
22
23
  import {
24
+ membershipNotKeptInStepError,
23
25
  orgNotFoundError,
24
26
  projectDefaultAlreadyExistsError,
25
27
  slugConflictError,
@@ -78,6 +80,39 @@ export function projectsRouter(
78
80
  ): Hono<AppEnv> {
79
81
  const r = new Hono<AppEnv>();
80
82
 
83
+ /**
84
+ * Change a member's role. With an authorizer, the row and its FGA tuple
85
+ * change together through the tenant-hierarchy binding, or the change
86
+ * is refused; without one, the membership binding alone.
87
+ */
88
+ async function updateMemberRole(
89
+ tenantId: TenantId,
90
+ projectId: ProjectId,
91
+ userId: UserId,
92
+ role: ProjectRole,
93
+ ): Promise<
94
+ | { readonly kind: 'done'; readonly outcome: ProjectMembershipUpdateRoleOutcome }
95
+ | { readonly kind: 'refused'; readonly error: ReturnType<typeof membershipNotKeptInStepError> }
96
+ > {
97
+ if (authorizer === undefined) {
98
+ return {
99
+ kind: 'done',
100
+ outcome: await membershipBinding.updateRole(tenantId, projectId, userId, role),
101
+ };
102
+ }
103
+ if (tenantHierarchy.updateProjectMemberRole === undefined) {
104
+ return { kind: 'refused', error: membershipNotKeptInStepError('updateProjectMemberRole') };
105
+ }
106
+ const res = await tenantHierarchy.updateProjectMemberRole({
107
+ tenantId,
108
+ projectId,
109
+ userId,
110
+ role,
111
+ });
112
+ if (res.kind === 'err') throw new Error(res.error.message, { cause: res.error });
113
+ return { kind: 'done', outcome: res.value };
114
+ }
115
+
81
116
  // ---------- GET /default (literal segment; must precede /:projectId) ----------
82
117
  r.get('/default', async (c) => {
83
118
  const requestId = c.get('requestId');
@@ -420,7 +455,19 @@ export function projectsRouter(
420
455
  const tenantId = c.get('tenantId') as TenantId;
421
456
  const projectId = c.req.param('projectId') as ProjectId;
422
457
  const userId = c.req.param('userId') as UserId;
423
- await membershipBinding.remove(tenantId, projectId, userId);
458
+ // With an authorizer, the row and its FGA tuple go together, or not
459
+ // at all: removing the row alone would leave the permission in place.
460
+ if (authorizer !== undefined) {
461
+ if (tenantHierarchy.removeProjectMember === undefined) {
462
+ const refusal = membershipNotKeptInStepError('removeProjectMember');
463
+ c.status(statusFor(refusal.code) as never);
464
+ return c.json(toWireError(refusal, c.get('requestId')));
465
+ }
466
+ const res = await tenantHierarchy.removeProjectMember({ tenantId, projectId, userId });
467
+ if (res.kind === 'err') throw new Error(res.error.message, { cause: res.error });
468
+ } else {
469
+ await membershipBinding.remove(tenantId, projectId, userId);
470
+ }
424
471
  c.status(204);
425
472
  return c.body(null);
426
473
  });
@@ -460,7 +507,12 @@ export function projectsRouter(
460
507
  ),
461
508
  );
462
509
  }
463
- const outcome = await membershipBinding.updateRole(tenantId, projectId, userId, b.role);
510
+ const updated = await updateMemberRole(tenantId, projectId, userId, b.role);
511
+ if (updated.kind === 'refused') {
512
+ c.status(statusFor(updated.error.code) as never);
513
+ return c.json(toWireError(updated.error, requestId));
514
+ }
515
+ const { outcome } = updated;
464
516
  if (outcome.kind === 'project-membership-not-found') {
465
517
  c.status(statusFor('project-membership-not-found') as never);
466
518
  return c.json(
@@ -62,15 +62,21 @@ export function retentionRouter(binding: RetentionBinding, authorizer?: Authoriz
62
62
  const limit = clampLimit(url.searchParams.get('limit') ?? undefined);
63
63
  const pastGraceRaw = url.searchParams.get('pastGraceOnly');
64
64
  const pastGraceOnly = pastGraceRaw === 'true';
65
+ const cursor = url.searchParams.get('cursor') ?? '';
65
66
 
66
- const page = await binding.scheduled({
67
+ const { hasMore, nextCursor, ...page } = await binding.scheduled({
67
68
  tenantId,
68
69
  limit,
69
70
  pastGraceOnly,
70
71
  ...(domain !== undefined && { domain }),
72
+ ...(cursor !== '' && { cursor }),
71
73
  });
72
74
 
73
- return c.json(page);
75
+ return c.json({
76
+ ...page,
77
+ hasMore: hasMore ?? someDomainFilled(page.data, limit),
78
+ ...(nextCursor !== undefined && { nextCursor }),
79
+ });
74
80
  });
75
81
 
76
82
  r.post('/sweep', async (c) => {
@@ -153,6 +159,19 @@ export function retentionRouter(binding: RetentionBinding, authorizer?: Authoriz
153
159
  return r;
154
160
  }
155
161
 
162
+ /**
163
+ * A runtime that doesn't report `hasMore`: there may be more when some
164
+ * domain's rows fill `limit` (each domain is read up to `limit`).
165
+ */
166
+ function someDomainFilled(
167
+ data: readonly { readonly domain: RetentionDomain }[],
168
+ limit: number,
169
+ ): boolean {
170
+ const perDomain = new Map<RetentionDomain, number>();
171
+ for (const row of data) perDomain.set(row.domain, (perDomain.get(row.domain) ?? 0) + 1);
172
+ return [...perDomain.values()].some((n) => n >= limit);
173
+ }
174
+
156
175
  function parseDomain(raw: string | null | undefined): RetentionDomain | 'invalid' | undefined {
157
176
  if (raw === null || raw === undefined || raw === '') return undefined;
158
177
  if ((RETENTION_DOMAINS as readonly string[]).includes(raw)) return raw as RetentionDomain;
@@ -3,10 +3,11 @@
3
3
 
4
4
  import { Hono } from 'hono';
5
5
 
6
- import type { ReviewerRole } from '@kindgi/authz';
6
+ import { type ReviewerRole, ref } from '@kindgi/authz';
7
7
  import type { Cursor, ReviewerId, TenantId, UserId } from '@kindgi/types';
8
8
 
9
9
  import { statusFor, toWireError } from '../errors.js';
10
+ import type { Authorizer } from '../middleware/authorize.js';
10
11
  import type { ReviewerRecord, ReviewerRegistryBinding } from '../reviewer-binding.js';
11
12
  import type { AppEnv } from '../types.js';
12
13
  import { clampLimit } from './pagination.js';
@@ -20,8 +21,9 @@ import { clampLimit } from './pagination.js';
20
21
  * inside `approvalsRouter`: registering a reviewer is an admin surface
21
22
  * action, not a reviewer-only one, so requiring a `reviewerRole` on the
22
23
  * caller would be circular ("only reviewers can appoint reviewers").
23
- * The caller still needs a valid bearer token — the `/v1/*` auth chain
24
- * covers that upstream.
24
+ * With authorization enforced, registering or unregistering needs
25
+ * `admin` on the tenant; reading the roster needs only a valid bearer
26
+ * token (the `/v1/*` auth chain upstream).
25
27
  *
26
28
  * Mounted BEFORE `approvalsRouter` on the parent `/v1` router so path
27
29
  * matching resolves `/v1/approvals/reviewers/*` here rather than being
@@ -29,9 +31,26 @@ import { clampLimit } from './pagination.js';
29
31
  */
30
32
  const ROLE_VALUES: ReadonlySet<ReviewerRole> = new Set(['standard', 'senior', 'admin']);
31
33
 
32
- export function reviewersRouter(binding: ReviewerRegistryBinding): Hono<AppEnv> {
34
+ export function reviewersRouter(
35
+ binding: ReviewerRegistryBinding,
36
+ /**
37
+ * When set, registering or unregistering a reviewer needs `admin` on
38
+ * the tenant: a reviewer (an `admin` one above all) decides approvals.
39
+ * Reading the list doesn't.
40
+ */
41
+ authorizer?: Authorizer,
42
+ ): Hono<AppEnv> {
33
43
  const r = new Hono<AppEnv>();
34
44
 
45
+ if (authorizer !== undefined) {
46
+ r.use('*', async (c, next) => {
47
+ if (c.req.method === 'GET') return next();
48
+ const tenantId = c.get('tenantId') as TenantId;
49
+ const mw = authorizer.authorize('admin', () => ref('tenant', tenantId as unknown as string));
50
+ return mw(c, next);
51
+ });
52
+ }
53
+
35
54
  // ---------- GET / (list, cursor-paginated) ----------
36
55
  r.get('/', async (c) => {
37
56
  const requestId = c.get('requestId');