@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
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * A flow candidate's `versions`, checked against the flow version when
6
+ * the comparison starts: each id must be an agent or tool the flow uses,
7
+ * at a published version. Checked once, here, before any run exists: the
8
+ * runs apply them as given, on resume too.
9
+ */
10
+
11
+ import type { AgentId } from '@kindgi/agents';
12
+ import { type FlowVersionOverrides, overridableRefs } from '@kindgi/flow';
13
+ import type { FlowId, Semver, TenantId, ToolId } from '@kindgi/types';
14
+
15
+ import type { AgentRegistryBinding } from '../agent-binding.js';
16
+ import type { FlowRegistryBinding } from '../flow-binding.js';
17
+ import type { ToolRegistryBinding } from '../tool-binding.js';
18
+
19
+ /** Where the check reads the flow version and the agents' and tools' versions. */
20
+ export interface FlowVersionsCheck {
21
+ readonly flows: FlowRegistryBinding;
22
+ readonly agents?: AgentRegistryBinding;
23
+ readonly tools?: ToolRegistryBinding;
24
+ }
25
+
26
+ export interface VersionsIssue {
27
+ readonly path: string;
28
+ readonly message: string;
29
+ }
30
+
31
+ /** A JSON Pointer token: `~` and `/` escaped. */
32
+ const token = (id: string): string => id.replaceAll('~', '~0').replaceAll('/', '~1');
33
+
34
+ async function agentIssues(
35
+ check: FlowVersionsCheck,
36
+ tenantId: TenantId,
37
+ label: string,
38
+ used: ReadonlySet<string>,
39
+ overrides: Readonly<Record<string, string>>,
40
+ ): Promise<VersionsIssue[]> {
41
+ const issues: VersionsIssue[] = [];
42
+ for (const [id, version] of Object.entries(overrides)) {
43
+ const path = `/versions/agents/${token(id)}`;
44
+ if (!used.has(id)) {
45
+ issues.push({ path, message: `flow ${label} doesn't use agent ${id}` });
46
+ continue;
47
+ }
48
+ const found = await check.agents?.getVersion({
49
+ tenantId,
50
+ agentId: id as AgentId,
51
+ version: version as Semver,
52
+ });
53
+ if (found === null) issues.push({ path, message: `agent ${id} has no version ${version}` });
54
+ else if (found?.unregisteredAt !== undefined) {
55
+ issues.push({ path, message: `agent ${id} ${version} is unregistered` });
56
+ }
57
+ }
58
+ return issues;
59
+ }
60
+
61
+ async function toolIssues(
62
+ check: FlowVersionsCheck,
63
+ tenantId: TenantId,
64
+ label: string,
65
+ used: ReadonlySet<string>,
66
+ overrides: Readonly<Record<string, string>>,
67
+ ): Promise<VersionsIssue[]> {
68
+ const issues: VersionsIssue[] = [];
69
+ for (const [id, version] of Object.entries(overrides)) {
70
+ const path = `/versions/tools/${token(id)}`;
71
+ if (!used.has(id)) {
72
+ issues.push({ path, message: `flow ${label} doesn't use tool ${id}` });
73
+ continue;
74
+ }
75
+ const found = await check.tools?.getVersion({
76
+ tenantId,
77
+ toolId: id as ToolId,
78
+ version: version as Semver,
79
+ });
80
+ if (found === null) issues.push({ path, message: `tool ${id} has no version ${version}` });
81
+ }
82
+ return issues;
83
+ }
84
+
85
+ /**
86
+ * What's wrong with a flow candidate's `versions`, one issue per id; none
87
+ * when they fit. Without an agent or tool registry, those versions'
88
+ * existence isn't checked (the runs refuse an unpublished one).
89
+ */
90
+ export async function checkFlowVersions(
91
+ check: FlowVersionsCheck,
92
+ tenantId: TenantId,
93
+ flowRef: { readonly flowId: string; readonly version: string },
94
+ versions: FlowVersionOverrides,
95
+ ): Promise<readonly VersionsIssue[]> {
96
+ const label = `${flowRef.flowId} ${flowRef.version}`;
97
+ const flow = await check.flows.getVersion({
98
+ tenantId,
99
+ flowId: flowRef.flowId as FlowId,
100
+ version: flowRef.version,
101
+ });
102
+ if (flow === null) {
103
+ return [{ path: '/flowRef/version', message: `flow ${label} isn't registered` }];
104
+ }
105
+ const refs = overridableRefs(flow);
106
+ return [
107
+ ...(await agentIssues(check, tenantId, label, new Set(refs.agents), versions.agents ?? {})),
108
+ ...(await toolIssues(check, tenantId, label, new Set(refs.tools), versions.tools ?? {})),
109
+ ];
110
+ }
@@ -12,7 +12,9 @@ import type { UnpinnableRef } from '../agent-pins.js';
12
12
  import { statusFor, toWireError } from '../errors.js';
13
13
  import type { FlowRegistryBinding, FlowVersionRecord } from '../flow-binding.js';
14
14
  import { resolveFlowPins } from '../flow-pins.js';
15
+ import type { LiveVersionBinding } from '../live-version-binding.js';
15
16
  import type { Authorizer } from '../middleware/authorize.js';
17
+ import { refuseWritesWhenReadOnly } from '../registry-read-only.js';
16
18
  import type { ToolRegistryBinding } from '../tool-binding.js';
17
19
  import type { AppEnv } from '../types.js';
18
20
  import { clampLimit } from './pagination.js';
@@ -36,18 +38,26 @@ import { parseScopeParams } from './scope-params.js';
36
38
  * `/v1/flows/:flowId/run` route is added here.
37
39
  *
38
40
  * With `pinning` (the tool and agent registries), a published version is
39
- * pinned: each tool it runs, and each agent it runs at no named version,
40
- * resolves once, at publish, to the latest version, which every run of
41
- * that version uses (`pins`, see `resolveFlowPins`); one with no
42
- * published version refuses the publish. Without it, versions carry no
43
- * pins and bind the latest versions per run.
41
+ * pinned: each tool it runs resolves once, at publish, to its latest
42
+ * version, and each agent it runs at no named version to what a run in
43
+ * the flow's project would get, its live version there with
44
+ * `pinning.live`, else its latest. Every run of that version uses those
45
+ * (`pins`, see `resolveFlowPins`); one with no published version refuses
46
+ * the publish. Without `pinning`, versions carry no pins and bind the
47
+ * latest versions per run.
44
48
  */
45
49
  export function flowsRouter(
46
50
  binding: FlowRegistryBinding,
47
51
  authorizer?: Authorizer,
48
- pinning?: { readonly tools: ToolRegistryBinding; readonly agents: AgentRegistryBinding },
52
+ pinning?: FlowPinning,
49
53
  ): Hono<AppEnv> {
50
54
  const r = new Hono<AppEnv>();
55
+ // A read-only registry (under `kindgi dev`, the pack's files) refuses
56
+ // every write before anything else runs.
57
+ r.use(
58
+ '*',
59
+ refuseWritesWhenReadOnly(() => binding.readOnly),
60
+ );
51
61
 
52
62
  // Authorization — mirrors agents 1:1. Check the resource directly;
53
63
  // permissions cascade from the parent project because publish writes
@@ -293,7 +303,7 @@ export function flowsRouter(
293
303
  // Pin the version: every tool and unversioned agent resolves now,
294
304
  // once, to the version its runs use. One with no published version
295
305
  // refuses the publish rather than store a partly pinned version.
296
- const pinned = await pinFlow(pinning, tenantId, loaded.value);
306
+ const pinned = await pinFlow(pinning, tenantId, projectId, loaded.value);
297
307
  if (pinned.kind === 'unpinnable') {
298
308
  c.status(statusFor('validation-failed') as never);
299
309
  return c.json(toWireError(unpinnableFlow(loaded.value, pinned.issues), requestId));
@@ -408,19 +418,32 @@ export function flowsRouter(
408
418
  return r;
409
419
  }
410
420
 
421
+ /** The registries a published flow version is pinned against. */
422
+ export interface FlowPinning {
423
+ readonly tools: ToolRegistryBinding;
424
+ readonly agents: AgentRegistryBinding;
425
+ /** The agents' live versions: an agent step pins the one live for the flow's project. */
426
+ readonly live?: LiveVersionBinding;
427
+ }
428
+
411
429
  /** The flow with its pins, or the references with no published version; as it is without `pinning`. */
412
430
  async function pinFlow(
413
- pinning:
414
- | { readonly tools: ToolRegistryBinding; readonly agents: AgentRegistryBinding }
415
- | undefined,
431
+ pinning: FlowPinning | undefined,
416
432
  tenantId: TenantId,
433
+ projectId: ProjectId,
417
434
  flow: Flow,
418
435
  ): Promise<
419
436
  | { readonly kind: 'ok'; readonly flow: Flow }
420
437
  | { readonly kind: 'unpinnable'; readonly issues: readonly UnpinnableRef[] }
421
438
  > {
422
439
  if (pinning === undefined) return { kind: 'ok', flow };
423
- const resolved = await resolveFlowPins(pinning.tools, pinning.agents, tenantId, flow);
440
+ const resolved = await resolveFlowPins(
441
+ pinning.tools,
442
+ pinning.agents,
443
+ tenantId,
444
+ flow,
445
+ pinning.live !== undefined ? { binding: pinning.live, projectId } : undefined,
446
+ );
424
447
  if (resolved.kind === 'unpinnable') return resolved;
425
448
  return {
426
449
  kind: 'ok',
@@ -0,0 +1,226 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { type Context, Hono } from 'hono';
5
+
6
+ import { ref } from '@kindgi/authz';
7
+ import type { Cursor, TenantId } from '@kindgi/types';
8
+
9
+ import { statusFor, toWireError } from '../errors.js';
10
+ import type { GatePolicyBinding, GatePolicyError } from '../gate-policy-binding.js';
11
+ import type { Authorizer } from '../middleware/authorize.js';
12
+ import type { AppEnv } from '../types.js';
13
+ import { scopeFromQuery } from './agent-releases.js';
14
+ import { parseGatePolicySpec } from './gate-policy-spec.js';
15
+ import { serializeGatePolicy } from './gate-policy-wire.js';
16
+ import { parseLiveScopeBody } from './live-scope-wire.js';
17
+ import { clampLimit } from './pagination.js';
18
+
19
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
20
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
21
+
22
+ /**
23
+ * Gate policies (evals step 4b): what a promotion of an agent must show
24
+ * before a version goes live for a scope. The same registry shape as
25
+ * retention policies:
26
+ * GET / each policy's latest active version (?agentId=, scope)
27
+ * GET /:policyId its latest active version
28
+ * GET /:policyId/versions[/:version] every version, or one
29
+ * POST / publish {id, version, agentId, scope, spec, description?}
30
+ * POST /:policyId/versions/:version/unregister|reinstate
31
+ *
32
+ * Writes need `admin` on the tenant: whoever may promote mustn't be able
33
+ * to loosen the gate on their own promotion.
34
+ */
35
+ export function gatePoliciesRouter(
36
+ binding: GatePolicyBinding,
37
+ authorizer?: Authorizer,
38
+ ): Hono<AppEnv> {
39
+ const r = new Hono<AppEnv>();
40
+
41
+ if (authorizer !== undefined) {
42
+ r.use('*', async (c, next) => {
43
+ if (c.req.method !== 'POST') return next();
44
+ const tenantId = c.get('tenantId') as unknown as string;
45
+ return authorizer.authorize('admin', () => ref('tenant', tenantId))(c, next);
46
+ });
47
+ }
48
+
49
+ r.get('/', async (c) => {
50
+ const requestId = c.get('requestId');
51
+ const scope = scopeFromQuery((n) => c.req.query(n), c.req.queries('segment') ?? []);
52
+ if (scope.kind === 'err') return badInput(c, requestId, scope.message);
53
+ const agentId = c.req.query('agentId');
54
+ const cursor = c.req.query('cursor');
55
+ const page = await binding.list({
56
+ tenantId: c.get('tenantId') as TenantId,
57
+ limit: clampLimit(c.req.query('limit')),
58
+ ...(agentId !== undefined && agentId.length > 0 && { agentId }),
59
+ ...(scope.scope !== undefined && { scope: scope.scope }),
60
+ ...(cursor !== undefined && cursor.length > 0 && { cursor: cursor as Cursor }),
61
+ });
62
+ return c.json({
63
+ data: page.data.map(serializeGatePolicy),
64
+ hasMore: page.nextCursor !== undefined,
65
+ ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
66
+ });
67
+ });
68
+
69
+ r.get('/:policyId', async (c) => {
70
+ const found = await binding.get({
71
+ tenantId: c.get('tenantId') as TenantId,
72
+ id: c.req.param('policyId'),
73
+ });
74
+ if (found === null) return notFound(c, c.req.param('policyId'));
75
+ return c.json(serializeGatePolicy(found));
76
+ });
77
+
78
+ r.get('/:policyId/versions', async (c) => {
79
+ const versions = await binding.listVersions({
80
+ tenantId: c.get('tenantId') as TenantId,
81
+ id: c.req.param('policyId'),
82
+ });
83
+ if (versions.length === 0) return notFound(c, c.req.param('policyId'));
84
+ return c.json({ data: versions.map(serializeGatePolicy), hasMore: false });
85
+ });
86
+
87
+ r.get('/:policyId/versions/:version', async (c) => {
88
+ const found = await binding.getVersion({
89
+ tenantId: c.get('tenantId') as TenantId,
90
+ id: c.req.param('policyId'),
91
+ version: c.req.param('version'),
92
+ });
93
+ if (found === null) {
94
+ return notFound(c, c.req.param('policyId'), c.req.param('version'));
95
+ }
96
+ return c.json(serializeGatePolicy(found));
97
+ });
98
+
99
+ r.post('/', async (c) => {
100
+ const requestId = c.get('requestId');
101
+ let body: unknown;
102
+ try {
103
+ body = await c.req.json();
104
+ } catch {
105
+ return badInput(c, requestId, 'Request body must be a JSON object');
106
+ }
107
+ const parsed = parsePublishBody(body);
108
+ if (parsed.kind === 'err') {
109
+ c.status(statusFor('validation-failed') as never);
110
+ return c.json(
111
+ toWireError(
112
+ {
113
+ code: 'validation-failed',
114
+ message: `The gate policy isn't valid: ${parsed.issues.map((i) => `${i.path}: ${i.message}`).join('; ')}`,
115
+ issues: parsed.issues,
116
+ },
117
+ requestId,
118
+ ),
119
+ );
120
+ }
121
+ const outcome = await binding.publish({
122
+ tenantId: c.get('tenantId') as TenantId,
123
+ ...parsed.value,
124
+ });
125
+ if (outcome.kind === 'err') return failed(c, requestId, outcome.error);
126
+ c.status(201);
127
+ return c.json(serializeGatePolicy(outcome.value));
128
+ });
129
+
130
+ for (const action of ['unregister', 'reinstate'] as const) {
131
+ r.post(`/:policyId/versions/:version/${action}`, async (c) => {
132
+ const outcome = await binding[action]({
133
+ tenantId: c.get('tenantId') as TenantId,
134
+ id: c.req.param('policyId'),
135
+ version: c.req.param('version'),
136
+ });
137
+ if (outcome.kind === 'err') return failed(c, c.get('requestId'), outcome.error);
138
+ return c.json(serializeGatePolicy(outcome.value));
139
+ });
140
+ }
141
+
142
+ return r;
143
+ }
144
+
145
+ type Ctx = Context<AppEnv>;
146
+
147
+ function badInput(c: Ctx, requestId: string, message: string) {
148
+ c.status(statusFor('bad-input') as never);
149
+ return c.json(toWireError({ code: 'bad-input', message }, requestId));
150
+ }
151
+
152
+ function notFound(c: Ctx, policyId: string, version?: string) {
153
+ c.status(statusFor('gate-policy-not-found') as never);
154
+ return c.json(
155
+ toWireError(
156
+ {
157
+ code: 'gate-policy-not-found',
158
+ message:
159
+ version === undefined
160
+ ? `No active gate policy "${policyId}"`
161
+ : `Gate policy "${policyId}" has no version ${version}`,
162
+ policyId,
163
+ },
164
+ c.get('requestId'),
165
+ ),
166
+ );
167
+ }
168
+
169
+ function failed(c: Ctx, requestId: string, error: GatePolicyError) {
170
+ c.status(statusFor(error.code) as never);
171
+ return c.json(toWireError({ ...error }, requestId));
172
+ }
173
+
174
+ type Issue = { readonly path: string; readonly message: string };
175
+
176
+ function parsePublishBody(body: unknown):
177
+ | {
178
+ kind: 'ok';
179
+ value: Omit<Parameters<GatePolicyBinding['publish']>[0], 'tenantId'>;
180
+ }
181
+ | { kind: 'err'; issues: readonly Issue[] } {
182
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
183
+ return { kind: 'err', issues: [{ path: '', message: 'the body must be a JSON object' }] };
184
+ }
185
+ const b = body as Record<string, unknown>;
186
+ const issues: Issue[] = [];
187
+ for (const key of Object.keys(b)) {
188
+ if (!['id', 'version', 'agentId', 'scope', 'spec', 'description'].includes(key)) {
189
+ issues.push({ path: `/${key}`, message: `\`${key}\` isn't a gate policy field` });
190
+ }
191
+ }
192
+ if (typeof b.id !== 'string' || !ID_RE.test(b.id)) {
193
+ issues.push({
194
+ path: '/id',
195
+ message:
196
+ '`id` must be lowercase letters, digits, `.`, `_` or `-` (e.g. "acme.drafting-prod")',
197
+ });
198
+ }
199
+ if (typeof b.version !== 'string' || !SEMVER_RE.test(b.version)) {
200
+ issues.push({ path: '/version', message: '`version` must be a semver version (e.g. "1.0.0")' });
201
+ }
202
+ if (typeof b.agentId !== 'string' || b.agentId === '') {
203
+ issues.push({ path: '/agentId', message: '`agentId` is required: the agent the policy gates' });
204
+ }
205
+ const scope = parseLiveScopeBody(b.scope);
206
+ if (scope.kind === 'err') issues.push({ path: '/scope', message: scope.message });
207
+ const spec = parseGatePolicySpec(b.spec);
208
+ if (spec.kind === 'err') issues.push(...spec.issues);
209
+ if (b.description !== undefined && (typeof b.description !== 'string' || b.description === '')) {
210
+ issues.push({ path: '/description', message: '`description` must be a non-empty string' });
211
+ }
212
+ if (issues.length > 0 || scope.kind === 'err' || spec.kind === 'err') {
213
+ return { kind: 'err', issues };
214
+ }
215
+ return {
216
+ kind: 'ok',
217
+ value: {
218
+ id: b.id as string,
219
+ version: b.version as string,
220
+ agentId: b.agentId as string,
221
+ scope: scope.scope,
222
+ spec: spec.spec,
223
+ ...(typeof b.description === 'string' && { description: b.description }),
224
+ },
225
+ };
226
+ }
@@ -0,0 +1,229 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { REVIEWER_ROLE_RANK, type ReviewerRole } from '@kindgi/authz';
5
+
6
+ import { GATE_METRICS, type GateMetricSpec, type GatePolicySpec } from '../gate-policy-binding.js';
7
+
8
+ /**
9
+ * A gate policy's `spec`, checked strictly: an unknown key is an issue, so
10
+ * a typo can't leave a check silently off.
11
+ */
12
+
13
+ export interface SpecIssue {
14
+ readonly path: string;
15
+ readonly message: string;
16
+ }
17
+
18
+ type Raw = Readonly<Record<string, unknown>>;
19
+
20
+ const REVIEWER_ROLES = Object.keys(REVIEWER_ROLE_RANK) as readonly ReviewerRole[];
21
+
22
+ const isObject = (v: unknown): v is Raw => v !== null && typeof v === 'object' && !Array.isArray(v);
23
+
24
+ /** Keys the policy may not use yet, with why. */
25
+ const LATER: Readonly<Record<string, string>> = {
26
+ '/spec/approvals/count': 'one reviewer decides a promotion today; leave `count` out',
27
+ '/spec/approvals/forRollback': "rollbacks aren't gated yet; leave `forRollback` out",
28
+ };
29
+
30
+ function unknownKeys(raw: Raw, allowed: readonly string[], at: string, issues: SpecIssue[]) {
31
+ for (const key of Object.keys(raw)) {
32
+ if (allowed.includes(key)) continue;
33
+ const path = `${at}/${key}`;
34
+ issues.push({
35
+ path,
36
+ message:
37
+ LATER[path] ??
38
+ `\`${key}\` isn't a gate policy setting here (expected: ${allowed.join(', ')})`,
39
+ });
40
+ }
41
+ }
42
+
43
+ /** A number in [min, max], when present. */
44
+ function num(
45
+ raw: Raw,
46
+ key: string,
47
+ at: string,
48
+ issues: SpecIssue[],
49
+ opts: { min: number; max?: number; integer?: boolean; positive?: boolean },
50
+ ): number | undefined {
51
+ const v = raw[key];
52
+ if (v === undefined) return undefined;
53
+ const ok =
54
+ typeof v === 'number' &&
55
+ Number.isFinite(v) &&
56
+ v >= opts.min &&
57
+ (opts.max === undefined || v <= opts.max) &&
58
+ (opts.integer !== true || Number.isInteger(v)) &&
59
+ (opts.positive !== true || v > 0);
60
+ if (!ok) {
61
+ const range = opts.max === undefined ? `at least ${opts.min}` : `${opts.min} to ${opts.max}`;
62
+ issues.push({
63
+ path: `${at}/${key}`,
64
+ message: `\`${key}\` must be ${opts.integer === true ? 'a whole number' : 'a number'}, ${opts.positive === true ? 'above 0' : range}`,
65
+ });
66
+ return undefined;
67
+ }
68
+ return v as number;
69
+ }
70
+
71
+ function bool(raw: Raw, key: string, at: string, issues: SpecIssue[]): boolean | undefined {
72
+ const v = raw[key];
73
+ if (v === undefined) return undefined;
74
+ if (typeof v !== 'boolean') {
75
+ issues.push({ path: `${at}/${key}`, message: `\`${key}\` must be true or false` });
76
+ return undefined;
77
+ }
78
+ return v;
79
+ }
80
+
81
+ function block(raw: Raw, key: string, issues: SpecIssue[]): Raw | undefined {
82
+ const v = raw[key];
83
+ if (v === undefined) return undefined;
84
+ if (!isObject(v)) {
85
+ issues.push({ path: `/spec/${key}`, message: `\`${key}\` must be an object` });
86
+ return undefined;
87
+ }
88
+ return v;
89
+ }
90
+
91
+ export function parseGatePolicySpec(
92
+ raw: unknown,
93
+ ): { kind: 'ok'; spec: GatePolicySpec } | { kind: 'err'; issues: readonly SpecIssue[] } {
94
+ if (!isObject(raw)) {
95
+ return { kind: 'err', issues: [{ path: '/spec', message: '`spec` must be an object' }] };
96
+ }
97
+ const issues: SpecIssue[] = [];
98
+ unknownKeys(
99
+ raw,
100
+ ['comparison', 'evidence', 'metrics', 'replay', 'approvals', 'onlyRestrictedClasses'],
101
+ '/spec',
102
+ issues,
103
+ );
104
+ const spec: {
105
+ -readonly [K in keyof GatePolicySpec]: GatePolicySpec[K];
106
+ } = {};
107
+
108
+ const comparison = block(raw, 'comparison', issues);
109
+ if (comparison !== undefined) {
110
+ const at = '/spec/comparison';
111
+ unknownKeys(comparison, ['maxAgeHours', 'suite'], at, issues);
112
+ const maxAgeHours = num(comparison, 'maxAgeHours', at, issues, { min: 0, positive: true });
113
+ let suite: { id: string; version?: string } | undefined;
114
+ if (comparison.suite !== undefined) {
115
+ const s = comparison.suite;
116
+ if (!isObject(s) || typeof s.id !== 'string' || s.id === '') {
117
+ issues.push({ path: `${at}/suite`, message: '`suite` must be `{ id, version? }`' });
118
+ } else {
119
+ unknownKeys(s, ['id', 'version'], `${at}/suite`, issues);
120
+ if (s.version !== undefined && (typeof s.version !== 'string' || s.version === '')) {
121
+ issues.push({ path: `${at}/suite/version`, message: '`version` must be a version' });
122
+ }
123
+ suite = { id: s.id, ...(typeof s.version === 'string' && { version: s.version }) };
124
+ }
125
+ }
126
+ spec.comparison = {
127
+ ...(maxAgeHours !== undefined && { maxAgeHours }),
128
+ ...(suite !== undefined && { suite }),
129
+ };
130
+ }
131
+
132
+ const evidence = block(raw, 'evidence', issues);
133
+ if (evidence !== undefined) {
134
+ const at = '/spec/evidence';
135
+ unknownKeys(evidence, ['minCases', 'minWeight'], at, issues);
136
+ const minCases = num(evidence, 'minCases', at, issues, { min: 0, integer: true });
137
+ const minWeight = num(evidence, 'minWeight', at, issues, { min: 0 });
138
+ spec.evidence = {
139
+ ...(minCases !== undefined && { minCases }),
140
+ ...(minWeight !== undefined && { minWeight }),
141
+ };
142
+ }
143
+
144
+ if (raw.metrics !== undefined) {
145
+ if (!Array.isArray(raw.metrics)) {
146
+ issues.push({ path: '/spec/metrics', message: '`metrics` must be a list' });
147
+ } else {
148
+ const metrics: GateMetricSpec[] = [];
149
+ const seen = new Set<string>();
150
+ raw.metrics.forEach((m: unknown, i: number) => {
151
+ const at = `/spec/metrics/${i}`;
152
+ const before = issues.length;
153
+ if (!isObject(m)) {
154
+ issues.push({ path: at, message: 'each metric must be an object' });
155
+ return;
156
+ }
157
+ unknownKeys(m, ['name', 'k', 'minCandidate', 'maxDrop'], at, issues);
158
+ const name = m.name;
159
+ if (typeof name !== 'string' || !(GATE_METRICS as readonly string[]).includes(name)) {
160
+ issues.push({
161
+ path: `${at}/name`,
162
+ message: `\`name\` must be one of: ${GATE_METRICS.join(', ')}`,
163
+ });
164
+ return;
165
+ }
166
+ if (seen.has(name)) {
167
+ issues.push({ path: `${at}/name`, message: `\`${name}\` is gated twice` });
168
+ return;
169
+ }
170
+ seen.add(name);
171
+ if (m.k !== undefined && name !== 'weightedPrecisionAtK') {
172
+ issues.push({ path: `${at}/k`, message: '`k` applies to `weightedPrecisionAtK` only' });
173
+ }
174
+ const k = num(m, 'k', at, issues, { min: 1, max: 100, integer: true });
175
+ const minCandidate = num(m, 'minCandidate', at, issues, { min: 0, max: 1 });
176
+ const maxDrop = num(m, 'maxDrop', at, issues, { min: 0, max: 1 });
177
+ if (minCandidate === undefined && maxDrop === undefined && issues.length === before) {
178
+ issues.push({
179
+ path: at,
180
+ message: `\`${name}\` needs \`minCandidate\`, \`maxDrop\` or both: what it must reach`,
181
+ });
182
+ }
183
+ metrics.push({
184
+ name: name as GateMetricSpec['name'],
185
+ ...(k !== undefined && { k }),
186
+ ...(minCandidate !== undefined && { minCandidate }),
187
+ ...(maxDrop !== undefined && { maxDrop }),
188
+ });
189
+ });
190
+ spec.metrics = metrics;
191
+ }
192
+ }
193
+
194
+ const replay = block(raw, 'replay', issues);
195
+ if (replay !== undefined) {
196
+ const at = '/spec/replay';
197
+ const knobs = ['maxDiverged', 'maxErrors', 'maxRefusedWrites', 'maxStopped'] as const;
198
+ unknownKeys(replay, knobs, at, issues);
199
+ const out: { -readonly [K in (typeof knobs)[number]]?: number } = {};
200
+ for (const knob of knobs) {
201
+ const v = num(replay, knob, at, issues, { min: 0, integer: true });
202
+ if (v !== undefined) out[knob] = v;
203
+ }
204
+ spec.replay = out;
205
+ }
206
+
207
+ const approvals = block(raw, 'approvals', issues);
208
+ if (approvals !== undefined) {
209
+ const at = '/spec/approvals';
210
+ unknownKeys(approvals, ['role', 'separateApprover'], at, issues);
211
+ const role = approvals.role;
212
+ if (role !== undefined && !(REVIEWER_ROLES as readonly unknown[]).includes(role)) {
213
+ issues.push({
214
+ path: `${at}/role`,
215
+ message: `\`role\` must be one of: ${REVIEWER_ROLES.join(', ')}`,
216
+ });
217
+ }
218
+ const separateApprover = bool(approvals, 'separateApprover', at, issues);
219
+ spec.approvals = {
220
+ ...(typeof role === 'string' && { role: role as ReviewerRole }),
221
+ ...(separateApprover !== undefined && { separateApprover }),
222
+ };
223
+ }
224
+
225
+ const onlyRestricted = bool(raw, 'onlyRestrictedClasses', '/spec', issues);
226
+ if (onlyRestricted !== undefined) spec.onlyRestrictedClasses = onlyRestricted;
227
+
228
+ return issues.length > 0 ? { kind: 'err', issues } : { kind: 'ok', spec };
229
+ }
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { GatePolicy } from '../gate-policy-binding.js';
5
+ import { liveScopeToWire } from './live-scope-wire.js';
6
+
7
+ export function serializeGatePolicy(p: GatePolicy): Record<string, unknown> {
8
+ return {
9
+ id: p.id,
10
+ version: p.version,
11
+ agentId: p.agentId,
12
+ scope: liveScopeToWire(p.scope),
13
+ spec: p.spec,
14
+ ...(p.description !== undefined && { description: p.description }),
15
+ createdAt: p.createdAt as unknown as string,
16
+ ...(p.unregisteredAt !== undefined && {
17
+ unregisteredAt: p.unregisteredAt as unknown as string,
18
+ }),
19
+ };
20
+ }