@cosmicdrift/kumiko-samples 0.352.0 → 0.354.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 (186) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +18 -2
  3. package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
  4. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +26 -7
  5. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  6. package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
  7. package/packages/bundled-features/src/agent-tools/index.ts +6 -1
  8. package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
  9. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
  10. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
  11. package/packages/bundled-features/src/audit/changes.json +6 -0
  12. package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
  13. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
  15. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
  17. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
  18. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
  19. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
  21. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +7 -3
  22. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
  23. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  24. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
  25. package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
  26. package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
  27. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
  28. package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
  29. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
  30. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
  31. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
  32. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  33. package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
  34. package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
  35. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
  36. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
  37. package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
  39. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
  40. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
  41. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +7 -8
  42. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  43. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
  44. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
  45. package/packages/bundled-features/src/cap-overview/access.ts +2 -9
  46. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
  48. package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
  49. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
  50. package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
  51. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
  52. package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
  53. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  54. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
  55. package/packages/bundled-features/src/delivery/changes.json +6 -0
  56. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
  57. package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
  58. package/packages/bundled-features/src/delivery/public-names.ts +1 -1
  59. package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
  60. package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
  61. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
  62. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
  63. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
  64. package/packages/bundled-features/src/files/changes.json +24 -0
  65. package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
  66. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
  67. package/packages/bundled-features/src/files-tenant-data/hooks.ts +75 -25
  68. package/packages/bundled-features/src/folders/changes.json +6 -0
  69. package/packages/bundled-features/src/folders/feature.ts +2 -12
  70. package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
  71. package/packages/bundled-features/src/form-draft/changes.json +7 -1
  72. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
  73. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
  74. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  75. package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
  76. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  77. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
  78. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
  79. package/packages/bundled-features/src/jobs/changes.json +30 -0
  80. package/packages/bundled-features/src/jobs/constants.ts +1 -0
  81. package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
  82. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
  83. package/packages/bundled-features/src/jobs/feature.ts +7 -1
  84. package/packages/bundled-features/src/jobs/handlers/list.query.ts +45 -12
  85. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  86. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
  87. package/packages/bundled-features/src/jobs/i18n.ts +1 -0
  88. package/packages/bundled-features/src/jobs/index.ts +2 -0
  89. package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
  90. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
  91. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
  92. package/packages/bundled-features/src/ledger/changes.json +23 -0
  93. package/packages/bundled-features/src/ledger/entity.ts +17 -5
  94. package/packages/bundled-features/src/ledger/feature.ts +3 -0
  95. package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
  96. package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
  97. package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
  98. package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
  99. package/packages/bundled-features/src/ledger/schemas.ts +8 -2
  100. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
  101. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  102. package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
  103. package/packages/bundled-features/src/notes-history/changes.json +13 -0
  104. package/packages/bundled-features/src/notes-history/entity.ts +4 -0
  105. package/packages/bundled-features/src/notes-history/feature.ts +2 -12
  106. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
  107. package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
  108. package/packages/bundled-features/src/seo/changes.json +5 -0
  109. package/packages/bundled-features/src/seo/feature.ts +23 -7
  110. package/packages/bundled-features/src/sessions/feature.ts +21 -8
  111. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
  112. package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
  113. package/packages/bundled-features/src/sessions/screens.ts +10 -8
  114. package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
  115. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
  116. package/packages/bundled-features/src/shared/index.ts +4 -2
  117. package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
  118. package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
  119. package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
  120. package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
  121. package/packages/bundled-features/src/tags/feature.ts +2 -12
  122. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  123. package/packages/bundled-features/src/template-resolver/changes.json +5 -0
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
  125. package/packages/bundled-features/src/tenant/changes.json +19 -0
  126. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
  127. package/packages/bundled-features/src/tenant/feature.ts +8 -1
  128. package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
  129. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
  130. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
  131. package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
  132. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
  133. package/packages/bundled-features/src/tenant/index.ts +1 -0
  134. package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
  135. package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
  136. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
  137. package/packages/bundled-features/src/tenant/screens.ts +1 -1
  138. package/packages/bundled-features/src/tenant-handover/changes.json +12 -0
  139. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
  140. package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
  141. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
  145. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +14 -1
  146. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +105 -29
  147. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +64 -10
  148. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
  149. package/packages/bundled-features/src/tier-engine/changes.json +5 -0
  150. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
  151. package/packages/bundled-features/src/user/changes.json +15 -0
  152. package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
  153. package/packages/bundled-features/src/user/handlers/list.query.ts +8 -2
  154. package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
  155. package/packages/bundled-features/src/user/seeding.ts +34 -0
  156. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
  157. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  158. package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
  159. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
  160. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
  161. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  162. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
  163. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
  164. package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
  165. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +80 -14
  166. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +11 -1
  167. package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
  168. package/samples/apps/showcase/src/features/items/feature.ts +2 -1
  169. package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
  170. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
  171. package/samples/apps/styleguide/src/features/widgets/feature.ts +131 -15
  172. package/samples/apps/styleguide/src/features/widgets/i18n.ts +40 -0
  173. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
  174. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
  175. package/samples/apps/ui-walkthrough/package.json +3 -0
  176. package/samples/apps/use-all-bundled/README.md +1 -2
  177. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  178. package/samples/apps/user-data-rights-demo/README.md +1 -3
  179. package/samples/recipes/legal-pages/README.md +3 -4
  180. package/samples/recipes/managed-pages/README.md +2 -2
  181. package/samples/recipes/public-share-token/README.md +4 -5
  182. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  183. package/samples/recipes/security-baseline/README.md +6 -0
  184. package/samples/recipes/seed-migration/README.md +0 -2
  185. package/samples/recipes/tenant-settings/README.md +1 -1
  186. package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
@@ -3,8 +3,10 @@ import {
3
3
  collectPiiSubjectFields,
4
4
  configuredPiiSubjectKms,
5
5
  encryptPiiFieldValues,
6
+ KeyErasedError,
6
7
  } from "@cosmicdrift/kumiko-framework/crypto";
7
8
  import type { EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { subjectErasedConflict } from "@cosmicdrift/kumiko-framework/errors";
8
10
 
9
11
  // Unmanaged direct-write stores (r.unmanagedTable) skip the executor, so its
10
12
  // PII encryption never runs — every insert of subject-annotated fields must
@@ -18,12 +20,17 @@ export async function encryptForDirectWrite(
18
20
  ): Promise<Record<string, unknown>> {
19
21
  const kms = configuredPiiSubjectKms();
20
22
  if (!kms) return row;
21
- return encryptPiiFieldValues(
22
- row,
23
- entity,
24
- collectPiiSubjectFields(entity),
25
- kms,
26
- { requestId: requestContext.get()?.requestId ?? fallbackRequestId },
27
- { entityName },
28
- );
23
+ try {
24
+ return await encryptPiiFieldValues(
25
+ row,
26
+ entity,
27
+ collectPiiSubjectFields(entity),
28
+ kms,
29
+ { requestId: requestContext.get()?.requestId ?? fallbackRequestId },
30
+ { entityName },
31
+ );
32
+ } catch (e) {
33
+ if (e instanceof KeyErasedError) throw subjectErasedConflict(e);
34
+ throw e;
35
+ }
29
36
  }
@@ -1,4 +1,5 @@
1
1
  export { collectErasureFailure, throwIfErasureFailed } from "./assert-erased.js";
2
+ export { assertNoWhereRuleInOwnershipWrite } from "./assert-no-where-rule-in-ownership-write.js";
2
3
  export {
3
4
  type ChunkedMigrationOptions,
4
5
  type ChunkedMigrationResult,
@@ -14,17 +15,18 @@ export { decryptStoredPii } from "./decrypt-stored-pii.js";
14
15
  export { encryptForDirectWrite } from "./encrypt-for-direct-write.js";
15
16
  export { entitiesOf } from "./entities-of.js";
16
17
  export { isWithinGracePeriod } from "./grace-period.js";
17
- export { hasWhereRule } from "./has-where-rule.js";
18
18
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash.js";
19
19
  export { isTenantDb } from "./is-tenant-db.js";
20
20
  export { createLockoutCounter, type LockoutCounterState } from "./lockout-counter.js";
21
21
  export { mapWithConcurrency } from "./map-with-concurrency.js";
22
22
  export {
23
23
  denyUnlessJoinRowParentVisible,
24
+ hostRowIsGone,
24
25
  joinRowParentIsVisible,
25
26
  parentRowIsVisible,
26
27
  } from "./parent-visibility.js";
27
28
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing.js";
29
+ export { rateLimitedTextResponse } from "./rate-limited-text-response.js";
28
30
  export {
29
31
  redactBotTokens,
30
32
  redactEmailAddresses,
@@ -37,7 +39,7 @@ export {
37
39
  signRowBoundGrant,
38
40
  } from "./row-bound-grant.js";
39
41
  export { runInSubTransaction } from "./run-in-sub-transaction.js";
40
- export { sessionLocaleField } from "./session-locale-field.js";
42
+ export { registrationLocaleFromRequest, sessionLocaleField } from "./session-locale-field.js";
41
43
  export { sessionTimezoneField } from "./session-timezone-field.js";
42
44
  export {
43
45
  peekTokenSubject,
@@ -1,4 +1,8 @@
1
- import type { EventStoreExecutor, TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
+ import {
2
+ buildEntityTable,
3
+ type EventStoreExecutor,
4
+ type TenantDb,
5
+ } from "@cosmicdrift/kumiko-framework/db";
2
6
  import {
3
7
  createEntityExecutor,
4
8
  type EntityDefinition,
@@ -61,6 +65,32 @@ export async function parentRowIsVisible(
61
65
  return (await executor.detail({ id: entityId }, user, db)) !== null;
62
66
  }
63
67
 
68
+ const hostTablesByEntity = new WeakMap<EntityDefinition, ReturnType<typeof buildEntityTable>>();
69
+
70
+ // True when the host row can never be read again by anyone: its entityType no
71
+ // longer names a registered entity, or the row is hard-deleted / soft-deleted.
72
+ // Deliberately skips the caller's `access.read` ownership (unlike
73
+ // parentRowIsVisible): a row hidden from this caller but alive for another team
74
+ // is NOT gone, and its join row must stay.
75
+ export async function hostRowIsGone(
76
+ registry: Registry,
77
+ entityType: string,
78
+ entityId: string,
79
+ db: TenantDb,
80
+ ): Promise<boolean> {
81
+ const entity = registry.getEntity(entityType);
82
+ if (!entity) return true;
83
+ if (!idShapeMatchesEntity(entity, entityId)) return true;
84
+
85
+ let table = hostTablesByEntity.get(entity);
86
+ if (!table) {
87
+ table = buildEntityTable(entityType, entity, { relations: registry.getRelations(entityType) });
88
+ hostTablesByEntity.set(entity, table);
89
+ }
90
+ const row = await db.fetchOne(table, { id: entityId });
91
+ return row === undefined || row["isDeleted"] === true;
92
+ }
93
+
64
94
  // Write-path half of the parentRef gate (fw#2766). Resolves the join entity's
65
95
  // own `parentRef` declaration from the registry — the same object the read
66
96
  // path's SQL gate reads — so the field names and the allowedTypes allowlist
@@ -0,0 +1,11 @@
1
+ import type { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
2
+
3
+ export function rateLimitedTextResponse(error: RateLimitError): Response {
4
+ return new Response("rate limited", {
5
+ status: 429,
6
+ headers: {
7
+ "content-type": "text/plain; charset=utf-8",
8
+ "retry-after": String(error.details.retryAfterSeconds),
9
+ },
10
+ });
11
+ }
@@ -54,6 +54,19 @@ function anchoredPurpose(purpose: string, anchor: string): string {
54
54
  return `${purpose}:${anchor}`;
55
55
  }
56
56
 
57
+ // The HMAC input is `${purpose}:${anchor}:${subject}.${expiresAtMs}` with no
58
+ // length prefixes. Colon-free anchor and subject make the split unambiguous
59
+ // from the right, so two different (purpose, anchor, subject) triples can
60
+ // never share a signature. The purpose may keep colons (tenant-handover
61
+ // namespaces its purpose by entity type).
62
+ function hasDelimiter(value: string): boolean {
63
+ return value.includes(":");
64
+ }
65
+
66
+ function isUnambiguousPart(value: string | null | undefined): value is string {
67
+ return !!value && !hasDelimiter(value);
68
+ }
69
+
57
70
  export function signRowBoundGrant(args: {
58
71
  readonly subject: string;
59
72
  readonly purpose: string;
@@ -62,6 +75,9 @@ export function signRowBoundGrant(args: {
62
75
  readonly secret: string;
63
76
  readonly now?: Temporal.Instant;
64
77
  }): { readonly token: string; readonly expiresAt: Temporal.Instant } {
78
+ if (hasDelimiter(args.anchor) || hasDelimiter(args.subject)) {
79
+ throw new Error("row-bound grant: subject and anchor must not contain ':'");
80
+ }
65
81
  return signToken(
66
82
  args.subject,
67
83
  anchoredPurpose(args.purpose, args.anchor),
@@ -90,7 +106,7 @@ export async function redeemRowBoundGrant(args: {
90
106
  if (!args.secret) return FAILED;
91
107
 
92
108
  const subject = peekTokenSubject(args.token);
93
- if (!subject) return FAILED;
109
+ if (!isUnambiguousPart(subject)) return FAILED;
94
110
 
95
111
  // The subject is unverified attacker input at this point, so a lookup that
96
112
  // throws on it (e.g. a non-uuid value against a uuid column) must not
@@ -107,7 +123,7 @@ export async function redeemRowBoundGrant(args: {
107
123
  });
108
124
  return FAILED;
109
125
  }
110
- if (!anchor) return FAILED;
126
+ if (!isUnambiguousPart(anchor)) return FAILED;
111
127
 
112
128
  const verified = verifyToken(
113
129
  args.token,
@@ -1,3 +1,4 @@
1
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
1
2
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
3
4
 
@@ -7,3 +8,9 @@ export function sessionLocaleField(
7
8
  if (locale === null || locale === undefined || !isValidLocaleTag(locale)) return {};
8
9
  return { locale: canonicalizeLocaleTag(locale) };
9
10
  }
11
+
12
+ // Only an explicit request signal (X-Locale) counts: the boot default is not a
13
+ // choice the new user made.
14
+ export function registrationLocaleFromRequest(): ReturnType<typeof sessionLocaleField> {
15
+ return sessionLocaleField(requestContext.get()?.locale);
16
+ }
@@ -27,7 +27,7 @@ import {
27
27
  type EntityDefinition,
28
28
  type FeatureRegistrar,
29
29
  } from "@cosmicdrift/kumiko-framework/engine";
30
- import { hasWhereRule } from "../shared/index.js";
30
+ import { assertNoWhereRuleInOwnershipWrite } from "../shared/index.js";
31
31
  import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants.js";
32
32
  import { createTagAssignmentEntity, tagEntity } from "./entity.js";
33
33
  import { createTagAssignmentExecutor } from "./executor.js";
@@ -201,17 +201,7 @@ export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFea
201
201
  "admissible as a host instead.",
202
202
  );
203
203
  }
204
- if (hasWhereRule(opts.ownership?.write)) {
205
- throw new Error(
206
- "createTagsFeature({ ownership }): ownership.write must not contain a " +
207
- '`{ kind: "where" }` rule — where-rules are evaluated only at the SQL ' +
208
- "layer (the read path, via buildOwnershipClause). Write paths that " +
209
- "consult access.write (userCanCreateFieldRow/userCanWriteFieldRow) can't " +
210
- "evaluate them, so such a rule can only ever deny — boot validation " +
211
- "rejects it too (fw#2626). Use a `from()` rule for ownership.write, or " +
212
- "leave it unset.",
213
- );
214
- }
204
+ assertNoWhereRuleInOwnershipWrite("createTagsFeature", opts.ownership?.write);
215
205
  const access = resolveAccess(opts);
216
206
  return defineFeature(TAGS_FEATURE_NAME, (r) =>
217
207
  registerTags(r, access, opts.toggleable, opts.ownership, opts.parents),
@@ -3,7 +3,7 @@
3
3
  // ctx.templateResolver at runtime.
4
4
 
5
5
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
6
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
6
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
7
7
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
9
9
  import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
@@ -76,7 +76,7 @@ export type TemplateResolverApi = {
76
76
  readonly resolveTemplate: (args: ResolveRequest) => Promise<TemplateResource>;
77
77
  };
78
78
 
79
- export function createTemplateResolverApi(db: DbConnection): TemplateResolverApi {
79
+ export function createTemplateResolverApi(db: DbRunner): TemplateResolverApi {
80
80
  return {
81
81
  findExact: async ({ tenantId, slug, kind, locale, scope }) => {
82
82
  const effectiveTenantId = scope === "system" ? SYSTEM_TENANT_ID : tenantId;
@@ -106,7 +106,7 @@ export function createTemplateResolverApi(db: DbConnection): TemplateResolverApi
106
106
  }
107
107
 
108
108
  async function fetchTemplate(
109
- db: DbConnection,
109
+ db: DbRunner,
110
110
  tenantId: string,
111
111
  slug: string,
112
112
  kind: TemplateKind,
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "template-resolver list filters by every template kind including text-block and ai-prompt"
6
+ },
2
7
  {
3
8
  "version": "0.335.0",
4
9
  "type": "breaking",
@@ -1,7 +1,7 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
- import { RENDER_KINDS, TEMPLATE_STATUSES } from "../constants.js";
4
+ import { TEMPLATE_KINDS, TEMPLATE_STATUSES } from "../constants.js";
5
5
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
6
6
 
7
7
  // List für Admin-UI: filterbar nach kind / locale / status. Liefert
@@ -10,7 +10,7 @@ import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
10
10
  export const listQuery = defineQueryHandler({
11
11
  name: "list",
12
12
  schema: z.object({
13
- kind: z.enum(RENDER_KINDS).optional(),
13
+ kind: z.enum(TEMPLATE_KINDS).optional(),
14
14
  locale: z.string().min(2).max(8).optional(),
15
15
  status: z.enum(TEMPLATE_STATUSES).optional(),
16
16
  includeSystem: z.boolean().default(true),
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "createTenantFeature fails the boot when a declared assignable app role is missing from its assignableAppRoles option",
6
+ "detail": "Final review batch E1: bundled features\n`createTenantFeature` now fails the boot when an `assignableRole` declaration is missing from its `assignableAppRoles` option, because the members and invite screens would otherwise omit the role and a save of the member-roles form would silently strip it. `composeFeatures(includeBundled)` already passes the option. Agent-callable handlers that change access or books now resolve risk `high`: `tenant:write:disable`, `tenant:write:updateMemberRoles`, `user:write:user:update`, `tier-engine:write:set-tenant-tier`, `ledger:write:create-transaction` and `ledger:write:reverse-transaction`. `in_app_messages` gets a `(tenant_id, user_id, created_at)` index. `GET /files/:id/download-url` no longer puts a personal `fileName` into the signed URL; the hint carries a neutral name derived from the MIME type, while `GET /files/:id` still sends the real name in its response header. The forget-subject denial audit event goes through the shared `appendDomainEventCore`, which the pipeline barrel now exports.",
7
+ "migration": "Pass assignableAppRoles: collectAssignableAppRoles(appFeatures) to createTenantFeature when you mount it by hand; composeFeatures(includeBundled) already does."
8
+ },
9
+ {
10
+ "version": "0.354.0",
11
+ "type": "fix",
12
+ "title": "tenant:write:disable and tenant:write:updateMemberRoles resolve agent risk high"
13
+ },
14
+ {
15
+ "version": "0.353.0",
16
+ "type": "breaking",
17
+ "title": "Tenant names are encrypted under the tenant record and crypto-shredded when the tenant is destroyed",
18
+ "detail": "Tenant names are PII encrypted under the tenant record and crypto-shredded on tenant destroy\n`tenant.name` is now `personal: { of: \"id\" }`, so it is stored as `kumiko-pii:` ciphertext in events and in `read_tenants`. Signup no longer writes the email into the tenant name; it uses the generated tenant key. The tenant destroy job erases the tenant record key and purges the tenant's search documents. The tenant queries (`me`, memberships, tenant-directory, user list, cap-overview, tenants-missing-profile) decrypt the name, and the tenant list sorts by `key` because encrypted names are not sortable.",
19
+ "migration": "After the bump run the PII backfill (backfillEventPiiEncryption), then rebuild the read_tenants projection; the backfill only encrypts event payloads. Raw reads of read_tenants.name in apps now return ciphertext and must go through decryptStoredPii or the tenant query handlers. Tenants that were already destroyed (read_tenants.destroyed_at set) get the erased sentinel instead of a fresh record key."
20
+ },
2
21
  {
3
22
  "version": "0.341.0",
4
23
  "type": "improvement",
@@ -0,0 +1,17 @@
1
+ import { decryptStoredPii, mapWithConcurrency } from "../shared/index.js";
2
+
3
+ // Bounded like tenant:query:members: each decrypt hits the KMS adapter's small pool.
4
+ const KMS_POOL_CONCURRENCY = 4;
5
+
6
+ // Raw reads of read_tenants return the stored column, which is ciphertext
7
+ // under an active KMS; executor reads (crud.list/detail) decrypt on their own.
8
+ export async function decryptTenantNames<T extends { readonly name?: unknown }>(
9
+ rows: readonly T[],
10
+ fallbackRequestId: string,
11
+ ): Promise<T[]> {
12
+ return mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, async (row) =>
13
+ typeof row.name === "string"
14
+ ? { ...row, name: await decryptStoredPii(row.name, "name", fallbackRequestId) }
15
+ : row,
16
+ );
17
+ }
@@ -81,8 +81,15 @@ export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefi
81
81
  r.systemScope();
82
82
  r.requires("config");
83
83
  r.extendsRegistrar(EXT_ASSIGNABLE_ROLE, {});
84
+ // The elevation guards read the registry, the screens only these options: a role the
85
+ // server accepts but the screen omits is silently stripped on the next member-roles save.
84
86
  r.bootCheck(({ features }) => {
85
- collectAssignableAppRoles(features);
87
+ for (const [role, assignableFrom] of collectAssignableAppRoles(features)) {
88
+ if (options?.assignableAppRoles?.get(role) === assignableFrom) continue;
89
+ throw new Error(
90
+ `[tenant] assignable app role "${role}" (assignableFrom "${assignableFrom}") is declared but missing from the tenant screens' options; pass assignableAppRoles: collectAssignableAppRoles(appFeatures) to createTenantFeature.`,
91
+ );
92
+ }
86
93
  });
87
94
  r.entity("tenant", tenantEntity);
88
95
  r.entity("tenant-membership", tenantMembershipEntity);
@@ -2,6 +2,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
+ import { decryptTenantNames } from "../decrypt-tenant-names.js";
5
6
  import { tenantTable } from "../schema/tenant.js";
6
7
 
7
8
  // Direct query — query handlers don't have a tenant-crud handle. A direct
@@ -33,6 +34,8 @@ export const meQuery = defineQueryHandler({
33
34
  }
34
35
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
35
36
  const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
36
- return row ?? null;
37
+ if (row === undefined) return null;
38
+ const [decrypted] = await decryptTenantNames([row], "tenant:me");
39
+ return decrypted ?? null;
37
40
  },
38
41
  });
@@ -3,6 +3,7 @@ import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/e
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
5
5
  import * as z from "zod";
6
+ import { decryptTenantNames } from "../decrypt-tenant-names.js";
6
7
  import { tenantMembershipsTable } from "../membership-table.js";
7
8
  import { tenantTable } from "../schema/tenant.js";
8
9
 
@@ -32,9 +33,12 @@ export const membershipsQuery = defineQueryHandler({
32
33
  // prefix would be indistinguishable). A single IN-batch over all
33
34
  // tenantIds instead of fetchOne per membership (#324).
34
35
  type TenantRow = { id: unknown; name?: unknown; key?: unknown; isEnabled?: unknown };
35
- const tenants = await selectMany<TenantRow>(db, tenantTable, {
36
- id: rows.map((row) => row["tenantId"]),
37
- });
36
+ const tenants = await decryptTenantNames(
37
+ await selectMany<TenantRow>(db, tenantTable, {
38
+ id: rows.map((row) => row["tenantId"]),
39
+ }),
40
+ "tenant:memberships",
41
+ );
38
42
  const tenantById = new Map<unknown, TenantRow>(tenants.map((t) => [t.id, t]));
39
43
 
40
44
  return rows
@@ -6,6 +6,7 @@ import {
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import * as z from "zod";
9
+ import { decryptTenantNames } from "../decrypt-tenant-names.js";
9
10
  import { tenantTable } from "../schema/tenant.js";
10
11
  import { isSystemAdmin } from "./is-system-admin.js";
11
12
 
@@ -48,7 +49,7 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
48
49
  tenants = await selectMany(db, tenantTable, undefined, {
49
50
  limit: scanLimit,
50
51
  orderBy: [
51
- { col: "name", direction: "asc" },
52
+ { col: "key", direction: "asc" },
52
53
  { col: "id", direction: "asc" },
53
54
  ],
54
55
  });
@@ -57,10 +58,15 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
57
58
  const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
58
59
  tenants = row ? [row] : [];
59
60
  }
60
- const labeled = tenants.map((tenant) => {
61
- const id = String(tenant.id);
62
- return { id, label: typeof tenant.name === "string" ? tenant.name : id };
63
- });
61
+ // name is ciphertext at rest, so the SQL page is keyed by the plaintext
62
+ // `key`; label order and search happen in memory after decrypting.
63
+ const decrypted = await decryptTenantNames(tenants, "tenant:tenant-directory");
64
+ const labeled = decrypted
65
+ .map((tenant) => {
66
+ const id = String(tenant.id);
67
+ return { id, label: typeof tenant.name === "string" ? tenant.name : id };
68
+ })
69
+ .sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
64
70
  const needle = search?.toLowerCase();
65
71
  const rows =
66
72
  needle === undefined
@@ -14,6 +14,8 @@ function createToggleTenantHandler(enable: boolean) {
14
14
  name: verbName,
15
15
  schema: z.object({ id: z.uuid() }),
16
16
  access: { roles: ["SystemAdmin"] },
17
+ // Disabling locks every member out; enabling is its undo.
18
+ ...(enable ? {} : { agent: { risk: "high" as const } }),
17
19
  description: enable
18
20
  ? "Re-enables a disabled tenant so its members can sign in and switch to it again; this is the undo for tenant:write:disable."
19
21
  : "Marks a tenant disabled so it stops counting as a membership — its members can no longer sign in to it or switch to it — while leaving all of its data in place.",
@@ -72,6 +72,7 @@ export const updateMemberRolesWrite = defineWriteHandler({
72
72
  access: { roles: ["system", ...access.admin] },
73
73
  description:
74
74
  "Sets the roles a user holds in a tenant, rejecting reserved role names and any grant that would raise the target above the caller; roles the caller could not grant (platform roles such as DataProtectionOfficer or TenantOwner, undeclared or higher-tier app roles) are kept, only the system user replaces the full list; admins act on their own tenant, SystemAdmins on any.",
75
+ agent: { risk: "high" },
75
76
  handler: async (event, ctx) => {
76
77
  if (!ctx.systemDb) {
77
78
  throw new InternalError({
@@ -7,6 +7,7 @@ export {
7
7
  TenantHandlers,
8
8
  TenantQueries,
9
9
  } from "./constants.js";
10
+ export { decryptTenantNames } from "./decrypt-tenant-names.js";
10
11
  export { collectAssignableAppRoles, createTenantFeature } from "./feature.js";
11
12
  export type { InvitationStatus } from "./invitation-table.js";
12
13
  export {
@@ -26,6 +26,7 @@ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
26
26
  import {
27
27
  createEntity,
28
28
  createMultiSelectField,
29
+ createNumberField,
29
30
  createSelectField,
30
31
  createTextField,
31
32
  createTimestampField,
@@ -94,6 +95,11 @@ export const tenantInvitationEntity = createEntity({
94
95
  personal: { of: "invitedBy" },
95
96
  find: "exact",
96
97
  }),
98
+ // Version of the invitee's membership row when the invitation was issued
99
+ // (0 = no membership yet). Accept compares it against the current version
100
+ // to detect a membership decision made after the invite. NULL only on rows
101
+ // issued before this field existed.
102
+ membershipVersion: createNumberField({ integer: true, min: 0 }),
97
103
  // UI-Anzeige — Wahrheit liegt in Redis-TTL.
98
104
  expiresAt: createTimestampField({ required: true }),
99
105
  },
@@ -3,6 +3,7 @@ import {
3
3
  createEventStoreExecutor,
4
4
  createTenantDb,
5
5
  type DbRunner,
6
+ type TenantDb,
6
7
  } from "@cosmicdrift/kumiko-framework/db";
7
8
  import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
9
  import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
@@ -23,7 +24,13 @@ export type InvitedMembershipOptions = {
23
24
  readonly userId: string;
24
25
  readonly tenantId: TenantId;
25
26
  readonly role: string;
26
- readonly invitationIssuedAt: Temporal.Instant;
27
+ readonly invitation: InvitationIssuance;
28
+ };
29
+
30
+ export type InvitationIssuance = {
31
+ readonly membershipVersion: number | null;
32
+ readonly insertedAt: Temporal.Instant;
33
+ readonly modifiedAt: Temporal.Instant | null;
27
34
  };
28
35
 
29
36
  type MembershipRow = {
@@ -34,6 +41,21 @@ type MembershipRow = {
34
41
  readonly modifiedAt: Temporal.Instant | null;
35
42
  };
36
43
 
44
+ const MEMBERSHIP_CREATED_VERSION = 1;
45
+
46
+ // 0 = the user has no membership in the tenant yet.
47
+ export async function membershipVersionOf(
48
+ db: Pick<TenantDb, "fetchOne">,
49
+ tenantId: string,
50
+ userId: string,
51
+ ): Promise<number> {
52
+ const membership = await db.fetchOne<{ readonly version: number }>(tenantMembershipsTable, {
53
+ tenantId,
54
+ userId,
55
+ });
56
+ return membership?.version ?? 0;
57
+ }
58
+
37
59
  // Add-only, so unlike updateMemberRoles no session revoke or last-TenantAdmin check.
38
60
  // Read unfiltered: tenant:query:memberships hides disabled tenants.
39
61
  export async function grantInvitedMembershipRole(db: DbRunner, options: InvitedMembershipOptions) {
@@ -54,7 +76,7 @@ export async function grantInvitedMembershipRole(db: DbRunner, options: InvitedM
54
76
  }
55
77
 
56
78
  // A membership decision made after the invitation was issued wins over it.
57
- if (isMembershipChangedAfter(existing, options.invitationIssuedAt)) {
79
+ if (isSupersededByMembership(existing, options.invitation)) {
58
80
  return writeFailure(invitationSupersededError());
59
81
  }
60
82
 
@@ -71,17 +93,28 @@ export async function grantInvitedMembershipRole(db: DbRunner, options: InvitedM
71
93
  return grantedMembership(true, roles);
72
94
  }
73
95
 
74
- // Resend reuses the invitation row, so its last modification is the latest issuance.
75
- export function invitationIssuedAt(invitation: {
76
- readonly insertedAt: Temporal.Instant;
77
- readonly modifiedAt: Temporal.Instant | null;
78
- }): Temporal.Instant {
79
- return invitation.modifiedAt ?? invitation.insertedAt;
80
- }
81
-
82
- function isMembershipChangedAfter(membership: MembershipRow, instant: Temporal.Instant): boolean {
96
+ // The pinned version is the membership's version when the invitation was issued
97
+ // (0 = none yet). A different version now means the membership was decided after
98
+ // the invite. Pin 0: a membership that appeared afterwards is an additive grant
99
+ // only while it is untouched since its creation (version 1). A role change bumps
100
+ // the version above 1 and counts as a decision. A removal in between hard-deletes
101
+ // the row and cancels the invitation (remove-member), so it never reaches this check.
102
+ // Rows issued before the pin existed (null) fall back to wall-clock timestamps,
103
+ // which can misjudge changes within the same millisecond but is all they carry.
104
+ function isSupersededByMembership(
105
+ membership: MembershipRow,
106
+ invitation: InvitationIssuance,
107
+ ): boolean {
108
+ if (invitation.membershipVersion !== null) {
109
+ if (invitation.membershipVersion === 0) {
110
+ return membership.version > MEMBERSHIP_CREATED_VERSION;
111
+ }
112
+ return membership.version !== invitation.membershipVersion;
113
+ }
114
+ // Resend reuses the invitation row, so its last modification is the latest issuance.
115
+ const issuedAt = invitation.modifiedAt ?? invitation.insertedAt;
83
116
  const lastChangedAt = membership.modifiedAt ?? membership.insertedAt;
84
- return Temporal.Instant.compare(lastChangedAt, instant) > 0;
117
+ return Temporal.Instant.compare(lastChangedAt, issuedAt) > 0;
85
118
  }
86
119
 
87
120
  function invitationSupersededError(): ConflictError {
@@ -28,19 +28,17 @@ export const tenantEntity = createEntity({
28
28
  key: createTextField({
29
29
  required: true,
30
30
  maxLength: 50,
31
+ sortable: true,
31
32
  personal: false,
32
33
  reason: "technical_reference",
33
34
  }),
34
- // Workspace/company display name, not an individual's identity —
35
- // same class as the `company.legalName` example in the boot-validator's
36
- // own PII-heuristic doc-comment.
35
+ // Subject is the tenant record itself: tenant events live in the writer's (system) tenant stream, so `personal: "tenant"` would not resolve to this tenant.
37
36
  name: createTextField({
38
37
  required: true,
39
38
  maxLength: 200,
40
39
  searchable: true,
41
- sortable: true,
42
- personal: false,
43
- reason: "is_business_data",
40
+ personal: { of: "id" },
41
+ find: "none",
44
42
  }),
45
43
  isEnabled: createBooleanField({ default: true, filterable: true }),
46
44
  // Tenant-destroy lifecycle (tenant-lifecycle feature). Defaults keep
@@ -43,7 +43,7 @@ export const tenantListScreen: EntityListScreenDefinition = {
43
43
  entityId: "id",
44
44
  },
45
45
  ],
46
- defaultSort: { field: "name", dir: "asc" },
46
+ defaultSort: { field: "key", dir: "asc" },
47
47
  searchable: true,
48
48
  access: { roles: ["SystemAdmin"] },
49
49
  // Inert until an app navs it (see file header) — no nav area to resolve
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "improvement",
5
+ "title": "Reference fields can opt out of the handover with handover: \"stay\"; boot depth limit and mover share one graph definition",
6
+ "detail": "ReferenceFieldDef gains handover: \"stay\". An entity that should NOT travel with its host but points at it through a plain reference no longer blocks the claim with entity_not_transferable when that reference field carries handover: \"stay\": the edge is not walked, its rows stay in the source tenant, and the reference crosses the tenant boundary after the handover. Without the opt-out the behaviour is unchanged (entity_not_transferable at claim time). Migration: an entity that should NOT travel with its host: mark the reference field handover: \"stay\" (removing transferable alone does not help once rows exist, the claim still fails). The framework now exports one adjacency definition (resolveEntityTransferAdjacency, reference and parentRef edges, non-transferable intermediate nodes included, handover: \"stay\" respected) that the boot validator's depth limit and the tenant-handover mover both use. Consequence: the boot-time MAX_TRANSFER_DEPTH check now also counts parentRef edges and chains running through non-transferable entities, as the mover always did; a graph that was deeper than 5 hops only through such nodes now fails the boot instead of the claim."
7
+ },
8
+ {
9
+ "version": "0.353.0",
10
+ "type": "fix",
11
+ "title": "Tenant handover claims fail with transfer_graph_too_deep only when rows exist beyond the last allowed hop",
12
+ "detail": "Tenant handover only fails with `transfer_graph_too_deep` when rows really hang below the fifth hop, and failed workflow runs report the suspended step\nA claim used to fail whenever the last round's entity type had outgoing edges, even with no rows behind them, so a chain of exactly five hops with an unrestricted `parentRef` entity registered was rejected. `workflow.run-failed` carries the real `stepIndex` for the unsupported-suspension case instead of always 0."
13
+ },
2
14
  {
3
15
  "version": "0.321.0",
4
16
  "type": "improvement",