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

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 (55) hide show
  1. package/dist/app.d.ts +7 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +12 -1
  4. package/dist/app.js.map +1 -1
  5. package/dist/errors.d.ts.map +1 -1
  6. package/dist/errors.js +7 -0
  7. package/dist/errors.js.map +1 -1
  8. package/dist/gate-policy-binding.d.ts +7 -5
  9. package/dist/gate-policy-binding.d.ts.map +1 -1
  10. package/dist/handler-binding.d.ts +9 -0
  11. package/dist/handler-binding.d.ts.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/live-version-binding.d.ts +40 -8
  16. package/dist/live-version-binding.d.ts.map +1 -1
  17. package/dist/middleware/error-mapper.d.ts.map +1 -1
  18. package/dist/middleware/error-mapper.js +2 -0
  19. package/dist/middleware/error-mapper.js.map +1 -1
  20. package/dist/middleware/request-log.d.ts +31 -0
  21. package/dist/middleware/request-log.d.ts.map +1 -0
  22. package/dist/middleware/request-log.js +80 -0
  23. package/dist/middleware/request-log.js.map +1 -0
  24. package/dist/openapi/operations.d.ts.map +1 -1
  25. package/dist/openapi/operations.js +8 -5
  26. package/dist/openapi/operations.js.map +1 -1
  27. package/dist/openapi/schemas.d.ts.map +1 -1
  28. package/dist/openapi/schemas.js +5 -0
  29. package/dist/openapi/schemas.js.map +1 -1
  30. package/dist/routes/agent-releases.d.ts.map +1 -1
  31. package/dist/routes/agent-releases.js +42 -1
  32. package/dist/routes/agent-releases.js.map +1 -1
  33. package/dist/routes/runs.d.ts.map +1 -1
  34. package/dist/routes/runs.js +5 -2
  35. package/dist/routes/runs.js.map +1 -1
  36. package/dist/secrets-binding.d.ts +8 -0
  37. package/dist/secrets-binding.d.ts.map +1 -1
  38. package/dist/types.d.ts +9 -0
  39. package/dist/types.d.ts.map +1 -1
  40. package/openapi.json +40 -6
  41. package/package.json +22 -21
  42. package/src/app.ts +17 -1
  43. package/src/errors.ts +7 -0
  44. package/src/gate-policy-binding.ts +7 -5
  45. package/src/handler-binding.ts +10 -0
  46. package/src/index.ts +1 -0
  47. package/src/live-version-binding.ts +39 -7
  48. package/src/middleware/error-mapper.ts +3 -0
  49. package/src/middleware/request-log.ts +89 -0
  50. package/src/openapi/operations.ts +16 -5
  51. package/src/openapi/schemas.ts +6 -0
  52. package/src/routes/agent-releases.ts +46 -2
  53. package/src/routes/runs.ts +11 -1
  54. package/src/secrets-binding.ts +5 -0
  55. package/src/types.ts +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kindgi/api",
3
- "version": "0.1.4-rc.3",
3
+ "version": "0.1.4-rc.5",
4
4
  "description": "REST + SSE HTTP surface for Kindgi™. createApp assembles a Hono app serving the /v1/* REST API (bearer or session-token auth; OpenAPI 3.1 document at /v1/openapi.json) and an optional S3-compatible /s3/* surface (SigV4), over caller-plugged bindings for the runtime (runs, agents, flows, HITL, supervisor) and for storage. Route conventions: docs/API-ROUTE-CONVENTIONS.md.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -32,30 +32,31 @@
32
32
  "openapi.json"
33
33
  ],
34
34
  "dependencies": {
35
- "@kindgi/agents": "0.1.4-rc.3",
36
- "@kindgi/audit-events": "0.1.4-rc.3",
37
- "@kindgi/compliance": "0.1.4-rc.3",
38
- "@kindgi/authz": "0.1.4-rc.3",
39
- "@kindgi/blob-binding": "0.1.4-rc.3",
40
- "@kindgi/capabilities": "0.1.4-rc.3",
41
- "@kindgi/crypto": "0.1.4-rc.3",
42
- "@kindgi/flow": "0.1.4-rc.3",
43
- "@kindgi/guardrails": "0.1.4-rc.3",
44
- "@kindgi/memory": "0.1.4-rc.3",
45
- "@kindgi/platform": "0.1.4-rc.3",
46
- "@kindgi/provenance": "0.1.4-rc.3",
47
- "@kindgi/runtime": "0.1.4-rc.3",
48
- "@kindgi/policy-contract": "0.1.4-rc.3",
49
- "@kindgi/schema": "0.1.4-rc.3",
50
- "@kindgi/tools": "0.1.4-rc.3",
51
- "@kindgi/types": "0.1.4-rc.3",
35
+ "@kindgi/agents": "0.1.4-rc.5",
36
+ "@kindgi/audit-events": "0.1.4-rc.5",
37
+ "@kindgi/compliance": "0.1.4-rc.5",
38
+ "@kindgi/authz": "0.1.4-rc.5",
39
+ "@kindgi/blob-binding": "0.1.4-rc.5",
40
+ "@kindgi/capabilities": "0.1.4-rc.5",
41
+ "@kindgi/crypto": "0.1.4-rc.5",
42
+ "@kindgi/flow": "0.1.4-rc.5",
43
+ "@kindgi/guardrails": "0.1.4-rc.5",
44
+ "@kindgi/log": "0.1.4-rc.5",
45
+ "@kindgi/memory": "0.1.4-rc.5",
46
+ "@kindgi/platform": "0.1.4-rc.5",
47
+ "@kindgi/provenance": "0.1.4-rc.5",
48
+ "@kindgi/runtime": "0.1.4-rc.5",
49
+ "@kindgi/policy-contract": "0.1.4-rc.5",
50
+ "@kindgi/schema": "0.1.4-rc.5",
51
+ "@kindgi/tools": "0.1.4-rc.5",
52
+ "@kindgi/types": "0.1.4-rc.5",
52
53
  "@scalar/hono-api-reference": "^0.12.2",
53
54
  "hono": "^4.6.14"
54
55
  },
55
56
  "devDependencies": {
56
- "@kindgi/audit-events-inmemory": "0.1.4-rc.3",
57
- "@kindgi/specs": "0.1.4-rc.3",
58
- "@kindgi/testing": "0.1.4-rc.3",
57
+ "@kindgi/audit-events-inmemory": "0.1.4-rc.5",
58
+ "@kindgi/specs": "0.1.4-rc.5",
59
+ "@kindgi/testing": "0.1.4-rc.5",
59
60
  "@types/aws4": "^1.11.6",
60
61
  "@types/node": "^22.10.5",
61
62
  "aws4": "^1.13.2",
package/src/app.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright (C) 2026 Kindgi Inc.
3
3
 
4
+ import { type Logger, noopLogger } from '@kindgi/log';
4
5
  import { Scalar } from '@scalar/hono-api-reference';
5
6
  import { Hono } from 'hono';
6
7
 
@@ -63,6 +64,7 @@ import { principalMiddleware } from './middleware/principal.js';
63
64
  import { PROJECT_REF_ROUTES, refuseBadProjectId } from './middleware/project-ref.js';
64
65
  import { publicRunCorsMiddleware, publicRunRouteMatcher } from './middleware/public-run-routes.js';
65
66
  import { requestIdMiddleware } from './middleware/request-id.js';
67
+ import { requestLogMiddleware } from './middleware/request-log.js';
66
68
  import { sigv4Middleware } from './middleware/sigv4.js';
67
69
  import { type GenerateOptions, generateOpenApiDocument } from './openapi/generate.js';
68
70
  import type { ProvenanceBinding } from './provenance-binding.js';
@@ -150,6 +152,12 @@ import type { WebhookEndpointBinding } from './webhook-endpoint-binding.js';
150
152
  * for tests.
151
153
  */
152
154
  export interface CreateAppInput {
155
+ /**
156
+ * Where the app's records go (`@kindgi/log`): the access line, logged
157
+ * 500s, and what routes log, each with the request's ids. Default:
158
+ * `noopLogger`, so an embedding app stays quiet unless it passes one.
159
+ */
160
+ readonly logger?: Logger;
153
161
  readonly resolveToken: TokenResolver;
154
162
  readonly runHandler: RunHandlerBinding;
155
163
  /**
@@ -893,7 +901,9 @@ export function createApp(input: CreateAppInput): Hono<AppEnv> {
893
901
 
894
902
  // ---------- global middleware ----------
895
903
  app.use('*', requestIdMiddleware());
896
- // A thrown exception: a 500 wire error with its message and request id.
904
+ // The request's trace context and logger, and its access line.
905
+ app.use('*', requestLogMiddleware(input.logger ?? noopLogger));
906
+ // A thrown exception: a 500 wire error with its message and request id, logged.
897
907
  app.onError(mapThrownError);
898
908
 
899
909
  // Public run tokens: checked once at startup; CORS for the two routes
@@ -968,6 +978,12 @@ export function createApp(input: CreateAppInput): Hono<AppEnv> {
968
978
  // still populate the principal (cheap, and lets `can`/`check` work
969
979
  // as inspection helpers even when authorize() enforcement is off).
970
980
  v1.use('*', principalMiddleware());
981
+ // From here on, the request's records carry its tenant.
982
+ v1.use('*', async (c, next) => {
983
+ const tenantId = c.get('tenantId');
984
+ if (tenantId !== undefined) c.set('log', c.get('log').child({ tenantId }));
985
+ await next();
986
+ });
971
987
  const authorizer: Authorizer | undefined =
972
988
  input.authz !== undefined ? createAuthorizer(input.authz.authzCheckBinding) : undefined;
973
989
 
package/src/errors.ts CHANGED
@@ -88,6 +88,10 @@ export const ERROR_CODE_TO_STATUS: Readonly<Record<string, number>> = {
88
88
  'budget-exceeded': 422,
89
89
  'output-schema-violation': 422,
90
90
  'model-invocation-failed': 422,
91
+ /** No registered provider satisfies the agent's capability declaration (`needs`). */
92
+ 'capability-unsatisfiable': 422,
93
+ /** A tool the agent names has no version satisfying its range (or the pinned one is gone). */
94
+ 'tool-version-unresolvable': 422,
91
95
  'tool-invocation-failed': 422,
92
96
  'capability-routing-failed': 422,
93
97
  'runtime-not-configured': 422,
@@ -195,6 +199,7 @@ export const ERROR_CODE_TO_STATUS: Readonly<Record<string, number>> = {
195
199
  'gate-policy-scope-changed': 409,
196
200
  'gate-policy-scope-unpinned': 409,
197
201
  'gate-policy-needs-pin': 409,
202
+ 'gate-policy-descendant-unpinned': 409,
198
203
  /** Unregister: the version is live in a scope; move that pin first. */
199
204
  'agent-version-live': 409,
200
205
  'run-not-finished': 409,
@@ -286,6 +291,8 @@ export const ERROR_CODE_TO_STATUS: Readonly<Record<string, number>> = {
286
291
  'secret-provider-unauthorized': 502,
287
292
  'secret-provider-rate-limited': 429,
288
293
  'secret-store-error': 500,
294
+ /** The secret store doesn't do this by design (the dev store's rotate and revoke). */
295
+ 'secret-operation-unsupported': 501,
289
296
  'env-store-error': 500,
290
297
  // Trigger HTTP surface.
291
298
  'trigger-not-found': 404,
@@ -102,9 +102,10 @@ export type GatePolicyErrorCode =
102
102
  /** A new version names another agent or scope than the policy's. */
103
103
  | 'gate-policy-scope-changed'
104
104
  /**
105
- * The scope resolves to the agent's latest version (no pin covers it),
106
- * so publishing a version would make it live there ungated: pin a
107
- * version for the scope, or one above it, first.
105
+ * The scope has no live version of its own. A gated scope holds its own
106
+ * pin: following the latest version, a publish would go live there
107
+ * ungated; following a pin above it, a promotion there would. Pin a
108
+ * version for the scope first.
108
109
  */
109
110
  | 'gate-policy-scope-unpinned'
110
111
  | 'gate-policy-not-found'
@@ -137,8 +138,9 @@ export interface GatePolicyVersionInput {
137
138
  export interface GatePolicyBinding {
138
139
  /**
139
140
  * Register `id` at `version`: a new policy, or a new version of one
140
- * (same agent and scope). A gated scope always resolves to a pin:
141
- * `gate-policy-scope-unpinned` when nothing covering the scope is pinned.
141
+ * (same agent and scope). A gated scope holds its own pin:
142
+ * `gate-policy-scope-unpinned` when the scope has none (a pin above it
143
+ * isn't enough). Reinstating a version checks the same.
142
144
  */
143
145
  publish(input: GatePolicyPublishInput): Promise<Result<GatePolicy, GatePolicyError>>;
144
146
  /** The policy's latest active version, or `null`. */
@@ -58,6 +58,12 @@ export interface RunHandlerBinding {
58
58
  resumeRun(input: ResumeRunBindingInput): Promise<RunHandlerOutcome>;
59
59
  }
60
60
 
61
+ /** The request's W3C trace context, for the run it starts (`traceId` is kept on the run). */
62
+ export interface RunTrace {
63
+ readonly traceId: string;
64
+ readonly spanId: string;
65
+ }
66
+
61
67
  export interface ResumeRunBindingInput {
62
68
  readonly tenantId: TenantId;
63
69
  readonly runId: RunId;
@@ -101,6 +107,8 @@ export interface InvokeAgentBindingInput {
101
107
  * can't run in the background may treat `false` like `true`.
102
108
  */
103
109
  readonly wait?: boolean;
110
+ /** The trace context of the request starting the run: the binding records its `traceId` on the run. */
111
+ readonly trace?: RunTrace;
104
112
  }
105
113
 
106
114
  export interface InvokeFlowBindingInput {
@@ -122,6 +130,8 @@ export interface InvokeFlowBindingInput {
122
130
  readonly wait?: boolean;
123
131
  /** Agents and tools to run at other exact versions than the flow version's pins (`RunFlowInput.versions`). */
124
132
  readonly versions?: FlowVersionOverrides;
133
+ /** The trace context of the request starting the run, as for an agent run. */
134
+ readonly trace?: RunTrace;
125
135
  }
126
136
 
127
137
  export type RunHandlerOutcome =
package/src/index.ts CHANGED
@@ -98,6 +98,7 @@ export type {
98
98
  RunHandlerBinding,
99
99
  RunHandlerFailure,
100
100
  RunHandlerOutcome,
101
+ RunTrace,
101
102
  } from './handler-binding.js';
102
103
  export type {
103
104
  ReviewerBinding,
@@ -133,10 +133,19 @@ export type PromotionErrorCode =
133
133
  /** The scope's live version changed while the gate ran: check again. */
134
134
  | 'promotion-superseded'
135
135
  /**
136
- * Unpin or rollback: it would leave a scope a gate policy applies to
137
- * resolving to the latest version, where publishing goes live ungated.
136
+ * Unpin: the scope has a gate policy of its own, and a gated scope keeps
137
+ * its own pin (else a change above it, or a publish, would reach it
138
+ * ungated). Unregister the gate policy first, or roll back instead.
138
139
  */
139
140
  | 'gate-policy-needs-pin'
141
+ /**
142
+ * Promote, rollback or unpin: the change would also move a narrower
143
+ * scope a gate policy applies to, which has no pin of its own and so
144
+ * follows this scope, without its gate. The message names each such
145
+ * scope: pin it at its current version first. (A gated scope must hold
146
+ * its own pin; this guards scopes gated before that rule.)
147
+ */
148
+ | 'gate-policy-descendant-unpinned'
140
149
  | 'persistence-error';
141
150
 
142
151
  export interface PromotionError {
@@ -172,6 +181,18 @@ export interface PromotionRequestInput extends PromoteInput {
172
181
  * promotion whose scope moved on becomes `superseded`.
173
182
  */
174
183
  readonly servingVersion: Semver;
184
+ /**
185
+ * A pin in place: the scope has no pin of its own and serves exactly
186
+ * the promoted version (`servingVersion`), so pinning it there changes
187
+ * nothing any run gets. The gate's checks and approval don't apply:
188
+ * `checks` holds one passing `pinInPlace` line and there's no
189
+ * `approval`. The binding re-checks both conditions in the write's
190
+ * transaction, under the scope's lock, and refuses with
191
+ * `promotion-superseded` when either no longer holds; it records the
192
+ * promotion `promoted`, with the policy, its reason starting
193
+ * `pin-in-place`.
194
+ */
195
+ readonly pinInPlace?: boolean;
175
196
  };
176
197
  }
177
198
 
@@ -203,22 +224,33 @@ export interface ListPromotionsInput {
203
224
  }
204
225
 
205
226
  export interface PromotionBinding {
206
- /** Make `version` live for `scope`. The version must be registered and active. */
227
+ /**
228
+ * Make `version` live for `scope`. The version must be registered and
229
+ * active. `gate-policy-descendant-unpinned` when it would also move a
230
+ * narrower gated scope with no pin of its own.
231
+ */
207
232
  promote(input: PromoteInput): Promise<Result<Promotion, PromotionError>>;
208
233
  /**
209
234
  * Record a gated promotion request (evals step 4b): `refused` when the
210
235
  * gate failed, `pending-approval` (opening a HITL approval, subject
211
236
  * `agent-promotion`) when it passed and the policy wants an approval,
212
237
  * else `promoted`. Optional: without it, the route refuses a promotion
213
- * a gate policy applies to (`501`), rather than promoting ungated.
238
+ * a gate policy applies to (`501`), rather than promoting ungated. A
239
+ * passing request that would also move a narrower gated scope with no
240
+ * pin of its own is `gate-policy-descendant-unpinned`, and so is its
241
+ * approval's apply (the promotion is then `superseded`).
214
242
  */
215
243
  request?(input: PromotionRequestInput): Promise<Result<Promotion, PromotionError>>;
216
- /** Back to the scope's previous live version, or a named earlier one. */
244
+ /**
245
+ * Back to the scope's previous live version, or a named earlier one,
246
+ * at once (no gate). `gate-policy-descendant-unpinned` as for `promote`.
247
+ */
217
248
  rollback(input: RollbackInput): Promise<Result<Promotion, PromotionError>>;
218
249
  /**
219
250
  * Remove the scope's own pin: it falls back to the next scope up. With
220
- * gate policies, `gate-policy-needs-pin` when that would leave a gated
221
- * scope resolving to the latest version.
251
+ * gate policies, `gate-policy-needs-pin` when the scope has a gate
252
+ * policy of its own, and `gate-policy-descendant-unpinned` when a
253
+ * narrower gated scope with no pin of its own follows it.
222
254
  */
223
255
  unpin(input: UnpinInput): Promise<Result<Promotion, PromotionError>>;
224
256
  list(input: ListPromotionsInput): Promise<{
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright (C) 2026 Kindgi Inc.
3
3
 
4
+ import type { Logger } from '@kindgi/log';
4
5
  import type { Context } from 'hono';
5
6
 
6
7
  import { statusFor, toWireError } from '../errors.js';
@@ -28,6 +29,8 @@ export function mapThrownError(cause: Error, c: Context): Response {
28
29
  return c.newResponse(res.body, res);
29
30
  }
30
31
  const requestId = (c.get('requestId') as string | undefined) ?? 'req-unknown';
32
+ // A 500 is something an operator should look at: logged, with the error.
33
+ (c.get('log') as Logger | undefined)?.error('unhandled error', { err: cause });
31
34
  const body = toWireError(
32
35
  {
33
36
  code: 'internal-server-error',
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { type LogLevel, type Logger, formatTraceparent, traceFromHeader } from '@kindgi/log';
5
+ import type { Context, MiddlewareHandler } from 'hono';
6
+ import { routePath } from 'hono/route';
7
+
8
+ import type { AppEnv } from '../types.js';
9
+
10
+ /** Paths a client or a probe reads all the time: their lines are `debug` even when they fail to read. */
11
+ const QUIET_PATHS: ReadonlySet<string> = new Set(['/health', '/ready', '/v1/openapi.json']);
12
+
13
+ const WRITES: ReadonlySet<string> = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
14
+
15
+ /**
16
+ * The access line's level, so `info` stays readable with a console open
17
+ * (it polls every few seconds): a write, and every 4xx, at `info`; every
18
+ * 5xx at `error`; a successful read (`GET`, `HEAD`: the polling), the
19
+ * health and readiness probes, the spec and the console's files, and a
20
+ * stream's opening, at `debug`.
21
+ */
22
+ export function accessLevel(c: Context<AppEnv>): LogLevel {
23
+ const status = c.res.status;
24
+ if (status >= 500) return 'error';
25
+ if (QUIET_PATHS.has(c.req.path) || !c.req.path.startsWith('/v1/')) return 'debug';
26
+ if (WRITES.has(c.req.method) || status >= 400) return 'info';
27
+ return 'debug';
28
+ }
29
+
30
+ /**
31
+ * The request's trace context and logger, then its access line.
32
+ *
33
+ * - **Trace:** an incoming `traceparent` (a load balancer's, an API
34
+ * gateway's, the caller's own app) is honoured: its trace id is kept
35
+ * and the request gets its own span, a child of the caller's. A
36
+ * missing or malformed one starts a fresh trace. `c.var.trace` holds
37
+ * it; the response answers `traceresponse` (W3C Trace Context Level
38
+ * 2) next to `X-Request-Id`.
39
+ * - **Logger:** `c.var.log` is a child of the app's logger with the
40
+ * request's `requestId`, `traceId` and `spanId` (subsystem `http`);
41
+ * routes log through it, and authentication adds `tenantId`.
42
+ * - **Access line:** `METHOD route status ms`, with the route's
43
+ * pattern (`/v1/runs/:runId`, never the raw path, so ids don't
44
+ * multiply lines), at the level `accessLevel` gives.
45
+ *
46
+ * Mounted right after the request-id middleware.
47
+ */
48
+ export function requestLogMiddleware(logger: Logger): MiddlewareHandler<AppEnv> {
49
+ return async (c, next) => {
50
+ const started = Date.now();
51
+ const trace = traceFromHeader(c.req.header('traceparent'));
52
+ c.set('trace', trace);
53
+ c.set(
54
+ 'log',
55
+ logger.child({
56
+ subsystem: 'http',
57
+ requestId: c.get('requestId'),
58
+ traceId: trace.traceId,
59
+ spanId: trace.spanId,
60
+ }),
61
+ );
62
+ try {
63
+ await next();
64
+ } finally {
65
+ c.header('traceresponse', formatTraceparent(trace));
66
+ const log = c.get('log');
67
+ const level = accessLevel(c);
68
+ if (log.isLevelEnabled(level)) {
69
+ // The responding route's pattern; a request no route matched ends on a wildcard.
70
+ const pattern = routePath(c, -1);
71
+ const route = pattern.endsWith('*') ? c.req.path : pattern;
72
+ const durationMs = Date.now() - started;
73
+ const stream = (c.res.headers.get('content-type') ?? '').startsWith('text/event-stream');
74
+ log[stream ? 'debug' : level](
75
+ `${c.req.method} ${route} ${c.res.status} ${durationMs}ms${stream ? ' (stream opened)' : ''}`,
76
+ {
77
+ method: c.req.method,
78
+ route,
79
+ status: c.res.status,
80
+ durationMs,
81
+ ...(c.get('tenantId') !== undefined && { tenantId: c.get('tenantId') }),
82
+ },
83
+ // The message says them: a terminal line needn't again.
84
+ { inMessage: ['method', 'route', 'status', 'durationMs'] },
85
+ );
86
+ }
87
+ }
88
+ };
89
+ }
@@ -1972,7 +1972,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
1972
1972
  operationId: 'agents.promotions.create',
1973
1973
  summary: 'Make a version live for a scope',
1974
1974
  description:
1975
- "Pins `version` live for `scope`: runs in that scope that don't name a version use it, from the next run. Open conversations keep their version. The version must be registered and active. Every promotion is recorded, with who asked and why. Needs `promote` on the agent.\n\nWith a gate policy for the scope (`GET …/gate-policy`), the promotion is checked first against the comparison named by `evalRunId`: `201` promoted; `202` the gate passed and the policy wants a reviewer's approval (a HITL approval, subject `agent-promotion`; the live version moves once it's approved, if nothing changed meanwhile); `422 gate-failed` with `details.promotionId`, `details.policy` and every check in `details.checks`. A refused promotion is recorded too.",
1975
+ "Pins `version` live for `scope`: runs in that scope that don't name a version use it, from the next run. Open conversations keep their version. The version must be registered and active. Every promotion is recorded, with who asked and why. Needs `promote` on the agent.\n\nWith a gate policy for the scope (`GET …/gate-policy`), the promotion is checked first against the comparison named by `evalRunId`: `201` promoted; `202` the gate passed and the policy wants a reviewer's approval (a HITL approval, subject `agent-promotion`; the live version moves once it's approved, if nothing changed meanwhile); `422 gate-failed` with `details.promotionId`, `details.policy` and every check in `details.checks`. A refused promotion is recorded too.\n\nA pin in place skips the gate: when the scope has no live version of its own and already serves exactly `version` (from a scope above, or as the latest), pinning it there changes nothing any run gets, so it is promoted (`201`) with one passing `pinInPlace` check, no comparison and no approval, its reason starting `pin-in-place`. It is re-checked as it is written; if the scope moved meanwhile, `409 promotion-superseded`: check again.",
1976
1976
  tags: ['agents'],
1977
1977
  security: 'bearer',
1978
1978
  parameters: [AgentIdPathParam, IdempotencyKeyParam],
@@ -1988,7 +1988,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
1988
1988
  'The version is not registered, or was unregistered; or the eval run is not found.',
1989
1989
  ),
1990
1990
  '409': ErrorResponse(
1991
- "`promotion-superseded`: the scope's live version changed while the gate ran; check again.",
1991
+ "`promotion-superseded`: the scope's live version changed while the gate ran; check again. `gate-policy-descendant-unpinned`: the change would also move a narrower scope a gate policy applies to, which has no live version of its own and so follows this scope, without its gate; the message names each one: pin it at its current version first.",
1992
1992
  ),
1993
1993
  '422': ErrorResponse(
1994
1994
  '`gate-failed`: the gate refused it. `details.checks` has every check; the refusal is recorded (`details.promotionId`).',
@@ -2086,7 +2086,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
2086
2086
  ...CommonMutationErrors,
2087
2087
  '400': ErrorResponse('Validation failed (see `details.issues`).'),
2088
2088
  '409': ErrorResponse(
2089
- '`gate-policy-already-registered`: that (id, version) exists. `gate-policy-scope-taken`: another policy gates the agent for the scope (`details.heldBy`). `gate-policy-scope-changed`: the version would change the agent or scope. `gate-policy-scope-unpinned`: nothing covering the scope is pinned, so a published version would go live there ungated; pin a version for the scope, or one above it, first.',
2089
+ '`gate-policy-already-registered`: that (id, version) exists. `gate-policy-scope-taken`: another policy gates the agent for the scope (`details.heldBy`). `gate-policy-scope-changed`: the version would change the agent or scope. `gate-policy-scope-unpinned`: the scope has no live version of its own; a gated scope holds its own pin (a pin above it is not enough, since a promotion there would change this scope ungated), so pin a version for the scope first.',
2090
2090
  ),
2091
2091
  },
2092
2092
  },
@@ -2168,6 +2168,9 @@ export const OPERATIONS: readonly OperationSpec[] = [
2168
2168
  '200': { description: 'The reinstated version.', schema: ref('GatePolicy') },
2169
2169
  ...CommonMutationErrors,
2170
2170
  '404': ErrorResponse('No such version.'),
2171
+ '409': ErrorResponse(
2172
+ '`gate-policy-scope-taken`: another policy gates the agent for the scope now (`details.heldBy`). `gate-policy-scope-unpinned`: the scope has no live version of its own; pin one first.',
2173
+ ),
2171
2174
  },
2172
2175
  },
2173
2176
  {
@@ -2225,7 +2228,9 @@ export const OPERATIONS: readonly OperationSpec[] = [
2225
2228
  '200': { description: 'Rolled back.', schema: ref('Promotion') },
2226
2229
  ...CommonMutationErrors,
2227
2230
  '404': ErrorResponse('`toVersion` is not registered, or was unregistered.'),
2228
- '409': ErrorResponse('The scope has no pin, or no earlier version to go back to.'),
2231
+ '409': ErrorResponse(
2232
+ '`not-pinned`: the scope has no pin of its own. `nothing-to-roll-back`: no earlier version to go back to. `gate-policy-descendant-unpinned`: the change would also move a narrower scope a gate policy applies to, which has no live version of its own and so follows this scope, without its gate; the message names each one: pin it at its current version first.',
2233
+ ),
2229
2234
  },
2230
2235
  },
2231
2236
  {
@@ -2244,7 +2249,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
2244
2249
  '200': { description: 'Unpinned.', schema: ref('Promotion') },
2245
2250
  ...CommonMutationErrors,
2246
2251
  '409': ErrorResponse(
2247
- '`not-pinned`: the scope has no pin of its own. `gate-policy-needs-pin`: unpinning would leave a scope a gate policy applies to on the latest version, where publishing goes live ungated.',
2252
+ '`not-pinned`: the scope has no pin of its own. `gate-policy-needs-pin`: the scope has a gate policy of its own, and a gated scope keeps its own pin; unregister the policy first, or roll back instead. `gate-policy-descendant-unpinned`: the change would also move a narrower scope a gate policy applies to, which has no live version of its own and so follows this scope, without its gate; the message names each one: pin it at its current version first.',
2248
2253
  ),
2249
2254
  },
2250
2255
  },
@@ -6077,6 +6082,9 @@ export const OPERATIONS: readonly OperationSpec[] = [
6077
6082
  ...CommonMutationErrors,
6078
6083
  '403': ErrorResponse('Bearer token missing `secrets:rotate` capability.'),
6079
6084
  '404': ErrorResponse('Unknown secret.'),
6085
+ '501': ErrorResponse(
6086
+ "The store doesn't rotate (`secret-operation-unsupported`): under `kindgi dev` secrets live in the env files and have no versions; the message says what to do instead.",
6087
+ ),
6080
6088
  },
6081
6089
  },
6082
6090
  {
@@ -6173,6 +6181,9 @@ export const OPERATIONS: readonly OperationSpec[] = [
6173
6181
  '400': ErrorResponse('Missing or malformed scope / envName.'),
6174
6182
  '403': ErrorResponse('Bearer token missing revoke capability.'),
6175
6183
  '404': ErrorResponse('Unknown secret.'),
6184
+ '501': ErrorResponse(
6185
+ "The store doesn't revoke (`secret-operation-unsupported`): under `kindgi dev` secrets live in the env files; the message says to remove the name there.",
6186
+ ),
6176
6187
  },
6177
6188
  },
6178
6189
 
@@ -637,6 +637,12 @@ export const RunSchema: JsonSchema = {
637
637
  description:
638
638
  "The segment path the run was started with (coarse to fine), which picks live agent versions. A child run has its parent's. Absent when there was none.",
639
639
  },
640
+ traceId: {
641
+ type: 'string',
642
+ pattern: '^[0-9a-f]{32}$',
643
+ description:
644
+ "The W3C trace id of the request that started the run: the caller's (from its `traceparent`) or one the API minted. The runtime's records about the run carry it; `GET` responses answer `traceresponse` with each request's own. Absent for a run no request started, and on runs from before runs recorded it.",
645
+ },
640
646
  publicAccessToken: {
641
647
  type: 'string',
642
648
  description:
@@ -184,6 +184,7 @@ export function mountAgentReleaseRoutes(
184
184
  passed: gate.result.passed,
185
185
  ...(gate.result.approval !== undefined && { approval: gate.result.approval }),
186
186
  servingVersion: gate.servingVersion as Semver,
187
+ ...(gate.pinInPlace && { pinInPlace: true }),
187
188
  },
188
189
  });
189
190
  if (outcome.kind === 'err') return failed(c, requestId, outcome.error);
@@ -441,10 +442,32 @@ function summaryOf(run: EvalRun): JudgedComparisonSummary | null {
441
442
  : null;
442
443
  }
443
444
 
445
+ /** Whether two live scopes are the same scope. */
446
+ function sameScope(a: LiveScope, b: LiveScope): boolean {
447
+ switch (a.kind) {
448
+ case 'tenant':
449
+ return b.kind === 'tenant';
450
+ case 'org':
451
+ return b.kind === 'org' && b.orgId === a.orgId;
452
+ case 'project':
453
+ return b.kind === 'project' && b.projectId === a.projectId;
454
+ case 'segment':
455
+ return (
456
+ b.kind === 'segment' &&
457
+ b.projectId === a.projectId &&
458
+ b.path.length === a.path.length &&
459
+ b.path.every((s, i) => s.key === a.path[i]?.key && s.value === a.path[i]?.value)
460
+ );
461
+ }
462
+ }
463
+
444
464
  /**
445
465
  * The gate for a promotion request: what serves the scope now, the
446
466
  * comparison it names, and the policy's checks against them. With no
447
- * policy there's nothing to check.
467
+ * policy there's nothing to check. A pin in place (the scope has no pin
468
+ * of its own and serves exactly the version already) changes nothing any
469
+ * run gets, so the policy's checks and approval don't apply: one passing
470
+ * `pinInPlace` check, and `pinInPlace` for the binding to re-check.
448
471
  */
449
472
  async function runGate(
450
473
  registry: AgentRegistryBinding,
@@ -459,7 +482,7 @@ async function runGate(
459
482
  },
460
483
  policy: GatePolicy | null,
461
484
  ): Promise<
462
- | { kind: 'ok'; result: GateResult; servingVersion: string }
485
+ | { kind: 'ok'; result: GateResult; servingVersion: string; pinInPlace?: boolean }
463
486
  | { kind: 'err'; error: { code: string; message: string } }
464
487
  > {
465
488
  const { tenantId, agentId } = req;
@@ -483,6 +506,27 @@ async function runGate(
483
506
  servingVersion = latest.version as unknown as string;
484
507
  }
485
508
  if (policy === null) return { kind: 'ok', result: { checks: [], passed: true }, servingVersion };
509
+ if (servingVersion === (req.version as unknown as string)) {
510
+ const pins = await releases.live.list({ tenantId, agentId });
511
+ if (!pins.some((pin) => sameScope(pin.scope, req.scope))) {
512
+ const from = live === null ? 'as the latest version' : 'from a pin above it';
513
+ return {
514
+ kind: 'ok',
515
+ result: {
516
+ checks: [
517
+ {
518
+ name: 'pinInPlace',
519
+ passed: true,
520
+ message: `${agentId} ${servingVersion} already serves this scope (${from}), so pinning it here changes nothing for runs: the gate's checks and approval don't apply.`,
521
+ },
522
+ ],
523
+ passed: true,
524
+ },
525
+ servingVersion,
526
+ pinInPlace: true,
527
+ };
528
+ }
529
+ }
486
530
 
487
531
  let summary: JudgedComparisonSummary | null = null;
488
532
  if (req.evalRunId !== undefined) {
@@ -23,6 +23,7 @@ import type {
23
23
  RunHandlerBinding,
24
24
  RunHandlerFailure,
25
25
  RunHandlerOutcome,
26
+ RunTrace,
26
27
  } from '../handler-binding.js';
27
28
  import type { Authorizer } from '../middleware/authorize.js';
28
29
  import type { MintPublicRunTokenResult } from '../public-run-token.js';
@@ -161,7 +162,13 @@ export function runsRouter(
161
162
  return c.json(toWireError(parsed.error, requestId));
162
163
  }
163
164
 
164
- const invocation = await invokeFromBody(binding, tenantId, parsed.value);
165
+ const trace = c.get('trace');
166
+ const invocation = await invokeFromBody(
167
+ binding,
168
+ tenantId,
169
+ parsed.value,
170
+ trace !== undefined ? { traceId: trace.traceId, spanId: trace.spanId } : undefined,
171
+ );
165
172
 
166
173
  if (invocation.kind === 'err') {
167
174
  c.status(statusFor(invocation.error.code) as never);
@@ -624,9 +631,11 @@ function invokeFromBody(
624
631
  binding: RunHandlerBinding,
625
632
  tenantId: TenantId,
626
633
  body: ParsedStartRunBody,
634
+ trace?: RunTrace,
627
635
  ): Promise<RunHandlerOutcome> {
628
636
  const common = {
629
637
  tenantId,
638
+ ...(trace !== undefined && { trace }),
630
639
  ...(body.projectId !== undefined && { projectId: body.projectId }),
631
640
  input: body.input,
632
641
  ...(body.segments !== undefined && { segments: body.segments }),
@@ -690,6 +699,7 @@ function serializeRun(
690
699
  row.segments.length > 0 && {
691
700
  segments: row.segments.map(({ key, value }) => ({ key, value })),
692
701
  }),
702
+ ...(row.traceId != null && { traceId: row.traceId }),
693
703
  };
694
704
  }
695
705
 
@@ -305,4 +305,9 @@ export type SecretError =
305
305
  readonly message: string;
306
306
  readonly currentVersion: number;
307
307
  }
308
+ /**
309
+ * The store doesn't do this by design (the dev store keeps no versions to
310
+ * rotate and no revocation): the message says what to do instead.
311
+ */
312
+ | { readonly code: 'secret-operation-unsupported'; readonly message: string }
308
313
  | { readonly code: 'secret-store-error'; readonly message: string; readonly cause?: unknown };
package/src/types.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Copyright (C) 2026 Kindgi Inc.
3
3
 
4
4
  import type { Principal, ReviewerRole } from '@kindgi/authz';
5
+ import type { Logger, TraceContext } from '@kindgi/log';
5
6
  import type { ApiTokenId, RunId, SessionId, TenantId, UserId } from '@kindgi/types';
6
7
 
7
8
  /**
@@ -11,6 +12,14 @@ import type { ApiTokenId, RunId, SessionId, TenantId, UserId } from '@kindgi/typ
11
12
  export interface AppEnv {
12
13
  Variables: {
13
14
  requestId: string;
15
+ /**
16
+ * The request's logger (`requestLogMiddleware`): the app's, with the
17
+ * request's `requestId`, `traceId` and `spanId`, and its `tenantId`
18
+ * once authenticated. Routes log through it.
19
+ */
20
+ log: Logger;
21
+ /** The request's W3C trace context: the caller's trace when it sent a valid `traceparent`. */
22
+ trace: TraceContext;
14
23
  /** Set by `bearerAuthMiddleware` on authenticated routes. */
15
24
  tenantId: TenantId;
16
25
  /**