@cosmicdrift/kumiko-framework 0.269.2 → 0.273.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 (176) hide show
  1. package/README.md +5 -1
  2. package/package.json +3 -3
  3. package/src/__tests__/anonymous-access.integration.test.ts +1 -1
  4. package/src/__tests__/error-contract.integration.test.ts +1 -1
  5. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  6. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  7. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  8. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  9. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +16 -0
  10. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +16 -0
  11. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.expected.ts +12 -0
  12. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.input.ts +12 -0
  13. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +7 -0
  14. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +7 -0
  15. package/src/__tests__/full-stack.integration.test.ts +2 -2
  16. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  17. package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
  18. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  19. package/src/__tests__/transition-guard.integration.test.ts +7 -7
  20. package/src/api/__tests__/api.test.ts +2 -2
  21. package/src/api/__tests__/body-limit.test.ts +1 -1
  22. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  23. package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -2
  24. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  25. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  26. package/src/api/server.ts +7 -20
  27. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  28. package/src/bun-db/query.ts +24 -21
  29. package/src/changes.json +99 -0
  30. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  31. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  32. package/src/db/__tests__/list-filter-field-access.integration.test.ts +1 -1
  33. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  34. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  35. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  36. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  37. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  38. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  39. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  40. package/src/db/apply-entity-event.ts +1 -1
  41. package/src/db/assert-exists-in.ts +1 -1
  42. package/src/db/event-store-executor-context.ts +5 -2
  43. package/src/db/event-store-executor-read.ts +6 -4
  44. package/src/db/event-store-executor-write.ts +24 -18
  45. package/src/db/queries/shadow-swap.ts +36 -0
  46. package/src/db/replay-migration-sql.ts +5 -5
  47. package/src/db/tenant-db-runner.ts +23 -0
  48. package/src/db/tenant-db.ts +97 -24
  49. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +2 -2
  50. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  51. package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
  52. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  53. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  54. package/src/engine/__tests__/boot-validator-projection-list.test.ts +108 -17
  55. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +161 -18
  56. package/src/engine/__tests__/boot-validator-query-refs.test.ts +3 -3
  57. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  58. package/src/engine/__tests__/boot-validator.test.ts +69 -18
  59. package/src/engine/__tests__/build-app-schema.test.ts +10 -6
  60. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +48 -0
  61. package/src/engine/__tests__/build-config-feature-schema.test.ts +34 -5
  62. package/src/engine/__tests__/engine.test.ts +39 -25
  63. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  64. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  65. package/src/engine/__tests__/entity-handlers.test.ts +47 -8
  66. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
  67. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  68. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  69. package/src/engine/__tests__/extends-registrar.test.ts +3 -3
  70. package/src/engine/__tests__/feature-crud-shorthand.test.ts +9 -3
  71. package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
  72. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  73. package/src/engine/__tests__/lifecycle-hooks.test.ts +9 -3
  74. package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
  75. package/src/engine/__tests__/post-query-hook.test.ts +6 -2
  76. package/src/engine/__tests__/projection-detail-actions.test.ts +2 -2
  77. package/src/engine/__tests__/projection-detail-singleton.test.ts +3 -3
  78. package/src/engine/__tests__/projection-detail-tabs.test.ts +3 -3
  79. package/src/engine/__tests__/projection-list-filter.integration.test.ts +1 -1
  80. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  81. package/src/engine/__tests__/registry.test.ts +19 -13
  82. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  83. package/src/engine/__tests__/screen.test.ts +176 -20
  84. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  85. package/src/engine/__tests__/steps-read.test.ts +204 -8
  86. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  87. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  88. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +5 -4
  89. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  90. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
  91. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  92. package/src/engine/boot-validator/access-declarations.ts +3 -5
  93. package/src/engine/boot-validator/entity-handler.ts +21 -0
  94. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  95. package/src/engine/boot-validator/nav.ts +4 -3
  96. package/src/engine/boot-validator/parent-ref.ts +1 -1
  97. package/src/engine/boot-validator/projection-list-screens.ts +67 -2
  98. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  99. package/src/engine/boot-validator/screens.ts +154 -31
  100. package/src/engine/build-config-feature-schema.ts +12 -2
  101. package/src/engine/entity-handlers.ts +72 -20
  102. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +3 -1
  103. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +2 -2
  104. package/src/engine/feature-ast/__tests__/parse.test.ts +106 -4
  105. package/src/engine/feature-ast/__tests__/patcher.test.ts +3 -3
  106. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +4 -4
  107. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +5 -1
  108. package/src/engine/feature-ast/extractors/handlers.ts +16 -2
  109. package/src/engine/feature-ast/extractors/hooks.ts +25 -4
  110. package/src/engine/feature-ast/patcher.ts +15 -3
  111. package/src/engine/feature-ast/patterns.ts +4 -0
  112. package/src/engine/feature-ast/render.ts +3 -0
  113. package/src/engine/feature-entity-handlers.ts +4 -4
  114. package/src/engine/index.ts +5 -0
  115. package/src/engine/pattern-library/shared-fields.ts +7 -3
  116. package/src/engine/registry-validate.ts +12 -4
  117. package/src/engine/schema-builder.ts +6 -0
  118. package/src/engine/screen-helpers.ts +52 -0
  119. package/src/engine/steps/_read-source.ts +12 -0
  120. package/src/engine/steps/read-find-many.ts +8 -2
  121. package/src/engine/steps/read-find-one.ts +11 -8
  122. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  123. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  124. package/src/engine/types/index.ts +8 -1
  125. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  126. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +3 -3
  127. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  128. package/src/errors/i18n/de.yaml +14 -0
  129. package/src/errors/i18n/en.yaml +13 -0
  130. package/src/errors/reasons.ts +4 -0
  131. package/src/es-ops/__tests__/runner.integration.test.ts +2 -2
  132. package/src/i18n/required-surface-keys.ts +6 -2
  133. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +2 -2
  134. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +2 -2
  135. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  136. package/src/jobs/__tests__/jobs.integration.test.ts +42 -1
  137. package/src/jobs/job-runner.ts +35 -5
  138. package/src/observability/__tests__/observability.integration.test.ts +7 -7
  139. package/src/pipeline/__tests__/archive-stream.integration.test.ts +2 -2
  140. package/src/pipeline/__tests__/dispatcher.test.ts +15 -15
  141. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  142. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  143. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +10 -10
  144. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  145. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  146. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  147. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  148. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  149. package/src/pipeline/__tests__/query-projection.integration.test.ts +172 -17
  150. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  151. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  152. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  153. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  154. package/src/pipeline/dispatch-query.ts +27 -8
  155. package/src/pipeline/dispatch-shared.ts +141 -57
  156. package/src/pipeline/dispatch-stream.ts +1 -3
  157. package/src/pipeline/dispatch-write.ts +62 -14
  158. package/src/pipeline/dispatcher.ts +20 -0
  159. package/src/pipeline/escape-hatch-report.ts +127 -0
  160. package/src/pipeline/index.ts +8 -0
  161. package/src/pipeline/member-read-only-transaction.ts +75 -0
  162. package/src/pipeline/msp-rebuild.ts +2 -0
  163. package/src/pipeline/projection-rebuild.ts +2 -0
  164. package/src/pipeline/projections-runner.ts +15 -13
  165. package/src/pipeline/system-identity-switch.ts +121 -9
  166. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  167. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  168. package/src/scripts/codemod/migrate-open-to-all.ts +249 -0
  169. package/src/secrets/__tests__/contains-secret.test.ts +5 -5
  170. package/src/stack/__tests__/request-helper.integration.test.ts +3 -3
  171. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  172. package/src/testing/__tests__/e2e-generator.test.ts +19 -5
  173. package/src/ui-types/index.ts +3 -0
  174. package/src/utils/__tests__/ids.test.ts +29 -1
  175. package/src/utils/ids.ts +10 -1
  176. package/src/utils/index.ts +1 -1
@@ -8,7 +8,8 @@ import {
8
8
  } from "../db/eagerload";
9
9
  import { createEventStoreExecutor, type EventStoreExecutor } from "../db/event-store-executor";
10
10
  import { buildEntityTable, type EntityTable } from "../db/table-builder";
11
- import { createTenantDb, type TenantDb } from "../db/tenant-db";
11
+ import { acknowledgeConventionCrossTenant, type TenantDb } from "../db/tenant-db";
12
+ import { isSystemIdentity } from "../pipeline/system-identity-switch";
12
13
  import { assertUnreachable } from "../utils";
13
14
  import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler";
14
15
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
@@ -16,6 +17,7 @@ import type {
16
17
  AccessRule,
17
18
  DeriveContext,
18
19
  EntityDefinition,
20
+ EscapeHatchDeclaration,
19
21
  HandlerContext,
20
22
  QueryHandlerDef,
21
23
  SessionUser,
@@ -107,6 +109,33 @@ type ListPayload = {
107
109
 
108
110
  const idSchema = z.object({ id: z.uuid() });
109
111
 
112
+ // Keyed by handler function: the registrar rebuilds the def object but keeps the function reference.
113
+ const deprecatedCrossTenantHandlers = new WeakSet<object>();
114
+
115
+ export function isDeprecatedCrossTenantHandler(handler: object): boolean {
116
+ return deprecatedCrossTenantHandlers.has(handler);
117
+ }
118
+
119
+ export const DEPRECATED_CROSS_TENANT_SIGNAL = "deprecation:entity-handler-cross-tenant";
120
+
121
+ function resolveCrossTenantReason(name: string, options: EntityHandlerOptions): string | undefined {
122
+ if (options.escapeHatch !== undefined && options.crossTenant === true) {
123
+ throw new Error(
124
+ `"${name}": declare either escapeHatch or the deprecated crossTenant, not both.`,
125
+ );
126
+ }
127
+ if (options.escapeHatch !== undefined) {
128
+ if (options.escapeHatch.reason.trim().length === 0) {
129
+ throw new Error(`"${name}": escapeHatch requires a non-empty reason.`);
130
+ }
131
+ return options.escapeHatch.reason;
132
+ }
133
+ if (options.crossTenant === true) {
134
+ return `deprecated crossTenant: true on entity convention handler ${name}`;
135
+ }
136
+ return undefined;
137
+ }
138
+
110
139
  // Upper bound on entity-list `limit`: unbounded/fractional values ran
111
140
  // straight into the raw `LIMIT ${limit}` SQL string (event-store-executor-
112
141
  // read.ts), letting a client demand a full-table materialisation or trip a
@@ -194,10 +223,10 @@ export function defineEntityWriteHandler(
194
223
  // behavior-preserving: ctx.db was already an unfiltered "system"-mode
195
224
  // TenantDb for these handlers before the ctx.db cutover.
196
225
  //
197
- // options.crossTenant mirrors the query-handler branch below: this ONE
198
- // write handler reads/writes across every tenant without making the
199
- // whole feature r.systemScope().
200
- const crossTenant = options?.crossTenant === true;
226
+ // crossTenantReason (escapeHatch or the deprecated crossTenant) mirrors the
227
+ // query-handler branch below: this ONE write handler reads/writes across
228
+ // every tenant without making the whole feature r.systemScope().
229
+ const crossTenantReason = resolveCrossTenantReason(name, options);
201
230
 
202
231
  const dbFor = (ctx: HandlerContext): TenantDb => {
203
232
  if (ctx.systemDb) {
@@ -205,7 +234,9 @@ export function defineEntityWriteHandler(
205
234
  `entity convention handler for r.systemScope() feature (${name})`,
206
235
  );
207
236
  }
208
- return crossTenant ? createTenantDb(ctx.db.raw, ctx.db.tenantId, "system") : ctx.db;
237
+ return crossTenantReason !== undefined
238
+ ? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
239
+ : ctx.db;
209
240
  };
210
241
 
211
242
  // The event stream is keyed by the acting user's tenantId (streamTenantFor in
@@ -219,7 +250,7 @@ export function defineEntityWriteHandler(
219
250
  id: unknown,
220
251
  db: TenantDb,
221
252
  ): Promise<SessionUser> => {
222
- if (!crossTenant || entity.systemStream) return user;
253
+ if (crossTenantReason === undefined || entity.systemStream) return user;
223
254
  const row = await db.fetchOne<Record<string, unknown>>(table, { id });
224
255
  const rowTenantId = row?.["tenantId"];
225
256
  if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId) return user;
@@ -234,7 +265,14 @@ export function defineEntityWriteHandler(
234
265
  schema = buildInsertSchema(entity);
235
266
  handler = async (event, ctx) => {
236
267
  const { runPreSave } = ctx;
237
- return executor.create(event.payload as DbRow, event.user, dbFor(ctx), {
268
+ // A caller-chosen id (deterministic-id idempotent creates) is only
269
+ // honored for a system-identity caller (jobs/hooks via
270
+ // createSystemUser) — an HTTP-authenticated end user can never
271
+ // present that identity, so their payload.id is dropped exactly as
272
+ // before this field became part of the schema.
273
+ const { id: _callerId, ...payloadWithoutId } = event.payload as DbRow;
274
+ const payload = isSystemIdentity(event.user) ? (event.payload as DbRow) : payloadWithoutId;
275
+ return executor.create(payload, event.user, dbFor(ctx), {
238
276
  preSave:
239
277
  runPreSave &&
240
278
  ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
@@ -288,6 +326,9 @@ export function defineEntityWriteHandler(
288
326
  assertUnreachable(verb, "write verb");
289
327
  }
290
328
 
329
+ if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
330
+
331
+ // escapeHatch stays off the def: WriteHandlerDef.escapeHatch also grants unsafeRaw, global writes, identity switches.
291
332
  return {
292
333
  name,
293
334
  schema,
@@ -340,21 +381,22 @@ export function defineEntityQueryHandler(
340
381
  // useReferenceLookup stays as a fallback (for apps that write custom
341
382
  // handlers by hand without this wrapper).
342
383
  const hasRefFields = collectReferenceFields(entity).length > 0;
384
+ const crossTenantReason = resolveCrossTenantReason(name, options);
343
385
 
344
386
  // Preference order:
345
387
  // 1. ctx.systemDb — the feature declared r.systemScope() (whole-feature
346
388
  // cutover, dispatch-shared.ts), so ctx.db is fail-closed. This is
347
389
  // behavior-preserving: ctx.db was already an unfiltered "system"-mode
348
390
  // TenantDb for these handlers before that cutover.
349
- // 2. options.crossTenant — this ONE handler reads across every tenant
350
- // (e.g. a SystemAdmin-only operator inspector) without making the
351
- // whole feature r.systemScope() — that would drop tenant isolation
352
- // from every OTHER handler the feature registers too. The executor's
353
- // list()/detail() only add a tenant filter when db.mode === "tenant"
354
- // (see event-store-executor.ts), so handing them a "system"-mode
355
- // TenantDb built from the same raw connection is enough to skip it —
356
- // access-control (who may call this handler at all) is unaffected,
357
- // still gated by `options.access`.
391
+ // 2. crossTenantReason (escapeHatch or the deprecated crossTenant) — this
392
+ // ONE handler reads across every tenant (e.g. a SystemAdmin-only
393
+ // operator inspector) without making the whole feature r.systemScope()
394
+ // — that would drop tenant isolation from every OTHER handler the
395
+ // feature registers too. The executor's list()/detail() only add a
396
+ // tenant filter when db.mode === "tenant" (see event-store-executor.ts),
397
+ // so handing them a "system"-mode TenantDb built from the same raw
398
+ // connection is enough to skip it — access-control (who may call this
399
+ // handler at all) is unaffected, still gated by `options.access`.
358
400
  // 3. plain ctx.db — the common case, tenant-filtered.
359
401
  const dbFor = (ctx: HandlerContext): TenantDb => {
360
402
  if (ctx.systemDb) {
@@ -362,7 +404,9 @@ export function defineEntityQueryHandler(
362
404
  `entity convention handler for r.systemScope() feature (${entityName}:${verb})`,
363
405
  );
364
406
  }
365
- return options?.crossTenant ? createTenantDb(ctx.db.raw, ctx.db.tenantId, "system") : ctx.db;
407
+ return crossTenantReason !== undefined
408
+ ? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
409
+ : ctx.db;
366
410
  };
367
411
 
368
412
  switch (verb) {
@@ -418,6 +462,9 @@ export function defineEntityQueryHandler(
418
462
  assertUnreachable(verb, "query verb");
419
463
  }
420
464
 
465
+ if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
466
+
467
+ // escapeHatch stays off the def: QueryHandlerDef.escapeHatch also grants unsafeRaw and identity switches.
421
468
  return {
422
469
  name,
423
470
  schema,
@@ -534,7 +581,7 @@ export function createEntityExecutor(
534
581
  //
535
582
  // r.queryHandler(
536
583
  // defineProjectionQueryHandler("revenue:list", "showcase:projection:customer-revenue", {
537
- // access: { openToAll: true },
584
+ // access: { openToAll: { reason: "any signed-in user reviews aggregate revenue" } },
538
585
  // }),
539
586
  // );
540
587
  //
@@ -543,7 +590,11 @@ export function createEntityExecutor(
543
590
  export function defineProjectionQueryHandler(
544
591
  name: string,
545
592
  projectionQualifiedName: string,
546
- options: { access: AccessRule; unsafeAllTenants?: boolean },
593
+ options: {
594
+ access: AccessRule;
595
+ unsafeAllTenants?: boolean;
596
+ escapeHatch?: EscapeHatchDeclaration;
597
+ },
547
598
  ): QueryHandlerDef {
548
599
  return {
549
600
  name,
@@ -558,6 +609,7 @@ export function defineProjectionQueryHandler(
558
609
  options.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
559
610
  ), // @wrapper-known semantic-alias
560
611
  access: options.access,
612
+ ...(options.escapeHatch && { escapeHatch: options.escapeHatch }),
561
613
  };
562
614
  }
563
615
 
@@ -70,13 +70,14 @@ defineFeature("todoList", (r) => {
70
70
  return { isSuccess: true, data: { id: "x" } };
71
71
  },
72
72
  access: { roles: ["user"] },
73
+ escapeHatch: { reason: "seeds default priority labels via db.global()" },
73
74
  });
74
75
 
75
76
  r.queryHandler({
76
77
  name: "task:list",
77
78
  schema: z.object({}),
78
79
  handler: async (q, ctx) => [],
79
- access: { openToAll: true },
80
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
80
81
  });
81
82
 
82
83
  r.streamHandler({
@@ -94,6 +95,7 @@ defineFeature("todoList", (r) => {
94
95
  handler: async (event, ctx) => {
95
96
  console.log("task saved");
96
97
  },
98
+ escapeHatch: { reason: "reindexes the task search doc via SYSTEM identity" },
97
99
  });
98
100
 
99
101
  r.hook({
@@ -64,7 +64,7 @@ defineFeature("todoList", (r) => {
64
64
  "task:list",
65
65
  z.object({}),
66
66
  async (q, ctx) => [],
67
- { access: { openToAll: true } },
67
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
68
68
  );
69
69
 
70
70
  r.hook("postSave", "task", async (event, ctx) => {
@@ -168,7 +168,7 @@ describe("parseSourceFile against a complete inline-form feature", () => {
168
168
 
169
169
  const queryList = result.patterns.find((p) => p.kind === "queryHandler");
170
170
  expect(queryList).toMatchObject({
171
- access: { openToAll: true },
171
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
172
172
  });
173
173
  });
174
174
 
@@ -1332,6 +1332,37 @@ defineFeature("f", (r) => {
1332
1332
  expect(result.patterns[0]).toMatchObject({ kind: "hook", phase: "afterCommit" });
1333
1333
  });
1334
1334
 
1335
+ test("captures the optional escapeHatch from the options object (positional form)", () => {
1336
+ const result = parseInline(`
1337
+ defineFeature("f", (r) => {
1338
+ r.hook("postSave", "task", (event, ctx) => {}, { escapeHatch: { reason: "reindexes via SYSTEM" } });
1339
+ });
1340
+ `);
1341
+
1342
+ expect(result.patterns[0]).toMatchObject({
1343
+ kind: "hook",
1344
+ escapeHatch: { reason: "reindexes via SYSTEM" },
1345
+ });
1346
+ });
1347
+
1348
+ test("captures the optional escapeHatch from the object form", () => {
1349
+ const result = parseInline(`
1350
+ defineFeature("f", (r) => {
1351
+ r.hook({
1352
+ type: "postSave",
1353
+ target: "task",
1354
+ handler: (event, ctx) => {},
1355
+ escapeHatch: { reason: "reindexes via SYSTEM" },
1356
+ });
1357
+ });
1358
+ `);
1359
+
1360
+ expect(result.patterns[0]).toMatchObject({
1361
+ kind: "hook",
1362
+ escapeHatch: { reason: "reindexes via SYSTEM" },
1363
+ });
1364
+ });
1365
+
1335
1366
  test("rejects an unknown hook type", () => {
1336
1367
  const result = parseInline(`
1337
1368
  defineFeature("f", (r) => {
@@ -1576,7 +1607,7 @@ defineFeature("f", (r) => {
1576
1607
  name: "task:approve",
1577
1608
  schema: z.object({ id: z.string() }),
1578
1609
  handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
1579
- access: { openToAll: true },
1610
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
1580
1611
  });
1581
1612
  });
1582
1613
  `);
@@ -1584,7 +1615,7 @@ defineFeature("f", (r) => {
1584
1615
  expect(result.patterns[0]).toMatchObject({
1585
1616
  kind: "writeHandler",
1586
1617
  handlerName: "task:approve",
1587
- access: { openToAll: true },
1618
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
1588
1619
  });
1589
1620
  });
1590
1621
 
@@ -1678,6 +1709,42 @@ defineFeature("f", (r) => {
1678
1709
  });
1679
1710
  expect(result.errors).toEqual([]);
1680
1711
  });
1712
+
1713
+ test("object form: escapeHatch is extracted", () => {
1714
+ const result = parseInline(`
1715
+ defineFeature("f", (r) => {
1716
+ r.writeHandler({
1717
+ name: "task:reindex",
1718
+ schema: z.object({}),
1719
+ handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
1720
+ access: { roles: ["Admin"] },
1721
+ escapeHatch: { reason: "bulk reindex writes via db.global()" },
1722
+ });
1723
+ });
1724
+ `);
1725
+
1726
+ expect(result.patterns[0]).toMatchObject({
1727
+ kind: "writeHandler",
1728
+ handlerName: "task:reindex",
1729
+ escapeHatch: { reason: "bulk reindex writes via db.global()" },
1730
+ });
1731
+ });
1732
+
1733
+ test("4-argument form: escapeHatch is read from the options object", () => {
1734
+ const result = parseInline(`
1735
+ defineFeature("f", (r) => {
1736
+ r.writeHandler("task:reindex", z.object({}), async (event, ctx) => {
1737
+ return { isSuccess: true, data: {} };
1738
+ }, { access: { roles: ["Admin"] }, escapeHatch: { reason: "bulk reindex writes via db.global()" } });
1739
+ });
1740
+ `);
1741
+
1742
+ expect(result.patterns[0]).toMatchObject({
1743
+ kind: "writeHandler",
1744
+ handlerName: "task:reindex",
1745
+ escapeHatch: { reason: "bulk reindex writes via db.global()" },
1746
+ });
1747
+ });
1681
1748
  });
1682
1749
 
1683
1750
  describe("extractQueryHandler", () => {
@@ -1714,6 +1781,26 @@ defineFeature("f", (r) => {
1714
1781
  agent: { risk: "low" },
1715
1782
  });
1716
1783
  });
1784
+
1785
+ test("object form: escapeHatch is extracted (query handlers may switch to SYSTEM via ctx.queryAs)", () => {
1786
+ const result = parseInline(`
1787
+ defineFeature("f", (r) => {
1788
+ r.queryHandler({
1789
+ name: "task:audit-list",
1790
+ schema: z.object({}),
1791
+ handler: async (q, ctx) => [],
1792
+ access: { roles: ["Admin"] },
1793
+ escapeHatch: { reason: "reads across tenants via ctx.queryAs(SYSTEM, ...)" },
1794
+ });
1795
+ });
1796
+ `);
1797
+
1798
+ expect(result.patterns[0]).toMatchObject({
1799
+ kind: "queryHandler",
1800
+ handlerName: "task:audit-list",
1801
+ escapeHatch: { reason: "reads across tenants via ctx.queryAs(SYSTEM, ...)" },
1802
+ });
1803
+ });
1717
1804
  });
1718
1805
 
1719
1806
  describe("extractStreamHandler", () => {
@@ -1740,7 +1827,7 @@ defineFeature("f", (r) => {
1740
1827
  name: "chat:complete",
1741
1828
  schema: z.object({ prompt: z.string() }),
1742
1829
  handler: async function* (input, ctx) { yield "token"; },
1743
- access: { openToAll: true },
1830
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
1744
1831
  });
1745
1832
  });
1746
1833
  `);
@@ -1748,7 +1835,7 @@ defineFeature("f", (r) => {
1748
1835
  expect(result.patterns[0]).toMatchObject({
1749
1836
  kind: "streamHandler",
1750
1837
  handlerName: "chat:complete",
1751
- access: { openToAll: true },
1838
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
1752
1839
  });
1753
1840
  });
1754
1841
 
@@ -1783,6 +1870,21 @@ defineFeature("f", (r) => {
1783
1870
  expect(result.patterns[0]).not.toHaveProperty("description");
1784
1871
  expect(result.patterns[0]).not.toHaveProperty("agent");
1785
1872
  });
1873
+
1874
+ test("escapeHatch is NOT part of StreamHandlerPattern's runtime shape (StreamHandlerDef has no escapeHatch)", () => {
1875
+ const result = parseInline(`
1876
+ defineFeature("f", (r) => {
1877
+ r.streamHandler({
1878
+ name: "chat:complete",
1879
+ schema: z.object({ prompt: z.string() }),
1880
+ handler: async function* (input, ctx) { yield "token"; },
1881
+ escapeHatch: { reason: "Should be ignored for streamHandler." },
1882
+ });
1883
+ });
1884
+ `);
1885
+
1886
+ expect(result.patterns[0]).not.toHaveProperty("escapeHatch");
1887
+ });
1786
1888
  });
1787
1889
 
1788
1890
  describe("extractJob", () => {
@@ -131,14 +131,14 @@ describe("FeaturePatcher — typed add helpers for mixed (closure-bearing) patte
131
131
  name: "task:list",
132
132
  schemaSource: "z.object({})",
133
133
  handlerSource: "async (q, ctx) => []",
134
- access: { openToAll: true },
134
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
135
135
  });
136
136
  const result = parseSourceFile(sf);
137
137
  const pattern = result.patterns[0];
138
138
  expect(pattern).toMatchObject({
139
139
  kind: "queryHandler",
140
140
  handlerName: "task:list",
141
- access: { openToAll: true },
141
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
142
142
  });
143
143
  });
144
144
 
@@ -441,7 +441,7 @@ defineFeature("tasks", (r) => {
441
441
  name: "task:list",
442
442
  schemaSource: "z.object({})",
443
443
  handlerSource: "async (q, ctx) => []",
444
- access: { openToAll: true },
444
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
445
445
  });
446
446
  p.addStreamHandler({
447
447
  name: "task:stream",
@@ -20,9 +20,9 @@ describe("readOptionalAccessRule — openToAll.personalData", () => {
20
20
  ).toEqual({ openToAll: { reason: "signup" } });
21
21
  });
22
22
 
23
- test("the deprecated openToAll: true form carries no personalData", () => {
24
- expect(readOptionalAccessRule({ openToAll: true, personalData: "tenant-members" })).toEqual({
25
- openToAll: true,
26
- });
23
+ test("the deprecated openToAll: true form is not extracted — fail-closed, consistent with isOpenToAllGranted", () => {
24
+ expect(
25
+ readOptionalAccessRule({ openToAll: true, personalData: "tenant-members" }),
26
+ ).toBeUndefined();
27
27
  });
28
28
  });
@@ -209,7 +209,11 @@ defineFeature("workflow", (r) => {
209
209
  "step:list",
210
210
  z.object({}),
211
211
  async (q, ctx) => [],
212
- { access: { openToAll: true }, description: "Lists steps.", agent: { risk: "low" } },
212
+ {
213
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
214
+ description: "Lists steps.",
215
+ agent: { risk: "low" },
216
+ },
213
217
  );
214
218
 
215
219
  r.streamHandler(
@@ -4,12 +4,13 @@ import type {
4
4
  AccessRule,
5
5
  AgentHandlerHints,
6
6
  AgentRisk,
7
+ EscapeHatchDeclaration,
7
8
  RateLimitOption,
8
9
  } from "../../types/handlers";
9
10
  import type { QueryHandlerPattern, StreamHandlerPattern, WriteHandlerPattern } from "../patterns";
10
11
  import type { SourceLocation } from "../source-location";
11
12
  import { sourceLocationFromNode } from "../source-location";
12
- import { readOptionalAccessRule, readOptionalRateLimit } from "./hooks";
13
+ import { readOptionalAccessRule, readOptionalEscapeHatch, readOptionalRateLimit } from "./hooks";
13
14
  import {
14
15
  type ExtractOutput,
15
16
  fail,
@@ -32,6 +33,7 @@ export type ParsedHandlerCall = {
32
33
  readonly agent?: AgentHandlerHints;
33
34
  readonly rateLimit?: RateLimitOption;
34
35
  readonly unsafeSkipTransitionGuard?: boolean;
36
+ readonly escapeHatch?: EscapeHatchDeclaration;
35
37
  };
36
38
 
37
39
  const AGENT_RISK_VALUES: readonly AgentRisk[] = ["low", "mid", "high"];
@@ -85,18 +87,20 @@ function readDescriptionAndAgent(
85
87
  */
86
88
  function readOptionsFields(
87
89
  options: unknown,
88
- ): Pick<ParsedHandlerCall, "access" | "rateLimit" | "description" | "agent"> {
90
+ ): Pick<ParsedHandlerCall, "access" | "rateLimit" | "description" | "agent" | "escapeHatch"> {
89
91
  if (!isPlainObject(options)) return {};
90
92
  const access = readOptionalAccessRule(options["access"]);
91
93
  const rateLimit = readOptionalRateLimit(options["rateLimit"]);
92
94
  const description =
93
95
  typeof options["description"] === "string" ? options["description"] : undefined;
94
96
  const agent = readOptionalAgentHints(options["agent"]);
97
+ const escapeHatch = readOptionalEscapeHatch(options["escapeHatch"]);
95
98
  return {
96
99
  ...(access !== undefined && { access }),
97
100
  ...(description !== undefined && { description }),
98
101
  ...(agent !== undefined && { agent }),
99
102
  ...(rateLimit !== undefined && { rateLimit }),
103
+ ...(escapeHatch !== undefined && { escapeHatch }),
100
104
  };
101
105
  }
102
106
 
@@ -188,6 +192,13 @@ export function parseHandlerCall(
188
192
  : undefined;
189
193
  const { description, agent } = readDescriptionAndAgent(obj);
190
194
  const skip = readBooleanProperty(obj, "unsafeSkipTransitionGuard");
195
+ const escapeHatchInit = obj
196
+ .getProperty("escapeHatch")
197
+ ?.asKind(SyntaxKind.PropertyAssignment)
198
+ ?.getInitializer();
199
+ const escapeHatch = escapeHatchInit
200
+ ? readOptionalEscapeHatch(readDataLiteralNode(escapeHatchInit))
201
+ : undefined;
191
202
  return ok({
192
203
  source: sourceLocationFromNode(call, sourceFile),
193
204
  handlerName: nameLiteral.getLiteralValue(),
@@ -198,6 +209,7 @@ export function parseHandlerCall(
198
209
  ...(agent !== undefined && { agent }),
199
210
  ...(rateLimit !== undefined && { rateLimit }),
200
211
  ...(skip === true && { unsafeSkipTransitionGuard: true }),
212
+ ...(escapeHatch !== undefined && { escapeHatch }),
201
213
  });
202
214
  }
203
215
 
@@ -271,6 +283,7 @@ export function extractWriteHandler(
271
283
  ...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
272
284
  ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
273
285
  ...(parsed.pattern.unsafeSkipTransitionGuard === true && { unsafeSkipTransitionGuard: true }),
286
+ ...(parsed.pattern.escapeHatch !== undefined && { escapeHatch: parsed.pattern.escapeHatch }),
274
287
  });
275
288
  }
276
289
 
@@ -296,6 +309,7 @@ export function extractQueryHandler(
296
309
  ...readHandlerFields(parsed),
297
310
  ...(parsed.pattern.description !== undefined && { description: parsed.pattern.description }),
298
311
  ...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
312
+ ...(parsed.pattern.escapeHatch !== undefined && { escapeHatch: parsed.pattern.escapeHatch }),
299
313
  });
300
314
  }
301
315
 
@@ -1,7 +1,7 @@
1
1
  import type { CallExpression, Node, SourceFile } from "ts-morph";
2
2
  import { SyntaxKind } from "ts-morph";
3
3
  import type { LifecycleHookType } from "../../constants";
4
- import type { AccessRule, RateLimitOption } from "../../types/handlers";
4
+ import type { AccessRule, EscapeHatchDeclaration, RateLimitOption } from "../../types/handlers";
5
5
  import type { HookPhase } from "../../types/hooks";
6
6
  import type { AuthClaimsPattern, HookPattern } from "../patterns";
7
7
  import { sourceLocationFromNode } from "../source-location";
@@ -37,14 +37,12 @@ export function readOptionalPhase(node: Node | undefined): HookPhase | undefined
37
37
  return undefined;
38
38
  }
39
39
 
40
+ // `openToAll: true` (deprecated) is not recognised here — isOpenToAllGranted never grants it, so extracting it would round-trip a rule that looks configured but denies everyone.
40
41
  export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
41
42
  if (!isPlainObject(value)) return undefined;
42
43
  if (Array.isArray(value["roles"]) && value["roles"].every((r) => typeof r === "string")) {
43
44
  return { roles: value["roles"] as readonly string[] };
44
45
  }
45
- if (value["openToAll"] === true) {
46
- return { openToAll: true };
47
- }
48
46
  const openToAll = value["openToAll"];
49
47
  if (isPlainObject(openToAll) && typeof openToAll["reason"] === "string") {
50
48
  const personalData =
@@ -64,6 +62,25 @@ export function readOptionalRateLimit(value: unknown): RateLimitOption | undefin
64
62
  return value as unknown as RateLimitOption;
65
63
  }
66
64
 
65
+ export function readOptionalEscapeHatch(value: unknown): EscapeHatchDeclaration | undefined {
66
+ if (!isPlainObject(value)) return undefined;
67
+ if (typeof value["reason"] !== "string") return undefined;
68
+ return { reason: value["reason"] };
69
+ }
70
+
71
+ // Extracts the `escapeHatch` sub-property node first, not via readDataLiteralNode on the whole object — a sibling property like `handler` (a closure) isn't representable as plain data, which would make the whole-object read return undefined.
72
+ export function readOptionalHookEscapeHatch(
73
+ node: Node | undefined,
74
+ ): EscapeHatchDeclaration | undefined {
75
+ const obj = node?.asKind(SyntaxKind.ObjectLiteralExpression);
76
+ if (!obj) return undefined;
77
+ const init = obj
78
+ .getProperty("escapeHatch")
79
+ ?.asKind(SyntaxKind.PropertyAssignment)
80
+ ?.getInitializer();
81
+ return init ? readOptionalEscapeHatch(readDataLiteralNode(init)) : undefined;
82
+ }
83
+
67
84
  // r.hook's target: a NameOrRef, a list of them, or an entity-wide
68
85
  // `{ allOf: entityRef }` (replaces the old r.entityHook(type, entity, fn)).
69
86
  // Checked first so a malformed `{ allOf }` doesn't silently fall through
@@ -154,6 +171,7 @@ export function extractHook(
154
171
  );
155
172
  }
156
173
  const phase = readOptionalPhase(obj);
174
+ const escapeHatch = readOptionalHookEscapeHatch(obj);
157
175
  return ok({
158
176
  kind: "hook",
159
177
  source: sourceLocationFromNode(call, sourceFile),
@@ -161,6 +179,7 @@ export function extractHook(
161
179
  target,
162
180
  fnBody: sourceLocationFromNode(fn, sourceFile),
163
181
  ...(phase !== undefined && { phase }),
182
+ ...(escapeHatch !== undefined && { escapeHatch }),
164
183
  });
165
184
  }
166
185
 
@@ -212,6 +231,7 @@ export function extractHook(
212
231
  );
213
232
  }
214
233
  const phase = readOptionalPhase(args[3]);
234
+ const escapeHatch = readOptionalHookEscapeHatch(args[3]);
215
235
  return ok({
216
236
  kind: "hook",
217
237
  source: sourceLocationFromNode(call, sourceFile),
@@ -219,6 +239,7 @@ export function extractHook(
219
239
  target,
220
240
  fnBody: sourceLocationFromNode(fn, sourceFile),
221
241
  ...(phase !== undefined && { phase }),
242
+ ...(escapeHatch !== undefined && { escapeHatch }),
222
243
  });
223
244
  }
224
245