@cosmicdrift/kumiko-framework 0.246.0 → 0.247.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.246.0",
3
+ "version": "0.247.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,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.246.0",
201
+ "@cosmicdrift/kumiko-types": "0.247.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.246.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.247.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -4,9 +4,10 @@
4
4
  // (userCanCreateFieldRow / userCanWriteFieldRow); there is no SQL layer on the
5
5
  // write path to run a where-rule against, and on create there is not even a
6
6
  // row yet. Boot validation rejects the shape outright
7
- // (boot-validator/ownership.ts), but setupTestStack wires the registry without
8
- // validateBoot, so this suite still reaches the runtime path the boot guard
9
- // protects and pins it to a fail-closed deny instead of a 500.
7
+ // (boot-validator/ownership.ts) and setupTestStack now runs that guard too, so
8
+ // the rule is installed after the stack is up — that is the only way left to
9
+ // reach the runtime path behind the guard and pin it to a fail-closed deny
10
+ // instead of a 500.
10
11
 
11
12
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
13
  import { asRawClient } from "../db/query";
@@ -17,7 +18,7 @@ import {
17
18
  defineEntityWriteHandler,
18
19
  defineFeature,
19
20
  } from "../engine";
20
- import type { SessionUser, WhereRule } from "../engine/types";
21
+ import type { OwnershipRule, SessionUser, WhereRule } from "../engine/types";
21
22
  import {
22
23
  createTestUser,
23
24
  setupTestStack,
@@ -36,6 +37,10 @@ const ownerWhereRule: WhereRule = {
36
37
  }),
37
38
  };
38
39
 
40
+ // Admin keeps a plain rule so the suite can seed rows over real HTTP; Member
41
+ // is the shape under test and is swapped in after boot (see beforeAll).
42
+ const memoWriteAccess: Record<string, OwnershipRule> = { Admin: "all", Member: "all" };
43
+
39
44
  const memoEntity = createEntity({
40
45
  table: "fw2626_memos",
41
46
  softDelete: true,
@@ -45,9 +50,7 @@ const memoEntity = createEntity({
45
50
  },
46
51
  access: {
47
52
  read: { Admin: "all", Member: "all" },
48
- // Admin keeps a plain rule so the suite can seed rows over real HTTP;
49
- // Member is the shape under test.
50
- write: { Admin: "all", Member: ownerWhereRule },
53
+ write: memoWriteAccess,
51
54
  },
52
55
  });
53
56
 
@@ -81,6 +84,7 @@ let seeded: { id: string; version: number };
81
84
  beforeAll(async () => {
82
85
  stack = await setupTestStack({ features: [memosFeature] });
83
86
  await unsafeCreateEntityTable(stack.db, memoEntity, "memo");
87
+ memoWriteAccess["Member"] = ownerWhereRule;
84
88
  });
85
89
 
86
90
  afterAll(async () => {
@@ -1266,7 +1266,7 @@ describe("boot-validator", () => {
1266
1266
  // Apps running only on openToAll / system handlers have no corpus
1267
1267
  // of known roles beyond "all"/"system" — validator must not flag
1268
1268
  // their OwnershipMap roles as unknown. This is the regression test
1269
- // for the shouldValidateRoles gate.
1269
+ // for the canValidateRoles gate.
1270
1270
  const features = [
1271
1271
  defineFeature("orders", (r) => {
1272
1272
  r.entity(
@@ -3851,6 +3851,83 @@ describe("boot-validator", () => {
3851
3851
  /rowAction "add-note" drawer-target "ghost-form" does not resolve to a registered screen/,
3852
3852
  );
3853
3853
  });
3854
+
3855
+ test("projectionDetail relatedList defaultSort.field not a listed column → throw", () => {
3856
+ const feature = defineFeature("shop", (r) => {
3857
+ r.screen({
3858
+ id: "order-detail",
3859
+ type: "projectionDetail",
3860
+ query: "shop:query:order-detail",
3861
+ layout: {
3862
+ sections: [
3863
+ {
3864
+ kind: "relatedList",
3865
+ title: "Positions",
3866
+ query: "shop:query:order-positions",
3867
+ columns: [{ field: "name", sortable: true }],
3868
+ defaultSort: { field: "price", dir: "asc" },
3869
+ },
3870
+ ],
3871
+ },
3872
+ });
3873
+ });
3874
+ expect(() => validateBoot([feature])).toThrow(
3875
+ /defaultSort\.field "price" is not a listed column/,
3876
+ );
3877
+ });
3878
+
3879
+ test("projectionDetail relatedList defaultSort.field not sortable → throw", () => {
3880
+ const feature = defineFeature("shop", (r) => {
3881
+ r.screen({
3882
+ id: "order-detail",
3883
+ type: "projectionDetail",
3884
+ query: "shop:query:order-detail",
3885
+ layout: {
3886
+ sections: [
3887
+ {
3888
+ kind: "relatedList",
3889
+ title: "Positions",
3890
+ query: "shop:query:order-positions",
3891
+ columns: [{ field: "name" }],
3892
+ defaultSort: { field: "name", dir: "asc" },
3893
+ },
3894
+ ],
3895
+ },
3896
+ });
3897
+ });
3898
+ expect(() => validateBoot([feature])).toThrow(/defaultSort\.field "name" is not sortable/);
3899
+ });
3900
+
3901
+ test("projectionDetail relatedList defaultSort.field sortable → kein Throw", () => {
3902
+ const feature = defineFeature("shop", (r) => {
3903
+ r.queryHandler("order-detail", z.object({}), async () => ({ id: "o1" }), {
3904
+ access: { openToAll: true },
3905
+ });
3906
+ r.queryHandler(
3907
+ "order-positions",
3908
+ z.object({}),
3909
+ async () => ({ rows: [], nextCursor: null }),
3910
+ { access: { openToAll: true } },
3911
+ );
3912
+ r.screen({
3913
+ id: "order-detail",
3914
+ type: "projectionDetail",
3915
+ query: "shop:query:order-detail",
3916
+ layout: {
3917
+ sections: [
3918
+ {
3919
+ kind: "relatedList",
3920
+ title: "Positions",
3921
+ query: "shop:query:order-positions",
3922
+ columns: [{ field: "name", sortable: true }],
3923
+ defaultSort: { field: "name", dir: "asc" },
3924
+ },
3925
+ ],
3926
+ },
3927
+ });
3928
+ });
3929
+ expect(() => validateBoot([feature])).not.toThrow();
3930
+ });
3854
3931
  });
3855
3932
 
3856
3933
  // --- Screen short-id collision across features ---
@@ -11,7 +11,7 @@ import type { OwnershipMap, WhereRule } from "../../types/ownership";
11
11
  import { validateOwnershipRules } from "../ownership";
12
12
 
13
13
  const NO_CLAIMS: ReadonlyMap<string, ClaimKeyDefinition> = new Map();
14
- // Empty corpus keeps shouldValidateRoles() false, so role-existence checks
14
+ // Empty corpus keeps canValidateRoles() false, so role-existence checks
15
15
  // stay out of the way of the where-rule assertions below.
16
16
  const NO_ROLES: ReadonlySet<string> = new Set();
17
17
 
@@ -1,6 +1,6 @@
1
1
  import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
2
2
  import { QnTypes, qualifyEntityName } from "../qualified-name";
3
- import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
3
+ import type { FeatureDefinition } from "../types";
4
4
  import { warnOnUniqueAccessRoles } from "./access-roles";
5
5
  import { validateActionWiring, validateFieldWiring } from "./action-wiring";
6
6
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
@@ -44,7 +44,7 @@ import {
44
44
  validateNavs,
45
45
  warnOnNavAccessInversion,
46
46
  } from "./nav";
47
- import { validateOwnershipRules } from "./ownership";
47
+ import { collectClaimKeys, validateOwnershipRules } from "./ownership";
48
48
  import { validatePiiAndRetention } from "./pii-retention";
49
49
  import { validateProjectionListScreens } from "./projection-list-screens";
50
50
  import { validateQueryOutputColumns } from "./query-output-columns";
@@ -119,12 +119,7 @@ export function validateBoot(
119
119
  // `from("claim:<feature>:<key>")` strings against this map. Qualified name
120
120
  // is how the resolver / readClaim / ownership system all reference claims,
121
121
  // so we key on the qualifiedName here too.
122
- const allClaimKeys = new Map<string, ClaimKeyDefinition>();
123
- for (const f of features) {
124
- for (const def of Object.values(f.claimKeys)) {
125
- allClaimKeys.set(def.qualifiedName, def);
126
- }
127
- }
122
+ const allClaimKeys = collectClaimKeys(features);
128
123
 
129
124
  // Cross-feature role set — derived from handler-access rules + framework
130
125
  // built-ins ("all", "system"). We don't have a dedicated role-registry
@@ -44,10 +44,40 @@ export type WhereRuleProbe = {
44
44
  // SQL, so it is the only path on which a where-rule is admissible.
45
45
  export type AccessPath = "read" | "write";
46
46
 
47
+ // Qualified-name → definition map of every claim key in the mounted feature
48
+ // set. `from("claim:<feature>:<key>")` rules resolve against exactly this.
49
+ export function collectClaimKeys(
50
+ features: readonly FeatureDefinition[],
51
+ ): Map<string, ClaimKeyDefinition> {
52
+ const claimKeys = new Map<string, ClaimKeyDefinition>();
53
+ for (const f of features) {
54
+ for (const def of Object.values(f.claimKeys)) {
55
+ claimKeys.set(def.qualifiedName, def);
56
+ }
57
+ }
58
+ return claimKeys;
59
+ }
60
+
61
+ // Subset-safe ownership validation for callers that mount a deliberate slice
62
+ // of the app rather than all of it (setupTestStack). Both cross-feature
63
+ // corpora go in as undefined — a three-feature stack genuinely does not know
64
+ // which roles and claims the whole app declares, and answering that question
65
+ // from a slice produces false "unknown role"/"unknown claim" errors. The
66
+ // subset-invariant checks (#2639 where-lint, #2626 where-on-write, unknown
67
+ // column) run in full: those hold or fail per feature, whatever else is
68
+ // mounted.
69
+ export function validateOwnershipBoot(features: readonly FeatureDefinition[]): void {
70
+ for (const feature of features) {
71
+ validateOwnershipRules(feature, undefined, undefined);
72
+ }
73
+ }
74
+
75
+ // undefined corpus = "this caller cannot answer that question", not "skip the
76
+ // check" — see validateOwnershipBoot. validateBoot passes the real corpora.
47
77
  export function validateOwnershipRules(
48
78
  feature: FeatureDefinition,
49
- allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>,
50
- knownRoles: ReadonlySet<string>,
79
+ allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition> | undefined,
80
+ knownRoles: ReadonlySet<string> | undefined,
51
81
  ): void {
52
82
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
53
83
  const columnNames = new Set<string>(Object.keys(entity.fields));
@@ -126,8 +156,8 @@ export function validateOwnershipRules(
126
156
  export function checkFieldAccess(args: {
127
157
  readonly access: OwnershipMap | readonly string[] | undefined;
128
158
  readonly columnNames: ReadonlySet<string>;
129
- readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>;
130
- readonly knownRoles: ReadonlySet<string>;
159
+ readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition> | undefined;
160
+ readonly knownRoles: ReadonlySet<string> | undefined;
131
161
  readonly scope: string;
132
162
  readonly featureName: string;
133
163
  readonly probe?: WhereRuleProbe;
@@ -162,12 +192,12 @@ export function checkFieldAccess(args: {
162
192
 
163
193
  export function checkLegacyRoleList(
164
194
  roles: readonly string[],
165
- knownRoles: ReadonlySet<string>,
195
+ knownRoles: ReadonlySet<string> | undefined,
166
196
  scope: string,
167
197
  featureName: string,
168
198
  ): void {
169
- // skip: no handler-declared roles in this app, role-validation disabled
170
- if (!shouldValidateRoles(knownRoles)) return;
199
+ // skip: corpus cannot answer "is this role real?" — see canValidateRoles
200
+ if (!canValidateRoles(knownRoles)) return;
171
201
  for (const roleName of roles) {
172
202
  if (!knownRoles.has(roleName)) {
173
203
  throw new Error(buildUnknownRoleMessage(roleName, knownRoles, scope, featureName));
@@ -175,13 +205,15 @@ export function checkLegacyRoleList(
175
205
  }
176
206
  }
177
207
 
178
- // Only validate role-existence when at least one handler in the system has
179
- // declared a non-builtin role. Apps that run entirely on openToAll +
180
- // system-role handlers don't benefit from role-typo detection and would
181
- // otherwise get false-positive errors on every OwnershipMap — their
182
- // knownRoles corpus is empty beyond "all"/"system", so any app-defined
183
- // role would flag as unknown.
184
- export function shouldValidateRoles(knownRoles: ReadonlySet<string>): boolean {
208
+ // A role corpus can only answer "is this role real?" when it actually holds
209
+ // app-defined roles. undefined means the caller could not derive one at all
210
+ // (a test stack mounts a deliberate subset of the app), and a corpus of
211
+ // nothing but "all"/"system" belongs to an app whose handlers never declare
212
+ // a role — in both cases every app-defined role would flag as unknown.
213
+ export function canValidateRoles(
214
+ knownRoles: ReadonlySet<string> | undefined,
215
+ ): knownRoles is ReadonlySet<string> {
216
+ if (!knownRoles) return false;
185
217
  for (const r of knownRoles) {
186
218
  if (r !== "all" && r !== "system") return true;
187
219
  }
@@ -191,8 +223,8 @@ export function shouldValidateRoles(knownRoles: ReadonlySet<string>): boolean {
191
223
  export function checkOwnershipMap(args: {
192
224
  readonly map: OwnershipMap;
193
225
  readonly columnNames: ReadonlySet<string>;
194
- readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>;
195
- readonly knownRoles: ReadonlySet<string>;
226
+ readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition> | undefined;
227
+ readonly knownRoles: ReadonlySet<string> | undefined;
196
228
  readonly scope: string;
197
229
  readonly featureName: string;
198
230
  readonly probe?: WhereRuleProbe;
@@ -201,9 +233,8 @@ export function checkOwnershipMap(args: {
201
233
  for (const [roleName, rawRule] of Object.entries(args.map)) {
202
234
  // Role-existence check — typos like `{"Admi": "all"}` where no handler
203
235
  // or other map mentions "Admi" would otherwise silently grant nothing.
204
- // Skip when no app-defined roles exist anywhere (handler-less or
205
- // system-only apps — shouldValidateRoles returns false there).
206
- if (shouldValidateRoles(args.knownRoles) && !args.knownRoles.has(roleName)) {
236
+ // Cross-feature, so it needs a corpus that can answer the question.
237
+ if (canValidateRoles(args.knownRoles) && !args.knownRoles.has(roleName)) {
207
238
  throw new Error(
208
239
  buildUnknownRoleMessage(roleName, args.knownRoles, args.scope, args.featureName),
209
240
  );
@@ -216,12 +247,14 @@ export function checkOwnershipMap(args: {
216
247
  if (args.path === "write") {
217
248
  throw new Error(buildWhereOnWritePathMessage(roleName, args.scope, args.featureName));
218
249
  }
219
- probeWhereRule(rule, roleName, args.probe, args.scope);
250
+ probeWhereRule(rule, roleName, args.probe, args.scope, args.featureName);
220
251
  continue; // escape hatch — feature author owns the SQL
221
252
  }
222
253
 
223
254
  // FromRule — validate ref + column.
224
- if (rule.refKind === "claim") {
255
+ // Claim-existence is cross-feature: only the whole-app corpus can tell a
256
+ // typo from a claim declared in a feature this caller did not mount.
257
+ if (rule.refKind === "claim" && args.allClaimKeys) {
225
258
  // refPath is the qualified claim name ("feature:shortName").
226
259
  const claim = args.allClaimKeys.get(rule.refPath);
227
260
  if (!claim) {
@@ -258,6 +291,7 @@ export function probeWhereRule(
258
291
  roleName: string,
259
292
  probe: WhereRuleProbe | undefined,
260
293
  scope: string,
294
+ featureName: string,
261
295
  ): void {
262
296
  // skip: no probe means the entity's column set could not be derived above;
263
297
  // without it there is nothing to check the rule's SQL against, so the
@@ -277,7 +311,11 @@ export function probeWhereRule(
277
311
  // skip: a rule producing no SQL fragment for the probe user has nothing to
278
312
  // lint here; ruleToFragment still validates it against a real request.
279
313
  if (typeof fragment?.sqlText !== "string") return;
280
- assertQualifiedWhereFragment(fragment.sqlText, probe.columns, `${scope} (role "${roleName}")`);
314
+ assertQualifiedWhereFragment(
315
+ fragment.sqlText,
316
+ probe.columns,
317
+ `${scope} (role "${roleName}", feature: "${featureName}")`,
318
+ );
281
319
  }
282
320
 
283
321
  export function buildWhereOnWritePathMessage(
@@ -846,6 +846,23 @@ export function validateScreens(
846
846
  );
847
847
  }
848
848
  }
849
+ if (section.defaultSort !== undefined) {
850
+ const sortField = section.defaultSort.field;
851
+ const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
852
+ if (col === undefined) {
853
+ throw new Error(
854
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
855
+ `(relatedList) defaultSort.field "${sortField}" is not a listed column.`,
856
+ );
857
+ }
858
+ if (normalizeListColumn(col).sortable !== true) {
859
+ throw new Error(
860
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
861
+ `(relatedList) defaultSort.field "${sortField}" is not sortable. Set sortable: true on ` +
862
+ `the column or pick another field.`,
863
+ );
864
+ }
865
+ }
849
866
  continue;
850
867
  }
851
868
  if (isWriteFormEditSection(section)) {
@@ -0,0 +1,108 @@
1
+ // setupTestStack runs the ownership boot-guards (fw#2639 unqualified
2
+ // where-fragments, fw#2626 where-rules on the write path). Before this, only
3
+ // the prod boot ran validateBoot, so a feature with a broken access map came
4
+ // through every test suite green and failed on the first real deploy.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ createEntity,
9
+ createTextField,
10
+ defineEntityQueryHandler,
11
+ defineFeature,
12
+ from,
13
+ } from "../../engine";
14
+ import type { WhereRule } from "../../engine/types";
15
+ import { setupTestStack } from "../test-stack";
16
+
17
+ // Bare `owner_id` inside the subquery binds to fwbootguard_shares, not to the
18
+ // outer memo table — the predicate collapses into a tautology and grants every
19
+ // row. This is the exact fail-open shape fw#2639 lints for.
20
+ const unqualifiedReadRule: WhereRule = {
21
+ kind: "where",
22
+ where: (user, ctx) => ({
23
+ sqlText: `${ctx.tableName}.id IN (SELECT memo_id FROM fwbootguard_shares WHERE owner_id = $${ctx.paramStart})`,
24
+ params: [user.id],
25
+ }),
26
+ };
27
+
28
+ const qualifiedWriteRule: WhereRule = {
29
+ kind: "where",
30
+ where: (user, ctx) => ({
31
+ sqlText: `${ctx.tableName}.owner_id = $${ctx.paramStart}`,
32
+ params: [user.id],
33
+ }),
34
+ };
35
+
36
+ function memoEntity(access: Parameters<typeof createEntity>[0]["access"]) {
37
+ return createEntity({
38
+ table: "fwbootguard_memos",
39
+ fields: {
40
+ ownerId: createTextField({ required: true }),
41
+ title: createTextField({ required: true }),
42
+ },
43
+ ...(access ? { access } : {}),
44
+ });
45
+ }
46
+
47
+ function featureWith(access: Parameters<typeof createEntity>[0]["access"]) {
48
+ const entity = memoEntity(access);
49
+ return defineFeature("fwbootguard", (r) => {
50
+ r.entity("memo", entity);
51
+ r.queryHandler(
52
+ defineEntityQueryHandler("memo:detail", entity, { access: { roles: ["Member"] } }),
53
+ );
54
+ });
55
+ }
56
+
57
+ describe("setupTestStack — ownership boot-guards", () => {
58
+ test("rejects an unqualified where-fragment on access.read (fw#2639)", async () => {
59
+ const promise = setupTestStack({
60
+ features: [featureWith({ read: { Member: unqualifiedReadRule } })],
61
+ });
62
+ await expect(promise).rejects.toThrow(
63
+ /references column "owner_id" unqualified inside a subquery/,
64
+ );
65
+ await expect(promise).rejects.toThrow(/entity "memo"\.access\.read/);
66
+ await expect(promise).rejects.toThrow(/role "Member", feature: "fwbootguard"/);
67
+ });
68
+
69
+ test("rejects a where-rule on access.write (fw#2626)", async () => {
70
+ const promise = setupTestStack({
71
+ features: [featureWith({ read: { Member: "all" }, write: { Member: qualifiedWriteRule } })],
72
+ });
73
+ await expect(promise).rejects.toThrow(/entity "memo"\.access\.write/);
74
+ await expect(promise).rejects.toThrow(/feature: "fwbootguard"/);
75
+ });
76
+
77
+ test("a qualified read-rule still boots", async () => {
78
+ const stack = await setupTestStack({
79
+ features: [featureWith({ read: { Member: qualifiedWriteRule } })],
80
+ });
81
+ expect(stack.registry.getEntity("memo")?.table).toBe("fwbootguard_memos");
82
+ await stack.cleanup();
83
+ });
84
+
85
+ // The role and claim corpora are whole-app facts. A stack that mounts a
86
+ // deliberate subset cannot answer them, so those two checks stay out of the
87
+ // test-stack path while the subset-invariant where-lint keeps firing on the
88
+ // very same map. Without this test, the next "fix" to collectKnownRoles
89
+ // quietly reinstates the false positives it removed.
90
+ test("a subset stack skips the cross-feature checks but still lints where-fragments", async () => {
91
+ const undeclaredRefs = {
92
+ Auditor: "all",
93
+ Reviewer: from("claim:fwbootguardelsewhere:teamId", "ownerId"),
94
+ } as const;
95
+
96
+ const stack = await setupTestStack({
97
+ features: [featureWith({ read: { ...undeclaredRefs, Member: "all" } })],
98
+ });
99
+ expect(stack.registry.getEntity("memo")?.table).toBe("fwbootguard_memos");
100
+ await stack.cleanup();
101
+
102
+ await expect(
103
+ setupTestStack({
104
+ features: [featureWith({ read: { ...undeclaredRefs, Member: unqualifiedReadRule } })],
105
+ }),
106
+ ).rejects.toThrow(/references column "owner_id" unqualified inside a subquery/);
107
+ });
108
+ });
@@ -5,6 +5,7 @@ import { buildServer } from "../api/server";
5
5
  import { createSseBroker, type SseBroker } from "../api/sse-broker";
6
6
  import type { PgClient } from "../db/connection";
7
7
  import { extractTableInfo } from "../db/query";
8
+ import { validateOwnershipBoot } from "../engine/boot-validator/ownership";
8
9
  import { createRegistry } from "../engine/registry";
9
10
  import type { AppContext, FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types";
10
11
  import { createArchivedStreamsTable, createEventsTable } from "../event-store";
@@ -180,6 +181,14 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
180
181
  const { ensureTemporalPolyfill } = await import("../time/polyfill");
181
182
  await ensureTemporalPolyfill();
182
183
 
184
+ // Ownership boot-guards (fw#2639 unqualified where-fragments, fw#2626
185
+ // where-rules on the write path) otherwise run only in the prod boot via
186
+ // validateBoot, leaving every test stack blind to a broken access map.
187
+ // Runs after the polyfill (buildEntityTable needs it to derive the probe
188
+ // columns) and before the ephemeral DB/Redis exist, so a throw can't leak
189
+ // a database that nothing will clean up.
190
+ validateOwnershipBoot(options.features);
191
+
183
192
  // Forward db-name/persistent-flag through to createTestDb. The
184
193
  // defaults (undefined dbName, persistent:false) keep the legacy
185
194
  // test contract: fresh kumiko_test_<random> DB per setup, dropped
@@ -85,6 +85,7 @@ export type {
85
85
  FormWidth,
86
86
  ListColumnSpec,
87
87
  ListFacetSpec,
88
+ ListSortSpec,
88
89
  PlatformComponent,
89
90
  ProjectionDetailScreenDefinition,
90
91
  ProjectionListScreenDefinition,