@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -0,0 +1,598 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/embedded-entity-form. Do not edit. -->
2
+
3
+ # embedded-entity-form
4
+
5
+ RenderEdit hosted in a Drawer, no screen or route — externally-sourced prefill, controlled mode, and a custom write handler instead of the built-in CRUD create.
6
+
7
+ ## README
8
+
9
+ # Embedded entity form
10
+
11
+ Host `RenderEdit` inside a `Drawer` instead of a dedicated screen — no nav
12
+ entry, no route. The form is prefilled from an externally-sourced record
13
+ (a suggestion, not this entity's own detail query), runs in controlled mode
14
+ so the host can read live dirty/valid state and re-validate after patching
15
+ values from outside, and saves through a custom write handler instead of
16
+ the built-in CRUD create.
17
+
18
+ ## What it shows
19
+
20
+ - **`RenderEdit` without a screen or route** — `screen` is a plain
21
+ `EntityEditScreenDefinition` literal, never passed to `r.screen`. Nothing
22
+ about `RenderEdit` touches Nav or the router.
23
+ - **Create-mode prefill from an external source** — `initial` comes from a
24
+ `suggestion` prop, `entityId={null}`, no detail-fetch for the entity being
25
+ created.
26
+ - **Controlled mode** — `onChange` reports `{ dirty, valid }` on every
27
+ keystroke; `onControlsReady` hands back `patch`/`validate` so the Drawer's
28
+ footer can restore the suggestion's values and re-validate without a
29
+ remount or a write.
30
+ - **`customSubmit` instead of the built-in CRUD create** — the client sends
31
+ only the fields the user actually changed (`snapshot.changes`, the same
32
+ delta the controlled mode reports); `prospect:accept` merges that onto the
33
+ suggestion it already has server-side.
34
+ - **One write, two entities, atomically** — `prospect:accept` creates the
35
+ prospect, stamps `source`/`acceptedBy`/`acceptedAt` (fields never on the
36
+ edit form, so the client can't set them), and flips the suggestion to
37
+ `accepted` — the same DB transaction, so a suggestion never ends up
38
+ accepted without a prospect or vice versa.
39
+
40
+ ## Feature composition
41
+
42
+ ```
43
+ suggestion — externally-sourced draft, seeded via the standard CRUD create
44
+ prospect — created only through prospect:accept, never through r.crud
45
+ ```
46
+
47
+ ## Flow
48
+
49
+ 1. A caller has a `suggestion` (elsewhere seeded, e.g. by an AI extraction
50
+ pipeline) and opens `AcceptSuggestionDrawer` next to it.
51
+ 2. The Drawer prefills `RenderEdit` from the suggestion's fields.
52
+ 3. The user edits a field or two; `onChange` updates the live status text.
53
+ 4. Submit calls `customSubmit`, which dispatches `prospect:accept` with
54
+ just `{ suggestionId, changes }`.
55
+ 5. The handler merges `changes` onto the suggestion, creates the prospect,
56
+ and marks the suggestion `accepted`. A second accept on the same
57
+ suggestion is rejected (`unprocessable`).
58
+
59
+ ## When to reach for it
60
+
61
+ You have a form that belongs next to something else — a list row, an inbox
62
+ item, a reference field — not on its own page, and saving it needs more
63
+ than a single-entity CRUD write (merging server-known data, writing a
64
+ second record, stamping fields the client must not control). Use the plain
65
+ `writeCommand` + built-in submit path instead when the form both lives on
66
+ its own screen and a single CRUD write is enough.
67
+
68
+ ## Source
69
+
70
+ The feature entry point is `src/feature.ts`; the Drawer component is
71
+ `src/web/accept-suggestion-drawer.tsx`. Integration tests under
72
+ `src/__tests__/` cover the merge, the atomic status flip, the double-accept
73
+ rejection, and per-role access.
74
+
75
+ ## Source files
76
+
77
+ ### src/__tests__/feature.integration.test.ts
78
+
79
+ ```ts
80
+ // Embedded Entity Form Sample — Integration Test
81
+ // Proves: prospect:accept merges client changes onto the suggestion it
82
+ // already knows, stamps provenance the client never sent, flips the
83
+ // suggestion to accepted, and rejects a second accept on the same
84
+ // suggestion.
85
+
86
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
87
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
88
+ import {
89
+ createTestUser,
90
+ setupTestStack,
91
+ type TestStack,
92
+ TestUsers,
93
+ unsafeCreateEntityTable,
94
+ } from "@cosmicdrift/kumiko-framework/stack";
95
+ import { prospectEntity, prospectsFeature, suggestionEntity } from "../feature";
96
+
97
+ let stack: TestStack;
98
+
99
+ const admin = TestUsers.admin;
100
+ const viewer = createTestUser({ id: 2, roles: ["Viewer"] });
101
+
102
+ async function seedSuggestion(overrides: Partial<Record<string, unknown>> = {}): Promise<string> {
103
+ const created = await stack.http.writeOk<SaveContext>(
104
+ "prospects:write:suggestion:create",
105
+ { name: "Max Mustermann", email: "max@test.de", company: "Acme", ...overrides },
106
+ admin,
107
+ );
108
+ return created.id as string;
109
+ }
110
+
111
+ beforeAll(async () => {
112
+ stack = await setupTestStack({ features: [prospectsFeature] });
113
+ await unsafeCreateEntityTable(stack.db, suggestionEntity);
114
+ await unsafeCreateEntityTable(stack.db, prospectEntity);
115
+ });
116
+
117
+ afterAll(async () => {
118
+ await stack.cleanup();
119
+ });
120
+
121
+ describe("prospect:accept", () => {
122
+ test("creates a prospect merged from the suggestion, no client-sent changes", async () => {
123
+ const suggestionId = await seedSuggestion();
124
+
125
+ const accepted = await stack.http.writeOk<SaveContext>(
126
+ "prospects:write:prospect:accept",
127
+ { suggestionId, changes: {} },
128
+ admin,
129
+ );
130
+
131
+ expect(accepted.data["name"]).toBe("Max Mustermann");
132
+ expect(accepted.data["email"]).toBe("max@test.de");
133
+ expect(accepted.data["company"]).toBe("Acme");
134
+ expect(accepted.data["source"]).toBe(`suggestion:${suggestionId}`);
135
+ expect(accepted.data["acceptedBy"]).toBe(`user:${admin.id}`);
136
+ expect(typeof accepted.data["acceptedAt"]).toBe("string");
137
+ });
138
+
139
+ test("client changes override the suggestion's field values", async () => {
140
+ const suggestionId = await seedSuggestion({ name: "Draft Name" });
141
+
142
+ const accepted = await stack.http.writeOk<SaveContext>(
143
+ "prospects:write:prospect:accept",
144
+ { suggestionId, changes: { name: "Corrected Name" } },
145
+ admin,
146
+ );
147
+
148
+ expect(accepted.data["name"]).toBe("Corrected Name");
149
+ });
150
+
151
+ test("client-sent provenance fields in changes are ignored, not adopted", async () => {
152
+ const suggestionId = await seedSuggestion();
153
+
154
+ const accepted = await stack.http.writeOk<SaveContext>(
155
+ "prospects:write:prospect:accept",
156
+ {
157
+ suggestionId,
158
+ changes: { name: "X", source: "evil", acceptedBy: "user:999" },
159
+ },
160
+ admin,
161
+ );
162
+
163
+ expect(accepted.data["name"]).toBe("X");
164
+ expect(accepted.data["source"]).toBe(`suggestion:${suggestionId}`);
165
+ expect(accepted.data["acceptedBy"]).toBe(`user:${admin.id}`);
166
+ });
167
+
168
+ test("flips the suggestion to accepted", async () => {
169
+ const suggestionId = await seedSuggestion();
170
+
171
+ await stack.http.writeOk<SaveContext>(
172
+ "prospects:write:prospect:accept",
173
+ { suggestionId, changes: {} },
174
+ admin,
175
+ );
176
+
177
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
178
+ "prospects:query:suggestion:detail",
179
+ { id: suggestionId },
180
+ admin,
181
+ );
182
+ expect(detail["status"]).toBe("accepted");
183
+ });
184
+
185
+ test("a second accept on the same suggestion is rejected", async () => {
186
+ const suggestionId = await seedSuggestion();
187
+
188
+ await stack.http.writeOk<SaveContext>(
189
+ "prospects:write:prospect:accept",
190
+ { suggestionId, changes: {} },
191
+ admin,
192
+ );
193
+
194
+ const error = await stack.http.writeErr(
195
+ "prospects:write:prospect:accept",
196
+ { suggestionId, changes: {} },
197
+ admin,
198
+ );
199
+ expect(error.code).toBe("unprocessable");
200
+ });
201
+
202
+ test("accepting an unknown suggestion is rejected", async () => {
203
+ const error = await stack.http.writeErr(
204
+ "prospects:write:prospect:accept",
205
+ { suggestionId: "00000000-0000-4000-8000-000000000999", changes: {} },
206
+ admin,
207
+ );
208
+ expect(error.code).toBe("not_found");
209
+ });
210
+
211
+ test("only Admin can accept", async () => {
212
+ const suggestionId = await seedSuggestion();
213
+
214
+ const error = await stack.http.writeErr(
215
+ "prospects:write:prospect:accept",
216
+ { suggestionId, changes: {} },
217
+ viewer,
218
+ );
219
+ expect(error.code).toBe("access_denied");
220
+ });
221
+
222
+ test("clearing the email via changes is accepted, not rejected as an invalid address", async () => {
223
+ const suggestionId = await seedSuggestion();
224
+
225
+ const accepted = await stack.http.writeOk<SaveContext>(
226
+ "prospects:write:prospect:accept",
227
+ { suggestionId, changes: { email: "" } },
228
+ admin,
229
+ );
230
+
231
+ expect(accepted.data["email"]).toBe("");
232
+ });
233
+ });
234
+
235
+ // kumiko-framework#1905: `suggestion.status` must only ever flip
236
+ // pending → accepted through prospect:accept, which stamps provenance the
237
+ // client can't set itself. A generic suggestion:update write would let an
238
+ // admin set `{ status: "accepted" }` directly, bypassing that path — the
239
+ // CRUD registration now omits the `update` verb, so the handler doesn't
240
+ // exist at all.
241
+ describe("suggestion:update", () => {
242
+ test("does not exist — an admin cannot flip status directly, bypassing prospect:accept", async () => {
243
+ const suggestionId = await seedSuggestion();
244
+
245
+ const error = await stack.http.writeErr(
246
+ "prospects:write:suggestion:update",
247
+ { id: suggestionId, version: 1, changes: { status: "accepted" } },
248
+ admin,
249
+ );
250
+
251
+ expect(error.code).toBe("not_found");
252
+ });
253
+ });
254
+
255
+ describe("prospect:detail", () => {
256
+ test("reads back the created prospect", async () => {
257
+ const suggestionId = await seedSuggestion();
258
+ const accepted = await stack.http.writeOk<SaveContext>(
259
+ "prospects:write:prospect:accept",
260
+ { suggestionId, changes: {} },
261
+ admin,
262
+ );
263
+
264
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
265
+ "prospects:query:prospect:detail",
266
+ { id: accepted.id },
267
+ viewer,
268
+ );
269
+ expect(detail["name"]).toBe("Max Mustermann");
270
+ });
271
+ });
272
+ ```
273
+
274
+ ### src/entities/prospect.ts
275
+
276
+ ```ts
277
+ import {
278
+ createEntity,
279
+ createTextField,
280
+ createTimestampField,
281
+ } from "@cosmicdrift/kumiko-framework/engine";
282
+
283
+ // Created only through `prospect:accept` — never through a built-in CRUD
284
+ // create, so `source`/`acceptedBy`/`acceptedAt` are never on the edit form
285
+ // and a client can't set them itself.
286
+ export const prospectEntity = createEntity({
287
+ table: "read_sample_prospects",
288
+ fields: {
289
+ name: createTextField({ required: true, personal: "self", find: "fuzzy" }),
290
+ email: createTextField({ format: "email", personal: "self", find: "none" }),
291
+ company: createTextField({ personal: false, reason: "is_business_data" }),
292
+ notes: createTextField({ personal: { of: "id" }, find: "none" }),
293
+ source: createTextField({ personal: false, reason: "is_business_data", required: true }),
294
+ acceptedBy: createTextField({ personal: "ref", required: true }),
295
+ acceptedAt: createTimestampField({ required: true }),
296
+ },
297
+ });
298
+ ```
299
+
300
+ ### src/entities/suggestion.ts
301
+
302
+ ```ts
303
+ import {
304
+ createEntity,
305
+ createSelectField,
306
+ createTextField,
307
+ } from "@cosmicdrift/kumiko-framework/engine";
308
+
309
+ // The externally-sourced draft (e.g. an AI extraction from an inbox item).
310
+ // Seeded via the standard CRUD create — `prospect:accept` is the only path
311
+ // that ever moves it from "pending" to "accepted".
312
+ export const suggestionEntity = createEntity({
313
+ table: "read_sample_suggestions",
314
+ fields: {
315
+ name: createTextField({ required: true, personal: "self", find: "fuzzy" }),
316
+ email: createTextField({ format: "email", personal: "self", find: "none" }),
317
+ company: createTextField({ personal: false, reason: "is_business_data" }),
318
+ notes: createTextField({ personal: { of: "id" }, find: "none" }),
319
+ status: createSelectField({
320
+ options: ["pending", "accepted", "rejected"] as const,
321
+ default: "pending",
322
+ }),
323
+ },
324
+ });
325
+ ```
326
+
327
+ ### src/feature.ts
328
+
329
+ ```ts
330
+ // Embedded Entity Form Sample
331
+ // Shows: RenderEdit hosted in a Drawer with no dedicated screen/route,
332
+ // prefilled from an externally-sourced record (a suggestion, not this
333
+ // entity's own detail query), controlled mode (onChange + validate-
334
+ // without-write via onControlsReady), and a custom write handler instead
335
+ // of the built-in CRUD create.
336
+ //
337
+ // Tables:
338
+ // suggestion — an externally-sourced draft (e.g. an AI extraction),
339
+ // seeded via the standard CRUD create
340
+ // prospect — created only through `prospect:accept`, which merges the
341
+ // caller's field-level changes onto the suggestion it
342
+ // already knows server-side, stamps provenance the client
343
+ // can't set itself, and flips the suggestion to accepted —
344
+ // one atomic write the built-in CRUD create can't do.
345
+
346
+ import { createEntityExecutor, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
347
+ import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
348
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
349
+ import { z } from "zod";
350
+ import { prospectEntity } from "./entities/prospect";
351
+ import { suggestionEntity } from "./entities/suggestion";
352
+
353
+ export { prospectEntity } from "./entities/prospect";
354
+ export { suggestionEntity } from "./entities/suggestion";
355
+
356
+ const adminWrite = { access: { roles: ["Admin"] } } as const;
357
+ const openRead = {
358
+ access: {
359
+ openToAll: {
360
+ reason:
361
+ "any signed-in user may read suggestions and prospects in this recipe; writes " +
362
+ "are still gated by adminWrite",
363
+ },
364
+ },
365
+ } as const;
366
+
367
+ const acceptChangesSchema = z.object({
368
+ name: z.string().min(1).optional(),
369
+ email: z.union([z.email(), z.literal("")]).optional(),
370
+ company: z.string().optional(),
371
+ notes: z.string().optional(),
372
+ });
373
+
374
+ export const prospectsFeature = defineFeature("prospects", (r) => {
375
+ // No `update` verb: `suggestion.status` must only ever flip pending →
376
+ // accepted through `prospect:accept` below, which merges the caller's
377
+ // changes onto the suggestion server-side and stamps provenance the
378
+ // client can't set itself. A generic `suggestion:update` write would let
379
+ // an admin set `{ status: "accepted" }` directly, skipping that path
380
+ // entirely and leaving an "accepted" suggestion with no matching
381
+ // prospect (see entities/suggestion.ts).
382
+ r.crud("suggestion", suggestionEntity, {
383
+ write: adminWrite,
384
+ read: openRead,
385
+ verbs: { update: false },
386
+ });
387
+ r.entity("prospect", prospectEntity);
388
+
389
+ const { executor: suggestionExecutor } = createEntityExecutor("suggestion", suggestionEntity);
390
+ const { executor: prospectExecutor } = createEntityExecutor("prospect", prospectEntity);
391
+
392
+ // Custom handler: the client only ever sends the fields the user actually
393
+ // edited (RenderEdit's onChange `changes`, same delta the controlled mode
394
+ // reports) — the handler owns merging that onto the suggestion it already
395
+ // has, so an untouched field never has to round-trip through the browser.
396
+ r.writeHandler(
397
+ "prospect:accept",
398
+ z.object({ suggestionId: z.uuid(), changes: acceptChangesSchema }),
399
+ async (event, ctx) => {
400
+ const suggestion = await suggestionExecutor.detail(
401
+ { id: event.payload.suggestionId },
402
+ event.user,
403
+ ctx.db,
404
+ );
405
+ if (!suggestion) return failNotFound("suggestion", event.payload.suggestionId);
406
+ if (suggestion["status"] !== "pending") {
407
+ return failUnprocessable("suggestion_already_processed", { status: suggestion["status"] });
408
+ }
409
+
410
+ const created = await prospectExecutor.create(
411
+ {
412
+ name: suggestion["name"],
413
+ email: suggestion["email"],
414
+ company: suggestion["company"],
415
+ notes: suggestion["notes"],
416
+ ...event.payload.changes,
417
+ source: `suggestion:${event.payload.suggestionId}`,
418
+ acceptedBy: `user:${event.user.id}`,
419
+ acceptedAt: getTemporal().Now.instant().toString(),
420
+ },
421
+ event.user,
422
+ ctx.db,
423
+ );
424
+ if (!created.isSuccess) return created;
425
+
426
+ // Same ctx.db as the create above — one transaction, so a suggestion
427
+ // never ends up "accepted" without a prospect or vice versa.
428
+ const flipped = await suggestionExecutor.update(
429
+ {
430
+ id: event.payload.suggestionId,
431
+ version: suggestion["version"] as number,
432
+ changes: { status: "accepted" },
433
+ },
434
+ event.user,
435
+ ctx.db,
436
+ );
437
+ if (!flipped.isSuccess) return flipped;
438
+ return created;
439
+ },
440
+ adminWrite,
441
+ );
442
+
443
+ r.queryHandler(
444
+ "prospect:detail",
445
+ z.object({ id: z.uuid() }),
446
+ async (query, ctx) => prospectExecutor.detail(query.payload, query.user, ctx.db),
447
+ openRead,
448
+ );
449
+ });
450
+ ```
451
+
452
+ ### src/screens/prospect-accept-screen.ts
453
+
454
+ ```ts
455
+ import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
456
+
457
+ // Not registered via `r.screen` — no nav entry, no route. RenderEdit only
458
+ // reads `entity`/`layout` off this object, so a plain literal is enough to
459
+ // host it inside a Drawer.
460
+ export const prospectAcceptScreen: EntityEditScreenDefinition = {
461
+ id: "prospect-accept-form",
462
+ type: "entityEdit",
463
+ entity: "prospect",
464
+ layout: {
465
+ sections: [{ fields: ["name", "email", "company", "notes"] }],
466
+ },
467
+ };
468
+ ```
469
+
470
+ ### src/web/accept-suggestion-drawer.tsx
471
+
472
+ ```tsx
473
+ import type { RenderEditControls } from "@cosmicdrift/kumiko-renderer";
474
+ import { RenderEdit, useDispatcher, usePrimitives } from "@cosmicdrift/kumiko-renderer";
475
+ import { Drawer } from "@cosmicdrift/kumiko-renderer-web";
476
+ import { type ReactNode, useMemo, useRef, useState } from "react";
477
+ import { z } from "zod";
478
+ import { prospectEntity } from "../entities/prospect";
479
+ import { prospectAcceptScreen } from "../screens/prospect-accept-screen";
480
+
481
+ const acceptSchema = z.object({
482
+ name: z.string().min(1),
483
+ email: z.union([z.email(), z.literal("")]).optional(),
484
+ company: z.string().optional(),
485
+ notes: z.string().optional(),
486
+ });
487
+
488
+ type FormFields = {
489
+ readonly name: string;
490
+ readonly email: string;
491
+ readonly company: string;
492
+ readonly notes: string;
493
+ };
494
+
495
+ export type Suggestion = {
496
+ readonly id: string;
497
+ readonly name: string;
498
+ readonly email?: string;
499
+ readonly company?: string;
500
+ readonly notes?: string;
501
+ };
502
+
503
+ // The create handler's success payload is `{ id, ... }` (see prospect:accept
504
+ // in feature.ts) but RenderEdit's onSubmit only types it as `unknown` —
505
+ // narrow defensively instead of casting through it.
506
+ function extractProspectId(data: unknown): string | undefined {
507
+ if (typeof data !== "object" || data === null) return undefined;
508
+ const id = (data as Record<string, unknown>)["id"];
509
+ return typeof id === "string" ? id : undefined;
510
+ }
511
+
512
+ function toFormFields(suggestion: Suggestion): FormFields {
513
+ return {
514
+ name: suggestion.name,
515
+ email: suggestion.email ?? "",
516
+ company: suggestion.company ?? "",
517
+ notes: suggestion.notes ?? "",
518
+ };
519
+ }
520
+
521
+ export type AcceptSuggestionDrawerProps = {
522
+ readonly open: boolean;
523
+ readonly onOpenChange: (open: boolean) => void;
524
+ readonly suggestion: Suggestion;
525
+ readonly onAccepted: (prospectId: string | undefined) => void;
526
+ };
527
+
528
+ // Hosts the prospect create-form inside a Drawer next to the suggestion it
529
+ // came from — no dedicated screen/route, prefilled from the suggestion
530
+ // (an external source, not this entity's own detail query), and saved
531
+ // through `prospect:accept` instead of the built-in CRUD create.
532
+ export function AcceptSuggestionDrawer({
533
+ open,
534
+ onOpenChange,
535
+ suggestion,
536
+ onAccepted,
537
+ }: AcceptSuggestionDrawerProps): ReactNode {
538
+ const { Button } = usePrimitives();
539
+ const dispatcher = useDispatcher();
540
+ const controls = useRef<RenderEditControls<FormFields> | null>(null);
541
+ const [live, setLive] = useState({ dirty: false, valid: true });
542
+ const initial = useMemo(() => toFormFields(suggestion), [suggestion]);
543
+
544
+ if (!open) return null;
545
+ return (
546
+ <Drawer
547
+ open={open}
548
+ onOpenChange={onOpenChange}
549
+ title="Accept suggestion"
550
+ description={
551
+ !live.valid
552
+ ? "Please fill in required fields"
553
+ : live.dirty
554
+ ? "Unsaved changes"
555
+ : "Unchanged from the suggestion"
556
+ }
557
+ footer={
558
+ <Button
559
+ variant="secondary"
560
+ onClick={() => {
561
+ controls.current?.patch(initial);
562
+ controls.current?.validate();
563
+ }}
564
+ >
565
+ Restore AI suggestion
566
+ </Button>
567
+ }
568
+ >
569
+ <RenderEdit
570
+ key={suggestion.id}
571
+ screen={prospectAcceptScreen}
572
+ entity={prospectEntity}
573
+ featureName="prospects"
574
+ initial={initial}
575
+ entityId={null}
576
+ schema={acceptSchema}
577
+ onChange={({ dirty, valid }) => setLive({ dirty, valid })}
578
+ onControlsReady={(next) => {
579
+ controls.current = next;
580
+ }}
581
+ customSubmit={async (snapshot) => {
582
+ const result = await dispatcher.write("prospects:write:prospect:accept", {
583
+ suggestionId: suggestion.id,
584
+ changes: snapshot.changes,
585
+ });
586
+ return { validationBlocked: false, ...result };
587
+ }}
588
+ onSubmit={(result) => {
589
+ if (result.validationBlocked || !result.isSuccess) return;
590
+ onAccepted(extractProspectId(result.data));
591
+ onOpenChange(false);
592
+ }}
593
+ onCancel={() => onOpenChange(false)}
594
+ />
595
+ </Drawer>
596
+ );
597
+ }
598
+ ```