@cosmicdrift/kumiko-framework 0.288.0 → 0.290.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 (75) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/field-access.integration.test.ts +7 -3
  3. package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
  4. package/src/__tests__/ownership.integration.test.ts +1 -1
  5. package/src/api/__tests__/server-boot-guards.test.ts +42 -0
  6. package/src/api/request-context.ts +28 -0
  7. package/src/api/server.ts +30 -4
  8. package/src/changes.json +75 -0
  9. package/src/compliance/__tests__/sub-processors.test.ts +10 -0
  10. package/src/compliance/sub-processors.ts +14 -1
  11. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  12. package/src/crypto/__tests__/kek-source.test.ts +298 -0
  13. package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
  14. package/src/crypto/__tests__/subject-resolver.test.ts +2 -2
  15. package/src/crypto/index.ts +3 -0
  16. package/src/crypto/kek-source.ts +189 -0
  17. package/src/crypto/kms-wiring.ts +20 -0
  18. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  19. package/src/db/__tests__/eagerload.integration.test.ts +12 -2
  20. package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
  21. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  22. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
  23. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +194 -1
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
  25. package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
  26. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
  27. package/src/db/event-store-executor-context.ts +79 -0
  28. package/src/db/event-store-executor-write.ts +42 -5
  29. package/src/db/index.ts +1 -0
  30. package/src/db/tenant-db.ts +8 -1
  31. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
  32. package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
  33. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
  34. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +26 -15
  35. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
  36. package/src/engine/__tests__/factories-long-text.test.ts +6 -1
  37. package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
  38. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
  39. package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
  40. package/src/engine/boot-validator/action-wiring.ts +2 -1
  41. package/src/engine/boot-validator/entity-handler.ts +10 -4
  42. package/src/engine/boot-validator/screens.ts +4 -12
  43. package/src/engine/extensions/storage-provider.ts +17 -2
  44. package/src/engine/extensions/tenant-data.ts +9 -0
  45. package/src/engine/factories.ts +2 -0
  46. package/src/engine/index.ts +1 -0
  47. package/src/engine/qualified-name.ts +9 -0
  48. package/src/engine/screen-helpers.ts +17 -0
  49. package/src/errors/classes.ts +24 -0
  50. package/src/errors/index.ts +3 -0
  51. package/src/errors/member-resolution.ts +12 -0
  52. package/src/event-store/__tests__/backfill-pii.integration.test.ts +2 -2
  53. package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
  54. package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
  55. package/src/event-store/event-store.ts +23 -2
  56. package/src/event-store/provenance-append.ts +9 -1
  57. package/src/files/__tests__/file-handle.test.ts +28 -1
  58. package/src/files/file-handle.ts +26 -7
  59. package/src/files/index.ts +1 -1
  60. package/src/i18n/required-surface-keys.ts +24 -12
  61. package/src/jobs/job-runner.ts +10 -2
  62. package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
  63. package/src/pipeline/active-membership.ts +10 -4
  64. package/src/pipeline/append-event-core.ts +2 -11
  65. package/src/pipeline/dispatch-shared.ts +31 -18
  66. package/src/pipeline/dispatch-stream.ts +8 -3
  67. package/src/pipeline/dispatch-write.ts +3 -2
  68. package/src/pipeline/event-dispatcher-delivery.ts +15 -3
  69. package/src/pipeline/event-dispatcher.ts +18 -5
  70. package/src/pipeline/member-read-only-transaction.ts +2 -6
  71. package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
  72. package/src/stack/table-helpers.ts +6 -0
  73. package/src/testing/__tests__/e2e-generator.test.ts +50 -0
  74. package/src/testing/e2e-generator.ts +4 -3
  75. package/src/ui-types/index.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.288.0",
3
+ "version": "0.290.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.288.0",
202
- "@cosmicdrift/kumiko-types": "0.288.0",
201
+ "@cosmicdrift/kumiko-http": "0.290.0",
202
+ "@cosmicdrift/kumiko-types": "0.290.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.288.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.290.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -26,10 +26,14 @@ import {
26
26
  const employeeEntity = createEntity({
27
27
  table: "fa_employees",
28
28
  fields: {
29
- email: createTextField({ required: true }),
30
- firstName: createTextField(),
29
+ email: createTextField({ personal: false, reason: "test_fixture", required: true }),
30
+ firstName: createTextField({ personal: false, reason: "test_fixture" }),
31
31
  salary: createNumberField({ access: { read: ["Admin", "Accounting"], write: ["Admin"] } }),
32
- notes: createTextField({ access: { read: ["Admin"], write: ["Admin"] } }),
32
+ notes: createTextField({
33
+ personal: false,
34
+ reason: "test_fixture",
35
+ access: { read: ["Admin"], write: ["Admin"] },
36
+ }),
33
37
  },
34
38
  });
35
39
 
@@ -45,7 +45,7 @@ const memoEntity = createEntity({
45
45
  table: "fw2626_memos",
46
46
  softDelete: true,
47
47
  fields: {
48
- ownerId: createTextField({ required: true }),
48
+ ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
49
49
  title: createTextField({ personal: false, reason: "test_fixture", required: true }),
50
50
  },
51
51
  access: {
@@ -37,7 +37,7 @@ const contractEntity = createEntity({
37
37
  softDelete: true,
38
38
  fields: {
39
39
  teamId: createTextField({ personal: false, reason: "test_fixture", required: true }),
40
- assigneeId: createTextField(),
40
+ assigneeId: createTextField({ personal: false, reason: "test_fixture" }),
41
41
  title: createTextField({ personal: false, reason: "test_fixture", required: true }),
42
42
  // propA: public on read + write
43
43
  propA: createTextField({ personal: false, reason: "test_fixture" }),
@@ -10,6 +10,7 @@ import {
10
10
  defineFeature,
11
11
  defineQueryHandler,
12
12
  EXT_PRINCIPAL_STATUS,
13
+ EXT_TENANT_LIFECYCLE_STATUS,
13
14
  } from "../../engine";
14
15
  import { createInMemorySearchAdapter } from "../../search";
15
16
  import { buildServer } from "../server";
@@ -263,3 +264,44 @@ describe("buildServer — auth membershipQuery requires a principalStatus provid
263
264
  ).not.toThrow();
264
265
  });
265
266
  });
267
+
268
+ describe("buildServer — tenant-lifecycle gate derivation", () => {
269
+ const lifecyclePlugin = { resolveStatus: async () => null };
270
+ const providerFeature = defineFeature("lifecycle-provider", (r) => {
271
+ r.extendsRegistrar(EXT_TENANT_LIFECYCLE_STATUS, {});
272
+ r.useExtension(EXT_TENANT_LIFECYCLE_STATUS, "lifecycle-provider", lifecyclePlugin);
273
+ });
274
+ const secondProviderFeature = defineFeature("lifecycle-provider-2", (r) => {
275
+ r.useExtension(EXT_TENANT_LIFECYCLE_STATUS, "lifecycle-provider-2", lifecyclePlugin);
276
+ });
277
+
278
+ test("throws when a lifecycle provider is mounted but context.db is missing", () => {
279
+ expect(() =>
280
+ buildServer({
281
+ registry: createRegistry([providerFeature]),
282
+ context: {},
283
+ jwtSecret: JWT_SECRET,
284
+ }),
285
+ ).toThrow(/tenantLifecycleStatus provider is mounted .* but context\.db is missing/);
286
+ });
287
+
288
+ test("throws when two lifecycle providers are registered", () => {
289
+ expect(() =>
290
+ buildServer({
291
+ registry: createRegistry([providerFeature, secondProviderFeature]),
292
+ context: {},
293
+ jwtSecret: JWT_SECRET,
294
+ }),
295
+ ).toThrow(/multiple "tenantLifecycleStatus" providers registered/);
296
+ });
297
+
298
+ test("no provider mounted leaves the gate unwired", () => {
299
+ expect(() =>
300
+ buildServer({
301
+ registry: createRegistry([]),
302
+ context: {},
303
+ jwtSecret: JWT_SECRET,
304
+ }),
305
+ ).not.toThrow();
306
+ });
307
+ });
@@ -38,6 +38,12 @@ export type RequestContextData = {
38
38
  // request-locale.ts. Undefined when neither header carried a valid tag;
39
39
  // callers fall back further (dispatch-shared.ts's ctx.locale chain).
40
40
  readonly locale?: string;
41
+ // Attribution of the currently executing scope (#3043): the feature that
42
+ // owns it and the qualified name of the handler / MSP-consumer / job
43
+ // inside it. event-store.append() reads both and stamps them onto every
44
+ // event written under this scope.
45
+ readonly feature?: string;
46
+ readonly handler?: string;
41
47
  };
42
48
 
43
49
  const storage = new AsyncLocalStorage<RequestContextData>();
@@ -55,3 +61,25 @@ export const requestContext = {
55
61
  return generateId();
56
62
  },
57
63
  };
64
+
65
+ // Enter a scope that attributes every event written inside it. Keeps the
66
+ // surrounding request's ids so correlation survives, and mints fresh ones
67
+ // when there is no surrounding request (job-runner, event-dispatcher) —
68
+ // requestId/correlationId are mandatory, `get()` may be undefined.
69
+ export function runWithOrigin<T>(
70
+ origin: { readonly feature?: string; readonly handler?: string },
71
+ fn: () => T,
72
+ ): T {
73
+ const current = requestContext.get();
74
+ const requestId = current?.requestId ?? requestContext.generateId();
75
+ return requestContext.run(
76
+ {
77
+ ...current,
78
+ requestId,
79
+ correlationId: current?.correlationId ?? requestId,
80
+ feature: origin.feature,
81
+ handler: origin.handler,
82
+ },
83
+ fn,
84
+ );
85
+ }
package/src/api/server.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  registerStandardMetrics,
20
20
  wrapRedisClient,
21
21
  } from "../observability";
22
+ import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership";
22
23
  import type { DispatcherOptions } from "../pipeline/dispatcher";
23
24
  import { createDispatcher, type Dispatcher } from "../pipeline/dispatcher";
24
25
  import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state";
@@ -43,7 +44,12 @@ import {
43
44
  import type { SearchAdapter } from "../search/types";
44
45
  import { assertUnreachable, generateId } from "../utils";
45
46
  import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS } from "./api-constants";
46
- import { type AnonymousAccessResolved, authMiddleware, getUser } from "./auth-middleware";
47
+ import {
48
+ type AnonymousAccessResolved,
49
+ authMiddleware,
50
+ getUser,
51
+ type TenantLifecycleStatusResolver,
52
+ } from "./auth-middleware";
47
53
  import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
48
54
  import { csrfMiddleware } from "./csrf-middleware";
49
55
  import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
@@ -670,12 +676,16 @@ export function buildServer(options: ServerOptions): KumikoServer {
670
676
  // a token (or, when anonymousAccess is wired, falls through as anonymous).
671
677
  // A session-checker is forwarded when the auth-config wires one, so the
672
678
  // middleware can reject revoked sids on every request.
679
+ // Mounting a tenantLifecycleStatus provider is what turns the 410 on, so no
680
+ // entrypoint can forget the wiring; `??` short-circuits, so an explicit
681
+ // auth.resolveTenantLifecycleStatus remains the override.
682
+ const tenantLifecycleResolver =
683
+ options.auth?.resolveTenantLifecycleStatus ??
684
+ deriveTenantLifecycleResolver(options.registry, baseDb);
673
685
  const jwtGuard = authMiddleware(jwt, {
674
686
  ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
675
687
  ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
676
- ...(options.auth?.resolveTenantLifecycleStatus
677
- ? { resolveTenantLifecycleStatus: options.auth.resolveTenantLifecycleStatus }
678
- : {}),
688
+ ...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
679
689
  ...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
680
690
  });
681
691
  app.use("/api/*", async (c, next) => {
@@ -890,6 +900,22 @@ export function buildServer(options: ServerOptions): KumikoServer {
890
900
  };
891
901
  }
892
902
 
903
+ function deriveTenantLifecycleResolver(
904
+ registry: Registry,
905
+ db: DbConnection | undefined,
906
+ ): TenantLifecycleStatusResolver | undefined {
907
+ const plugin = resolveTenantLifecyclePlugin(registry, "buildServer");
908
+ if (!plugin) return undefined;
909
+ if (!db) {
910
+ throw new Error(
911
+ "[kumiko] a tenantLifecycleStatus provider is mounted (tenant-lifecycle) but context.db is " +
912
+ "missing — the request-level teardown gate needs a DbConnection. Pass context.db, or wire " +
913
+ "auth.resolveTenantLifecycleStatus explicitly.",
914
+ );
915
+ }
916
+ return (tenantId) => plugin.resolveStatus(tenantId, { db });
917
+ }
918
+
893
919
  // Scans every feature's entities for a file/image/files/images field. Short-
894
920
  // circuits on the first hit — no need to build a full inventory, we only want
895
921
  // the yes/no answer for the boot check.
package/src/changes.json CHANGED
@@ -1,4 +1,79 @@
1
1
  [
2
+ {
3
+ "version": "0.290.0",
4
+ "type": "improvement",
5
+ "title": "Event-Attribution: metadata.feature + metadata.handler auf jedem Event",
6
+ "detail": "event-store.append() stempelt Feature und Handler-Namen aus dem Ausfuehrungsscope (requestContext) auf jedes geschriebene Event. Dispatch-Handler, Entity-Executor-Writes, MSP-Applies und Jobs sind damit ohne Signaturaenderung attribuiert; Appends ausserhalb eines Scopes tragen UNATTRIBUTED_ORIGIN. appendRaw/appendRawBatch bleiben unveraendert."
7
+ },
8
+ {
9
+ "version": "0.290.0",
10
+ "type": "fix",
11
+ "title": "Section readers see groups[].fields",
12
+ "detail": "Boot-guard and E2E-generator now flatten section.groups via sectionFieldSpecs instead of iterating section.fields only: a function renderer inside groups[].fields fails at boot, and generated E2E specs cover group fields (required fields, fill ops, text assertions)."
13
+ },
14
+ {
15
+ "version": "0.290.0",
16
+ "type": "improvement",
17
+ "title": "The tenant-teardown 410 gate is derived from the mounted tenantLifecycleStatus provider (fw#2881).",
18
+ "detail": "buildServer now resolves the EXT_TENANT_LIFECYCLE_STATUS extension point itself, so every server with tenant-lifecycle mounted rejects requests against a tenant in teardown with 410 tenant_unavailable — prod, dev and test stacks can no longer drift because one entrypoint forgot the wiring. An explicit auth.resolveTenantLifecycleStatus still takes precedence and stays the escape hatch. A mounted provider without context.db is a boot error instead of a silently skipped gate."
19
+ },
20
+ {
21
+ "version": "0.289.0",
22
+ "type": "breaking",
23
+ "title": "appendProvenanceEvent enforces event.tenantId === db.tenantId",
24
+ "detail": "`appendProvenanceEvent(db, event)` checked the event type namespace but wrote `event.tenantId` unverified while granting itself the framework-fixed unsafeRaw reason internally. Since `createLLMProviderForTenant(ctx, tenantId, …)` takes the tenantId as an argument, that made the entry point a cross-tenant write door with no escapeHatch declaration behind it.\nThe check now runs after `unsafeRawForDeclaredStep` — so a holder not built by `createTenantDb` still fails closed with `InternalError` first — and before the savepoint, so a rejected append leaves no row. It is unconditional, including `mode: \"system\"`: `crossTenantRebinders` keeps the original tenantId and only flips the mode, so a system-scoped db must not append provenance for a foreign tenant either.\n`appendProvenanceEvent` deliberately keeps `withUnsafeRawGrant` + `unsafeRawForDeclaredStep` instead of resolving the runner through the ungated `tenantDbRunner`, because that grant path is the choke point the member-read lock in kumiko-framework#2927 hooks into.",
25
+ "migration": "appendProvenanceEvent rejects with AccessDeniedError when event.tenantId does not match the tenantId of the passed TenantDb. Provenance for a foreign tenant has no path left through this entry point — a caller that needs it declares its own escapeHatch path instead. Blast radius checked: every known call site in kumiko-enterprise passes event.user.tenantId through, so no call site changes."
26
+ },
27
+ {
28
+ "version": "0.289.0",
29
+ "type": "breaking",
30
+ "title": "Boot fails when an anonymous-accessible handler declares no rateLimit",
31
+ "detail": "validateAnonymousRateLimit (packages/framework/src/engine/boot-validator/entity-handler.ts) previously returned early when a handler declared no rateLimit at all, so an anonymous, internet-facing handler with zero throttling passed boot silently while one with a merely wrong bucket (rateLimit.per=\"user\") was rejected. The check now runs for every handler whose access.roles includes \"anonymous\": it still skips openToAll handlers (they never admit anonymous) and rateLimit: { disabled: true, reason } declarations, but a handler with access.roles including \"anonymous\" and no rateLimit at all now throws at boot, naming the handler.",
32
+ "migration": "Every handler whose access.roles includes \"anonymous\" needs a rateLimit declaration: either rateLimit: { per: \"ip\" | \"ip+handler\", limit, windowSeconds } (per must not be \"user\"/\"user+handler\" — anonymous callers share user.id=\"anonymous\", so a user-keyed bucket is a single global tap), or the documented exception rateLimit: { disabled: true, reason: \"...\" } for a handler that must not be rate-limited. This PR fixes the eight bundled-features handlers that tripped this at authoring time: compliance-profiles:query:sub-processors, template-resolver:query:by-slug, template-resolver:query:by-tenant, managed-pages:query:by-slug, managed-pages:query:by-tenant-published, managed-pages:query:branding, auth-email-password:query:signup-registration-status, seo:query:config."
33
+ },
34
+ {
35
+ "version": "0.289.0",
36
+ "type": "breaking",
37
+ "title": "ctx.db.unsafeRaw is denied under ctx.queryAsMember",
38
+ "migration": "Query handlers reached through ctx.queryAsMember can no longer call ctx.db.unsafeRaw — not even for reads. Move them to the ctx.db query APIs, or stop calling them via queryAsMember. Consequence for kumiko-enterprise: ai-call provenance writes under queryAsMember now fail closed; withProvenance swallows the error and counts provenance_drop_total."
39
+ },
40
+ {
41
+ "version": "0.289.0",
42
+ "type": "improvement",
43
+ "title": "Drawer gains a modal prop for non-modal side panels",
44
+ "detail": "Drawer accepts modal (default true, behavior unchanged). With modal={false} the overlay and the focus trap are gone, so the page behind stays interactive and its text can be selected and copied; Escape still closes, a click beside the panel does not."
45
+ },
46
+ {
47
+ "version": "0.289.0",
48
+ "type": "improvement",
49
+ "title": "Entity graph traversal helpers for cross-tenant ownership changes",
50
+ "detail": "physicalColumnName is now exported from @cosmicdrift/kumiko-framework/db (was framework-internal). storageKeyStemPrefix (files/file-handle.ts) gives the prefix that covers a stored file's key plus every derive()d variant. StorageProviderHookCtx (EXT_STORAGE_PROVIDER destroy hooks) gains db: DbRunner — the tenant-destroy stage runner already carried it for this hook family unconditionally, this only widens the declared type to match. TenantDataHookCtx (EXT_TENANT_DATA destroy hooks) gains fileProviderResolver and log, threaded through from the same stage runner. Built for the tenant-handover bundled feature (kumiko-framework#3035); no behavior change for existing hook implementations that don't read the new fields."
51
+ },
52
+ {
53
+ "version": "0.289.0",
54
+ "type": "fix",
55
+ "title": "EventDispatcher gains drain() for race-free test teardown",
56
+ "detail": "New `EventDispatcher.drain(): Promise<void>` waits out a pass already in flight (the timer tick or LISTEN/NOTIFY wake-up a suite's own `.start()` triggered) without touching the timer or the LISTEN subscription — unlike `stop()`, the dispatcher keeps running afterwards. `stack/table-helpers.ts`'s `resetEventStore` now calls it before truncating `kumiko_events` and the other framework tables, so a beforeEach/afterEach reset can no longer race a pass this same dispatcher started (kumiko-framework#3017): every suite that calls `resetEventStore` after starting the dispatcher (directly or via `jobs`) inherits the fix automatically. No behavior change for suites that never start the dispatcher — `drain()` is then a no-op, since nothing is ever in flight."
57
+ },
58
+ {
59
+ "version": "0.289.0",
60
+ "type": "improvement",
61
+ "title": "Event-store executor writes accept a declarative `expect:` precondition",
62
+ "detail": "`expect: { field: value }` on an executor write applies the change only if those fields still hold at write time, turning a check-then-act sequence into one step. Values are scalars (string/number/boolean/null) compared with strict equality; several fields mean AND, and the first mismatch names its field. A failed precondition raises the new `PreconditionFailedError` instead of writing.\nThe precondition fields and the `version` used for the append are read in one query, so a concurrent writer cannot slip between the check and the append — reading them separately reproduced a ~40% failure rate under real concurrency. The version is taken from the event stream itself, not from the projection row: a projection `version` is only in step for rows the executor wrote, so a row seeded directly would otherwise be checked against a predecessor that never existed."
63
+ },
64
+ {
65
+ "version": "0.289.0",
66
+ "type": "improvement",
67
+ "title": "PLATFORM_KEK can now be resolved from a Key Manager ciphertext at boot",
68
+ "detail": "New `resolvePlatformKeks` (packages/framework/src/crypto/kek-source.ts) decrypts `PLATFORM_KEK_CIPHERTEXT` / `PLATFORM_KEK_PREVIOUS_CIPHERTEXT` through Scaleway Key Manager when the matching plaintext (`PLATFORM_KEK` / `PLATFORM_KEK_PREVIOUS`) is absent, so the KEK no longer has to sit in the pod env in the clear. Each slot resolves independently: a plaintext value always wins over its ciphertext sibling with no request made, which keeps a KEK rollback bootable without a network call. `resolveKmsWiringAsync` and `requireKmsWiringAsync` wrap the existing sync `resolveKmsWiring` / `requireKmsWiring` with this resolution step; the sync functions and every other exported signature are unchanged. `PLATFORM_KEK` plaintext stays fully valid and keeps precedence — this is purely additive. Boot now logs one line per configured slot naming the source it used (`source=plaintext-env`, `source=key-manager keyId=… region=…`, and explicitly `(ciphertext present and ignored)` when a leftover plaintext wins), because a half-finished cutover otherwise boots green and looks exactly like a finished one. The line carries no key material and no token; `options.log` redirects it, the default is `console.info`."
69
+ },
70
+ {
71
+ "version": "0.289.0",
72
+ "type": "breaking",
73
+ "title": "requiredKeysFromScreen reads section.groups — group field labels and group titles are i18n-required",
74
+ "detail": "`requiredKeysFromScreen` (packages/framework/src/i18n/required-surface-keys.ts) read only `section.fields` in all six `EditFieldsSection` branches (`entityEdit`, `actionForm`, `secretMint` mint + confirm layout, `configEdit`, `projectionDetail`), so a field declared through `section.groups` never reached the required-key set and `groups[].title` was never required at all — while `computeEditViewModel` translates a group title exactly like a section title. `validateI18nSurfaceKeys` therefore let a screen boot with untranslated group field labels and group titles, the one error class that guard exists for.\nThe new `sectionFieldSpecs` in packages/framework/src/engine/screen-helpers.ts returns the union of `section.fields` and `section.groups[].fields` (a union, unlike the boot-validator's either-or `flattenFieldsOrGroups`, which runs after the fields-XOR-groups check) and is used at all six branches; each branch now also pushes every `groups[].title` alongside `section.title`, honoring the same `treatDotFormAsKey` option. The `writeForm` branch in `projectionDetail` is unchanged — `EditWriteFormSection` has no `groups`.",
75
+ "migration": "The i18n boot guard now demands translations it silently skipped before. An app whose entityEdit/actionForm/secretMint/configEdit/projectionDetail screens declare fields through `layout.sections[].groups` can newly fail boot with `required translation key missing: \"<feature>:entity:<entity>:field:<name>\"`. Add the missing field-label keys (or a `fieldLabels` override, where that screen type supports one) for every field declared under `groups[].fields`. Colon-form `groups[].title` values are now required too — add the key, or keep the title as literal display text (no colon, no `i18nKey()`) if it is not meant to be translated. Dot-form group titles behave exactly like dot-form section titles: only required when the caller passes `treatDotFormAsKey`."
76
+ },
2
77
  {
3
78
  "version": "0.287.0",
4
79
  "type": "improvement",
@@ -113,6 +113,16 @@ describe("KUMIKO_SUB_PROCESSORS", () => {
113
113
  "sccRequired": false,
114
114
  "status": "active",
115
115
  },
116
+ {
117
+ "appliesTo": [
118
+ "all-tiers",
119
+ ],
120
+ "name": "Scaleway SAS",
121
+ "optInOnly": false,
122
+ "region": "EU (France)",
123
+ "sccRequired": false,
124
+ "status": "active",
125
+ },
116
126
  ]
117
127
  `);
118
128
  });
@@ -4,7 +4,10 @@
4
4
  // Plattform-Betrieb einsetzt. Wird oeffentlich exposed unter
5
5
  // /api/compliance/sub-processors (JSON, Sprint 1)
6
6
  // /api/compliance/sub-processors.rss (RSS, Sprint 1)
7
- // kumiko.rocks/subprocessors (HTML, Marketing-Repo)
7
+ //
8
+ // Separate from the public marketing overview (kumiko.rocks/subprocessors),
9
+ // which is compiled independently from templates/legal/_shared/subprocessors/
10
+ // catalog.*.md. The two lists are not synced.
8
11
  //
9
12
  // Tenant-Admins muessen ueber Add/Change/Remove informiert werden mit
10
13
  // Lead-Time aus dem Compliance-Profile (typisch 30d). Cron-Job kommt
@@ -132,6 +135,16 @@ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
132
135
  appliesTo: ["all-tiers"],
133
136
  status: "active",
134
137
  },
138
+ {
139
+ name: "Scaleway SAS",
140
+ purpose:
141
+ "Offsite Backup Mirror (Object Storage); planned: platform key custody (Key Manager) and Transactional Email",
142
+ region: "EU (France)",
143
+ dpa: "https://www.scaleway.com/en/contracts/",
144
+ addedAt: "2026-09-18",
145
+ appliesTo: ["all-tiers"],
146
+ status: "active",
147
+ },
135
148
  ];
136
149
 
137
150
  /**
@@ -25,7 +25,7 @@ const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
25
25
  const userLikeEntity = createEntity({
26
26
  fields: {
27
27
  email: createTextField({ required: true, personal: "self", find: "exact" }),
28
- role: createTextField(),
28
+ role: createTextField({ personal: false, reason: "test_fixture" }),
29
29
  },
30
30
  table: "bidx_users",
31
31
  });