@cosmicdrift/kumiko-framework 0.270.0 → 0.274.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 (111) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  3. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  4. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  5. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  6. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  7. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  8. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  9. package/src/api/server.ts +7 -20
  10. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  11. package/src/bun-db/query.ts +24 -21
  12. package/src/changes.json +105 -0
  13. package/src/db/__tests__/build-filter-where.test.ts +8 -0
  14. package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
  15. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
  16. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  18. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  19. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  20. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  21. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  22. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  23. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  24. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  25. package/src/db/apply-entity-event.ts +1 -1
  26. package/src/db/assert-exists-in.ts +1 -1
  27. package/src/db/entity-field-encryption.ts +18 -0
  28. package/src/db/event-store-executor-context.ts +12 -5
  29. package/src/db/event-store-executor-read.ts +125 -37
  30. package/src/db/event-store-executor-write.ts +24 -18
  31. package/src/db/queries/shadow-swap.ts +36 -0
  32. package/src/db/replay-migration-sql.ts +5 -5
  33. package/src/db/tenant-db-runner.ts +23 -0
  34. package/src/db/tenant-db.ts +97 -24
  35. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  36. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  37. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  38. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  39. package/src/engine/__tests__/boot-validator.test.ts +71 -1
  40. package/src/engine/__tests__/engine.test.ts +86 -0
  41. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  42. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  43. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  44. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  45. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  46. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  47. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  48. package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
  49. package/src/engine/__tests__/screen.test.ts +150 -0
  50. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  51. package/src/engine/__tests__/steps-read.test.ts +204 -8
  52. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  53. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  54. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  55. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  56. package/src/engine/boot-validator/entity-handler.ts +21 -0
  57. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  58. package/src/engine/boot-validator/parent-ref.ts +1 -1
  59. package/src/engine/boot-validator/projection-list-screens.ts +68 -3
  60. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  61. package/src/engine/boot-validator/screens.ts +154 -31
  62. package/src/engine/build-config-feature-schema.ts +7 -4
  63. package/src/engine/entity-handlers.ts +74 -22
  64. package/src/engine/feature-entity-handlers.ts +4 -4
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/registry-validate.ts +36 -6
  67. package/src/engine/schema-builder.ts +6 -0
  68. package/src/engine/screen-filter-ops.ts +10 -2
  69. package/src/engine/screen-helpers.ts +52 -0
  70. package/src/engine/steps/_read-source.ts +12 -0
  71. package/src/engine/steps/read-find-many.ts +8 -2
  72. package/src/engine/steps/read-find-one.ts +11 -8
  73. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  74. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  75. package/src/engine/types/index.ts +8 -1
  76. package/src/errors/i18n/de.yaml +14 -0
  77. package/src/errors/i18n/en.yaml +13 -0
  78. package/src/errors/reasons.ts +4 -0
  79. package/src/i18n/required-surface-keys.ts +6 -2
  80. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  81. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  82. package/src/jobs/job-runner.ts +35 -5
  83. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  84. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  85. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  86. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  87. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  88. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  89. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  90. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  91. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  92. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  93. package/src/pipeline/dispatch-query.ts +27 -8
  94. package/src/pipeline/dispatch-shared.ts +141 -57
  95. package/src/pipeline/dispatch-stream.ts +1 -3
  96. package/src/pipeline/dispatch-write.ts +62 -14
  97. package/src/pipeline/dispatcher.ts +20 -0
  98. package/src/pipeline/escape-hatch-report.ts +127 -0
  99. package/src/pipeline/index.ts +8 -0
  100. package/src/pipeline/member-read-only-transaction.ts +75 -0
  101. package/src/pipeline/msp-rebuild.ts +2 -0
  102. package/src/pipeline/projection-rebuild.ts +2 -0
  103. package/src/pipeline/projections-runner.ts +15 -13
  104. package/src/pipeline/system-identity-switch.ts +121 -9
  105. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  106. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  107. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  108. package/src/ui-types/index.ts +3 -0
  109. package/src/utils/__tests__/ids.test.ts +29 -1
  110. package/src/utils/ids.ts +10 -1
  111. package/src/utils/index.ts +1 -1
@@ -7,6 +7,14 @@ export type { DistributedLock } from "./distributed-lock";
7
7
  export { createDistributedLock } from "./distributed-lock";
8
8
  export type { EntityCache, EntityCacheOptions } from "./entity-cache";
9
9
  export { createEntityCache } from "./entity-cache";
10
+ export type { EscapeHatchReportWindow } from "./escape-hatch-report";
11
+ export {
12
+ createEscapeHatchReporter,
13
+ createEscapeHatchReportWindow,
14
+ ESCAPE_HATCH_USED_SIGNAL,
15
+ fallbackEscapeHatchReporter,
16
+ reportEscapeHatchUse,
17
+ } from "./escape-hatch-report";
10
18
  export type { ConsumerStatus } from "./event-consumer-state";
11
19
  export {
12
20
  ConsumerStatuses,
@@ -0,0 +1,75 @@
1
+ import type { DbTx } from "../db/connection";
2
+ import { extractPgError } from "../db/pg-error";
3
+ import { asRawClient, runInSavepoint, transaction } from "../db/query";
4
+ import { AccessDeniedError, InternalError } from "../errors";
5
+ import {
6
+ type DispatchContext,
7
+ memberResolutionReadOnlyDenied,
8
+ resolveDbSource,
9
+ } from "./dispatch-shared";
10
+
11
+ const PG_READ_ONLY_SQLSTATE = "25006";
12
+
13
+ // Bun.SQL surfaces the PG SQLSTATE as `errno` (code stays "ERR_POSTGRES_SERVER_ERROR");
14
+ // postgres.js surfaces it as `code`, possibly one `.cause` layer down (DrizzleQueryError-style wrapping).
15
+ function isReadOnlyTransactionViolation(e: unknown): boolean {
16
+ if (extractPgError(e)?.code === PG_READ_ONLY_SQLSTATE) return true;
17
+ if (typeof e !== "object" || e === null) return false;
18
+ // @cast-boundary error-details — Bun.SQL error shape (errno)
19
+ if ((e as { errno?: unknown }).errno === PG_READ_ONLY_SQLSTATE) return true;
20
+ // @cast-boundary error-details — Bun.SQL error shape (errno) under .cause
21
+ const cause = (e as { cause?: unknown }).cause;
22
+ if (typeof cause !== "object" || cause === null) return false;
23
+ return (cause as { errno?: unknown }).errno === PG_READ_ONLY_SQLSTATE;
24
+ }
25
+
26
+ async function runInDiscardedReadOnlySavepoint<T>(
27
+ tx: DbTx,
28
+ fn: (readOnlyTx: DbTx) => Promise<T>,
29
+ ): Promise<T> {
30
+ const discardMarker = {};
31
+ const settled: { outcome?: { readonly value: T } } = {};
32
+ try {
33
+ await runInSavepoint(tx, async (sp) => {
34
+ // kumiko-lint-ignore raw-sql transaction characteristic, no query helper
35
+ await asRawClient(sp).unsafe("SET TRANSACTION READ ONLY");
36
+ // @cast-boundary driver savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
37
+ settled.outcome = { value: await fn(sp as DbTx) };
38
+ // Always discarded: RELEASE SAVEPOINT would leave the outer tx read-only too.
39
+ throw discardMarker;
40
+ });
41
+ } catch (e) {
42
+ if (e !== discardMarker) throw e;
43
+ }
44
+ if (!settled.outcome) {
45
+ throw new InternalError({
46
+ message: "runInDiscardedReadOnlySavepoint: savepoint ended without settling — this is a bug.",
47
+ });
48
+ }
49
+ return settled.outcome.value;
50
+ }
51
+
52
+ export async function runInMemberReadOnlyTransaction<T>(
53
+ ctx: DispatchContext,
54
+ tx: DbTx | undefined,
55
+ fn: (readOnlyTx: DbTx) => Promise<T>,
56
+ ): Promise<T> {
57
+ try {
58
+ if (tx) return await runInDiscardedReadOnlySavepoint(tx, fn);
59
+ const pool = resolveDbSource(ctx, undefined);
60
+ if (!pool) {
61
+ throw new InternalError({
62
+ message: "ctx.queryAsMember requires a database connection — none is configured.",
63
+ });
64
+ }
65
+ // Savepoint even on the pool path: Postgres rejects a READ WRITE reset (SQLSTATE 25001) inside a subtransaction.
66
+ return await transaction(pool, (outer) =>
67
+ // @cast-boundary driver begin() handle — structurally a DbTx, same shape runInSavepoint's caller relies on
68
+ runInDiscardedReadOnlySavepoint(outer as DbTx, fn),
69
+ );
70
+ } catch (e) {
71
+ if (e instanceof AccessDeniedError) throw e;
72
+ if (isReadOnlyTransactionViolation(e)) throw memberResolutionReadOnlyDenied(e);
73
+ throw e;
74
+ }
75
+ }
@@ -7,6 +7,7 @@ import {
7
7
  } from "../db/queries/event-consumer";
8
8
  import {
9
9
  assertLiveColumnsMatchMeta,
10
+ assertLiveTableHasNoRowLevelSecurity,
10
11
  buildShadowTable,
11
12
  ensureRebuildSchema,
12
13
  rebuildMetaOrThrow,
@@ -149,6 +150,7 @@ export async function rebuildMultiStreamProjection(
149
150
  await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
150
151
 
151
152
  await assertLiveColumnsMatchMeta(tx, meta, mspName);
153
+ await assertLiveTableHasNoRowLevelSecurity(tx, meta.tableName, mspName);
152
154
  await buildShadowTable(tx, meta);
153
155
 
154
156
  const subscribedTypes = Object.keys(msp.apply);
@@ -8,6 +8,7 @@ import {
8
8
  } from "../db/queries/projection-rebuild";
9
9
  import {
10
10
  assertLiveColumnsMatchMeta,
11
+ assertLiveTableHasNoRowLevelSecurity,
11
12
  assertNoUnreachableLiveRows,
12
13
  buildShadowTable,
13
14
  type ColumnDriftResult,
@@ -292,6 +293,7 @@ export async function rebuildProjection(
292
293
  await db.begin(async (tx: DbTx) => {
293
294
  await markProjectionRebuilding(tx, projectionName);
294
295
  await assertLiveColumnsMatchMeta(tx, meta, projectionName);
296
+ await assertLiveTableHasNoRowLevelSecurity(tx, meta.tableName, projectionName);
295
297
  await buildShadowTable(tx, meta);
296
298
  deps.__test_onBuildShadowTable?.();
297
299
 
@@ -1,5 +1,6 @@
1
1
  import type { DbRunner } from "../db";
2
- import type { HandlerContext, LifecycleResult, Registry } from "../engine/types";
2
+ import type { LifecycleResult, Registry } from "../engine/types";
3
+ import { InternalError } from "../errors";
3
4
  import type { StoredEvent } from "../event-store";
4
5
 
5
6
  // Run custom projections for a save or delete result. Lives INSIDE the
@@ -18,23 +19,24 @@ import type { StoredEvent } from "../event-store";
18
19
  // - Projections receive the exact StoredEvent from the executor. If you
19
20
  // hand-craft a SaveContext (tests, non-executor writes), just don't set
20
21
  // `event` and the runner no-ops.
21
- // - `tx`-scoped DbRunner is passed via the registered apply() — we reuse
22
- // `ctx.db.raw`, which the dispatcher already scoped to the active tx.
22
+ // - `runner` is the caller's already-resolved DbRunner — projections-runner
23
+ // has no TenantDb access of its own to derive one from.
23
24
  // - Apply-function throws bubble up unchanged. The dispatcher wraps the
24
25
  // whole lifecycle in a try/catch that rolls the tx back; the event is
25
26
  // gone from the events table just like a rolled-back state change.
26
- export async function runProjections(result: LifecycleResult, ctx: HandlerContext): Promise<void> {
27
+ export async function runProjections(
28
+ result: LifecycleResult,
29
+ registry: Registry,
30
+ runner: DbRunner | undefined,
31
+ ): Promise<void> {
27
32
  // skip: hand-crafted result with no event — nothing to project
28
33
  if (!result.event) return;
29
- // r.systemScope() handlers: ctx.db is fail-closed — custom projections are
30
- // framework-wired, not per-handler opt-in, so this reaches for systemDb
31
- // itself rather than pushing the concern onto every r.projection() author.
32
- const tx = ctx.systemDb
33
- ? ctx.systemDb.acknowledgeCrossTenant(
34
- `inline projection apply for r.systemScope() write (${result.event.aggregateType})`,
35
- ).raw
36
- : ctx.db.raw;
37
- await runProjectionsForEvent(result.event, ctx.registry, tx);
34
+ if (!runner) {
35
+ throw new InternalError({
36
+ message: `runProjections("${result.event.aggregateType}") requires a database connection — none is configured.`,
37
+ });
38
+ }
39
+ await runProjectionsForEvent(result.event, registry, runner);
38
40
  }
39
41
 
40
42
  // Fire every projection whose source matches the event's aggregate type AND
@@ -3,6 +3,8 @@ import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
3
3
  import type {
4
4
  ActiveMembershipResult,
5
5
  EscapeHatchDeclaration,
6
+ EscapeHatchReporter,
7
+ HandlerContext,
6
8
  LifecycleHookFn,
7
9
  MemberReader,
8
10
  SessionUser,
@@ -17,7 +19,12 @@ export type ResolveActiveMembershipFn = (
17
19
  userId: string,
18
20
  tenantId: TenantId,
19
21
  ) => Promise<ActiveMembershipResult>;
22
+ export type ProjectionReader = HandlerContext["queryProjection"];
20
23
  export type IdentitySwitch = { readonly queryAs: QueryAsFn; readonly writeAs: WriteAsFn };
24
+ export type IdentitySwitchAudit = {
25
+ readonly reason: string | undefined;
26
+ readonly report: EscapeHatchReporter;
27
+ };
21
28
 
22
29
  export function isSystemIdentity(user: SessionUser): boolean {
23
30
  return user.id === SYSTEM_USER_ID || user.roles.includes(SYSTEM_ROLE);
@@ -76,36 +83,69 @@ export function identitySwitchDenied(callerLabel: string, asUser: SessionUser):
76
83
  });
77
84
  }
78
85
 
86
+ export function unsafeAllTenantsDenied(callerLabel: string): AccessDeniedError {
87
+ return new AccessDeniedError({
88
+ message:
89
+ `${callerLabel} may not call ctx.queryProjection(..., { unsafeAllTenants: true }) — declare ` +
90
+ "r.systemScope() or escapeHatch: { reason } on it",
91
+ details: { reason: FrameworkReasons.unsafeAllTenantsDenied },
92
+ });
93
+ }
94
+
79
95
  type GatedIdentitySwitchSource = {
80
96
  readonly ungated: IdentitySwitch;
81
97
  readonly caller: SessionUser | undefined;
98
+ readonly report?: EscapeHatchReporter;
99
+ };
100
+
101
+ type MemberReaderAudit = IdentitySwitchAudit & { readonly tenantId: TenantId };
102
+
103
+ type GatedMemberReaderSource = {
104
+ readonly ungated: MemberReader;
105
+ readonly audit?: MemberReaderAudit;
82
106
  };
83
107
 
84
108
  // Reverse-lookup so withHookEscapeHatchGrant can re-gate the SAME ungated pair (and caller) under a narrower grant.
85
109
  const sourceByGated = new WeakMap<QueryAsFn | WriteAsFn, GatedIdentitySwitchSource>();
86
110
  // Same idea, for ctx.queryAsMember — a single function rather than a pair.
87
- const ungatedMemberReaderByGated = new WeakMap<MemberReader, MemberReader>();
111
+ const ungatedMemberReaderByGated = new WeakMap<MemberReader, GatedMemberReaderSource>();
112
+
113
+ function reportGrantedSwitch(
114
+ caller: SessionUser | undefined,
115
+ asUser: SessionUser,
116
+ hasGrant: boolean,
117
+ audit: IdentitySwitchAudit | undefined,
118
+ ): void {
119
+ // skip: no audit wired, switch wasn't grant-gated, or grant carries no reason to report
120
+ if (!audit || !hasGrant || audit.reason === undefined) return;
121
+ // skip: caller switching to themselves isn't a privilege escalation worth auditing
122
+ if (caller !== undefined && isSelfDelegation(caller, asUser)) return;
123
+ audit.report("identity-switch", audit.reason, { id: asUser.id, tenantId: asUser.tenantId });
124
+ }
88
125
 
89
126
  export function createGatedIdentitySwitch(
90
127
  callerLabel: string,
91
128
  caller: SessionUser | undefined,
92
129
  hasGrant: boolean,
93
130
  ungated: IdentitySwitch,
131
+ audit?: IdentitySwitchAudit,
94
132
  ): IdentitySwitch {
95
133
  const queryAs: QueryAsFn = async (asUser, qn, payload) => {
96
134
  if (!isIdentitySwitchAllowed(caller, asUser, hasGrant)) {
97
135
  throw identitySwitchDenied(callerLabel, asUser);
98
136
  }
137
+ reportGrantedSwitch(caller, asUser, hasGrant, audit);
99
138
  return ungated.queryAs(asUser, qn, payload);
100
139
  };
101
140
  const writeAs: WriteAsFn = async (asUser, qn, payload) => {
102
141
  if (!isIdentitySwitchAllowed(caller, asUser, hasGrant)) {
103
142
  throw identitySwitchDenied(callerLabel, asUser);
104
143
  }
144
+ reportGrantedSwitch(caller, asUser, hasGrant, audit);
105
145
  return ungated.writeAs(asUser, qn, payload);
106
146
  };
107
147
  const gated: IdentitySwitch = { queryAs, writeAs };
108
- const source: GatedIdentitySwitchSource = { ungated, caller };
148
+ const source: GatedIdentitySwitchSource = { ungated, caller, report: audit?.report };
109
149
  sourceByGated.set(queryAs, source);
110
150
  sourceByGated.set(writeAs, source);
111
151
  return gated;
@@ -117,20 +157,56 @@ export function createGatedMemberReader(
117
157
  callerLabel: string,
118
158
  allowSystemIdentity: boolean,
119
159
  ungated: MemberReader,
160
+ audit?: MemberReaderAudit,
120
161
  ): MemberReader {
121
162
  const gated: MemberReader = async (userId, qn, payload) => {
122
163
  if (!allowSystemIdentity) throw systemIdentitySwitchDenied(callerLabel);
164
+ if (audit?.reason !== undefined) {
165
+ audit.report("identity-switch", audit.reason, { id: userId, tenantId: audit.tenantId });
166
+ }
123
167
  return ungated(userId, qn, payload);
124
168
  };
125
- ungatedMemberReaderByGated.set(gated, ungated);
169
+ ungatedMemberReaderByGated.set(gated, { ungated, audit });
170
+ return gated;
171
+ }
172
+
173
+ type GatedProjectionReaderSource = {
174
+ readonly ungated: ProjectionReader;
175
+ readonly report?: EscapeHatchReporter;
176
+ };
177
+
178
+ // Same idea, for ctx.queryProjection — a single function rather than a pair.
179
+ const ungatedProjectionReaderByGated = new WeakMap<ProjectionReader, GatedProjectionReaderSource>();
180
+
181
+ // ctx.queryProjection's gate: only { unsafeAllTenants: true } is grant-checked —
182
+ // a plain call always passes through, tenant-scoped by the projection's own table.
183
+ export function createGatedProjectionReader(
184
+ callerLabel: string,
185
+ hasGrant: boolean,
186
+ ungated: ProjectionReader,
187
+ audit?: IdentitySwitchAudit,
188
+ ): ProjectionReader {
189
+ const gated = async <T = Record<string, unknown>>(
190
+ qualifiedName: string,
191
+ options?: { readonly unsafeAllTenants?: boolean },
192
+ ): Promise<readonly T[]> => {
193
+ if (options?.unsafeAllTenants === true) {
194
+ if (!hasGrant) throw unsafeAllTenantsDenied(callerLabel);
195
+ if (audit?.reason !== undefined) {
196
+ audit.report("unsafe-all-tenants", audit.reason);
197
+ }
198
+ }
199
+ return ungated<T>(qualifiedName, options);
200
+ };
201
+ ungatedProjectionReaderByGated.set(gated, { ungated, report: audit?.report });
126
202
  return gated;
127
203
  }
128
204
 
129
205
  function readIdentitySwitchFn<
130
- TFn extends QueryAsFn | WriteAsFn | ResolveActiveMembershipFn | MemberReader,
206
+ TFn extends QueryAsFn | WriteAsFn | ResolveActiveMembershipFn | MemberReader | ProjectionReader,
131
207
  >(
132
208
  context: object,
133
- key: "queryAs" | "writeAs" | "resolveActiveMembership" | "queryAsMember",
209
+ key: "queryAs" | "writeAs" | "resolveActiveMembership" | "queryAsMember" | "queryProjection",
134
210
  ): TFn | undefined {
135
211
  if (!(key in context)) return undefined;
136
212
  const value = (context as Record<string, unknown>)[key];
@@ -195,7 +271,15 @@ function gatedIdentitySwitchFields(
195
271
  const ungatedWriteAs = ctxWriteAs && (writeSource?.ungated.writeAs ?? ctxWriteAs);
196
272
  const ungated = fallbackUngatedIdentitySwitch(callerLabel, ungatedQueryAs, ungatedWriteAs);
197
273
  const caller = resolveDispatcherCaller(querySource, writeSource);
198
- const gated = createGatedIdentitySwitch(callerLabel, caller, escapeHatch !== undefined, ungated);
274
+ const report = (querySource ?? writeSource)?.report;
275
+ const audit = report ? { reason: escapeHatch?.reason, report } : undefined;
276
+ const gated = createGatedIdentitySwitch(
277
+ callerLabel,
278
+ caller,
279
+ escapeHatch !== undefined,
280
+ ungated,
281
+ audit,
282
+ );
199
283
  return {
200
284
  ...(ctxQueryAs && { queryAs: gated.queryAs }),
201
285
  ...(ctxWriteAs && { writeAs: gated.writeAs }),
@@ -210,9 +294,34 @@ function gatedMemberReaderField(
210
294
  ctxQueryAsMember: MemberReader | undefined,
211
295
  ): { queryAsMember?: MemberReader } {
212
296
  if (!ctxQueryAsMember) return {};
213
- const ungated = ungatedMemberReaderByGated.get(ctxQueryAsMember) ?? ctxQueryAsMember;
297
+ const source = ungatedMemberReaderByGated.get(ctxQueryAsMember);
298
+ const ungated = source?.ungated ?? ctxQueryAsMember;
299
+ const audit = source?.audit
300
+ ? { reason: escapeHatch?.reason, report: source.audit.report, tenantId: source.audit.tenantId }
301
+ : undefined;
302
+ return {
303
+ queryAsMember: createGatedMemberReader(callerLabel, escapeHatch !== undefined, ungated, audit),
304
+ };
305
+ }
306
+
307
+ // Re-gates ctx.queryProjection under the hook's own escapeHatch, same
308
+ // re-gating rule as the other fields below — see withHookEscapeHatchGrant.
309
+ function gatedProjectionReaderField(
310
+ callerLabel: string,
311
+ escapeHatch: EscapeHatchDeclaration | undefined,
312
+ ctxQueryProjection: ProjectionReader | undefined,
313
+ ): { queryProjection?: ProjectionReader } {
314
+ if (!ctxQueryProjection) return {};
315
+ const source = ungatedProjectionReaderByGated.get(ctxQueryProjection);
316
+ const ungated = source?.ungated ?? ctxQueryProjection;
317
+ const audit = source?.report ? { reason: escapeHatch?.reason, report: source.report } : undefined;
214
318
  return {
215
- queryAsMember: createGatedMemberReader(callerLabel, escapeHatch !== undefined, ungated),
319
+ queryProjection: createGatedProjectionReader(
320
+ callerLabel,
321
+ escapeHatch !== undefined,
322
+ ungated,
323
+ audit,
324
+ ),
216
325
  };
217
326
  }
218
327
 
@@ -228,6 +337,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
228
337
  "resolveActiveMembership",
229
338
  );
230
339
  const ctxQueryAsMember = readIdentitySwitchFn<MemberReader>(context, "queryAsMember");
340
+ const ctxQueryProjection = readIdentitySwitchFn<ProjectionReader>(context, "queryProjection");
231
341
  const ctxDb = readDbLikeValue(context, "db");
232
342
  const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
233
343
  if (
@@ -235,6 +345,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
235
345
  !ctxWriteAs &&
236
346
  !ctxResolveActiveMembership &&
237
347
  !ctxQueryAsMember &&
348
+ !ctxQueryProjection &&
238
349
  !ctxDb &&
239
350
  !ctxDbOutsideTransaction
240
351
  ) {
@@ -249,6 +360,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
249
360
  resolveActiveMembership: deniedResolveActiveMembership(callerLabel),
250
361
  }),
251
362
  ...gatedMemberReaderField(callerLabel, escapeHatch, ctxQueryAsMember),
363
+ ...gatedProjectionReaderField(callerLabel, escapeHatch, ctxQueryProjection),
252
364
  // @cast-boundary engine-bridge — withUnsafeRawGrant passes non-TenantDb values (e.g. a guard Proxy) through unchanged.
253
365
  ...(ctxDb && { db: withUnsafeRawGrant(ctxDb as TenantDb, escapeHatch) }),
254
366
  ...(ctxDbOutsideTransaction && {
@@ -257,7 +369,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
257
369
  };
258
370
  }
259
371
 
260
- // Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
372
+ // Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
261
373
  export function bindHookEscapeHatchGrant(
262
374
  fn: LifecycleHookFn,
263
375
  label: string,