@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
@@ -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
+ }
@@ -58,3 +58,17 @@ export function projectDefaultAlreadyExistsError() {
58
58
  message: 'The tenant already has a Default project',
59
59
  } as const;
60
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
  ),