@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 (281) hide show
  1. package/AGENTS.md +338 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +446 -0
  4. package/agents/examples/anonymous-access.md +288 -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 +319 -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 +315 -0
  20. package/agents/examples/custom-handlers.md +444 -0
  21. package/agents/examples/dashboard-stats.md +229 -0
  22. package/agents/examples/delivery-notifications.md +432 -0
  23. package/agents/examples/designer-demo.md +86 -0
  24. package/agents/examples/e2e-generator.md +211 -0
  25. package/agents/examples/embedded-entity-form.md +598 -0
  26. package/agents/examples/embedded.md +649 -0
  27. package/agents/examples/encrypted-tenant-config.md +414 -0
  28. package/agents/examples/error-contract.md +535 -0
  29. package/agents/examples/event-sourcing.md +1287 -0
  30. package/agents/examples/feature-toggles.md +274 -0
  31. package/agents/examples/field-access.md +271 -0
  32. package/agents/examples/files-post-processing.md +433 -0
  33. package/agents/examples/i18n.md +166 -0
  34. package/agents/examples/idempotency.md +179 -0
  35. package/agents/examples/inbound-mail-triage.md +315 -0
  36. package/agents/examples/lane-routing.md +246 -0
  37. package/agents/examples/ledger-banking.md +215 -0
  38. package/agents/examples/ledger-invoicing.md +236 -0
  39. package/agents/examples/legal-pages.md +393 -0
  40. package/agents/examples/lifecycle-hooks.md +368 -0
  41. package/agents/examples/managed-config.md +428 -0
  42. package/agents/examples/managed-pages.md +353 -0
  43. package/agents/examples/notes-history-basic.md +346 -0
  44. package/agents/examples/ownership.md +404 -0
  45. package/agents/examples/pdf-audit-export.md +306 -0
  46. package/agents/examples/pipeline-basics.md +792 -0
  47. package/agents/examples/public-share-token.md +351 -0
  48. package/agents/examples/rate-limiting.md +211 -0
  49. package/agents/examples/realtime-sse.md +187 -0
  50. package/agents/examples/record-detail-layout.md +297 -0
  51. package/agents/examples/reference-data.md +151 -0
  52. package/agents/examples/relations.md +280 -0
  53. package/agents/examples/screens-nav.md +353 -0
  54. package/agents/examples/search.md +233 -0
  55. package/agents/examples/secrets-demo.md +417 -0
  56. package/agents/examples/security-baseline.md +187 -0
  57. package/agents/examples/seed-migration.md +99 -0
  58. package/agents/examples/session-revocation.md +250 -0
  59. package/agents/examples/state-machine.md +920 -0
  60. package/agents/examples/tags-basic.md +401 -0
  61. package/agents/examples/tenant-isolation.md +173 -0
  62. package/agents/examples/tenant-settings.md +275 -0
  63. package/agents/examples/tier-admin.md +347 -0
  64. package/agents/examples/timezones.md +366 -0
  65. package/agents/examples/user-data-rights.md +418 -0
  66. package/agents/examples/user-profile.md +226 -0
  67. package/agents/examples/webhook-step.md +597 -0
  68. package/agents/examples/wizard-form.md +379 -0
  69. package/agents/examples/workflow-engine.md +1731 -0
  70. package/agents/examples/writeform-section.md +300 -0
  71. package/agents/examples.md +75 -0
  72. package/agents/features.md +768 -0
  73. package/agents/widgets.md +181 -0
  74. package/dist/api/auth-middleware.d.ts +2 -0
  75. package/dist/api/auth-middleware.js +7 -0
  76. package/dist/api/auth-routes.d.ts +0 -1
  77. package/dist/api/auth-routes.js +49 -46
  78. package/dist/api/extra-route.d.ts +3 -0
  79. package/dist/api/index.d.ts +2 -2
  80. package/dist/api/index.js +1 -1
  81. package/dist/api/routes.js +46 -17
  82. package/dist/api/server.d.ts +4 -3
  83. package/dist/api/server.js +39 -10
  84. package/dist/api/session-cookies.d.ts +1 -0
  85. package/dist/api/session-cookies.js +20 -5
  86. package/dist/api/sse-route.js +16 -1
  87. package/dist/bun-db/connection.js +3 -16
  88. package/dist/bun-db/index.d.ts +1 -1
  89. package/dist/bun-db/index.js +1 -1
  90. package/dist/bun-db/query.d.ts +15 -3
  91. package/dist/bun-db/query.js +76 -24
  92. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  93. package/dist/crypto/owner-transition-rekey.js +7 -3
  94. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  95. package/dist/crypto/pii-field-encryption.js +9 -2
  96. package/dist/crypto/subject-resolver.d.ts +1 -0
  97. package/dist/crypto/subject-resolver.js +7 -0
  98. package/dist/db/api.d.ts +4 -3
  99. package/dist/db/apply-entity-event.js +26 -2
  100. package/dist/db/blind-index-cleanup.js +2 -1
  101. package/dist/db/bun-provider.js +9 -18
  102. package/dist/db/collect-table-metas.js +11 -7
  103. package/dist/db/dialect.d.ts +1 -1
  104. package/dist/db/dialect.js +2 -0
  105. package/dist/db/eagerload.js +4 -3
  106. package/dist/db/event-store-executor-context.d.ts +6 -6
  107. package/dist/db/event-store-executor-context.js +25 -22
  108. package/dist/db/event-store-executor-read.js +31 -16
  109. package/dist/db/event-store-executor-write.d.ts +1 -1
  110. package/dist/db/event-store-executor-write.js +84 -63
  111. package/dist/db/index.d.ts +2 -1
  112. package/dist/db/index.js +2 -1
  113. package/dist/db/lock-guard.d.ts +4 -0
  114. package/dist/db/lock-guard.js +14 -0
  115. package/dist/db/migrate-generator.js +1 -1
  116. package/dist/db/migrate-runner.d.ts +1 -0
  117. package/dist/db/migrate-runner.js +64 -38
  118. package/dist/db/queries/backfill-pii.js +63 -25
  119. package/dist/db/queries/event-consumer.d.ts +6 -6
  120. package/dist/db/queries/event-consumer.js +2 -1
  121. package/dist/db/queries/event-store-transfer.js +15 -4
  122. package/dist/db/queries/event-store.d.ts +1 -1
  123. package/dist/db/queries/event-store.js +11 -10
  124. package/dist/db/queries/pii-backfill-state.js +3 -2
  125. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  126. package/dist/db/queries/postgres-array-literal.js +6 -0
  127. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  128. package/dist/db/queries/projection-rebuild.js +3 -2
  129. package/dist/db/queries/shadow-swap.d.ts +1 -1
  130. package/dist/db/queries/shadow-swap.js +12 -8
  131. package/dist/db/query.d.ts +1 -1
  132. package/dist/db/query.js +1 -1
  133. package/dist/db/retired-framework-tables.js +22 -4
  134. package/dist/db/table-builder.js +6 -2
  135. package/dist/db/tenant-db-runner.d.ts +4 -0
  136. package/dist/db/tenant-db-runner.js +17 -0
  137. package/dist/db/tenant-db.d.ts +4 -0
  138. package/dist/db/tenant-db.js +50 -5
  139. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  140. package/dist/derivatives/delete-stored-file.js +9 -0
  141. package/dist/derivatives/derivatives-context.js +1 -1
  142. package/dist/derivatives/index.d.ts +1 -1
  143. package/dist/derivatives/index.js +1 -1
  144. package/dist/engine/boot-validator/access-declarations.js +2 -2
  145. package/dist/engine/boot-validator/api-ext.js +3 -3
  146. package/dist/engine/boot-validator/boot-check.js +1 -1
  147. package/dist/engine/boot-validator/config-deps.js +13 -13
  148. package/dist/engine/boot-validator/detail-screens.js +1 -1
  149. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  150. package/dist/engine/boot-validator/entity-handler.js +87 -35
  151. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  152. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  153. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  154. package/dist/engine/boot-validator/nav.js +1 -1
  155. package/dist/engine/boot-validator/pii-retention.js +8 -8
  156. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  157. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  158. package/dist/engine/boot-validator/query-refs.js +14 -2
  159. package/dist/engine/boot-validator/screens.d.ts +2 -1
  160. package/dist/engine/boot-validator/screens.js +153 -95
  161. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  162. package/dist/engine/dedupe-features.js +31 -4
  163. package/dist/engine/define-handler.d.ts +2 -2
  164. package/dist/engine/define-handler.js +8 -1
  165. package/dist/engine/entity-handlers.js +14 -7
  166. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  167. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  168. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  169. package/dist/engine/feature-ast/render.js +10 -2
  170. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  171. package/dist/engine/feature-entity-handlers.js +12 -3
  172. package/dist/engine/handler-field-references.d.ts +21 -2
  173. package/dist/engine/handler-field-references.js +55 -17
  174. package/dist/engine/index.d.ts +3 -3
  175. package/dist/engine/index.js +1 -1
  176. package/dist/engine/schema-builder.js +2 -1
  177. package/dist/engine/screen-helpers.d.ts +1 -0
  178. package/dist/engine/screen-helpers.js +2 -2
  179. package/dist/engine/soft-delete-cleanup.js +10 -3
  180. package/dist/engine/steps/_read-source.js +5 -2
  181. package/dist/engine/steps/retry.js +2 -1
  182. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  183. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  184. package/dist/engine/template-variables.d.ts +3 -0
  185. package/dist/engine/template-variables.js +3 -0
  186. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  187. package/dist/engine/tier-resolver-extension.js +6 -0
  188. package/dist/engine/types/index.d.ts +1 -1
  189. package/dist/entrypoint/index.d.ts +2 -2
  190. package/dist/env/_zod-introspect.d.ts +1 -0
  191. package/dist/errors/classes.js +1 -0
  192. package/dist/errors/docs-url.js +6 -6
  193. package/dist/errors/index.d.ts +1 -1
  194. package/dist/errors/index.js +1 -1
  195. package/dist/errors/kumiko-error.d.ts +2 -0
  196. package/dist/errors/kumiko-error.js +2 -0
  197. package/dist/errors/subject-erased.d.ts +1 -0
  198. package/dist/errors/subject-erased.js +6 -0
  199. package/dist/errors/write-error-info.js +2 -0
  200. package/dist/event-store/admin-api.js +3 -2
  201. package/dist/event-store/errors.d.ts +9 -0
  202. package/dist/event-store/errors.js +20 -0
  203. package/dist/event-store/event-store.d.ts +6 -0
  204. package/dist/event-store/event-store.js +28 -6
  205. package/dist/event-store/index.d.ts +1 -1
  206. package/dist/event-store/index.js +1 -1
  207. package/dist/event-store/tenant-db-append.js +2 -2
  208. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  209. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  210. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  211. package/dist/files/bind-pending-file-refs.js +3 -3
  212. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  213. package/dist/files/bind-referenced-files.write.js +22 -13
  214. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  215. package/dist/files/create-file-ref-from-bytes.js +67 -4
  216. package/dist/files/create-file-ref.d.ts +24 -1
  217. package/dist/files/create-file-ref.js +67 -25
  218. package/dist/files/dispose-removed-file-refs.js +21 -6
  219. package/dist/files/feature.d.ts +1 -0
  220. package/dist/files/feature.js +2 -1
  221. package/dist/files/file-handle.d.ts +1 -1
  222. package/dist/files/file-handle.js +12 -0
  223. package/dist/files/file-routes.js +10 -6
  224. package/dist/files/index.d.ts +3 -3
  225. package/dist/files/index.js +2 -2
  226. package/dist/files/load-save-file-refs.d.ts +10 -0
  227. package/dist/files/load-save-file-refs.js +47 -0
  228. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  229. package/dist/files/purge-expired-file-ref-bytes.js +46 -21
  230. package/dist/files/resolve-content-type.js +40 -4
  231. package/dist/files/storage-tracking.js +5 -4
  232. package/dist/files/types.js +5 -8
  233. package/dist/files/zip-entry-names.d.ts +1 -0
  234. package/dist/files/zip-entry-names.js +46 -0
  235. package/dist/jobs/index.d.ts +1 -1
  236. package/dist/jobs/job-runner.d.ts +5 -1
  237. package/dist/jobs/job-runner.js +124 -37
  238. package/dist/observability/escape-hatch-report.d.ts +2 -1
  239. package/dist/observability/escape-hatch-report.js +48 -7
  240. package/dist/observability/index.d.ts +1 -1
  241. package/dist/observability/index.js +1 -1
  242. package/dist/observability/metrics-handle.d.ts +1 -0
  243. package/dist/observability/metrics-handle.js +7 -0
  244. package/dist/observability/standard-metrics.js +4 -4
  245. package/dist/pipeline/cascade-handler.js +5 -2
  246. package/dist/pipeline/dispatch-batch.js +79 -31
  247. package/dist/pipeline/dispatch-query.d.ts +2 -1
  248. package/dist/pipeline/dispatch-query.js +6 -6
  249. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  250. package/dist/pipeline/dispatch-shared.js +136 -48
  251. package/dist/pipeline/dispatch-stream.js +10 -4
  252. package/dist/pipeline/dispatch-write.js +34 -22
  253. package/dist/pipeline/dispatcher.js +3 -1
  254. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  255. package/dist/pipeline/event-dispatcher.js +11 -9
  256. package/dist/pipeline/event-retention.d.ts +1 -0
  257. package/dist/pipeline/event-retention.js +7 -1
  258. package/dist/pipeline/index.d.ts +1 -0
  259. package/dist/pipeline/index.js +1 -0
  260. package/dist/pipeline/notify-scope.js +3 -1
  261. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  262. package/dist/pipeline/pre-transaction-reservations.js +29 -9
  263. package/dist/rate-limit/bucket.d.ts +3 -3
  264. package/dist/rate-limit/bucket.js +14 -4
  265. package/dist/redis/cache-sync-bus.js +9 -3
  266. package/dist/schema-cli.js +12 -1
  267. package/dist/search/meilisearch-adapter.js +36 -9
  268. package/dist/stack/db.d.ts +2 -2
  269. package/dist/stack/test-stack.js +2 -1
  270. package/dist/testing/index.d.ts +1 -0
  271. package/dist/testing/index.js +1 -0
  272. package/dist/testing/multipart-helper.d.ts +6 -0
  273. package/dist/testing/multipart-helper.js +1 -1
  274. package/dist/testing/zip-fixture.d.ts +1 -0
  275. package/dist/testing/zip-fixture.js +33 -0
  276. package/dist/ui-types/card-column-roles.js +4 -2
  277. package/dist/ui-types/index.d.ts +1 -1
  278. package/dist/ui-types/index.js +1 -1
  279. package/dist/upgrade-cli.js +46 -13
  280. package/package.json +7 -5
  281. package/src/changes.json +512 -8
@@ -0,0 +1,404 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/ownership. Do not edit. -->
2
+
3
+ # ownership
4
+
5
+ Entity- and Field-level Row-Ownership: from() helper, Straddle-safe multi-role checks, Field-level Read/Write filtering.
6
+
7
+ ## README
8
+
9
+ # Ownership
10
+
11
+ Row-level ownership for read and write.
12
+
13
+ ## What it shows
14
+
15
+ - Entity- and field-level ownership rules
16
+ - Straddle-safe checks across tenants/users
17
+
18
+ ## Source
19
+
20
+ Feature entry point: `src/feature.ts`.
21
+
22
+ ## Tests
23
+
24
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
25
+
26
+ ```bash
27
+ cd samples/recipes/ownership
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // Ownership Sample — Integration Test
37
+ //
38
+ // Drives the whole Read/Write × Entity/Field matrix against the contract
39
+ // feature. Each test maps directly to a cell in the design-doc matrix.
40
+
41
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
42
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
43
+ import {
44
+ createTestUser,
45
+ setupTestStack,
46
+ type TestStack,
47
+ TestUsers,
48
+ testTenantId,
49
+ unsafeCreateEntityTable,
50
+ } from "@cosmicdrift/kumiko-framework/stack";
51
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
52
+ import { contractEntity, contractsFeature, teamsFeature } from "../feature";
53
+
54
+ let stack: TestStack;
55
+
56
+ function usersForTenant(tenant: TenantId) {
57
+ return {
58
+ admin: { ...TestUsers.admin, tenantId: tenant },
59
+ teamMemberEng: createTestUser({
60
+ id: "11111111-0000-4000-8000-000000000001",
61
+ tenantId: tenant,
62
+ roles: ["TeamMember"],
63
+ claims: { "teams:teamId": "eng" },
64
+ }),
65
+ teamMemberOps: createTestUser({
66
+ id: "11111111-0000-4000-8000-000000000002",
67
+ tenantId: tenant,
68
+ roles: ["TeamMember"],
69
+ claims: { "teams:teamId": "ops" },
70
+ }),
71
+ driverAlice: createTestUser({
72
+ id: "22222222-0000-4000-8000-000000000001",
73
+ tenantId: tenant,
74
+ roles: ["Driver"],
75
+ }),
76
+ };
77
+ }
78
+
79
+ // Each test runs in its own tenant, so contract rows from other tests never show up.
80
+ let nextTenant = 1;
81
+ let { admin, teamMemberEng, teamMemberOps, driverAlice } = usersForTenant(testTenantId(nextTenant));
82
+
83
+ beforeAll(async () => {
84
+ stack = await setupTestStack({ features: [teamsFeature, contractsFeature] });
85
+ await unsafeCreateEntityTable(stack.db, contractEntity, "contract");
86
+ });
87
+
88
+ afterAll(async () => {
89
+ await stack.cleanup();
90
+ });
91
+
92
+ let engRowId: string;
93
+ let engRowVersion: number;
94
+ let opsRowId: string;
95
+
96
+ beforeEach(async () => {
97
+ nextTenant += 1;
98
+ ({ admin, teamMemberEng, teamMemberOps, driverAlice } = usersForTenant(testTenantId(nextTenant)));
99
+ const eng = await stack.http.writeOk<{ id: string; data: { version: number } }>(
100
+ "contracts:write:contract:create",
101
+ {
102
+ teamId: "eng",
103
+ assigneeId: driverAlice.id,
104
+ propA: "public-eng",
105
+ propB: "admin-eng",
106
+ propC: "team-eng",
107
+ },
108
+ admin,
109
+ );
110
+ engRowId = eng.id;
111
+ engRowVersion = eng.data.version;
112
+ const ops = await stack.http.writeOk<{ id: string }>(
113
+ "contracts:write:contract:create",
114
+ {
115
+ teamId: "ops",
116
+ propA: "public-ops",
117
+ propB: "admin-ops",
118
+ propC: "team-ops",
119
+ },
120
+ admin,
121
+ );
122
+ opsRowId = ops.id;
123
+ });
124
+
125
+ // --- Entity-level READ ---
126
+
127
+ describe("entity-level read: list / detail filtering", () => {
128
+ test("Admin lists both rows", async () => {
129
+ const res = await stack.http.queryOk<{ rows: Array<{ teamId: string }> }>(
130
+ "contracts:query:contract:list",
131
+ {},
132
+ admin,
133
+ );
134
+ expect(res.rows).toHaveLength(2);
135
+ });
136
+
137
+ test("TeamMember eng lists only eng's row", async () => {
138
+ const res = await stack.http.queryOk<{ rows: Array<{ teamId: string }> }>(
139
+ "contracts:query:contract:list",
140
+ {},
141
+ teamMemberEng,
142
+ );
143
+ expect(res.rows).toHaveLength(1);
144
+ expect(res.rows[0]?.teamId).toBe("eng");
145
+ });
146
+
147
+ test("Driver alice lists only rows assigned to her", async () => {
148
+ const res = await stack.http.queryOk<{ rows: Array<{ assigneeId: string }> }>(
149
+ "contracts:query:contract:list",
150
+ {},
151
+ driverAlice,
152
+ );
153
+ expect(res.rows).toHaveLength(1);
154
+ expect(res.rows[0]?.assigneeId).toBe(driverAlice.id);
155
+ });
156
+
157
+ test("TeamMember eng getting ops row via detail → null (indistinguishable from not-found)", async () => {
158
+ const res = await stack.http.queryOk(
159
+ "contracts:query:contract:detail",
160
+ {
161
+ id: opsRowId,
162
+ },
163
+ teamMemberEng,
164
+ );
165
+ expect(res).toBeNull();
166
+ });
167
+ });
168
+
169
+ // --- Field-level READ ---
170
+
171
+ describe("field-level read: response JSON strips unreadable fields", () => {
172
+ test("TeamMember eng sees propA + propC; propB missing", async () => {
173
+ const res = await stack.http.queryOk<Record<string, unknown>>(
174
+ "contracts:query:contract:detail",
175
+ { id: engRowId },
176
+ teamMemberEng,
177
+ );
178
+ expect(res["propA"]).toBe("public-eng");
179
+ expect(res["propC"]).toBe("team-eng");
180
+ expect(res).not.toHaveProperty("propB");
181
+ });
182
+
183
+ test("TeamMember ops reading eng's row — blocked at entity level (detail=null)", async () => {
184
+ const res = await stack.http.queryOk(
185
+ "contracts:query:contract:detail",
186
+ {
187
+ id: engRowId,
188
+ },
189
+ teamMemberOps,
190
+ );
191
+ expect(res).toBeNull();
192
+ });
193
+ });
194
+
195
+ // --- Entity-level WRITE ---
196
+
197
+ describe("entity-level write: create/update/delete blocked on foreign rows", () => {
198
+ test("TeamMember eng CANNOT create an ops-team contract", async () => {
199
+ const err = await stack.http.writeErr(
200
+ "contracts:write:contract:create",
201
+ { teamId: "ops", propA: "rogue" },
202
+ teamMemberEng,
203
+ );
204
+ expectErrorIncludes(err, "ownership_denied");
205
+ });
206
+
207
+ test("TeamMember eng CANNOT update the ops row (not visible → not modifiable)", async () => {
208
+ // The row exists but entity-level ownership rejects the update.
209
+ // version is guessed — doesn't matter, the ownership check fires first.
210
+ const err = await stack.http.writeErr(
211
+ "contracts:write:contract:update",
212
+ { id: opsRowId, version: 1, changes: { propA: "hack" } },
213
+ teamMemberEng,
214
+ );
215
+ expectErrorIncludes(err, "ownership_denied");
216
+ });
217
+
218
+ test("TeamMember eng CANNOT move their row to another team (teamId rewrite blocked)", async () => {
219
+ const err = await stack.http.writeErr(
220
+ "contracts:write:contract:update",
221
+ {
222
+ id: engRowId,
223
+ version: engRowVersion,
224
+ changes: { teamId: "ops" },
225
+ },
226
+ teamMemberEng,
227
+ );
228
+ expectErrorIncludes(err, "ownership_denied");
229
+ });
230
+ });
231
+
232
+ // --- Field-level WRITE ---
233
+
234
+ describe("field-level write: individual fields rejected fail-loud", () => {
235
+ test("TeamMember eng updating propB (Admin-only) → access_denied (role gate)", async () => {
236
+ const err = await stack.http.writeErr(
237
+ "contracts:write:contract:update",
238
+ {
239
+ id: engRowId,
240
+ version: engRowVersion,
241
+ changes: { propB: "sneak" },
242
+ },
243
+ teamMemberEng,
244
+ );
245
+ expectErrorIncludes(err, "access_denied");
246
+ });
247
+
248
+ test("TeamMember eng updating propA succeeds on their own row", async () => {
249
+ const res = await stack.http.writeOk<{ data: { propA: string } }>(
250
+ "contracts:write:contract:update",
251
+ {
252
+ id: engRowId,
253
+ version: engRowVersion,
254
+ changes: { propA: "my-edit" },
255
+ },
256
+ teamMemberEng,
257
+ );
258
+ expect(res.data.propA).toBe("my-edit");
259
+ });
260
+ });
261
+
262
+ // --- Admin short-circuit ---
263
+
264
+ describe("admin-all short-circuit skips every ownership predicate", () => {
265
+ test("Admin reads both rows with all fields intact", async () => {
266
+ const eng = await stack.http.queryOk<Record<string, unknown>>(
267
+ "contracts:query:contract:detail",
268
+ { id: engRowId },
269
+ admin,
270
+ );
271
+ expect(eng["propA"]).toBe("public-eng");
272
+ expect(eng["propB"]).toBe("admin-eng");
273
+ expect(eng["propC"]).toBe("team-eng");
274
+ });
275
+
276
+ test("Admin updates, deletes, restores on any team", async () => {
277
+ const updated = await stack.http.writeOk<{ data: { propB: string; version: number } }>(
278
+ "contracts:write:contract:update",
279
+ {
280
+ id: opsRowId,
281
+ version: 1,
282
+ changes: { propB: "admin-edit-across-teams" },
283
+ },
284
+ admin,
285
+ );
286
+ expect(updated.data.propB).toBe("admin-edit-across-teams");
287
+ const deleted = await stack.http.writeOk<{ id: string }>(
288
+ "contracts:write:contract:delete",
289
+ { id: opsRowId },
290
+ admin,
291
+ );
292
+ expect(deleted.id).toBe(opsRowId);
293
+ const restored = await stack.http.writeOk<{ id: string }>(
294
+ "contracts:write:contract:restore",
295
+ { id: opsRowId },
296
+ admin,
297
+ );
298
+ expect(restored.id).toBe(opsRowId);
299
+ });
300
+ });
301
+ ```
302
+
303
+ ### src/feature.ts
304
+
305
+ ```ts
306
+ // Ownership Sample
307
+ //
308
+ // Concrete contract-entity showing the full ownership matrix:
309
+ //
310
+ // Dimension Entity-level Field-level
311
+ // --------- ------------ -----------
312
+ // Read list/detail filter propX stripped from response JSON
313
+ // Write create/update/delete individual fields rejected loud
314
+ // Rule shape { Role: OwnershipRule } (same on both dimensions)
315
+ // Rule kinds "all" / from(ref, col?) / { where: (user, table) => SQL }
316
+ //
317
+ // The entity below is the "contract" example from the H.2 design
318
+ // discussion: propA is public, propB is Admin-only, propC requires a
319
+ // matching team claim. Integration tests prove each row/column path.
320
+ //
321
+ // Why fail-loud on write (not silent drop): Silent-drop makes bugs
322
+ // invisible at the reporting layer. "Save doesn't work" with no error
323
+ // becomes weeks of blame between frontend / API / infrastructure. A loud
324
+ // `field_ownership_denied` with the field name + role + userId in the
325
+ // details payload is a single log-line to a root cause.
326
+
327
+ import {
328
+ createEntity,
329
+ createTextField,
330
+ defineFeature,
331
+ from,
332
+ } from "@cosmicdrift/kumiko-framework/engine";
333
+
334
+ // The "teams" feature declares the teamId claim. Any other feature can
335
+ // reference it via the QN string "teams:teamId" in ownership rules — no
336
+ // cross-feature import needed. The boot-validator rejects a reference to a
337
+ // claim no feature declared.
338
+ export const teamsFeature = defineFeature("teams", (r) => {
339
+ r.claimKey("teamId", { type: "string" });
340
+ });
341
+
342
+ // Contract entity demonstrating the full access matrix.
343
+ export const contractEntity = createEntity({
344
+ table: "read_ownership_contracts",
345
+ softDelete: true,
346
+ fields: {
347
+ // teamId is the ownership-scoping column. It's referenced by claim-rules
348
+ // via the default column (rule's shortName = "teamId" matches).
349
+ teamId: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
350
+ // Optional — when set, only that user (via user:id rule) can access.
351
+ assigneeId: createTextField({ personal: "ref" }),
352
+
353
+ // propA: public field. No access declared — every caller reads AND writes.
354
+ propA: createTextField({ personal: false, reason: "is_business_data" }),
355
+
356
+ // propB: Admin-only. Non-admins don't see it in responses; attempts
357
+ // to write it return access_denied (role gate in dispatcher).
358
+ propB: createTextField({
359
+ personal: false,
360
+ reason: "is_business_data",
361
+ access: {
362
+ read: { Admin: "all" },
363
+ write: { Admin: "all" },
364
+ },
365
+ }),
366
+
367
+ // propC: Admin + Matching-Team. Non-admins with the wrong team see no
368
+ // propC in responses; writes to propC on a foreign team's row return
369
+ // field_ownership_denied (executor ownership check).
370
+ propC: createTextField({
371
+ personal: false,
372
+ reason: "is_business_data",
373
+ access: {
374
+ read: { Admin: "all", TeamMember: from("claim:teams:teamId") },
375
+ write: { Admin: "all", TeamMember: from("claim:teams:teamId") },
376
+ },
377
+ }),
378
+ },
379
+
380
+ // Entity-level access: controls list/detail/update/delete/restore of the
381
+ // whole row. Admin sees everything; TeamMember scoped to their team;
382
+ // assigned Driver sees rows assigned to them.
383
+ access: {
384
+ read: {
385
+ Admin: "all",
386
+ TeamMember: from("claim:teams:teamId"),
387
+ Driver: from("user:id", "assigneeId"),
388
+ },
389
+ write: {
390
+ Admin: "all",
391
+ TeamMember: from("claim:teams:teamId"),
392
+ Driver: from("user:id", "assigneeId"),
393
+ },
394
+ },
395
+ });
396
+
397
+ export const contractsFeature = defineFeature("contracts", (r) => {
398
+ const contractAccess = { access: { roles: ["Admin", "TeamMember", "Driver"] } } as const;
399
+ r.crud("contract", contractEntity, {
400
+ write: contractAccess,
401
+ read: contractAccess,
402
+ });
403
+ });
404
+ ```