@cosmicdrift/kumiko-framework 0.337.1 → 0.339.0

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 (61) hide show
  1. package/dist/api/api-constants.d.ts +1 -0
  2. package/dist/api/api-constants.js +9 -0
  3. package/dist/api/auth-middleware.d.ts +1 -0
  4. package/dist/api/auth-middleware.js +56 -12
  5. package/dist/api/routes.d.ts +2 -1
  6. package/dist/api/routes.js +29 -6
  7. package/dist/api/server.js +20 -2
  8. package/dist/bun-db/query.js +8 -2
  9. package/dist/compliance/sub-processors.js +10 -1
  10. package/dist/crypto/event-pii.js +13 -4
  11. package/dist/crypto/index.d.ts +1 -1
  12. package/dist/crypto/index.js +1 -1
  13. package/dist/crypto/pii-ciphertext-format.d.ts +4 -0
  14. package/dist/crypto/pii-ciphertext-format.js +11 -0
  15. package/dist/crypto/pii-field-encryption.d.ts +4 -2
  16. package/dist/crypto/pii-field-encryption.js +50 -21
  17. package/dist/crypto/system-event-pii.js +2 -2
  18. package/dist/db/blind-index-cleanup.d.ts +1 -0
  19. package/dist/db/blind-index-cleanup.js +11 -0
  20. package/dist/db/event-store-executor-read.js +18 -3
  21. package/dist/db/index.d.ts +1 -1
  22. package/dist/db/index.js +1 -1
  23. package/dist/db/queries/backfill-pii.js +4 -0
  24. package/dist/db/tenant-db.js +25 -13
  25. package/dist/engine/boot-validator/access-declarations.js +13 -7
  26. package/dist/engine/boot-validator/entity-handler.d.ts +2 -0
  27. package/dist/engine/boot-validator/entity-handler.js +45 -0
  28. package/dist/engine/boot-validator/index.js +2 -1
  29. package/dist/engine/boot-validator/pii-retention.js +41 -1
  30. package/dist/engine/config-helpers.d.ts +2 -1
  31. package/dist/engine/config-helpers.js +1 -0
  32. package/dist/engine/define-handler.js +1 -0
  33. package/dist/engine/extensions/escape-hatch-usage.js +3 -1
  34. package/dist/engine/extensions/user-data.d.ts +1 -1
  35. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  36. package/dist/engine/feature-ast/extractors/hooks.js +7 -1
  37. package/dist/engine/feature-ast/pattern-change-schema.d.ts +40 -0
  38. package/dist/engine/feature-ast/pattern-change-schema.js +2 -0
  39. package/dist/engine/feature-config-events-jobs.js +5 -1
  40. package/dist/engine/feature-entity-handlers.js +1 -0
  41. package/dist/engine/feature-manifest.d.ts +8 -0
  42. package/dist/engine/feature-manifest.js +9 -0
  43. package/dist/engine/feature-ui-extensions.js +9 -0
  44. package/dist/engine/index.d.ts +3 -2
  45. package/dist/engine/index.js +1 -0
  46. package/dist/engine/registry-ingest.d.ts +1 -0
  47. package/dist/engine/registry-ingest.js +9 -0
  48. package/dist/engine/registry.js +2 -1
  49. package/dist/engine/steps/retry.js +0 -1
  50. package/dist/engine/steps/wait-for-event.js +0 -1
  51. package/dist/engine/steps/wait.js +0 -4
  52. package/dist/engine/steps/workflow-system-events.d.ts +2 -0
  53. package/dist/engine/steps/workflow-system-events.js +89 -0
  54. package/dist/engine/types/index.d.ts +1 -1
  55. package/dist/jobs/job-runner.js +2 -1
  56. package/dist/pipeline/append-event-core.d.ts +3 -1
  57. package/dist/pipeline/append-event-core.js +8 -4
  58. package/dist/pipeline/dispatch-shared.js +37 -7
  59. package/dist/pipeline/system-identity-switch.js +10 -7
  60. package/package.json +4 -4
  61. package/src/changes.json +102 -0
@@ -33,6 +33,7 @@ export declare const Routes: {
33
33
  };
34
34
  export declare const PUBLIC_API_PATHS: ReadonlySet<string>;
35
35
  export declare const NON_PUBLIC_API_PATHS: ReadonlySet<string>;
36
+ export declare function isSessionRequiredApiPath(path: string): boolean;
36
37
  export declare const BODY_LIMIT_OPT_OUT_PATHS: ReadonlySet<string>;
37
38
  export declare const AUTH_RATE_LIMIT_EXEMPT_ROUTES: ReadonlyArray<{
38
39
  readonly method: string;
@@ -87,6 +87,15 @@ export const NON_PUBLIC_API_PATHS = new Set([
87
87
  `/api${Routes.files}`,
88
88
  `/api${Routes.schema}`,
89
89
  ]);
90
+ // Paths that need a session but no tenant: without a token they answer 401
91
+ // before any tenant resolution, so an unresolvable tenant can never turn
92
+ // "not logged in" into a 400 tenant_required.
93
+ const SESSION_REQUIRED_API_PREFIXES = [`/api${Routes.auth}/`];
94
+ const SESSION_REQUIRED_API_EXACT_PATHS = new Set([`/api${Routes.schema}`]);
95
+ export function isSessionRequiredApiPath(path) {
96
+ return (SESSION_REQUIRED_API_EXACT_PATHS.has(path) ||
97
+ SESSION_REQUIRED_API_PREFIXES.some((prefix) => path.startsWith(prefix)));
98
+ }
90
99
  // Opt-out from the default request-body-size cap (registerBodyLimit applies
91
100
  // it to all of /api/* by construction — a new route needs no entry here to
92
101
  // be covered). Only routes with their own, deliberately different size
@@ -23,6 +23,7 @@ export type AuthMiddlewareOptions = {
23
23
  readonly anonymousAccess?: AnonymousAccessResolved;
24
24
  readonly resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver;
25
25
  readonly foreignCookieOrigins?: ReadonlySet<string>;
26
+ readonly isTenantlessAnonymousWrite?: (handlerQn: string) => boolean;
26
27
  };
27
28
  export type TenantResolver = (c: Context) => Promise<TenantId | null> | TenantId | null;
28
29
  export type TenantExists = (tenantId: TenantId) => Promise<boolean> | boolean;
@@ -1,8 +1,8 @@
1
1
  import { getCookie } from "hono/cookie";
2
2
  import { TENANT_TEARDOWN_STATUSES } from "../engine/active-membership.js";
3
3
  import { createAnonymousUser } from "../engine/system-user.js";
4
- import { parseTenantId } from "../engine/types/identifiers.js";
5
- import { TENANT_COOKIE_NAME, TENANT_HEADER_NAME } from "./api-constants.js";
4
+ import { parseTenantId, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
5
+ import { isSessionRequiredApiPath, TENANT_COOKIE_NAME, TENANT_HEADER_NAME, } from "./api-constants.js";
6
6
  import { isForeignCookieOrigin } from "./origin-middleware.js";
7
7
  const USER_KEY = "pipelineUser";
8
8
  const AUTH_TRANSPORT_KEY = "authTransport";
@@ -78,7 +78,7 @@ function extractToken(c) {
78
78
  return { error: "missing" };
79
79
  }
80
80
  export function authMiddleware(jwt, options = {}) {
81
- const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus, foreignCookieOrigins, } = options;
81
+ const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus, foreignCookieOrigins, isTenantlessAnonymousWrite, } = options;
82
82
  // Fail loud at boot, not silently at request time: a tenantResolver
83
83
  // without a declared resolverTrust is an ambiguous trust decision no
84
84
  // sane default can make on the app's behalf (see AnonymousAccessConfig).
@@ -87,6 +87,7 @@ export function authMiddleware(jwt, options = {}) {
87
87
  'declare "authoritative" (resolver wins over client header/cookie) or ' +
88
88
  '"fallback-only" (client wins, resolver is a last resort).');
89
89
  }
90
+ const anonymousExtras = { isTenantlessAnonymousWrite };
90
91
  return async (c, next) => {
91
92
  const extracted = extractToken(c);
92
93
  if ("error" in extracted) {
@@ -109,7 +110,7 @@ export function authMiddleware(jwt, options = {}) {
109
110
  // missing authentication, nicht missing tenant. Daher hier direkt
110
111
  // 401, ohne den anonymous-Tenant-Flow zu durchlaufen.
111
112
  if (anonymousAccess) {
112
- if (c.req.path.startsWith("/api/auth/")) {
113
+ if (isSessionRequiredApiPath(c.req.path)) {
113
114
  return middlewareReject(c, {
114
115
  code: "missing_token",
115
116
  status: 401,
@@ -117,7 +118,7 @@ export function authMiddleware(jwt, options = {}) {
117
118
  i18nKey: "auth.errors.missingToken",
118
119
  });
119
120
  }
120
- return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus);
121
+ return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, anonymousExtras);
121
122
  }
122
123
  return middlewareReject(c, {
123
124
  code: "missing_token",
@@ -136,7 +137,7 @@ export function authMiddleware(jwt, options = {}) {
136
137
  !c.req.path.startsWith("/api/auth/") &&
137
138
  foreignCookieOrigins &&
138
139
  isForeignCookieOrigin(c, foreignCookieOrigins)) {
139
- return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus);
140
+ return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, anonymousExtras);
140
141
  }
141
142
  // Generic bearer-verifier path: try the wired tokenVerifier (PAT, future
142
143
  // JWT-provider, ...) BEFORE jwt.verify. A hit short-circuits the JWT path
@@ -153,7 +154,7 @@ export function authMiddleware(jwt, options = {}) {
153
154
  const rejectInvalidCredential = async (rejection) => {
154
155
  if (isStaleCookieDowngradable(c, transport, anonymousAccess)) {
155
156
  return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, {
156
- rejectionIfTenantUnresolved: rejection,
157
+ staleCredential: { rejectionIfTenantUnresolved: rejection },
157
158
  });
158
159
  }
159
160
  return middlewareReject(c, rejection);
@@ -271,7 +272,8 @@ async function handleVerifiedBearerUser(c, user, next, resolveTenantLifecycleSta
271
272
  // 5. Synthesise the anonymous SessionUser. The transport flag stays unset
272
273
  // so csrf-middleware skips the double-submit check (no auth-cookie ⇒
273
274
  // no CSRF vector to defend against).
274
- async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, staleCredential) {
275
+ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, extras = {}) {
276
+ const { staleCredential, isTenantlessAnonymousWrite } = extras;
275
277
  // A downgraded stale credential whose anonymous tenant can't be resolved or
276
278
  // is torn down answers with the credential's own 401: a tenant error would
277
279
  // hide the session end from the client.
@@ -292,11 +294,14 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, st
292
294
  ? { id: cookieCheck.tenantId, source: "cookie" }
293
295
  : null;
294
296
  // Step 3: pick the authoritative tenant.
295
- const resolved = await resolveTenant(c, config, clientTenant);
297
+ const resolved = await resolveTenantOrTenantless(c, config, clientTenant, {
298
+ allowTenantless: staleCredential === undefined,
299
+ isTenantlessAnonymousWrite,
300
+ });
296
301
  if ("error" in resolved)
297
302
  return rejectTenant(resolved.error);
298
303
  // Step 4: existence check for untrusted sources.
299
- if (resolved.source !== "default" && config.tenantExists) {
304
+ if (resolved.source !== "default" && resolved.source !== "tenantless" && config.tenantExists) {
300
305
  const exists = await config.tenantExists(resolved.tenantId);
301
306
  if (!exists) {
302
307
  return rejectTenant({
@@ -308,8 +313,10 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, st
308
313
  });
309
314
  }
310
315
  }
311
- // Step 5: synthesise + continue.
312
- const lifecycleReject = await rejectIfTenantTeardown(c, resolved.tenantId, resolveTenantLifecycleStatus);
316
+ // Step 5: synthesise + continue. SYSTEM_TENANT_ID is no lifecycle-managed tenant.
317
+ const lifecycleReject = resolved.source === "tenantless"
318
+ ? undefined
319
+ : await rejectIfTenantTeardown(c, resolved.tenantId, resolveTenantLifecycleStatus);
313
320
  if (lifecycleReject) {
314
321
  return staleCredential
315
322
  ? middlewareReject(c, staleCredential.rejectionIfTenantUnresolved)
@@ -321,6 +328,43 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, st
321
328
  // when next() ran; explicit return makes the union return-type honest.
322
329
  return;
323
330
  }
331
+ // The exemption applies only where resolveTenant answers tenant_required:
332
+ // never on tenant_mismatch, never when the client named a tenant via X-Tenant,
333
+ // never for a downgraded stale credential. An ambient tenant cookie does not
334
+ // block it: tenant_required with a client tenant only happens when an
335
+ // authoritative resolver stayed silent, and that path ignores the cookie
336
+ // anyway, so a customer who also uses the app on this host can still cancel.
337
+ async function resolveTenantOrTenantless(c, config, clientTenant, tenantless) {
338
+ const resolved = await resolveTenant(c, config, clientTenant);
339
+ const { isTenantlessAnonymousWrite } = tenantless;
340
+ if ("error" in resolved &&
341
+ resolved.error.code === "tenant_required" &&
342
+ clientTenant?.source !== "header" &&
343
+ tenantless.allowTenantless &&
344
+ isTenantlessAnonymousWrite !== undefined &&
345
+ (await isFlaggedTenantlessWrite(c, isTenantlessAnonymousWrite))) {
346
+ return { tenantId: SYSTEM_TENANT_ID, source: "tenantless" };
347
+ }
348
+ return resolved;
349
+ }
350
+ // Body peek for the tenantless exemption: only a single-command POST
351
+ // /api/write qualifies, never /api/batch. Parsing the clone leaves the body
352
+ // intact for the dispatcher; the body-size limit already ran upstream.
353
+ async function isFlaggedTenantlessWrite(c, isTenantlessAnonymousWrite) {
354
+ if (c.req.method !== "POST" || c.req.path !== "/api/write")
355
+ return false;
356
+ try {
357
+ const body = await c.req.raw.clone().json();
358
+ return (typeof body === "object" &&
359
+ body !== null &&
360
+ "type" in body &&
361
+ typeof body.type === "string" &&
362
+ isTenantlessAnonymousWrite(body.type));
363
+ }
364
+ catch {
365
+ return false;
366
+ }
367
+ }
324
368
  // Validates an X-Tenant / cookie value against the tenantId format. Returns
325
369
  // `{tenantId: null}` when absent, `{tenantId: TenantId}` when valid, or
326
370
  // `{error: …}` when the value is non-empty junk (so the caller can return
@@ -1,4 +1,4 @@
1
- import { Hono } from "hono";
1
+ import { type Context, Hono } from "hono";
2
2
  import type { Dispatcher } from "../pipeline/dispatcher.js";
3
3
  export declare const StreamFrame: {
4
4
  readonly chunk: "chunk";
@@ -18,3 +18,4 @@ export type SseWriter = {
18
18
  };
19
19
  export declare function pumpStream(stream: SseWriter, generator: AsyncGenerator<unknown>, heartbeatMs: number, firstPull?: Promise<IteratorResult<unknown>>): Promise<void>;
20
20
  export declare const MAX_PAYLOAD_DEPTH = 20;
21
+ export declare function handleUncaughtRouteError(err: Error, c: Context): Response | Promise<Response>;
@@ -1,4 +1,5 @@
1
1
  import { Hono } from "hono";
2
+ import { HTTPException } from "hono/http-exception";
2
3
  import { streamSSE } from "hono/streaming";
3
4
  import { AccessDeniedError, reraiseAsKumikoError, serializeError, toKumikoError, ValidationError, } from "../errors/index.js";
4
5
  import { createFallbackLogger } from "../logging/index.js";
@@ -356,17 +357,39 @@ function writeErrorResponse(c, err, type) {
356
357
  const { error } = serializeError(err, requestId);
357
358
  return c.json({ isSuccess: false, error }, err.httpStatus); // @cast-boundary engine-payload
358
359
  }
360
+ function clientAbortResponse(c, requestId, type) {
361
+ logClientWarn("request aborted by client", requestId, type, {
362
+ status: CLIENT_CLOSED_REQUEST_STATUS,
363
+ });
364
+ return c.body(null, CLIENT_CLOSED_REQUEST_STATUS); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
365
+ }
359
366
  // For /query + /command: no isSuccess on success (just { data } / {ok}), so we
360
367
  // keep the same lean shape on failure — only the `error` key.
361
368
  function queryErrorResponse(c, err, type) {
362
369
  const requestId = requestContext.get()?.requestId;
363
- if (isClientAbort(err)) {
364
- logClientWarn("request aborted by client", requestId, type, {
365
- status: CLIENT_CLOSED_REQUEST_STATUS,
366
- });
367
- return c.body(null, CLIENT_CLOSED_REQUEST_STATUS); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
368
- }
370
+ if (isClientAbort(err))
371
+ return clientAbortResponse(c, requestId, type);
369
372
  logServerFault(err, requestId, type);
370
373
  const body = serializeError(err, requestId);
371
374
  return c.json(body, err.httpStatus); // @cast-boundary engine-payload
372
375
  }
376
+ // A raw throw under the request signal (signal.throwIfAborted(), an aborted
377
+ // fetch) is the signal's own reason or wraps it as `cause`.
378
+ function isRequestAbortError(err, signal) {
379
+ return signal.aborted && (err === signal.reason || err.cause === signal.reason);
380
+ }
381
+ // Root `app.onError`: whatever a route rethrows instead of shaping itself.
382
+ // Without it Hono's default handler console.error()s the bare error and
383
+ // answers an unclassified 500 — including for a client that simply hung up.
384
+ // requestContext only exists under /api/*, so the abort check reads the raw
385
+ // request signal.
386
+ export function handleUncaughtRouteError(err, c) {
387
+ if (err instanceof HTTPException)
388
+ return err.getResponse();
389
+ const requestId = requestContext.get()?.requestId;
390
+ if (isRequestAbortError(err, c.req.raw.signal))
391
+ return clientAbortResponse(c, requestId);
392
+ const kumikoError = toKumiko(err);
393
+ logServerFault(kumikoError, requestId);
394
+ return c.json(serializeError(kumikoError, requestId), kumikoError.httpStatus); // @cast-boundary engine-payload
395
+ }
@@ -23,7 +23,7 @@ import { authEndpointRateLimit, createRateLimitResolver, globalIpRateLimit, } fr
23
23
  import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
24
24
  import { assertUnreachable, generateId } from "../utils/index.js";
25
25
  import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
26
- import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants.js";
26
+ import { NO_ROUTE_MATCH_HEADER_NAME, NON_PUBLIC_API_PATHS, PUBLIC_API_PATHS, Routes, } from "./api-constants.js";
27
27
  import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.js";
28
28
  import { assertSessionCallbacksPaired, createAuthRoutes, } from "./auth-routes.js";
29
29
  import { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
@@ -39,7 +39,7 @@ import { createDefaultSseBroker } from "./redis-sse-broker.js";
39
39
  import { requestContext } from "./request-context.js";
40
40
  import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware.js";
41
41
  import { DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
42
- import { createApiRoutes } from "./routes.js";
42
+ import { createApiRoutes, handleUncaughtRouteError } from "./routes.js";
43
43
  import { createSseRoute } from "./sse-route.js";
44
44
  import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, hasWebSocketUpgradeServer, isWebSocketUpgradeRequest, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES, } from "./websocket-route.js";
45
45
  // The per-tenant file-provider resolver, built once for a registry+context so
@@ -380,6 +380,8 @@ export function buildServer(options) {
380
380
  });
381
381
  }
382
382
  const app = new Hono();
383
+ // Sub-apps mounted via app.route() without their own handler inherit this one.
384
+ app.onError(handleUncaughtRouteError);
383
385
  // Only entry:"signature" bypasses jwtGuard (verify() authenticates
384
386
  // itself); entry:"anonymous" still needs the anonymousAccess fallthrough
385
387
  // (tenant-by-host), entry:"user" needs c.get("user") populated.
@@ -453,7 +455,13 @@ export function buildServer(options) {
453
455
  const normalizedForeignCookieOrigins = options.auth?.allowedOrigins?.length
454
456
  ? new Set(options.auth.allowedOrigins.map(normalizeOrigin))
455
457
  : undefined;
458
+ const tenantlessAnonymousQns = new Set([...options.registry.getAllWriteHandlers()]
459
+ .filter(([, handler]) => handler.tenantlessAnonymous === true)
460
+ .map(([qn]) => qn));
456
461
  const jwtGuard = authMiddleware(jwt, {
462
+ ...(tenantlessAnonymousQns.size > 0
463
+ ? { isTenantlessAnonymousWrite: (qn) => tenantlessAnonymousQns.has(qn) }
464
+ : {}),
457
465
  ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
458
466
  ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
459
467
  ...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
@@ -708,6 +716,16 @@ export function buildServer(options) {
708
716
  throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
709
717
  'use a wildcard under "/api/" — it would bypass the auth chain for every matching path.');
710
718
  }
719
+ // A `:param` segment is as broad as a wildcard, and the public matchers test
720
+ // the bare request path: "/:seg/write" and "/api/:hook" both match /api/write.
721
+ if (route.entry === ExtraRouteEntries.signature) {
722
+ const routePattern = honoPathToRegex(route.path);
723
+ const shadowedFrameworkPath = [...PUBLIC_API_PATHS, ...NON_PUBLIC_API_PATHS].find((path) => routePattern.test(path));
724
+ if (shadowedFrameworkPath !== undefined) {
725
+ throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
726
+ `match the framework path "${shadowedFrameworkPath}" — it would bypass the auth chain for it.`);
727
+ }
728
+ }
711
729
  }
712
730
  const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
713
731
  const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
@@ -24,6 +24,7 @@ import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types
24
24
  import { Temporal } from "temporal-polyfill";
25
25
  import { requestContext } from "../api/request-context.js";
26
26
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index.js";
27
+ import { isPiiCiphertext } from "../crypto/pii-ciphertext-format.js";
27
28
  import { SQL_EXPR_BRAND } from "../db/dialect.js";
28
29
  import { extractPgError } from "../db/pg-error.js";
29
30
  import { toSnakeCase } from "../db/table-builder.js";
@@ -88,7 +89,7 @@ export function asRawClient(db) {
88
89
  // closed instead of silently unwrapping a tenant-scoped handle.
89
90
  if (isTenantDbShape(db)) {
90
91
  throw new Error("asRawClient: received a tenant-scoped TenantDb (ctx.db). Raw SQL helpers do not apply the " +
91
- "tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw(reason) with escapeHatch: " +
92
+ "tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw() with escapeHatch: " +
92
93
  "{ reason } on the handler/hook (r.systemScope() features: ctx.systemDb.unsafeRaw(reason)).");
93
94
  }
94
95
  // Drizzle DbConnection (legacy compat): $client = postgres-js Sql.
@@ -174,7 +175,7 @@ function tenantDbDelegate(db) {
174
175
  function assertNotTenantScoped(db, fnName) {
175
176
  if (tenantDbDelegate(db) !== undefined) {
176
177
  throw new Error(`${fnName}: received a tenant-scoped db but this helper does not apply the tenant filter. ` +
177
- "Pass ctx.db.unsafeRaw(reason) (with escapeHatch: { reason } on the handler/hook) for an " +
178
+ "Pass ctx.db.unsafeRaw() (with escapeHatch: { reason } on the handler/hook) for an " +
178
179
  "explicit cross-tenant op, or use a scoped TenantDb method.");
179
180
  }
180
181
  }
@@ -462,6 +463,11 @@ function prepareValue(value, pgType) {
462
463
  if (isSqlExpression(value)) {
463
464
  return { kind: "literal", literal: value.text };
464
465
  }
466
+ // A PII-encrypted jsonb field is a JSON string scalar; a raw string bound to
467
+ // ::jsonb would be parsed as JSON text (or double-encoded under Bun.SQL).
468
+ if (pgType === "jsonb" && isPiiCiphertext(value)) {
469
+ return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
470
+ }
465
471
  if (pgType === "jsonb" && value !== null) {
466
472
  const prepared = prepareJsonbValue(value);
467
473
  if (prepared)
@@ -84,13 +84,22 @@ export const KUMIKO_SUB_PROCESSORS = [
84
84
  },
85
85
  {
86
86
  name: "Scaleway SAS",
87
- purpose: "Offsite Backup Mirror (Object Storage); planned: platform key custody (Key Manager) and Transactional Email",
87
+ purpose: "Offsite Backup Mirror (Object Storage)",
88
88
  region: "EU (France)",
89
89
  dpa: "https://www.scaleway.com/en/contracts/",
90
90
  addedAt: "2026-09-18",
91
91
  appliesTo: ["all-tiers"],
92
92
  status: "active",
93
93
  },
94
+ {
95
+ name: "Scaleway SAS",
96
+ purpose: "Platform key custody (Key Manager) and Transactional Email Delivery",
97
+ region: "EU (France)",
98
+ dpa: "https://www.scaleway.com/en/contracts/",
99
+ addedAt: "2026-09-18",
100
+ appliesTo: ["all-tiers"],
101
+ status: "planned",
102
+ },
94
103
  ];
95
104
  /**
96
105
  * Filter helper — nur aktive Sub-Processors (kein status="planned").
@@ -14,7 +14,7 @@
14
14
  // silent no-op (forgetting the option) is now either an explicit "none" or a
15
15
  // missing subject KMS at encrypt-time, both visible states, not a gap.
16
16
  import { requestContext } from "../api/request-context.js";
17
- import { configuredPiiSubjectKms, encryptPiiValueForSubject } from "./pii-field-encryption.js";
17
+ import { configuredPiiSubjectKms, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, } from "./pii-field-encryption.js";
18
18
  import { resolveEventSubject } from "./subject-resolver.js";
19
19
  import { SYSTEM_EVENT_PII_STANCES, SYSTEM_EVENT_PREFIX } from "./system-event-pii.js";
20
20
  // Boot-injected like configurePiiSubjectKms — createRegistry calls this with
@@ -41,6 +41,12 @@ function resolvePiiStance(eventType) {
41
41
  }
42
42
  return stance;
43
43
  }
44
+ // The catalog carries no field types: strings keep the plain format, objects
45
+ // and arrays (jsonb payload fields) use the self-describing JSON format.
46
+ // Bare numbers/booleans stay a loud error so a mistyped string field is caught.
47
+ function isStructuredJsonValue(value) {
48
+ return typeof value === "object" && value !== null;
49
+ }
44
50
  // Encrypts catalogued payload fields under the declared subject's DEK
45
51
  // (user/tenant/self — resolveEventSubject, fw#2801). No-op when the event
46
52
  // type is uncatalogued or no subject KMS is configured (plaintext rollout
@@ -63,13 +69,16 @@ export async function encryptEventPayloadPii(eventType, payload, envelope) {
63
69
  const value = payload[field];
64
70
  if (value === null || value === undefined)
65
71
  continue;
66
- if (typeof value !== "string") {
67
- throw new Error(`Event "${eventType}" piiFields."${field}" must be a string payload field, got ${typeof value}`);
72
+ if (typeof value !== "string" && !isStructuredJsonValue(value)) {
73
+ throw new Error(`Event "${eventType}" piiFields."${field}" must be a string payload field (or an object/array for jsonb), got ${typeof value}`);
68
74
  }
69
75
  const subject = resolveEventSubject(field, spec, payload, envelope);
70
76
  if (subject === null)
71
77
  continue;
72
- const encrypted = await encryptPiiValueForSubject(kms, subject, value, { requestId: requestContext.get()?.requestId ?? "append-event" }, field);
78
+ const kmsCtx = { requestId: requestContext.get()?.requestId ?? "append-event" };
79
+ const encrypted = typeof value === "string"
80
+ ? await encryptPiiValueForSubject(kms, subject, value, kmsCtx, field)
81
+ : await encryptPiiJsonValueForSubject(kms, subject, value, kmsCtx, field);
73
82
  if (encrypted !== value) {
74
83
  out ??= { ...payload };
75
84
  out[field] = encrypted;
@@ -6,6 +6,6 @@ export { type KekSourceEnv, type KekSourceOptions, resolvePlatformKeks } from ".
6
6
  export { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, type KmsAdapter, type KmsContext, type KmsHealth, type LocalKeyKmsAdapter, RECORD_ENTITY_PATTERN, type RemoteCryptoKmsAdapter, type SubjectDek, type SubjectId, type SubjectKey, subjectIdFromKey, subjectIdSchema, subjectIdToKey, subjectKeyForRecord, subjectKeyForTenant, subjectKeyForUser, } from "./kms-adapter.js";
7
7
  export { type ActiveKmsWiring, buildPgKmsOptions, type KmsWiring, type KmsWiringEnv, type KmsWiringOptions, type KmsWiringRelease, type PgKmsRotationEnv, type PlaintextPiiWiring, requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.js";
8
8
  export { createPgKmsAdapter, PgKmsAdapter, type PgKmsAdapterOptions, type RewrapOptions, type RewrapResult, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
9
- export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, type EncryptPiiOptions, encryptPiiFieldValues, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
9
+ export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, type EncryptPiiOptions, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
10
10
  export { createRequestKmsCache, type RequestKmsCache, } from "./request-kms-cache.js";
11
11
  export { collectPiiSubjectFields, collectSearchableSubjectFields, type ResolveSubjectOptions, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
@@ -6,6 +6,6 @@ export { resolvePlatformKeks } from "./kek-source.js";
6
6
  export { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, RECORD_ENTITY_PATTERN, subjectIdFromKey, subjectIdSchema, subjectIdToKey, subjectKeyForRecord, subjectKeyForTenant, subjectKeyForUser, } from "./kms-adapter.js";
7
7
  export { buildPgKmsOptions, requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.js";
8
8
  export { createPgKmsAdapter, PgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
9
- export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
9
+ export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
10
10
  export { createRequestKmsCache, } from "./request-kms-cache.js";
11
11
  export { collectPiiSubjectFields, collectSearchableSubjectFields, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
@@ -0,0 +1,4 @@
1
+ export declare const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
2
+ export declare const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
3
+ export declare const PII_CIPHERTEXT_PREFIX_JSON = "kumiko-pii:v3:";
4
+ export declare function isPiiCiphertext(value: unknown): value is string;
@@ -0,0 +1,11 @@
1
+ // Leaf module (no engine imports) so bun-db and the executors can sniff PII
2
+ // ciphertext without pulling the KMS/pipeline graph into an import cycle.
3
+ export const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
4
+ export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
5
+ export const PII_CIPHERTEXT_PREFIX_JSON = "kumiko-pii:v3:";
6
+ export function isPiiCiphertext(value) {
7
+ return (typeof value === "string" &&
8
+ (value.startsWith(PII_CIPHERTEXT_PREFIX) ||
9
+ value.startsWith(PII_CIPHERTEXT_PREFIX_JSON) ||
10
+ value.startsWith(PII_CIPHERTEXT_PREFIX_V1)));
11
+ }
@@ -1,10 +1,11 @@
1
1
  import type { EntityDefinition } from "../engine/types/fields.js";
2
2
  import type { TenantId } from "../engine/types/identifiers.js";
3
3
  import { type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, type SubjectId } from "./kms-adapter.js";
4
+ import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON } from "./pii-ciphertext-format.js";
5
+ export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON };
4
6
  export declare const PII_ERASED_SENTINEL = "[[erased]]";
5
- export declare const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
6
- export declare function isPiiCiphertext(value: unknown): value is string;
7
7
  export declare function encryptPiiValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
8
+ export declare function encryptPiiJsonValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: unknown, kmsCtx: KmsContext, field: string): Promise<string>;
8
9
  export declare function decryptPiiValueForSubject(kms: LocalKeyKmsAdapter, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
9
10
  export interface EncryptPiiOptions {
10
11
  readonly onlyKeys?: Iterable<string>;
@@ -13,6 +14,7 @@ export interface EncryptPiiOptions {
13
14
  readonly entityName: string;
14
15
  }
15
16
  export declare function encryptPiiFieldValues(row: Record<string, unknown>, entity: EntityDefinition, piiFields: readonly string[], kms: LocalKeyKmsAdapter, kmsCtx: KmsContext, opts: EncryptPiiOptions): Promise<Record<string, unknown>>;
17
+ export declare function serializeJsonbValue(field: string, value: unknown): string;
16
18
  export declare function decryptPiiFieldValues(row: Record<string, unknown>, piiFields: readonly string[], kms: LocalKeyKmsAdapter, kmsCtx: KmsContext): Promise<Record<string, unknown>>;
17
19
  export declare function configurePiiSubjectKms(adapter: KmsAdapter | undefined): void;
18
20
  export declare function configuredPiiSubjectKms(): LocalKeyKmsAdapter | undefined;
@@ -9,34 +9,41 @@
9
9
  // SAME subject (same DEK, so key selection alone can't catch that) from
10
10
  // decrypting silently. v1 (no AAD) stays decrypt-only for pre-#1263 rows;
11
11
  // every new write emits v2.
12
+ // v3 = v2 for jsonb fields: the plaintext is JSON.stringify(value) and decrypt
13
+ // JSON.parses it back, so a jsonb string/number/object round-trips by type.
14
+ // The version digit (not a suffix) keeps `kumiko-pii:v\d+:` leak guards and
15
+ // the `kumiko-pii:v%:` LIKE sweeps matching. The AAD carries a "json" tag so
16
+ // a v2<->v3 prefix swap fails GCM instead of silently changing the type.
12
17
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
13
18
  import { assertIrreversibleOperationAllowed } from "../pipeline/irreversible-operation-gate.js";
14
19
  import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
20
+ import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_CIPHERTEXT_PREFIX_V1, } from "./pii-ciphertext-format.js";
15
21
  import { resolveSubjectForField } from "./subject-resolver.js";
16
22
  // Spec value (crypto-shredding.md) — renderers show it verbatim.
23
+ export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON };
17
24
  export const PII_ERASED_SENTINEL = "[[erased]]";
18
- const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
19
- export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
20
25
  const IV_LENGTH = 12;
21
26
  const AUTH_TAG_LENGTH = 16;
22
- export function isPiiCiphertext(value) {
23
- return (typeof value === "string" &&
24
- (value.startsWith(PII_CIPHERTEXT_PREFIX) || value.startsWith(PII_CIPHERTEXT_PREFIX_V1)));
27
+ function buildAad(subject, field, isJson) {
28
+ return Buffer.from(`${subjectIdToKey(subject)}|${field}${isJson ? "|json" : ""}`, "utf8");
25
29
  }
26
- function buildAad(subject, field) {
27
- return Buffer.from(`${subjectIdToKey(subject)}|${field}`, "utf8");
28
- }
29
- function encryptValue(subject, dek, plaintext, field) {
30
+ function encryptValue(subject, dek, plaintext, field, isJson = false) {
30
31
  const iv = randomBytes(IV_LENGTH);
31
32
  const cipher = createCipheriv("aes-256-gcm", dek, iv);
32
- cipher.setAAD(buildAad(subject, field));
33
+ cipher.setAAD(buildAad(subject, field, isJson));
33
34
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
34
35
  const blob = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
35
- return `${PII_CIPHERTEXT_PREFIX}${subjectIdToKey(subject)}:${blob.toString("base64")}`;
36
+ const prefix = isJson ? PII_CIPHERTEXT_PREFIX_JSON : PII_CIPHERTEXT_PREFIX;
37
+ return `${prefix}${subjectIdToKey(subject)}:${blob.toString("base64")}`;
36
38
  }
37
39
  function parseCiphertext(value) {
38
- const hasAad = value.startsWith(PII_CIPHERTEXT_PREFIX);
39
- const prefix = hasAad ? PII_CIPHERTEXT_PREFIX : PII_CIPHERTEXT_PREFIX_V1;
40
+ const isJson = value.startsWith(PII_CIPHERTEXT_PREFIX_JSON);
41
+ const hasAad = isJson || value.startsWith(PII_CIPHERTEXT_PREFIX);
42
+ const prefix = isJson
43
+ ? PII_CIPHERTEXT_PREFIX_JSON
44
+ : hasAad
45
+ ? PII_CIPHERTEXT_PREFIX
46
+ : PII_CIPHERTEXT_PREFIX_V1;
40
47
  const rest = value.slice(prefix.length);
41
48
  // subjectKey itself contains ":" ("user:<id>") — base64 never does, so the
42
49
  // last ":" is always the key/blob separator.
@@ -47,15 +54,16 @@ function parseCiphertext(value) {
47
54
  subject: subjectIdFromKey(rest.slice(0, sep)),
48
55
  blob: Buffer.from(rest.slice(sep + 1), "base64"),
49
56
  hasAad,
57
+ isJson,
50
58
  };
51
59
  }
52
- function decryptValue(dek, blob, subject, field, hasAad) {
60
+ function decryptValue(dek, blob, subject, field, hasAad, isJson) {
53
61
  const iv = blob.subarray(0, IV_LENGTH);
54
62
  const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
55
63
  const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
56
64
  const decipher = createDecipheriv("aes-256-gcm", dek, iv);
57
65
  if (hasAad)
58
- decipher.setAAD(buildAad(subject, field));
66
+ decipher.setAAD(buildAad(subject, field, isJson));
59
67
  decipher.setAuthTag(tag);
60
68
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
61
69
  }
@@ -91,6 +99,12 @@ export async function encryptPiiValueForSubject(kms, subject, value, kmsCtx, fie
91
99
  const dek = await getOrCreateDek(kms, subject, kmsCtx);
92
100
  return encryptValue(subject, dek, value, field);
93
101
  }
102
+ // Event-payload variant for non-string values (jsonb-typed payload fields):
103
+ // same DEK path, but the self-describing v3 format so decrypt restores the type.
104
+ export async function encryptPiiJsonValueForSubject(kms, subject, value, kmsCtx, field) {
105
+ const dek = await getOrCreateDek(kms, subject, kmsCtx);
106
+ return encryptValue(subject, dek, serializeJsonbValue(field, value), field, true);
107
+ }
94
108
  // Single-value decrypt for callers that don't have an entity/field-map to
95
109
  // pass through decryptPiiFieldValues (config values). The subject lives
96
110
  // inside the ciphertext itself, so no subject/scope resolution is needed
@@ -99,9 +113,12 @@ export async function encryptPiiValueForSubject(kms, subject, value, kmsCtx, fie
99
113
  export async function decryptPiiValueForSubject(kms, value, kmsCtx, field) {
100
114
  if (!isPiiCiphertext(value))
101
115
  return value;
102
- const { subject, blob, hasAad } = parseCiphertext(value);
116
+ const { subject, blob, hasAad, isJson } = parseCiphertext(value);
117
+ if (isJson) {
118
+ throw new Error(`PII field "${field}" holds a jsonb ciphertext — decrypt it via decryptPiiFieldValues`);
119
+ }
103
120
  try {
104
- return decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, field, hasAad);
121
+ return decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, field, hasAad, false);
105
122
  }
106
123
  catch (e) {
107
124
  if (!(e instanceof KeyErasedError))
@@ -127,9 +144,11 @@ export async function encryptPiiFieldValues(row, entity, piiFields, kms, kmsCtx,
127
144
  // are already ciphertext or the erased sentinel — both stay as-is.
128
145
  if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL)
129
146
  continue;
130
- if (typeof value !== "string") {
147
+ const isJsonbField = entity.fields[name]?.type === "jsonb";
148
+ if (!isJsonbField && typeof value !== "string") {
131
149
  throw new Error(`PII field "${name}" must be a string, got ${typeof value}`);
132
150
  }
151
+ const plaintext = isJsonbField ? serializeJsonbValue(name, value) : String(value);
133
152
  const subject = resolveSubjectForField(entity, name, subjectSource, {
134
153
  entityName: opts.entityName,
135
154
  ...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
@@ -138,10 +157,17 @@ export async function encryptPiiFieldValues(row, entity, piiFields, kms, kmsCtx,
138
157
  if (subject === null)
139
158
  continue;
140
159
  const dek = await getOrCreateDek(kms, subject, kmsCtx);
141
- out[name] = encryptValue(subject, dek, value, name);
160
+ out[name] = encryptValue(subject, dek, plaintext, name, isJsonbField);
142
161
  }
143
162
  return out;
144
163
  }
164
+ export function serializeJsonbValue(field, value) {
165
+ const json = JSON.stringify(value);
166
+ // undefined/function/symbol serialize to undefined — never storable as jsonb
167
+ if (json === undefined)
168
+ throw new Error(`PII jsonb field "${field}" is not JSON-serializable`);
169
+ return json;
170
+ }
145
171
  export async function decryptPiiFieldValues(row, piiFields, kms, kmsCtx) {
146
172
  if (piiFields.length === 0)
147
173
  return row;
@@ -152,9 +178,12 @@ export async function decryptPiiFieldValues(row, piiFields, kms, kmsCtx) {
152
178
  // work during rollout; backfill is tracked in kumiko-framework#799).
153
179
  if (!isPiiCiphertext(value))
154
180
  continue;
155
- const { subject, blob, hasAad } = parseCiphertext(value);
181
+ const { subject, blob, hasAad, isJson } = parseCiphertext(value);
156
182
  try {
157
- out[name] = decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, name, hasAad);
183
+ const key = await kms.getKey(subject, kmsCtx);
184
+ const plain = decryptValue(key, blob, subject, name, hasAad, isJson);
185
+ // @cast-boundary crypto-decrypt — AEAD-authenticated plaintext that this module serialized
186
+ out[name] = isJson ? JSON.parse(plain) : plain;
158
187
  }
159
188
  catch (e) {
160
189
  // KeyNotFound deliberately propagates: ciphertext without a key row
@@ -9,8 +9,8 @@ export const SYSTEM_EVENT_PREFIX = "kumiko:system:";
9
9
  // an event-store import cycle; transfer.ts re-exports it.
10
10
  export const AGGREGATE_TRANSFERRED_EVENT_TYPE = `${SYSTEM_EVENT_PREFIX}aggregate.transferred`;
11
11
  const SELF = { personal: "self" };
12
- // Workflow payloads carry `triggerPayload`, the stored payload of the trigger
13
- // event: catalogued fields there are already ciphertext, so "none" leaks nothing.
12
+ // Workflow run-stream payloads carry references and step bookkeeping only,
13
+ // never a copy of a foreign event payload, so "none" leaves nothing to shred.
14
14
  export const SYSTEM_EVENT_PII_STANCES = new Map([
15
15
  [
16
16
  STEP_DISPATCH_REQUESTED_TYPE,