Policy feed
What the SDK polls: the snapshot and the delta.
packages/api/src/policy/policy.controller.ts by packages/docs/tools/generate-reference.mjs. Every example is a real exchange recorded against the control plane by packages/api/src/testing/reference-examples.spec.ts, with ids from that run. Credentials in the formats this product issues (kr_, krsk_, krses_, JWTs) and values under credential-named fields (password, *_secret, *_token, *_key, lookup_hash) are elided at record time; the recorded file and every page of this site are swept for credential shapes (kr_, krsk_, krses_, JWTs, URLs carrying a password, password fields) before either is accepted. Do not edit by hand: src/reference.spec.ts regenerates it and fails on a difference.The policy distribution feed: the endpoints that make "zero network requests on the hot path" true rather than aspirational (report section 2.7).
Everything here is a read. No handler writes an audit row, and none takes the
per-workspace advisory lock that keyring.audit_log_link does, because a
vendor's own control-plane write traffic must never be able to stall the feed
that keeps their fleet verifying -- that is the 22.7 s stall the week 2 review
reproduced, and the reason config.ts grew timeouts.
The one write is the node watermark, and it is conditional: a node that is up
to date and was seen recently writes nothing (see recordNodeAck).
GET /v1/policy/snapshot
Answers 200 on success.
Authentication.
- A
krsk_secret key only. A person's session is refused on this plane.
Query. Validated by this schema, from the control plane's own source:
const SnapshotQuery = z.object({
project_id: z.uuid(),
env: Env.optional(),
tenant_id: TenantIds,
node: NodeId.optional(),
max_keys: MaxKeys,
cursor: z.string().min(1).max(512).optional(),
});Report section 6.1's first and largest fix: a host node needs the keys of the tenants that call it, not every key in the project. Narrowing is what keeps a 1 M-key project from being a 267 MB response and 555 MiB of someone else's heap.
On the delta route too, and that is the PR 4 review's finding 8a. The
fallback there hardcoded null and the SDK never sent the parameter, so the
one request where narrowing matters most -- a node far enough behind to need
a re-seed -- was the one request that received the whole project.
const TenantIds = z
.union([z.uuid(), z.array(z.uuid()).min(1).max(50)])
.optional()
.transform((value) =>
value === undefined ? null : Array.isArray(value) ? value : [value],
);Matches policy_node_id_shape in migration 0005. The node id is chosen by
the SDK and is therefore untrusted input: it is constrained here so that a
malformed one is a 400 rather than a constraint violation, and it is only
ever a bound parameter.
const NodeId = z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/);The caller's statement that it understands a paged answer, and how large a
page it wants. Clamped to policySnapshotMaxKeys server-side, so asking for
more is not a way around the ceiling.
Its presence is the protocol version. Absent, an oversized snapshot is a
413 that names the two ways out rather than a first page the caller would
take for the whole set -- see snapshotTooLarge.
const MaxKeys = z.coerce.number().int().min(1).optional();Example.
curl https://keyring-api.belghalem.fr/v1/policy/snapshot?project_id=01a0ade7-b8fb-7711-bb1a-7d1bd5962995&node=api-eu-1&max_keys=1000 \
-H "Authorization: Bearer $KEYRING_SECRET_KEY"HTTP/1.1 200 OK
etag: W/"snap:01a0ade7-b8fb-7711-bb1a-7d1bd5962995:all:all:5"
Content-Type: application/json
{
"object": "policy_snapshot",
"workspace_id": "01a0ade7-b8df-722f-854f-ebac1cc717a7",
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"env": null,
"version": 5,
"project_deleted": false,
"generated_at": "2026-09-17T05:47:19.727Z",
"keys": [
{
"key_id": "01a0ade7-b91e-75e6-a213-7dc943e17366",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"lookup_hash": "…",
"display_prefix": "kr_live_-sPZ4f",
"scopes": [
"orders:read",
"refunds:read"
],
"expires_at": "2027-01-01T00:00:00.000Z",
"rate_limits": [
{
"id": "per-second",
"limit": 20,
"scope": "key",
"algorithm": "sliding",
"window_ms": 1000
}
]
},
{
"key_id": "01a0ade7-b916-78a6-801e-79f4ba8233fb",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "test",
"lookup_hash": "…",
"display_prefix": "kr_test_KVME4u",
"scopes": [
"orders:read",
"orders:write"
],
"expires_at": null,
"rate_limits": [
{
"id": "per-second",
"limit": 100,
"scope": "key",
"algorithm": "sliding",
"window_ms": 1000
},
{
"id": "daily",
"limit": 100000,
"scope": "tenant",
"algorithm": "fixed",
"window_ms": 86400000
}
]
}
],
"next_cursor": null
}GET /v1/policy/delta
Answers 200 on success.
Authentication.
- A
krsk_secret key only. A person's session is refused on this plane.
Query. Validated by this schema, from the control plane's own source:
const DeltaQuery = z.object({
project_id: z.uuid(),
since: z.coerce.number().int().min(0),
env: Env.optional(),
tenant_id: TenantIds,
node: NodeId.optional(),
max_keys: MaxKeys,
});Report section 6.1's first and largest fix: a host node needs the keys of the tenants that call it, not every key in the project. Narrowing is what keeps a 1 M-key project from being a 267 MB response and 555 MiB of someone else's heap.
On the delta route too, and that is the PR 4 review's finding 8a. The
fallback there hardcoded null and the SDK never sent the parameter, so the
one request where narrowing matters most -- a node far enough behind to need
a re-seed -- was the one request that received the whole project.
const TenantIds = z
.union([z.uuid(), z.array(z.uuid()).min(1).max(50)])
.optional()
.transform((value) =>
value === undefined ? null : Array.isArray(value) ? value : [value],
);Matches policy_node_id_shape in migration 0005. The node id is chosen by
the SDK and is therefore untrusted input: it is constrained here so that a
malformed one is a 400 rather than a constraint violation, and it is only
ever a bound parameter.
const NodeId = z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/);The caller's statement that it understands a paged answer, and how large a
page it wants. Clamped to policySnapshotMaxKeys server-side, so asking for
more is not a way around the ceiling.
Its presence is the protocol version. Absent, an oversized snapshot is a
413 that names the two ways out rather than a first page the caller would
take for the whole set -- see snapshotTooLarge.
const MaxKeys = z.coerce.number().int().min(1).optional();Example: Changes since a version the node holds.
curl https://keyring-api.belghalem.fr/v1/policy/delta?project_id=01a0ade7-b8fb-7711-bb1a-7d1bd5962995&since=5&node=api-eu-1 \
-H "Authorization: Bearer $KEYRING_SECRET_KEY"HTTP/1.1 200 OK
etag: W/"delta:01a0ade7-b8fb-7711-bb1a-7d1bd5962995:all:all:7"
Content-Type: application/json
{
"object": "policy_delta",
"workspace_id": "01a0ade7-b8df-722f-854f-ebac1cc717a7",
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"env": null,
"since": 5,
"version": 7,
"project_deleted": false,
"generated_at": "2026-09-17T05:47:19.743Z",
"changes": [
{
"change": "upsert",
"key_id": "01a0ade7-b91e-75e6-a213-7dc943e17366",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"lookup_hash": "…",
"display_prefix": "kr_live_-sPZ4f",
"scopes": [
"orders:read",
"refunds:read"
],
"expires_at": "2026-09-18T05:47:19.731Z",
"rate_limits": [
{
"id": "per-second",
"limit": 20,
"scope": "key",
"algorithm": "sliding",
"window_ms": 1000
}
]
},
{
"change": "upsert",
"key_id": "01a0ade7-b935-77fe-bc3a-7dd4ef3c8dbe",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"lookup_hash": "…",
"display_prefix": "kr_live_mPXMEX",
"scopes": [
"orders:read",
"refunds:read"
],
"expires_at": null,
"rate_limits": [
{
"id": "per-second",
"limit": 20,
"scope": "key",
"algorithm": "sliding",
"window_ms": 1000
}
]
}
]
}Example: Nothing changed.
curl https://keyring-api.belghalem.fr/v1/policy/delta?project_id=01a0ade7-b8fb-7711-bb1a-7d1bd5962995&since=7&node=api-eu-1 \
-H "Authorization: Bearer $KEYRING_SECRET_KEY"HTTP/1.1 304 Not Modified
etag: W/"delta:01a0ade7-b8fb-7711-bb1a-7d1bd5962995:all:all:7"Shared validators
Defined once in packages/api/src/validation.ts and used by the schemas above.
const Env = z.enum(['live', 'test']);