@cosmicdrift/kumiko-framework 0.295.0 → 0.296.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.295.0",
3
+ "version": "0.296.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.295.0",
202
- "@cosmicdrift/kumiko-types": "0.295.0",
201
+ "@cosmicdrift/kumiko-http": "0.296.0",
202
+ "@cosmicdrift/kumiko-types": "0.296.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.295.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.296.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -0,0 +1,172 @@
1
+ // `schema apply`'s KMS wiring resolves the schema-declared slots
2
+ // (RunSchemaCliOptions["kmsSlots"]) instead of resolvePlatformKeks's
3
+ // LEGACY_SLOTS default. Real PgKmsAdapter + real blind-index decode, only
4
+ // globalThis.fetch is mocked (the Key Manager decrypt call).
5
+
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { resetBlindIndexKeyForTests } from "../crypto/blind-index";
11
+ import { resetPiiSubjectKmsForTests } from "../crypto/pii-field-encryption";
12
+ import { defineFeature } from "../engine";
13
+ import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
14
+ import { createTestDb, type TestDb } from "../stack";
15
+ import { ensureTemporalPolyfill } from "../time/polyfill";
16
+
17
+ const feature = defineFeature("kmsslotstest", () => {});
18
+
19
+ const PLATFORM_KEK_PLAINTEXT = Buffer.alloc(32, 1).toString("base64");
20
+ const BLIND_INDEX_PLAINTEXT = Buffer.alloc(32, 2).toString("base64");
21
+ const PLATFORM_KEK_CIPHERTEXT = "ct-platform-kek";
22
+ const BLIND_INDEX_CIPHERTEXT = "ct-blind-index";
23
+ const PLATFORM_KEK_PREVIOUS_CIPHERTEXT = "ct-platform-kek-previous";
24
+
25
+ function captureOut(): { out: SchemaCliOut; log: string[]; err: string[] } {
26
+ const log: string[] = [];
27
+ const err: string[] = [];
28
+ return { out: { log: (l) => log.push(l), err: (l) => err.push(l) }, log, err };
29
+ }
30
+
31
+ function writeAppWithTrivialMigration(migrationId: string): string {
32
+ const appCwd = join(
33
+ tmpdir(),
34
+ `kumiko-kms-slots-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
35
+ );
36
+ const migrationsDir = join(appCwd, "kumiko/migrations");
37
+ mkdirSync(migrationsDir, { recursive: true });
38
+ writeFileSync(join(migrationsDir, `${migrationId}.sql`), "SELECT 1;\n");
39
+ return appCwd;
40
+ }
41
+
42
+ function mockDecryptFetch(
43
+ ciphertextToPlaintext: Readonly<Record<string, string>>,
44
+ requestedCiphertexts?: string[],
45
+ ): typeof fetch {
46
+ return (async (_url: string, init?: RequestInit) => {
47
+ const body = JSON.parse(String(init?.body)) as { ciphertext: string };
48
+ requestedCiphertexts?.push(body.ciphertext);
49
+ const plaintext = ciphertextToPlaintext[body.ciphertext];
50
+ if (plaintext === undefined) {
51
+ throw new Error(`mockDecryptFetch: no plaintext mapped for ciphertext "${body.ciphertext}"`);
52
+ }
53
+ return new Response(JSON.stringify({ plaintext }), { status: 200 });
54
+ }) as typeof fetch;
55
+ }
56
+
57
+ const KMS_ENV_KEYS = [
58
+ "DATABASE_URL",
59
+ "SUBJECT_KEYS_DATABASE_URL",
60
+ "PLATFORM_KEK",
61
+ "PLATFORM_KEK_CIPHERTEXT",
62
+ "PLATFORM_KEK_PREVIOUS",
63
+ "PLATFORM_KEK_PREVIOUS_CIPHERTEXT",
64
+ "PLATFORM_KEK_PREVIOUS_VERSION",
65
+ "PLATFORM_KEK_KMS_KEY_ID",
66
+ "PLATFORM_KEK_KMS_TOKEN",
67
+ "PLATFORM_KEK_KMS_REGION",
68
+ "KUMIKO_BLIND_INDEX_KEY",
69
+ "KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT",
70
+ ] as const;
71
+
72
+ let testDb: TestDb;
73
+ let testDbUrl: string;
74
+ let prevEnv: Record<string, string | undefined>;
75
+ let prevFetch: typeof fetch;
76
+
77
+ beforeAll(async () => {
78
+ await ensureTemporalPolyfill();
79
+ testDb = await createTestDb();
80
+ const baseUrl =
81
+ process.env["TEST_DATABASE_URL"] ??
82
+ process.env["DATABASE_URL"] ??
83
+ "postgresql://kumiko:kumiko@localhost:15432/kumiko_test";
84
+ testDbUrl = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await testDb?.cleanup();
89
+ });
90
+
91
+ beforeEach(() => {
92
+ prevEnv = {};
93
+ for (const key of KMS_ENV_KEYS) prevEnv[key] = process.env[key];
94
+ for (const key of KMS_ENV_KEYS) delete process.env[key];
95
+ process.env["DATABASE_URL"] = testDbUrl;
96
+ prevFetch = globalThis.fetch;
97
+ });
98
+
99
+ afterEach(() => {
100
+ for (const key of KMS_ENV_KEYS) {
101
+ if (prevEnv[key] === undefined) delete process.env[key];
102
+ else process.env[key] = prevEnv[key];
103
+ }
104
+ globalThis.fetch = prevFetch;
105
+ resetBlindIndexKeyForTests();
106
+ resetPiiSubjectKmsForTests();
107
+ });
108
+
109
+ describe("runSchemaCli apply — kmsSlots", () => {
110
+ test("resolves every declared slot from its Key Manager ciphertext", async () => {
111
+ process.env["SUBJECT_KEYS_DATABASE_URL"] = testDbUrl;
112
+ process.env["PLATFORM_KEK_CIPHERTEXT"] = PLATFORM_KEK_CIPHERTEXT;
113
+ process.env["KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT"] = BLIND_INDEX_CIPHERTEXT;
114
+ process.env["PLATFORM_KEK_KMS_KEY_ID"] = "key-1";
115
+ process.env["PLATFORM_KEK_KMS_TOKEN"] = "token-1";
116
+ globalThis.fetch = mockDecryptFetch({
117
+ [PLATFORM_KEK_CIPHERTEXT]: PLATFORM_KEK_PLAINTEXT,
118
+ [BLIND_INDEX_CIPHERTEXT]: BLIND_INDEX_PLAINTEXT,
119
+ });
120
+
121
+ const appCwd = writeAppWithTrivialMigration("0001_init");
122
+ const cap = captureOut();
123
+ const code = await runSchemaCli(["apply"], appCwd, cap.out, {
124
+ features: [feature],
125
+ kmsSlots: ["PLATFORM_KEK", "KUMIKO_BLIND_INDEX_KEY"],
126
+ });
127
+
128
+ expect(code).toBe(0);
129
+ expect(cap.log.join("\n")).toContain("PLATFORM_KEK source=key-manager");
130
+ expect(cap.log.join("\n")).toContain("KUMIKO_BLIND_INDEX_KEY source=key-manager");
131
+ });
132
+
133
+ test("only resolves the declared slots — an undeclared _PREVIOUS ciphertext is left untouched", async () => {
134
+ process.env["SUBJECT_KEYS_DATABASE_URL"] = testDbUrl;
135
+ process.env["PLATFORM_KEK_CIPHERTEXT"] = PLATFORM_KEK_CIPHERTEXT;
136
+ process.env["PLATFORM_KEK_PREVIOUS_CIPHERTEXT"] = PLATFORM_KEK_PREVIOUS_CIPHERTEXT;
137
+ process.env["PLATFORM_KEK_KMS_KEY_ID"] = "key-1";
138
+ process.env["PLATFORM_KEK_KMS_TOKEN"] = "token-1";
139
+ process.env["KUMIKO_BLIND_INDEX_KEY"] = BLIND_INDEX_PLAINTEXT;
140
+ const fetchCalls: string[] = [];
141
+ globalThis.fetch = mockDecryptFetch(
142
+ { [PLATFORM_KEK_CIPHERTEXT]: PLATFORM_KEK_PLAINTEXT },
143
+ fetchCalls,
144
+ );
145
+
146
+ const appCwd = writeAppWithTrivialMigration("0001_init");
147
+ const cap = captureOut();
148
+ const code = await runSchemaCli(["apply"], appCwd, cap.out, {
149
+ features: [feature],
150
+ kmsSlots: ["PLATFORM_KEK"],
151
+ });
152
+
153
+ expect(code).toBe(0);
154
+ expect(fetchCalls).toEqual([PLATFORM_KEK_CIPHERTEXT]);
155
+ });
156
+
157
+ test("without kmsSlots, apply still resolves PLATFORM_KEK from its ciphertext (legacy default)", async () => {
158
+ process.env["SUBJECT_KEYS_DATABASE_URL"] = testDbUrl;
159
+ process.env["PLATFORM_KEK_CIPHERTEXT"] = PLATFORM_KEK_CIPHERTEXT;
160
+ process.env["KUMIKO_BLIND_INDEX_KEY"] = BLIND_INDEX_PLAINTEXT;
161
+ process.env["PLATFORM_KEK_KMS_KEY_ID"] = "key-1";
162
+ process.env["PLATFORM_KEK_KMS_TOKEN"] = "token-1";
163
+ globalThis.fetch = mockDecryptFetch({ [PLATFORM_KEK_CIPHERTEXT]: PLATFORM_KEK_PLAINTEXT });
164
+
165
+ const appCwd = writeAppWithTrivialMigration("0001_init");
166
+ const cap = captureOut();
167
+ const code = await runSchemaCli(["apply"], appCwd, cap.out, { features: [feature] });
168
+
169
+ expect(code).toBe(0);
170
+ expect(cap.log.join("\n")).toContain("PLATFORM_KEK source=key-manager");
171
+ });
172
+ });
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.296.0",
4
+ "type": "breaking",
5
+ "title": "refEntity on projectionList/relatedList columns and projectionDetail fields is boot-checked against registered entities",
6
+ "migration": "A refEntity that does not resolve to a registered entity now fails boot with the target and the known entities of the target feature (same message as a reference facet). Fix the typo, or mount and r.requires() the target feature; test stacks booting a feature without its refEntity target feature must add it."
7
+ },
8
+ {
9
+ "version": "0.296.0",
10
+ "type": "improvement",
11
+ "title": "kumiko schema apply can resolve the schema-declared Key Manager slots",
12
+ "detail": "`runSchemaCli`'s `apply` accepts a new `kmsSlots` option, passed straight through to `resolveKmsWiringAsync`'s `slots` for the rebuild-triggering KMS wiring. Given `kmsSlots`, `apply` resolves exactly those slots from their Key-Manager ciphertext (via `resolvePlatformKeks`); omitted, behavior is unchanged except that the source line it already logged now goes through the CLI's own `out.log` instead of `console.info`. `resolvePlatformKeks` will lose its three-platform-slot default in a later release, and only a caller that already passes its own slots stays unaffected when that happens. An app whose env schema declares its slots with `.meta({ kumiko: { kms: true } })` should pass `kmsSlots: kmsSlotsOf(<app>ComposedEnv.schema)` from its `bin/kumiko.ts` now. The framework core env schema does not declare `PLATFORM_KEK` / `PLATFORM_KEK_PREVIOUS` / `KUMIKO_BLIND_INDEX_KEY` as `kms` slots, so the scaffolded `bin/kumiko.ts` keeps relying on the default for now."
13
+ },
2
14
  {
3
15
  "version": "0.294.1",
4
16
  "type": "fix",
@@ -0,0 +1,196 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
+ import { validateBoot as validateBootRaw } from "../boot-validator";
5
+ import { defineFeature } from "../define-feature";
6
+ import { createEntity, createTextField } from "../factories";
7
+
8
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
9
+ validateBootRaw(withBootValidatorFixture(features));
10
+ }
11
+
12
+ const openToAllAccess = {
13
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
14
+ };
15
+
16
+ // fw#3108: refEntity on projectionList/relatedList columns and projectionDetail
17
+ // fields must resolve to a registered entity, same as a reference facet
18
+ // (fw#2224) — a typo or an unmounted target feature is otherwise a silent
19
+ // render-time break instead of a boot error.
20
+ describe("validateBoot — refEntity targets (fw#3108)", () => {
21
+ test("projectionList column with a typo'd refEntity throws", () => {
22
+ const feature = defineFeature("ledger", (r) => {
23
+ r.entity(
24
+ "tenant",
25
+ createEntity({
26
+ table: "Tenants",
27
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
28
+ }),
29
+ );
30
+ r.queryHandler(
31
+ "schedule:list",
32
+ z.object({}),
33
+ async () => ({ rows: [], nextCursor: null }),
34
+ openToAllAccess,
35
+ );
36
+ r.screen({
37
+ id: "schedule-list",
38
+ type: "projectionList",
39
+ query: "ledger:query:schedule:list",
40
+ columns: [{ field: "tenantId", refEntity: "tenat" }],
41
+ });
42
+ });
43
+ expect(() => validateBoot([feature])).toThrow(
44
+ /column "tenantId" \(refEntity\) targets entity "tenat", which does not resolve to a registered entity\. Known entities in feature "ledger": tenant\./,
45
+ );
46
+ });
47
+
48
+ test("projectionList column refEntity targeting an unmounted feature throws with (none)", () => {
49
+ const feature = defineFeature("ledger", (r) => {
50
+ r.queryHandler(
51
+ "schedule:list",
52
+ z.object({}),
53
+ async () => ({ rows: [], nextCursor: null }),
54
+ openToAllAccess,
55
+ );
56
+ r.screen({
57
+ id: "schedule-list",
58
+ type: "projectionList",
59
+ query: "ledger:query:schedule:list",
60
+ columns: [{ field: "ownerId", refEntity: "owners:owner" }],
61
+ });
62
+ });
63
+ expect(() => validateBoot([feature])).toThrow(
64
+ /column "ownerId" \(refEntity\) targets entity "owners:owner".*Known entities in feature "owners": \(none\)\./,
65
+ );
66
+ });
67
+
68
+ test("projectionList column refEntity typo on a mounted feature lists that feature's entities", () => {
69
+ const owners = defineFeature("owners", (r) => {
70
+ r.entity(
71
+ "owner",
72
+ createEntity({
73
+ table: "Owners",
74
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
75
+ }),
76
+ );
77
+ });
78
+ const feature = defineFeature("ledger", (r) => {
79
+ r.queryHandler(
80
+ "schedule:list",
81
+ z.object({}),
82
+ async () => ({ rows: [], nextCursor: null }),
83
+ openToAllAccess,
84
+ );
85
+ r.screen({
86
+ id: "schedule-list",
87
+ type: "projectionList",
88
+ query: "ledger:query:schedule:list",
89
+ columns: [{ field: "ownerId", refEntity: "owners:ownr" }],
90
+ });
91
+ });
92
+ expect(() => validateBoot([feature, owners])).toThrow(
93
+ /targets entity "owners:ownr".*Known entities in feature "owners": owner\./,
94
+ );
95
+ });
96
+
97
+ test("projectionDetail relatedList column with an unknown refEntity throws", () => {
98
+ const feature = defineFeature("app", (r) => {
99
+ r.queryHandler(
100
+ "rent:detail",
101
+ z.object({}),
102
+ async () => ({ description: "x" }),
103
+ openToAllAccess,
104
+ );
105
+ r.queryHandler(
106
+ "rent:payments",
107
+ z.object({}),
108
+ async () => ({ rows: [], nextCursor: null }),
109
+ openToAllAccess,
110
+ );
111
+ r.screen({
112
+ id: "rent-detail",
113
+ type: "projectionDetail",
114
+ query: "app:query:rent:detail",
115
+ layout: {
116
+ sections: [
117
+ {
118
+ kind: "relatedList",
119
+ title: "Payments",
120
+ query: "app:query:rent:payments",
121
+ columns: [{ field: "payerId", refEntity: "payer" }],
122
+ },
123
+ ],
124
+ },
125
+ });
126
+ });
127
+ expect(() => validateBoot([feature])).toThrow(
128
+ /section "Payments" \(relatedList\) column "payerId" \(refEntity\) targets entity "payer", which does not resolve to a registered entity/,
129
+ );
130
+ });
131
+
132
+ test("projectionDetail field-section field with an unknown refEntity throws", () => {
133
+ const feature = defineFeature("app", (r) => {
134
+ r.queryHandler(
135
+ "rent:detail",
136
+ z.object({}),
137
+ async () => ({ description: "x" }),
138
+ openToAllAccess,
139
+ );
140
+ r.screen({
141
+ id: "rent-detail",
142
+ type: "projectionDetail",
143
+ query: "app:query:rent:detail",
144
+ layout: {
145
+ sections: [
146
+ {
147
+ id: "overview",
148
+ title: "Overview",
149
+ fields: [{ field: "ownerId", refEntity: "owner" }],
150
+ },
151
+ ],
152
+ },
153
+ });
154
+ });
155
+ expect(() => validateBoot([feature])).toThrow(
156
+ /field "ownerId" \(refEntity\) targets entity "owner", which does not resolve to a registered entity/,
157
+ );
158
+ });
159
+
160
+ test("same-feature shorthand and cross-feature refEntity targets pass boot", () => {
161
+ const owners = defineFeature("owners", (r) => {
162
+ r.entity(
163
+ "owner",
164
+ createEntity({
165
+ table: "Owners",
166
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
167
+ }),
168
+ );
169
+ });
170
+ const feature = defineFeature("ledger", (r) => {
171
+ r.entity(
172
+ "tenant",
173
+ createEntity({
174
+ table: "Tenants",
175
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
176
+ }),
177
+ );
178
+ r.queryHandler(
179
+ "schedule:list",
180
+ z.object({}),
181
+ async () => ({ rows: [], nextCursor: null }),
182
+ openToAllAccess,
183
+ );
184
+ r.screen({
185
+ id: "schedule-list",
186
+ type: "projectionList",
187
+ query: "ledger:query:schedule:list",
188
+ columns: [
189
+ { field: "tenantId", refEntity: "tenant" },
190
+ { field: "ownerId", refEntity: "owners:owner" },
191
+ ],
192
+ });
193
+ });
194
+ expect(() => validateBoot([feature, owners])).not.toThrow();
195
+ });
196
+ });
@@ -98,7 +98,10 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
98
98
  expect(() => validateBoot([feature])).toThrow(/duplicate tab id "overview"/);
99
99
  });
100
100
 
101
- test("mode: tabs on entityEdit throws tabs are projectionDetail-only", () => {
101
+ // fw#3134: entityEdit joined projectionDetail as a tabs host. It renders
102
+ // every tab mounted and validates across all of them on submit, so a
103
+ // required field on an unopened tab surfaces instead of blocking silently.
104
+ test("mode: tabs on entityEdit boots", () => {
102
105
  const feature = defineFeature("app", (r) => {
103
106
  r.entity(
104
107
  "rent",
@@ -119,8 +122,82 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
119
122
  },
120
123
  });
121
124
  });
125
+ expect(() => validateBoot([feature])).not.toThrow();
126
+ });
127
+
128
+ test("mode: tabs on entityEdit enforces the same per-tab id as projectionDetail", () => {
129
+ const feature = defineFeature("app", (r) => {
130
+ r.entity(
131
+ "rent",
132
+ createEntity({
133
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
134
+ }),
135
+ );
136
+ r.screen({
137
+ id: "rent-edit",
138
+ type: "entityEdit",
139
+ entity: "rent",
140
+ layout: {
141
+ mode: "tabs",
142
+ sections: [
143
+ { title: "Overview", columns: 1, fields: ["name"] },
144
+ { id: "history", title: "History", columns: 1, fields: ["name"] },
145
+ ],
146
+ },
147
+ });
148
+ });
149
+ expect(() => validateBoot([feature])).toThrow(
150
+ /Screen "rent-edit" \(entityEdit\).*sections\[0\] \("Overview"\) has no id/s,
151
+ );
152
+ });
153
+
154
+ test("mode: tabs on entityEdit with a single section throws", () => {
155
+ const feature = defineFeature("app", (r) => {
156
+ r.entity(
157
+ "rent",
158
+ createEntity({
159
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
160
+ }),
161
+ );
162
+ r.screen({
163
+ id: "rent-edit",
164
+ type: "entityEdit",
165
+ entity: "rent",
166
+ layout: {
167
+ mode: "tabs",
168
+ sections: [{ id: "overview", title: "Overview", columns: 1, fields: ["name"] }],
169
+ },
170
+ });
171
+ });
172
+ expect(() => validateBoot([feature])).toThrow(/tabs need at least 2 sections/);
173
+ });
174
+
175
+ // The other three edit-screen types have no jump-to-erroring-tab path, so a
176
+ // required field on a hidden tab would still block their submit in silence.
177
+ test("mode: tabs on actionForm still throws", () => {
178
+ const feature = defineFeature("app", (r) => {
179
+ r.writeHandler(
180
+ "archive",
181
+ z.object({ reason: z.string() }),
182
+ async () => ({ isSuccess: true as const, data: {} }),
183
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
184
+ );
185
+ r.screen({
186
+ id: "rent-action",
187
+ type: "actionForm",
188
+ handler: "app:write:archive",
189
+ fields: { reason: { type: "text" } } as never,
190
+ layout: {
191
+ mode: "tabs",
192
+ sections: [
193
+ { id: "overview", title: "Overview", columns: 1, fields: ["reason"] },
194
+ { id: "history", title: "History", columns: 1, fields: ["reason"] },
195
+ ] as never,
196
+ },
197
+ });
198
+ });
122
199
  expect(() => validateBoot([feature])).toThrow(
123
- /Screen "rent-edit" \(entityEdit\) sets mode: "tabs" — tabs are only supported on projectionDetail/,
200
+ /Screen "rent-action" \(actionForm\) sets mode: "tabs" — tabs are only supported on projectionDetail and entityEdit/,
124
201
  );
125
202
  });
126
203
 
@@ -0,0 +1,126 @@
1
+ // fw#3088 — boot guard for the tenant-handover transfer graph. Both shapes
2
+ // checked here would otherwise leave rows behind in the source tenant at
3
+ // handover time, which is the silent partial move the issue exists to end.
4
+ // Scoped to `transferable: true`, so a consumer that has the shape but never
5
+ // hands the entity over keeps booting.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { defineFeature } from "../../define-feature";
9
+ import { createEntity, createTextField } from "../../factories";
10
+ import type { EntityDefinition, FeatureDefinition } from "../../types";
11
+ import { MAX_TRANSFER_DEPTH, validateTransferGraph } from "../transfer-graph";
12
+
13
+ const textField = () => createTextField({ personal: false, reason: "technical_reference" });
14
+
15
+ function entity(opts: {
16
+ readonly references?: Readonly<Record<string, { entity: string; multiple?: true }>>;
17
+ readonly transferable?: boolean;
18
+ }): EntityDefinition {
19
+ const references = Object.fromEntries(
20
+ Object.entries(opts.references ?? {}).map(([field, ref]) => [
21
+ field,
22
+ { type: "reference" as const, entity: ref.entity, ...(ref.multiple && { multiple: true }) },
23
+ ]),
24
+ );
25
+ return createEntity({
26
+ table: "graph_rows",
27
+ ...(opts.transferable !== false && { transferable: true }),
28
+ fields: { ...references, label: textField() },
29
+ });
30
+ }
31
+
32
+ function validate(entities: Readonly<Record<string, EntityDefinition>>): void {
33
+ const feature = defineFeature("graph", (r) => {
34
+ for (const [name, def] of Object.entries(entities)) r.entity(name, def);
35
+ });
36
+ const featureMap: ReadonlyMap<string, FeatureDefinition> = new Map([[feature.name, feature]]);
37
+ validateTransferGraph(feature, featureMap);
38
+ }
39
+
40
+ describe("validateTransferGraph", () => {
41
+ test("accepts a nested reference chain within the depth limit", () => {
42
+ expect(() =>
43
+ validate({
44
+ run: entity({}),
45
+ campaign: entity({ references: { runId: { entity: "run" } } }),
46
+ channelText: entity({ references: { campaignId: { entity: "campaign" } } }),
47
+ }),
48
+ ).not.toThrow();
49
+ });
50
+
51
+ test("rejects a multiple reference on a transferable entity", () => {
52
+ expect(() =>
53
+ validate({
54
+ run: entity({}),
55
+ bulk: entity({ references: { runIds: { entity: "run", multiple: true } } }),
56
+ }),
57
+ ).toThrow(/multiple reference field "runIds"/);
58
+ });
59
+
60
+ // The narrow scope is the point: boot-rejecting every multiple reference
61
+ // would break consumers who never hand that entity over.
62
+ test("leaves a multiple reference alone when the entity is not transferable", () => {
63
+ expect(() =>
64
+ validate({
65
+ run: entity({}),
66
+ bulk: entity({
67
+ references: { runIds: { entity: "run", multiple: true } },
68
+ transferable: false,
69
+ }),
70
+ }),
71
+ ).not.toThrow();
72
+ });
73
+
74
+ // The boundary the mover actually draws: it runs MAX_TRANSFER_DEPTH rounds of
75
+ // one hop each, so 5 edges (6 entities) still move as one graph and the
76
+ // validator must not reject them. Off by one here and a supported schema
77
+ // stops booting.
78
+ test("accepts a chain of exactly the maximum depth", () => {
79
+ const chain: Record<string, EntityDefinition> = { e0: entity({}) };
80
+ for (let i = 1; i <= MAX_TRANSFER_DEPTH; i++) {
81
+ chain[`e${i}`] = entity({ references: { parentId: { entity: `e${i - 1}` } } });
82
+ }
83
+
84
+ expect(() => validate(chain)).not.toThrow();
85
+ });
86
+
87
+ test("rejects a chain one edge past the limit and names the path", () => {
88
+ const chain: Record<string, EntityDefinition> = { e0: entity({}) };
89
+ for (let i = 1; i <= MAX_TRANSFER_DEPTH + 1; i++) {
90
+ chain[`e${i}`] = entity({ references: { parentId: { entity: `e${i - 1}` } } });
91
+ }
92
+
93
+ expect(() => validate(chain)).toThrow(/deeper than the 5-level limit.*e0 -> e1/s);
94
+ });
95
+
96
+ test("rejects a chain far deeper than the limit and names the path", () => {
97
+ const chain: Record<string, EntityDefinition> = { e0: entity({}) };
98
+ for (let i = 1; i <= 7; i++) {
99
+ chain[`e${i}`] = entity({ references: { parentId: { entity: `e${i - 1}` } } });
100
+ }
101
+
102
+ expect(() => validate(chain)).toThrow(/deeper than the 5-level limit.*e0 -> e1/s);
103
+ });
104
+
105
+ test("does not count a non-transferable link as part of the chain", () => {
106
+ const chain: Record<string, EntityDefinition> = { e0: entity({}) };
107
+ for (let i = 1; i <= 7; i++) {
108
+ chain[`e${i}`] = entity({
109
+ references: { parentId: { entity: `e${i - 1}` } },
110
+ // Breaks the chain in the middle: the walk stops here.
111
+ ...(i === 3 && { transferable: false }),
112
+ });
113
+ }
114
+
115
+ expect(() => validate(chain)).not.toThrow();
116
+ });
117
+
118
+ test("terminates on a reference cycle instead of reporting false depth", () => {
119
+ expect(() =>
120
+ validate({
121
+ a: entity({ references: { bId: { entity: "b" } } }),
122
+ b: entity({ references: { aId: { entity: "a" } } }),
123
+ }),
124
+ ).not.toThrow();
125
+ });
126
+ });
@@ -67,6 +67,7 @@ import {
67
67
  validateScreens,
68
68
  } from "./screens";
69
69
  import { warnOnMissingSecurityBaseline } from "./security-baseline";
70
+ import { validateTransferGraph } from "./transfer-graph";
70
71
  import {
71
72
  collectWorkspaceQns,
72
73
  resolveNavAllowlist,
@@ -80,6 +81,7 @@ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns";
80
81
  // dieselbe Extraktionslogik.
81
82
  export { collectWriteHandlerQns } from "./nav";
82
83
  export { SECURITY_BASELINE_FEATURE_NAMES } from "./security-baseline";
84
+ export { MAX_TRANSFER_DEPTH } from "./transfer-graph";
83
85
 
84
86
  export type ValidateBootOptions = {
85
87
  /** Warn when an access role is used by exactly one handler/config-key/
@@ -227,6 +229,7 @@ export function validateBoot(
227
229
  validateConfigKeyPiiEncrypted(feature);
228
230
  validateOwnershipRules(feature, allClaimKeys, knownRoles);
229
231
  validateParentRefs(feature, featureMap);
232
+ validateTransferGraph(feature, featureMap);
230
233
  validateMultiStreamProjections(feature);
231
234
  // Vor validateScreens: dessen visible/entityId-Feldref-Checks werfen für
232
235
  // einen Function-Wert bereits (mit verwirrender "unknown field undefined"-
@@ -197,6 +197,52 @@ function validateRowActionNavigateParams(
197
197
  }
198
198
  }
199
199
 
200
+ // Shared by projectionDetail and entityEdit: a tab strip needs something to
201
+ // label each tab with and a stable id per tab — the id anchors the ?tab=
202
+ // param on projectionDetail and the jump-to-erroring-tab logic on entityEdit
203
+ // (fw#3134).
204
+ function validateTabSections(
205
+ featureName: string,
206
+ screenId: string,
207
+ screenType: "projectionDetail" | "entityEdit",
208
+ sections: readonly { readonly id?: string; readonly title?: string }[],
209
+ ): void {
210
+ if (sections.length < 2) {
211
+ throw new Error(
212
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but only ` +
213
+ `${sections.length} section(s) — tabs need at least 2 sections.`,
214
+ );
215
+ }
216
+ const tabIds = new Set<string>();
217
+ sections.forEach((section, index) => {
218
+ if (section.title === undefined || section.title.trim().length === 0) {
219
+ throw new Error(
220
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but ` +
221
+ `sections[${index}] has no title — every tab needs a title.`,
222
+ );
223
+ }
224
+ if (section.id === undefined || section.id.trim().length === 0) {
225
+ throw new Error(
226
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but ` +
227
+ `sections[${index}] ("${section.title}") has no id — every tab needs a stable id.`,
228
+ );
229
+ }
230
+ if (!isKebabSegment(section.id)) {
231
+ throw new Error(
232
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) sections[${index}] ` +
233
+ `("${section.title}") has id "${section.id}" — must be kebab-case.`,
234
+ );
235
+ }
236
+ if (tabIds.has(section.id)) {
237
+ throw new Error(
238
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) has duplicate tab id ` +
239
+ `"${section.id}" (sections[${index}]).`,
240
+ );
241
+ }
242
+ tabIds.add(section.id);
243
+ });
244
+ }
245
+
200
246
  // Wizard layouts (mode: "wizard") need >= 2 titled sections — a single or
201
247
  // untitled step would leave the progress indicator blank, so both fail at
202
248
  // boot rather than as a broken step UI.
@@ -207,16 +253,19 @@ function validateWizardLayout(
207
253
  layout: EditLayout,
208
254
  featureMap: ReadonlyMap<string, FeatureDefinition>,
209
255
  ): void {
210
- // Tabs truncate the layout to one section, but scopeFieldNames derives
211
- // required-field validation from the `fields` prop, not from layout a
212
- // hidden tab could hide a required field and silently block submit.
213
- // projectionDetail has no submit, so tabs are safe there (see its own
214
- // branch in validateScreens) but not here.
256
+ // entityEdit renders tabs like wizard steps: every section stays mounted,
257
+ // submit validates across all of them, and a field error jumps to the tab
258
+ // holding it (fw#3134). The other three have no such jump — actionForm and
259
+ // secretMint are one-shot forms, configEdit fires a write per field so a
260
+ // required field on a hidden tab would still block their submit silently.
215
261
  if (layout.mode === "tabs") {
216
- throw new Error(
217
- `[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets mode: "tabs" — tabs are only ` +
218
- `supported on projectionDetail. Use mode: "wizard" or "single" instead.`,
219
- );
262
+ if (screenType !== "entityEdit") {
263
+ throw new Error(
264
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets mode: "tabs" tabs are only ` +
265
+ `supported on projectionDetail and entityEdit. Use mode: "wizard" or "single" instead.`,
266
+ );
267
+ }
268
+ validateTabSections(featureName, screenId, screenType, layout.sections);
220
269
  }
221
270
  // "form-draft" is hardcoded because the framework layer must not depend on
222
271
  // @cosmicdrift/kumiko-bundled-features — same precedence as the
@@ -961,7 +1010,17 @@ export function validateScreens(
961
1010
  );
962
1011
  }
963
1012
  for (const col of screen.columns) {
964
- validateColumnRendererForm(feature.name, screenId, normalizeListColumn(col));
1013
+ const normalizedCol = normalizeListColumn(col);
1014
+ validateColumnRendererForm(feature.name, screenId, normalizedCol);
1015
+ if (normalizedCol.refEntity !== undefined) {
1016
+ assertRefTargetRegistered(
1017
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList)`,
1018
+ `column "${normalizedCol.field}" (refEntity)`,
1019
+ normalizedCol.refEntity,
1020
+ feature.name,
1021
+ featureMap,
1022
+ );
1023
+ }
965
1024
  }
966
1025
  // Screen filter (fw#2224) — field existence can't be checked without
967
1026
  // an entity (columns aren't a complete field inventory of the
@@ -1077,41 +1136,7 @@ export function validateScreens(
1077
1136
  );
1078
1137
  }
1079
1138
  if (screen.layout.mode === "tabs") {
1080
- if (screen.layout.sections.length < 2) {
1081
- throw new Error(
1082
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but only ` +
1083
- `${screen.layout.sections.length} section(s) — tabs need at least 2 sections.`,
1084
- );
1085
- }
1086
- const tabIds = new Set<string>();
1087
- screen.layout.sections.forEach((section, index) => {
1088
- if (section.title === undefined || section.title.trim().length === 0) {
1089
- throw new Error(
1090
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
1091
- `sections[${index}] has no title — every tab needs a title.`,
1092
- );
1093
- }
1094
- if (section.id === undefined || section.id.trim().length === 0) {
1095
- throw new Error(
1096
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
1097
- `sections[${index}] ("${section.title}") has no id — every tab needs a stable id for ` +
1098
- `the ?tab= param.`,
1099
- );
1100
- }
1101
- if (!isKebabSegment(section.id)) {
1102
- throw new Error(
1103
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sections[${index}] ` +
1104
- `("${section.title}") has id "${section.id}" — must be kebab-case.`,
1105
- );
1106
- }
1107
- if (tabIds.has(section.id)) {
1108
- throw new Error(
1109
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has duplicate tab id ` +
1110
- `"${section.id}" (sections[${index}]).`,
1111
- );
1112
- }
1113
- tabIds.add(section.id);
1114
- });
1139
+ validateTabSections(feature.name, screenId, "projectionDetail", screen.layout.sections);
1115
1140
  }
1116
1141
  if (screen.metrics !== undefined) {
1117
1142
  for (const metric of screen.metrics) {
@@ -1181,6 +1206,18 @@ export function validateScreens(
1181
1206
  `(relatedList) has empty or non-string query.`,
1182
1207
  );
1183
1208
  }
1209
+ for (const col of section.columns) {
1210
+ const normalizedCol = normalizeListColumn(col);
1211
+ if (normalizedCol.refEntity !== undefined) {
1212
+ assertRefTargetRegistered(
1213
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" (relatedList)`,
1214
+ `column "${normalizedCol.field}" (refEntity)`,
1215
+ normalizedCol.refEntity,
1216
+ feature.name,
1217
+ featureMap,
1218
+ );
1219
+ }
1220
+ }
1184
1221
  if (screen.layout.mode === "wizard") {
1185
1222
  throw new Error(
1186
1223
  `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
@@ -1346,6 +1383,18 @@ export function validateScreens(
1346
1383
  `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail)`,
1347
1384
  section,
1348
1385
  );
1386
+ for (const fieldSpec of sectionFieldSpecs(section)) {
1387
+ const normalizedField = normalizeEditField(fieldSpec);
1388
+ if (normalizedField.refEntity !== undefined) {
1389
+ assertRefTargetRegistered(
1390
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail)`,
1391
+ `field "${normalizedField.field}" (refEntity)`,
1392
+ normalizedField.refEntity,
1393
+ feature.name,
1394
+ featureMap,
1395
+ );
1396
+ }
1397
+ }
1349
1398
  }
1350
1399
  // Header actions reuse RowAction (the displayed record stands in for
1351
1400
  // the row), so the same navigate/writeHandler existence checks as
@@ -2181,6 +2230,30 @@ export function validateColumnRendererForm(
2181
2230
  }
2182
2231
  }
2183
2232
 
2233
+ // Shared entity-target resolution for reference facets, refEntity columns
2234
+ // (projectionList/relatedList) and refEntity fields (projectionDetail).
2235
+ function assertRefTargetRegistered(
2236
+ prefix: string,
2237
+ subject: string,
2238
+ refTarget: string,
2239
+ currentFeatureName: string,
2240
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
2241
+ ): void {
2242
+ const target = parseRefTarget(refTarget, currentFeatureName);
2243
+ const targetFeature = featureMap.get(target.featureName);
2244
+ if (targetFeature?.entities?.[target.entityName] === undefined) {
2245
+ throw new Error(
2246
+ `${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
2247
+ `registered entity. Known entities in feature "${target.featureName}": ` +
2248
+ `${
2249
+ Object.keys(targetFeature?.entities ?? {})
2250
+ .sort()
2251
+ .join(", ") || "(none)"
2252
+ }.`,
2253
+ );
2254
+ }
2255
+ }
2256
+
2184
2257
  // Facets (fw#2224) — unlike filter, a field inventory IS available here: the
2185
2258
  // declared columns. A facet on a field with no column is almost always a
2186
2259
  // typo (the user never sees the field anywhere), so this is hard-checked
@@ -2226,20 +2299,13 @@ function validateListFacets(
2226
2299
  );
2227
2300
  }
2228
2301
  if (facet.type === "reference") {
2229
- const target = parseRefTarget(facet.entity, currentFeatureName);
2230
- const targetFeature = featureMap.get(target.featureName);
2231
- if (targetFeature?.entities?.[target.entityName] === undefined) {
2232
- throw new Error(
2233
- `${prefix} facet "${facet.field}" (type "reference") targets entity "${facet.entity}", ` +
2234
- `which does not resolve to a registered entity. Known entities in feature ` +
2235
- `"${target.featureName}": ` +
2236
- `${
2237
- Object.keys(targetFeature?.entities ?? {})
2238
- .sort()
2239
- .join(", ") || "(none)"
2240
- }.`,
2241
- );
2242
- }
2302
+ assertRefTargetRegistered(
2303
+ prefix,
2304
+ `facet "${facet.field}" (type "reference")`,
2305
+ facet.entity,
2306
+ currentFeatureName,
2307
+ featureMap,
2308
+ );
2243
2309
  }
2244
2310
  }
2245
2311
  }
@@ -0,0 +1,134 @@
1
+ import type { EntityDefinition, FeatureDefinition } from "../types";
2
+
3
+ // --- Transfer-graph boot validation (fw#3088) ---
4
+ //
5
+ // tenant-handover walks an entity graph across the tenant boundary along two
6
+ // declared edge kinds: `parentRef` and plain `reference` fields. Two shapes
7
+ // cannot be walked, and both have to fail loudly here rather than silently
8
+ // leaving rows behind in the source tenant — the partial move is exactly what
9
+ // #3088 exists to end.
10
+ //
11
+ // Scoped to entities declaring `transferable: true`, so a consumer that has
12
+ // one of these shapes but never hands that entity over is unaffected.
13
+
14
+ // The single definition of the limit. It lives here rather than next to the
15
+ // resolver in bundled-features because the dependency only runs that way:
16
+ // bundled-features imports from the framework, never the reverse.
17
+ //
18
+ // Guards against a schema whose reference edges span more levels than anyone
19
+ // intended — a runaway graph would move rows an operator never associated with
20
+ // the handover. Deliberately a constant and not per-feature config: the limit
21
+ // is a safety net, and a configurable one gets raised by whoever trips it
22
+ // instead of prompting them to reconsider their graph.
23
+ export const MAX_TRANSFER_DEPTH = 5;
24
+
25
+ type EntityEntry = { readonly name: string; readonly entity: EntityDefinition };
26
+
27
+ function allEntities(featureMap: ReadonlyMap<string, FeatureDefinition>): readonly EntityEntry[] {
28
+ const out: EntityEntry[] = [];
29
+ for (const feature of featureMap.values()) {
30
+ for (const [name, entity] of Object.entries(feature.entities ?? {})) {
31
+ out.push({ name, entity });
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+
37
+ function referenceTargets(entity: EntityDefinition): readonly string[] {
38
+ const targets: string[] = [];
39
+ for (const field of Object.values(entity.fields)) {
40
+ if (field.type !== "reference") continue;
41
+ if (field.multiple === true) continue;
42
+ targets.push(field.entity);
43
+ }
44
+ return targets;
45
+ }
46
+
47
+ // A `multiple` reference stores a jsonb array of ids, which the handover's
48
+ // `= ANY($ids)` column match cannot address. Declaring one on a transferable
49
+ // entity would mean its rows never move with their host.
50
+ function validateNoMultipleReferenceEdge(entry: EntityEntry, featureName: string): void {
51
+ // skip: the entity never travels, so the shape that would strand its rows
52
+ // during a handover cannot arise — rejecting it would break consumers that
53
+ // legitimately declare a multiple reference on a non-transferable entity.
54
+ if (entry.entity.transferable !== true) return;
55
+ for (const [fieldName, field] of Object.entries(entry.entity.fields)) {
56
+ if (field.type !== "reference" || field.multiple !== true) continue;
57
+ throw new Error(
58
+ `[Kumiko TransferGraph] entity "${entry.name}" declares transferable: true and a ` +
59
+ `multiple reference field "${fieldName}" -> "${field.entity}" (feature: "${featureName}"). ` +
60
+ `A multiple reference stores a jsonb array, which the tenant-handover transfer graph ` +
61
+ `cannot match rows on — those rows would stay behind in the source tenant. ` +
62
+ `Fix: model the link as a single reference on the owning side, or drop transferable ` +
63
+ `from "${entry.name}".`,
64
+ );
65
+ }
66
+ }
67
+
68
+ // Depth is measured over reference edges between transferable entities, the
69
+ // only chain that nests (parentRef is one level by construction — see
70
+ // engine/boot-validator/parent-ref.ts).
71
+ //
72
+ // `onPath` counts NODES, the limit counts EDGES: a chain of exactly
73
+ // MAX_TRANSFER_DEPTH edges holds MAX_TRANSFER_DEPTH + 1 entities, and the
74
+ // mover runs MAX_TRANSFER_DEPTH rounds of one hop each, so it still walks that
75
+ // chain whole. Rejecting it here would make a schema the mover handles
76
+ // correctly refuse to boot.
77
+ function longestTransferableChain(
78
+ startName: string,
79
+ bySource: ReadonlyMap<string, readonly string[]>,
80
+ onPath: readonly string[],
81
+ ): readonly string[] | undefined {
82
+ if (onPath.length > MAX_TRANSFER_DEPTH + 1) return onPath;
83
+ for (const target of bySource.get(startName) ?? []) {
84
+ // skip: a cycle revisits a type already on this path, and schema depth
85
+ // cannot measure how far it actually runs — that depends on the rows, not
86
+ // the declaration. The mover carries this one instead, failing with
87
+ // `transfer_graph_too_deep` when its rounds run out (#3131).
88
+ if (onPath.includes(target)) continue;
89
+ const deeper = longestTransferableChain(target, bySource, [...onPath, target]);
90
+ if (deeper !== undefined) return deeper;
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ export function validateTransferGraph(
96
+ feature: FeatureDefinition,
97
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
98
+ ): void {
99
+ for (const [name, entity] of Object.entries(feature.entities ?? {})) {
100
+ validateNoMultipleReferenceEdge({ name, entity }, feature.name);
101
+ }
102
+
103
+ const transferable = new Set(
104
+ allEntities(featureMap)
105
+ .filter((e) => e.entity.transferable === true)
106
+ .map((e) => e.name),
107
+ );
108
+ // Edges point child -> parent in the schema; the graph walks parent -> child,
109
+ // so the lookup is inverted here.
110
+ const childrenByParent = new Map<string, string[]>();
111
+ for (const { name, entity } of allEntities(featureMap)) {
112
+ if (!transferable.has(name)) continue;
113
+ for (const target of referenceTargets(entity)) {
114
+ if (!transferable.has(target)) continue;
115
+ const children = childrenByParent.get(target) ?? [];
116
+ children.push(name);
117
+ childrenByParent.set(target, children);
118
+ }
119
+ }
120
+
121
+ for (const [name, entity] of Object.entries(feature.entities ?? {})) {
122
+ if (entity.transferable !== true) continue;
123
+ const tooDeep = longestTransferableChain(name, childrenByParent, [name]);
124
+ if (tooDeep !== undefined) {
125
+ throw new Error(
126
+ `[Kumiko TransferGraph] the transfer graph rooted at entity "${name}" is deeper than ` +
127
+ `the ${MAX_TRANSFER_DEPTH}-level limit (feature: "${feature.name}"): ` +
128
+ `${tooDeep.join(" -> ")}. Beyond that depth tenant-handover would move fewer rows ` +
129
+ `than the declaration implies. Fix: flatten the chain, or drop transferable from an ` +
130
+ `entity in it that should not move with its host.`,
131
+ );
132
+ }
133
+ }
134
+ }
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  collectWriteHandlerQns,
3
+ MAX_TRANSFER_DEPTH,
3
4
  SECURITY_BASELINE_FEATURE_NAMES,
4
5
  type ValidateBootOptions,
5
6
  validateAppCustomScreenWriteQns,
@@ -12,6 +12,7 @@ export {
12
12
  } from "./active-membership";
13
13
  export {
14
14
  collectWriteHandlerQns,
15
+ MAX_TRANSFER_DEPTH,
15
16
  SECURITY_BASELINE_FEATURE_NAMES,
16
17
  type ValidateBootOptions,
17
18
  validateAppCustomScreenWriteQns,
package/src/schema-cli.ts CHANGED
@@ -91,6 +91,11 @@ export type RunSchemaCliOptions = {
91
91
  * tables a freshly applied migration changed (via its `.rebuild.json`
92
92
  * marker). Omitted (dev `kumiko schema`) → no rebuild, migrations only. */
93
93
  readonly features?: readonly FeatureDefinition[];
94
+ /** Schema-declared Key Manager slots, typically `kmsSlotsOf(composedEnv.schema)`.
95
+ * This CLI parses no env schema, so `apply`'s KMS wiring cannot derive them
96
+ * itself; omitted, `resolvePlatformKeks` (inside `resolveKmsWiringAsync`)
97
+ * falls back to its own default. */
98
+ readonly kmsSlots?: readonly string[];
94
99
  };
95
100
 
96
101
  /**
@@ -361,6 +366,8 @@ export async function runSchemaCli(
361
366
  // the row (fw#3091).
362
367
  wiring = await resolveKmsWiringAsync(process.env, {
363
368
  logPrefix: "[kumiko schema apply]",
369
+ log: out.log,
370
+ ...(options.kmsSlots && { slots: options.kmsSlots }),
364
371
  });
365
372
  if ("kms" in wiring) {
366
373
  configurePiiSubjectKms(wiring.kms);
@@ -138,10 +138,12 @@ export type {
138
138
  export type {
139
139
  ListRowMetaColumnType,
140
140
  ListRowMetaReference,
141
+ ReferenceLookupSource,
141
142
  SystemReferenceLabel,
142
143
  } from "./list-row-meta";
143
144
  export {
144
145
  LIST_ROW_META_COLUMNS,
145
146
  LIST_ROW_META_REFERENCES,
147
+ REFERENCE_LOOKUP_SOURCES,
146
148
  SYSTEM_REFERENCE_LABELS,
147
149
  } from "./list-row-meta";
@@ -32,9 +32,9 @@ export type ListRowMetaReference = {
32
32
  readonly refLabelField: string;
33
33
  };
34
34
 
35
- // tenantId would otherwise render the raw GUID; the lookup query is
36
- // `tenant:query:tenant:list`, cross-tenant because the feature is
37
- // `r.systemScope()`.
35
+ // tenantId would otherwise render the raw GUID; the lookup query is resolved
36
+ // through REFERENCE_LOOKUP_SOURCES (tenant-directory), not the
37
+ // entity-convention `tenant:query:tenant:list`.
38
38
  // ponytail: bulk lookup is capped at REFERENCE_LIST_LOOKUP_LIMIT (200) —
39
39
  // above that, rows fall back to the GUID; paginate the lookup if an install
40
40
  // ever exceeds it.
@@ -42,6 +42,31 @@ export const LIST_ROW_META_REFERENCES: Readonly<Record<string, ListRowMetaRefere
42
42
  tenantId: { refFeature: "tenant", refEntity: "tenant", refLabelField: "name" },
43
43
  };
44
44
 
45
+ export type ReferenceLookupSource = {
46
+ readonly queryQn: string;
47
+ readonly labelKey: string;
48
+ };
49
+
50
+ // Overrides the `<refFeature>:query:<refEntity>:list` convention the renderer
51
+ // otherwise derives for a reference lookup. Keyed by `${refFeature}:${refEntity}`
52
+ // like SYSTEM_REFERENCE_LABELS, so the override holds for every screen that
53
+ // references the entity instead of each one re-declaring it.
54
+ //
55
+ // user:user — the convention QN is a SystemAdmin cross-tenant roster whose
56
+ // `tenants` join must not reach a TenantAdmin, so it stays SystemAdmin-only and
57
+ // the label lookup goes through the member directory instead: own-tenant
58
+ // members for an admin, every user for a SystemAdmin (fw#3107).
59
+ //
60
+ // tenant:tenant — the convention QN, `tenant:query:tenant:list`, is a
61
+ // SystemAdmin-only entity-list handler, so a TenantAdmin got a 403 and every
62
+ // tenant cell fell back to the raw UUID. The label lookup goes through the
63
+ // tenant directory instead: the caller's own tenant for an admin, every
64
+ // tenant for a SystemAdmin (fw#3142).
65
+ export const REFERENCE_LOOKUP_SOURCES: Readonly<Record<string, ReferenceLookupSource>> = {
66
+ "user:user": { queryQn: "tenant:query:member-directory", labelKey: "label" },
67
+ "tenant:tenant": { queryQn: "tenant:query:tenant-directory", labelKey: "label" },
68
+ };
69
+
45
70
  export type SystemReferenceLabel = {
46
71
  readonly id: string;
47
72
  readonly labelKey: string;