@cosmicdrift/kumiko-framework 0.2.2 → 0.3.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 (191) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/package.json +124 -38
  3. package/src/__tests__/full-stack.integration.ts +2 -2
  4. package/src/api/auth-routes.ts +5 -5
  5. package/src/api/jwt.ts +2 -2
  6. package/src/api/route-registrars.ts +1 -1
  7. package/src/api/routes.ts +3 -3
  8. package/src/api/server.ts +6 -7
  9. package/src/auth/__tests__/roles.test.ts +24 -0
  10. package/src/auth/index.ts +7 -0
  11. package/src/auth/roles.ts +42 -0
  12. package/src/compliance/__tests__/duration-spec.test.ts +72 -0
  13. package/src/compliance/__tests__/profiles.test.ts +308 -0
  14. package/src/compliance/__tests__/sub-processors.test.ts +139 -0
  15. package/src/compliance/duration-spec.ts +44 -0
  16. package/src/compliance/index.ts +31 -0
  17. package/src/compliance/override-schema.ts +136 -0
  18. package/src/compliance/profiles.ts +427 -0
  19. package/src/compliance/sub-processors.ts +152 -0
  20. package/src/db/__tests__/big-int-field.test.ts +131 -0
  21. package/src/db/assert-exists-in.ts +2 -2
  22. package/src/db/cursor.ts +3 -3
  23. package/src/db/event-store-executor.ts +19 -13
  24. package/src/db/located-timestamp.ts +1 -1
  25. package/src/db/money.ts +12 -2
  26. package/src/db/pg-error.ts +1 -1
  27. package/src/db/row-helpers.ts +1 -1
  28. package/src/db/table-builder.ts +20 -5
  29. package/src/db/tenant-db.ts +9 -9
  30. package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
  31. package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
  32. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
  33. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
  34. package/src/engine/__tests__/build-target.test.ts +135 -0
  35. package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
  36. package/src/engine/__tests__/entity-handlers.test.ts +3 -3
  37. package/src/engine/__tests__/event-helpers.test.ts +4 -4
  38. package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
  39. package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
  40. package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
  41. package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
  42. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
  43. package/src/engine/__tests__/raw-table.test.ts +2 -2
  44. package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
  45. package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
  46. package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
  47. package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
  48. package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
  49. package/src/engine/__tests__/steps-read.test.ts +142 -0
  50. package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
  51. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
  52. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
  53. package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
  54. package/src/engine/__tests__/steps-workflow.test.ts +198 -0
  55. package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
  56. package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
  57. package/src/engine/boot-validator/api-ext.ts +77 -0
  58. package/src/engine/boot-validator/config-deps.ts +163 -0
  59. package/src/engine/boot-validator/entity-handler.ts +466 -0
  60. package/src/engine/boot-validator/index.ts +159 -0
  61. package/src/engine/boot-validator/ownership.ts +198 -0
  62. package/src/engine/boot-validator/pii-retention.ts +155 -0
  63. package/src/engine/boot-validator/screens-nav.ts +624 -0
  64. package/src/engine/boot-validator.ts +1 -1528
  65. package/src/engine/build-app-schema.ts +1 -1
  66. package/src/engine/build-target.ts +99 -0
  67. package/src/engine/codemod/index.ts +15 -0
  68. package/src/engine/codemod/pipeline-codemod.ts +641 -0
  69. package/src/engine/config-helpers.ts +9 -19
  70. package/src/engine/constants.ts +1 -1
  71. package/src/engine/define-feature.ts +127 -9
  72. package/src/engine/define-handler.ts +89 -3
  73. package/src/engine/define-roles.ts +2 -2
  74. package/src/engine/define-step.ts +28 -0
  75. package/src/engine/define-workflow.ts +110 -0
  76. package/src/engine/entity-handlers.ts +10 -9
  77. package/src/engine/event-helpers.ts +4 -4
  78. package/src/engine/extension-names.ts +105 -0
  79. package/src/engine/extensions/user-data.ts +106 -0
  80. package/src/engine/factories.ts +26 -16
  81. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
  82. package/src/engine/feature-ast/extractors/index.ts +74 -0
  83. package/src/engine/feature-ast/extractors/round1.ts +110 -0
  84. package/src/engine/feature-ast/extractors/round2.ts +253 -0
  85. package/src/engine/feature-ast/extractors/round3.ts +471 -0
  86. package/src/engine/feature-ast/extractors/round4.ts +1365 -0
  87. package/src/engine/feature-ast/extractors/round5.ts +72 -0
  88. package/src/engine/feature-ast/extractors/round6.ts +66 -0
  89. package/src/engine/feature-ast/extractors/shared.ts +177 -0
  90. package/src/engine/feature-ast/parse.ts +13 -0
  91. package/src/engine/feature-ast/patch.ts +9 -1
  92. package/src/engine/feature-ast/patcher.ts +10 -3
  93. package/src/engine/feature-ast/patterns.ts +71 -1
  94. package/src/engine/feature-ast/render.ts +31 -1
  95. package/src/engine/index.ts +66 -2
  96. package/src/engine/pattern-library/__tests__/library.test.ts +11 -0
  97. package/src/engine/pattern-library/library.ts +78 -2
  98. package/src/engine/pipeline.ts +88 -0
  99. package/src/engine/projection-helpers.ts +1 -1
  100. package/src/engine/read-claim.ts +1 -1
  101. package/src/engine/registry.ts +30 -2
  102. package/src/engine/resolve-config-or-param.ts +4 -0
  103. package/src/engine/run-pipeline.ts +162 -0
  104. package/src/engine/schema-builder.ts +10 -4
  105. package/src/engine/state-machine.ts +1 -1
  106. package/src/engine/steps/_drizzle-boundary.ts +19 -0
  107. package/src/engine/steps/_duration-utils.ts +33 -0
  108. package/src/engine/steps/_no-return-guard.ts +21 -0
  109. package/src/engine/steps/_resolver-utils.ts +42 -0
  110. package/src/engine/steps/_step-dispatch-constants.ts +38 -0
  111. package/src/engine/steps/aggregate-append-event.ts +56 -0
  112. package/src/engine/steps/aggregate-create.ts +56 -0
  113. package/src/engine/steps/aggregate-update.ts +68 -0
  114. package/src/engine/steps/branch.ts +84 -0
  115. package/src/engine/steps/call-feature.ts +49 -0
  116. package/src/engine/steps/compute.ts +41 -0
  117. package/src/engine/steps/for-each.ts +111 -0
  118. package/src/engine/steps/mail-send.ts +44 -0
  119. package/src/engine/steps/read-find-many.ts +51 -0
  120. package/src/engine/steps/read-find-one.ts +58 -0
  121. package/src/engine/steps/retry.ts +87 -0
  122. package/src/engine/steps/return.ts +34 -0
  123. package/src/engine/steps/unsafe-projection-delete.ts +46 -0
  124. package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
  125. package/src/engine/steps/wait-for-event.ts +71 -0
  126. package/src/engine/steps/wait.ts +69 -0
  127. package/src/engine/steps/webhook-send.ts +71 -0
  128. package/src/engine/system-user.ts +1 -1
  129. package/src/engine/types/feature.ts +143 -1
  130. package/src/engine/types/fields.ts +134 -10
  131. package/src/engine/types/handlers.ts +18 -10
  132. package/src/engine/types/identifiers.ts +1 -0
  133. package/src/engine/types/index.ts +15 -1
  134. package/src/engine/types/step.ts +334 -0
  135. package/src/engine/types/target-ref.ts +21 -0
  136. package/src/engine/types/tree-node.ts +130 -0
  137. package/src/engine/types/workspace.ts +7 -0
  138. package/src/engine/validate-projection-allowlist.ts +161 -0
  139. package/src/event-store/snapshot.ts +1 -1
  140. package/src/event-store/upcaster-dead-letter.ts +1 -1
  141. package/src/event-store/upcaster.ts +1 -1
  142. package/src/files/__tests__/read-stream.test.ts +105 -0
  143. package/src/files/__tests__/write-stream.test.ts +233 -0
  144. package/src/files/__tests__/zip-stream.test.ts +357 -0
  145. package/src/files/file-routes.ts +1 -1
  146. package/src/files/in-memory-provider.ts +38 -0
  147. package/src/files/index.ts +3 -0
  148. package/src/files/local-provider.ts +58 -1
  149. package/src/files/types.ts +36 -8
  150. package/src/files/zip-stream.ts +251 -0
  151. package/src/jobs/job-runner.ts +10 -10
  152. package/src/lifecycle/lifecycle.ts +0 -3
  153. package/src/logging/index.ts +1 -0
  154. package/src/logging/pino-logger.ts +11 -7
  155. package/src/logging/utils.ts +24 -0
  156. package/src/observability/prometheus-meter.ts +7 -5
  157. package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
  158. package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
  159. package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
  160. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
  161. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
  162. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
  163. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  164. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
  165. package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
  166. package/src/pipeline/append-event-core.ts +22 -6
  167. package/src/pipeline/dispatcher-utils.ts +188 -0
  168. package/src/pipeline/dispatcher.ts +63 -283
  169. package/src/pipeline/distributed-lock.ts +1 -1
  170. package/src/pipeline/entity-cache.ts +2 -2
  171. package/src/pipeline/event-consumer-state.ts +0 -13
  172. package/src/pipeline/event-dispatcher.ts +4 -4
  173. package/src/pipeline/index.ts +0 -2
  174. package/src/pipeline/lifecycle-pipeline.ts +6 -12
  175. package/src/pipeline/msp-rebuild.ts +5 -5
  176. package/src/pipeline/multi-stream-apply-context.ts +6 -7
  177. package/src/pipeline/projection-rebuild.ts +2 -2
  178. package/src/pipeline/projection-state.ts +0 -12
  179. package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
  180. package/src/rate-limit/resolver.ts +1 -1
  181. package/src/search/in-memory-adapter.ts +1 -1
  182. package/src/search/meilisearch-adapter.ts +3 -3
  183. package/src/search/types.ts +1 -1
  184. package/src/secrets/leak-guard.ts +2 -2
  185. package/src/stack/request-helper.ts +9 -5
  186. package/src/stack/test-stack.ts +1 -1
  187. package/src/testing/handler-context.ts +4 -4
  188. package/src/testing/http-cookies.ts +1 -1
  189. package/src/time/tz-context.ts +1 -2
  190. package/src/ui-types/index.ts +4 -0
  191. package/src/engine/feature-ast/extractors.ts +0 -2562
@@ -0,0 +1,198 @@
1
+ import type { OwnershipMap, OwnershipRule } from "../ownership";
2
+ import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
3
+
4
+ // --- Ownership rule validation (H.2) ---
5
+ //
6
+ // Walks every entity.access and every field.access map, resolves each
7
+ // FromRule against the cross-feature claim registry, and confirms the
8
+ // referenced column exists on the entity. Catches typos, renames, and
9
+ // cross-feature-claim-removal scenarios at boot instead of at request time.
10
+
11
+ export function validateOwnershipRules(
12
+ feature: FeatureDefinition,
13
+ allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>,
14
+ knownRoles: ReadonlySet<string>,
15
+ ): void {
16
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
17
+ const columnNames = new Set<string>(Object.keys(entity.fields));
18
+ // Framework-managed columns that rules are allowed to reference too.
19
+ // These are the base columns buildDrizzleTable adds unconditionally.
20
+ const frameworkColumns = ["id", "tenantId", "version", "insertedAt", "modifiedAt"];
21
+ for (const col of frameworkColumns) columnNames.add(col);
22
+
23
+ // Entity-level access
24
+ if (entity.access?.read) {
25
+ checkOwnershipMap({
26
+ map: entity.access.read,
27
+ columnNames,
28
+ allClaimKeys,
29
+ knownRoles,
30
+ scope: `entity "${entityName}".access.read`,
31
+ featureName: feature.name,
32
+ });
33
+ }
34
+ if (entity.access?.write) {
35
+ checkOwnershipMap({
36
+ map: entity.access.write,
37
+ columnNames,
38
+ allClaimKeys,
39
+ knownRoles,
40
+ scope: `entity "${entityName}".access.write`,
41
+ featureName: feature.name,
42
+ });
43
+ }
44
+
45
+ // Field-level access — OwnershipMap form goes through checkOwnershipMap,
46
+ // legacy string[] through checkLegacyRoleList. Both enforce role-name
47
+ // existence against knownRoles so typos fail loud.
48
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
49
+ checkFieldAccess({
50
+ access: field.access?.read,
51
+ columnNames,
52
+ allClaimKeys,
53
+ knownRoles,
54
+ scope: `${entityName}.${fieldName}.access.read`,
55
+ featureName: feature.name,
56
+ });
57
+ checkFieldAccess({
58
+ access: field.access?.write,
59
+ columnNames,
60
+ allClaimKeys,
61
+ knownRoles,
62
+ scope: `${entityName}.${fieldName}.access.write`,
63
+ featureName: feature.name,
64
+ });
65
+ }
66
+ }
67
+ }
68
+
69
+ export function checkFieldAccess(args: {
70
+ readonly access: OwnershipMap | readonly string[] | undefined;
71
+ readonly columnNames: ReadonlySet<string>;
72
+ readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>;
73
+ readonly knownRoles: ReadonlySet<string>;
74
+ readonly scope: string;
75
+ readonly featureName: string;
76
+ }): void {
77
+ // skip: no access rules on this field, nothing to validate
78
+ if (!args.access) return;
79
+ if (Array.isArray(args.access)) {
80
+ // Legacy string[] form — every entry is a role name. Ref/column
81
+ // validation is n/a here (no claim refs in this shape), but the
82
+ // role-existence check applies.
83
+ checkLegacyRoleList(
84
+ args.access as readonly string[], // @cast-boundary schema-walk
85
+ args.knownRoles,
86
+ args.scope,
87
+ args.featureName,
88
+ );
89
+ // skip: legacy form validated, OwnershipMap check below doesn't apply
90
+ return;
91
+ }
92
+ checkOwnershipMap({
93
+ map: args.access as OwnershipMap, // @cast-boundary schema-walk
94
+ columnNames: args.columnNames,
95
+ allClaimKeys: args.allClaimKeys,
96
+ knownRoles: args.knownRoles,
97
+ scope: args.scope,
98
+ featureName: args.featureName,
99
+ });
100
+ }
101
+
102
+ export function checkLegacyRoleList(
103
+ roles: readonly string[],
104
+ knownRoles: ReadonlySet<string>,
105
+ scope: string,
106
+ featureName: string,
107
+ ): void {
108
+ // skip: no handler-declared roles in this app, role-validation disabled
109
+ if (!shouldValidateRoles(knownRoles)) return;
110
+ for (const roleName of roles) {
111
+ if (!knownRoles.has(roleName)) {
112
+ throw new Error(buildUnknownRoleMessage(roleName, knownRoles, scope, featureName));
113
+ }
114
+ }
115
+ }
116
+
117
+ // Only validate role-existence when at least one handler in the system has
118
+ // declared a non-builtin role. Apps that run entirely on openToAll +
119
+ // system-role handlers don't benefit from role-typo detection and would
120
+ // otherwise get false-positive errors on every OwnershipMap — their
121
+ // knownRoles corpus is empty beyond "all"/"system", so any app-defined
122
+ // role would flag as unknown.
123
+ export function shouldValidateRoles(knownRoles: ReadonlySet<string>): boolean {
124
+ for (const r of knownRoles) {
125
+ if (r !== "all" && r !== "system") return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ export function checkOwnershipMap(args: {
131
+ readonly map: OwnershipMap;
132
+ readonly columnNames: ReadonlySet<string>;
133
+ readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>;
134
+ readonly knownRoles: ReadonlySet<string>;
135
+ readonly scope: string;
136
+ readonly featureName: string;
137
+ }): void {
138
+ for (const [roleName, rawRule] of Object.entries(args.map)) {
139
+ // Role-existence check — typos like `{"Admi": "all"}` where no handler
140
+ // or other map mentions "Admi" would otherwise silently grant nothing.
141
+ // Skip when no app-defined roles exist anywhere (handler-less or
142
+ // system-only apps — shouldValidateRoles returns false there).
143
+ if (shouldValidateRoles(args.knownRoles) && !args.knownRoles.has(roleName)) {
144
+ throw new Error(
145
+ buildUnknownRoleMessage(roleName, args.knownRoles, args.scope, args.featureName),
146
+ );
147
+ }
148
+
149
+ // @cast-boundary schema-walk — extracted from feature-config inspection
150
+ const rule = rawRule as OwnershipRule;
151
+ if (rule === "all") continue;
152
+ if (rule.kind === "where") continue; // escape hatch — feature author owns the SQL
153
+
154
+ // FromRule — validate ref + column.
155
+ if (rule.refKind === "claim") {
156
+ // refPath is the qualified claim name ("feature:shortName").
157
+ const claim = args.allClaimKeys.get(rule.refPath);
158
+ if (!claim) {
159
+ const known = [...args.allClaimKeys.keys()].sort().join(", ") || "(none)";
160
+ throw new Error(
161
+ `[Kumiko Ownership] ${args.scope} references unknown claim "${rule.refPath}" ` +
162
+ `(role: "${roleName}", feature: "${args.featureName}"). ` +
163
+ `Declare it via r.claimKey("...", { type: "..." }) in the owning feature. ` +
164
+ `Known claims: ${known}`,
165
+ );
166
+ }
167
+ // String-compatible columns accept string and string[] claims equally
168
+ // (array → inArray). For other claim types we rely on the author
169
+ // knowing the row-column shape; we can't introspect PG types without
170
+ // the schema built. This is a best-effort ref-existence check.
171
+ }
172
+
173
+ if (!args.columnNames.has(rule.column)) {
174
+ const known = [...args.columnNames].sort().join(", ");
175
+ throw new Error(
176
+ `[Kumiko Ownership] ${args.scope} references column "${rule.column}" ` +
177
+ `which does not exist on the entity (role: "${roleName}", feature: ` +
178
+ `"${args.featureName}"). Available columns: ${known}`,
179
+ );
180
+ }
181
+ }
182
+ }
183
+
184
+ export function buildUnknownRoleMessage(
185
+ roleName: string,
186
+ knownRoles: ReadonlySet<string>,
187
+ scope: string,
188
+ featureName: string,
189
+ ): string {
190
+ const known = [...knownRoles].sort().join(", ");
191
+ return (
192
+ `[Kumiko Ownership] ${scope} references unknown role "${roleName}" ` +
193
+ `(feature: "${featureName}"). Roles are collected from handler access ` +
194
+ `rules across all features plus the "all" and "system" built-ins; if ` +
195
+ `"${roleName}" is real, make sure at least one handler declares ` +
196
+ `access.roles: ["${roleName}"]. Known roles: ${known}`
197
+ );
198
+ }
@@ -0,0 +1,155 @@
1
+ import type { FeatureDefinition } from "../types";
2
+ import type { PiiAnnotations } from "../types/fields";
3
+ import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS } from "./entity-handler";
4
+
5
+ // Framework-managed Timestamp-Spalten — dürfen als retention.reference
6
+ // genutzt werden auch wenn nicht in entity.fields deklariert.
7
+ const FRAMEWORK_TIMESTAMP_FIELDS: ReadonlySet<string> = new Set([
8
+ "createdAt",
9
+ "updatedAt",
10
+ "lastSeenAt",
11
+ "deletedAt",
12
+ ]);
13
+
14
+ // Erlaubtes Format fuer retention.keepFor — Zahlen + Suffix (h/d/w/m/y).
15
+ // Echtes Parsen kommt mit dem Cleanup-Job in Sprint 2; Boot-Validator
16
+ // macht nur den Sanity-Check damit Tippfehler ("30days") frueh sichtbar
17
+ // werden statt erst beim ersten Cleanup-Run.
18
+ const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
19
+
20
+ // --- PII / Subject-Key Annotations + Retention validation ---
21
+ //
22
+ // Drei Klassen von Checks:
23
+ //
24
+ // 1. Mutual exclusion: pro Field nur EINE der drei Subject-Annotations
25
+ // (pii / userOwned / tenantOwned). Mehr ist semantisch widersprüchlich
26
+ // weil pro Field genau ein Subject-Key gehört.
27
+ //
28
+ // 2. Reference-Integrity: userOwned.ownerField muss auf ein existierendes
29
+ // reference-Field zeigen (das auf user-Entity zeigen sollte). Erkennt
30
+ // Tippfehler und Drop-Refactorings beim Boot statt beim ersten
31
+ // Encrypt-Aufruf.
32
+ //
33
+ // 3. Heuristik-Warnings: Field-Namen die typischerweise PII enthalten
34
+ // (email, name, phone, body, etc.) ohne Annotation → Boot-Warning.
35
+ // Mit `allowPlaintext: "<reason>"` unterdrückbar (geht in Audit).
36
+ //
37
+ // 4. Retention-Integrity: retention.reference (wenn gesetzt) muss auf
38
+ // ein bestehendes Field zeigen (oder Framework-Timestamp). retention.
39
+ // strategy="blockDelete" ohne anonymize-Felder ist sinnlos — User-
40
+ // Forget kann nichts machen, Warning.
41
+ //
42
+ // Encrypt/Decrypt-Mechanik landet in Sprint 3 (crypto-shredding); diese
43
+ // Validation greift schon ab Sprint 0 damit Schema-Drift früh auffällt.
44
+ export function validatePiiAndRetention(feature: FeatureDefinition): void {
45
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
46
+ const fieldsByName = entity.fields;
47
+
48
+ for (const [fieldName, field] of Object.entries(fieldsByName)) {
49
+ // PiiAnnotations-Properties sind type-level optional. Auf Field-
50
+ // Defs die nicht via "& PiiAnnotations" erweitert sind (Boolean,
51
+ // Money, Reference, Embedded, Tz, LocatedTimestamp, File*, Image*)
52
+ // liefert property-access undefined zur Runtime. Die TS-Compile-
53
+ // Time-Validation hat dort schon abgelehnt → Cast ist safe.
54
+ const annot = field as PiiAnnotations; // @cast-boundary schema-walk
55
+
56
+ const hasPii = Boolean(annot.pii);
57
+ const hasUserOwned = Boolean(annot.userOwned);
58
+ const hasTenantOwned = Boolean(annot.tenantOwned);
59
+ const annotCount = (hasPii ? 1 : 0) + (hasUserOwned ? 1 : 0) + (hasTenantOwned ? 1 : 0);
60
+
61
+ if (annotCount > 1) {
62
+ throw new Error(
63
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has multiple subject-key annotations (pii / userOwned / tenantOwned). Pick one — each field belongs to exactly one subject.`,
64
+ );
65
+ }
66
+
67
+ if (annot.userOwned) {
68
+ const ownerName = annot.userOwned.ownerField;
69
+ if (!ownerName || typeof ownerName !== "string") {
70
+ throw new Error(
71
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has userOwned without ownerField name`,
72
+ );
73
+ }
74
+ const ownerField = fieldsByName[ownerName];
75
+ if (!ownerField) {
76
+ const known = Object.keys(fieldsByName).sort().join(", ");
77
+ throw new Error(
78
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" references userOwned.ownerField "${ownerName}" but no such field exists. Known fields: ${known}`,
79
+ );
80
+ }
81
+ if (ownerField.type !== "reference") {
82
+ throw new Error(
83
+ `[Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" must be a reference field, got type "${ownerField.type}"`,
84
+ );
85
+ }
86
+ // Soft-Warning wenn das reference-target nicht offensichtlich user
87
+ // ist — custom subject-entities (HR-Mitarbeiter, Patient) sind
88
+ // erlaubt, müssen aber bewusste Wahl sein.
89
+ const refTarget = ownerField.entity;
90
+ const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
91
+ if (targetEntity !== "user") {
92
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
93
+ console.warn(
94
+ `[kumiko:boot] [Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`,
95
+ );
96
+ }
97
+ }
98
+
99
+ // PII-Heuristik: nur wenn keine Annotation gesetzt UND kein
100
+ // allowPlaintext-Marker. Ergibt false positives auf Geschäftsdaten
101
+ // mit personenartigem Namen (z.B. company.legalName) — Author
102
+ // unterdrückt mit { allowPlaintext: "is-business-data" }.
103
+ const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
104
+ if (noAnnotation) {
105
+ const lower = fieldName.toLowerCase();
106
+ if (PII_DIRECT_NAME_HINTS.has(lower)) {
107
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
108
+ console.warn(
109
+ `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no { pii: true } annotation. If this is PII, mark it. If business data, set { allowPlaintext: "is-business-data" } to silence.`,
110
+ );
111
+ } else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
112
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
113
+ console.warn(
114
+ `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no { userOwned } annotation. If this contains user-generated content, mark it { userOwned: { ownerField: "<authorIdField>" }}. If business data, set { allowPlaintext: "..." } to silence.`,
115
+ );
116
+ }
117
+ }
118
+ }
119
+
120
+ // --- Entity-level retention ---
121
+ const retention = entity.retention;
122
+ if (retention) {
123
+ if (!KEEP_FOR_PATTERN.test(retention.keepFor)) {
124
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
125
+ console.warn(
126
+ `[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.keepFor="${retention.keepFor}" hat ungueltiges Format. Erwartet: <Zahl><h|d|w|m|y> (z.B. "30d", "10y", "6m"). Cleanup-Job (Sprint 2) wird das nicht parsen koennen.`,
127
+ );
128
+ }
129
+
130
+ if (retention.reference !== undefined) {
131
+ const refName = retention.reference;
132
+ if (!fieldsByName[refName] && !FRAMEWORK_TIMESTAMP_FIELDS.has(refName)) {
133
+ const known = Object.keys(fieldsByName).sort().join(", ");
134
+ const framework = [...FRAMEWORK_TIMESTAMP_FIELDS].sort().join(", ");
135
+ throw new Error(
136
+ `[Feature ${feature.name}] Entity "${entityName}" retention.reference "${refName}" does not exist. Known fields: ${known} — framework-managed timestamps also accepted: ${framework}`,
137
+ );
138
+ }
139
+ }
140
+
141
+ if (retention.strategy === "blockDelete") {
142
+ const hasAnonymize = Object.values(fieldsByName).some((f) => {
143
+ const a = f as PiiAnnotations; // @cast-boundary schema-walk
144
+ return Boolean(a.anonymize);
145
+ });
146
+ if (!hasAnonymize) {
147
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
148
+ console.warn(
149
+ `[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`,
150
+ );
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }