@cosmicdrift/kumiko-framework 0.322.0 → 0.323.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.322.0",
3
+ "version": "0.323.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -202,8 +202,8 @@
202
202
  "./package.json": "./package.json"
203
203
  },
204
204
  "dependencies": {
205
- "@cosmicdrift/kumiko-http": "0.322.0",
206
- "@cosmicdrift/kumiko-types": "0.322.0",
205
+ "@cosmicdrift/kumiko-http": "0.323.0",
206
+ "@cosmicdrift/kumiko-types": "0.323.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "bun-types": "^1.3.13",
209
209
  "hono": "^4.13.1",
@@ -219,7 +219,7 @@
219
219
  "zod": "^4.4.3"
220
220
  },
221
221
  "devDependencies": {
222
- "@cosmicdrift/kumiko-dispatcher-live": "0.322.0",
222
+ "@cosmicdrift/kumiko-dispatcher-live": "0.323.0",
223
223
  "bun-types": "^1.3.13",
224
224
  "pino-pretty": "^13.1.3"
225
225
  },
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import type { Context } from "hono";
3
3
  import { Hono } from "hono";
4
4
  import { deleteCookie, setCookie } from "hono/cookie";
5
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
5
6
  import type Redis from "ioredis";
6
7
  import * as z from "zod";
7
8
  import { buildSessionRoles } from "../engine/membership-roles";
@@ -13,7 +14,7 @@ import {
13
14
  SYSTEM_TENANT_ID,
14
15
  type TenantId,
15
16
  } from "../engine/types";
16
- import { NotFoundError } from "../errors";
17
+ import { NotFoundError, toKumikoError, toWriteErrorInfo } from "../errors";
17
18
  import type { Dispatcher } from "../pipeline/dispatcher";
18
19
  import { assertUnreachable } from "../utils";
19
20
  import { parseRoles } from "../utils/serialization";
@@ -184,6 +185,10 @@ const InviteSignupCompleteBody = z.object({
184
185
  password: z.string().min(8).max(200),
185
186
  });
186
187
 
188
+ const InviteInfoBody = z.object({
189
+ token: z.string().min(1),
190
+ });
191
+
187
192
  // Shape guard for "handler not registered" — the only legitimate reason to
188
193
  // fall back to a single-tenant reply on /auth/tenants or /auth/switch-tenant.
189
194
  // Every other error (DB down, revoker throws, access denied, …) has to
@@ -503,6 +508,10 @@ export type InviteConfig = {
503
508
  readonly acceptHandler: string;
504
509
  readonly acceptWithLoginHandler: string;
505
510
  readonly signupCompleteHandler: string;
511
+ // Optional: qualified query handler name for the anonymous invite-info
512
+ // lookup (email + hasAccount for a pending invite, token not consumed).
513
+ // When unset, POST /auth/invite-info is not mounted.
514
+ readonly infoHandler?: string;
506
515
  };
507
516
 
508
517
  // Magic-Link Self-Signup. Anders als reset/verify NICHT HMAC-signed —
@@ -1315,6 +1324,39 @@ export function createAuthRoutes(
1315
1324
  ...landingPath,
1316
1325
  });
1317
1326
  });
1327
+
1328
+ // Anonymous, read-only invite lookup for the acceptance page: pre-fill
1329
+ // the invited email and pick Branch 2 (existing account) vs Branch 3
1330
+ // (new account) before the user submits. Token is never consumed here.
1331
+ if (inv.infoHandler) {
1332
+ const infoHandler = inv.infoHandler;
1333
+ api.post(Routes.authInviteInfo, async (c) => {
1334
+ const raw = await c.req.json().catch(() => null);
1335
+ const parsed = InviteInfoBody.safeParse(raw);
1336
+ if (!parsed.success) {
1337
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
1338
+ }
1339
+ try {
1340
+ // @cast-boundary engine-payload — generic dispatcher.query result
1341
+ const data = (await dispatcher.query(
1342
+ infoHandler,
1343
+ parsed.data,
1344
+ createAnonymousUser(SYSTEM_TENANT_ID),
1345
+ )) as { email: string; hasAccount: boolean };
1346
+ return c.json({ isSuccess: true, ...data });
1347
+ } catch (e) {
1348
+ const err = toKumikoError(e);
1349
+ // Same body shape as the write routes' `{ isSuccess: false, error:
1350
+ // result.error }` (WriteErrorInfo) — this is a query, not a write,
1351
+ // so there's no WriteResult to read `.error` off; toWriteErrorInfo
1352
+ // builds the equivalent shape directly from the caught error.
1353
+ return c.json(
1354
+ { isSuccess: false, error: toWriteErrorInfo(err) },
1355
+ err.httpStatus as ContentfulStatusCode, // @cast-boundary engine-payload
1356
+ );
1357
+ }
1358
+ });
1359
+ }
1318
1360
  }
1319
1361
 
1320
1362
  // POST /auth/logout — revokes the current session. Requires a valid JWT so
package/src/changes.json CHANGED
@@ -1,4 +1,24 @@
1
1
  [
2
+ {
3
+ "version": "0.323.0",
4
+ "type": "improvement",
5
+ "title": "auth-routes: optional invite.infoHandler mounts POST /auth/invite-info",
6
+ "detail": "InviteConfig gained an optional infoHandler (qualified query-handler name). When set, createAuthRoutes mounts POST /auth/invite-info, dispatching the query anonymously and returning {isSuccess:true, email, hasAccount} or the query's serialized error.",
7
+ "migration": "No action needed: infoHandler is optional and existing invite configs without it are unaffected — the route simply isn't mounted."
8
+ },
9
+ {
10
+ "version": "0.323.0",
11
+ "type": "fix",
12
+ "title": "kms.eraseKey is now gated by the agent-risk floor for irreversible operations",
13
+ "migration": "`configurePiiSubjectKms(adapter)` now stores a wrapper whose `eraseKey`\nruns `assertIrreversibleOperationAllowed(\"kms.eraseKey\")` before\ndelegating — the same agent-risk floor `executor.forget()`/`delete()`\nalready enforce. A directly-dispatched write handler that calls\n`configuredPiiSubjectKms()!.eraseKey(...)` must resolve `agent.risk:\n\"high\"`, or the call is denied with `access_denied` /\n`irreversible_operation_requires_high_risk`.\n\nCron-triggered jobs and pipelines started by a `risk: \"high\"` handler are\nunaffected — they run outside any entry handler or already resolve\n\"high\". `crypto-shredding:write:forget-subject`, the manual\n`run-forget-cleanup` operator trigger, and framework-internal cleanup\nhooks already satisfy this."
14
+ },
15
+ {
16
+ "version": "0.323.0",
17
+ "type": "breaking",
18
+ "title": "createTextField/createLongTextField require an explicit personal-data stance",
19
+ "detail": "Both factories' `overrides` parameter is no longer optional and must\ninclude a `personal` stance:\n`{ personal: \"self\", find: \"exact\" | \"fuzzy\" | \"none\" | \"secret\" }`\n(longText: `find: \"none\" | \"secret\"`), `{ personal: \"tenant\", find: … }`,\n`{ personal: { of: \"<ownerField>\" }, find: … }`, `{ personal: \"ref\" }`, or\n`{ personal: false, reason: \"<why this is not personal data>\" }`. A\nmissing/malformed shape throws at the call site (`createTextField(...)` /\n`createLongTextField(...)` in the message, with every valid option\nlisted) instead of silently resolving to an unannotated field — this\nalso removes the #2918 boot-time deprecation warning, since the shape it\nwarned about can no longer be constructed.",
20
+ "migration": "Two call forms now throw: `createTextField()` / `createLongTextField()`\nwith no argument at all, and an options object with no `personal` key,\ne.g. `createTextField({ required: true })`. Add a stance to every call:\n\n createTextField({ personal: \"self\", find: \"exact\" })\n createTextField({ personal: \"tenant\", find: \"none\" })\n createTextField({ personal: { of: \"authorId\" }, find: \"none\" })\n createTextField({ personal: \"ref\" })\n createTextField({ personal: false, reason: \"is_business_data\" })\n\n`personal: false` additionally requires a non-empty `reason` string.\n`find` is mandatory whenever `personal` names a subject (`\"self\"` /\n`\"tenant\"` / `{ of }`) — not for `\"ref\"` or `false`. The #2918 boot-time\ndeprecation warning for this shape is gone; there's nothing left for it\nto warn about.\n\nTo find remaining call sites (the #2919 codemod mode below is the\nmigration tool): `kumiko-guards guards\n--guard=\"Text-Field Personal-Stance Guard\"` flags every\nstatically-decidable call missing a stance. `bun\nnode_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/pii-personal-migration.ts\n<targetDir> --report-stance` classifies unannotated text/longText fields\nby name heuristic (direct/user-owned/user-reference/near-miss/\nunclassified) to help pick the right stance — it does not add one\nautomatically, since a wrong guess would be worse than the throw. This\ncodemod is not wired into `kumiko upgrade --apply`; run it by hand. It\nalso only transforms fields still carrying the OLD flag-based API\n(`pii`/`userOwned`/`tenantOwned`/`subjectRef`/`allowPlaintext`) — a field\nwith no annotation at all was never in its mapping table and needs a\nstance added by hand either way."
21
+ },
2
22
  {
3
23
  "version": "0.322.0",
4
24
  "type": "breaking",
@@ -13,6 +13,7 @@
13
13
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
14
14
  import type { EntityDefinition } from "../engine/types/fields";
15
15
  import type { TenantId } from "../engine/types/identifiers";
16
+ import { assertIrreversibleOperationAllowed } from "../pipeline/irreversible-operation-gate";
16
17
  import {
17
18
  isLocalKeyKmsAdapter,
18
19
  KeyAlreadyExistsError,
@@ -231,6 +232,20 @@ export async function decryptPiiFieldValues(
231
232
  // (phase E); until then no production deployment can satisfy it.
232
233
  let injectedKms: LocalKeyKmsAdapter | undefined;
233
234
 
235
+ // Explicit delegation: adapters are class instances with prototype methods (no spread/Object.create).
236
+ function gateIrreversibleKmsErase(adapter: LocalKeyKmsAdapter): LocalKeyKmsAdapter {
237
+ return {
238
+ capabilities: adapter.capabilities,
239
+ createKey: (subject, ctx) => adapter.createKey(subject, ctx),
240
+ getKey: (subject, ctx) => adapter.getKey(subject, ctx),
241
+ health: () => adapter.health(),
242
+ eraseKey: async (subject, ctx) => {
243
+ assertIrreversibleOperationAllowed("kms.eraseKey");
244
+ await adapter.eraseKey(subject, ctx);
245
+ },
246
+ };
247
+ }
248
+
234
249
  export function configurePiiSubjectKms(adapter: KmsAdapter | undefined): void {
235
250
  if (adapter !== undefined && !isLocalKeyKmsAdapter(adapter)) {
236
251
  throw new Error(
@@ -238,7 +253,7 @@ export function configurePiiSubjectKms(adapter: KmsAdapter | undefined): void {
238
253
  "(Vault transit) support lands with the BYOK adapter.",
239
254
  );
240
255
  }
241
- injectedKms = adapter;
256
+ injectedKms = adapter && gateIrreversibleKmsErase(adapter);
242
257
  }
243
258
 
244
259
  export function configuredPiiSubjectKms(): LocalKeyKmsAdapter | undefined {
@@ -650,128 +650,6 @@ describe("validateBoot — PII annotations", () => {
650
650
  );
651
651
  expect(matchingWarn).toBeDefined();
652
652
  });
653
-
654
- // --- #2918: deprecation warning for text fields without any stance ---
655
-
656
- const NO_STANCE = "declares no personal stance";
657
-
658
- test("text field without a personal stance warns, naming feature, entity and field", () => {
659
- const feature = defineFeature("test", (r) => {
660
- r.entity(
661
- "invoice",
662
- createEntity({
663
- fields: {
664
- externalRef: { ...unannotatedText },
665
- },
666
- }),
667
- );
668
- });
669
- validateBoot([feature]);
670
- const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
671
- String(args[0]).includes(NO_STANCE),
672
- );
673
- expect(matchingWarn).toBeDefined();
674
- const message = String((matchingWarn as unknown[])[0]);
675
- expect(message).toContain("[Feature test]");
676
- expect(message).toContain('Field "externalRef" on entity "invoice"');
677
- expect(message).toContain('{ reason: "..." }');
678
- });
679
-
680
- test("longText field without a personal stance warns", () => {
681
- const feature = defineFeature("test", (r) => {
682
- r.entity(
683
- "invoice",
684
- createEntity({
685
- fields: {
686
- remarks: { ...unannotatedLongText },
687
- },
688
- }),
689
- );
690
- });
691
- validateBoot([feature]);
692
- const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
693
- String(args[0]).includes(NO_STANCE),
694
- );
695
- expect(matchingWarn).toBeDefined();
696
- });
697
-
698
- test("personal: false with a reason silences the stance warning", () => {
699
- const feature = defineFeature("test", (r) => {
700
- r.entity(
701
- "invoice",
702
- createEntity({
703
- fields: {
704
- externalRef: createTextField({
705
- personal: false,
706
- reason: "is_business_data",
707
- }),
708
- },
709
- }),
710
- );
711
- });
712
- validateBoot([feature]);
713
- const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
714
- String(args[0]).includes(NO_STANCE),
715
- );
716
- expect(matchingWarn).toBeUndefined();
717
- });
718
-
719
- test("personal: self silences the stance warning", () => {
720
- const feature = defineFeature("test", (r) => {
721
- r.entity(
722
- "invoice",
723
- createEntity({
724
- fields: {
725
- externalRef: createTextField({
726
- personal: "self",
727
- find: "none",
728
- }),
729
- },
730
- }),
731
- );
732
- });
733
- validateBoot([feature]);
734
- const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
735
- String(args[0]).includes(NO_STANCE),
736
- );
737
- expect(matchingWarn).toBeUndefined();
738
- });
739
-
740
- test("personal: ref silences the stance warning", () => {
741
- const feature = defineFeature("test", (r) => {
742
- r.entity(
743
- "invoice",
744
- createEntity({
745
- fields: {
746
- ownerRef: createTextField({ personal: "ref" }),
747
- },
748
- }),
749
- );
750
- });
751
- validateBoot([feature]);
752
- const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
753
- String(args[0]).includes(NO_STANCE),
754
- );
755
- expect(matchingWarn).toBeUndefined();
756
- });
757
-
758
- test("number field without a personal stance does not warn", () => {
759
- const feature = defineFeature("test", (r) => {
760
- r.entity(
761
- "invoice",
762
- createEntity({
763
- fields: {
764
- total: { type: "number", required: false },
765
- },
766
- }),
767
- );
768
- });
769
- validateBoot([feature]);
770
- const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
771
- String(args[0]).includes(NO_STANCE),
772
- );
773
- expect(matchingWarn).toBeUndefined();
774
- });
775
653
  });
776
654
 
777
655
  describe("validateBoot — retention", () => {
@@ -17,9 +17,11 @@ import type { LongTextFieldDef } from "../types";
17
17
  // =============================================================================
18
18
 
19
19
  describe("createLongTextField — runtime shape", () => {
20
- test("default returns { type: 'longText', required: false }", () => {
21
- const f = createLongTextField();
22
- expect(f).toEqual({ type: "longText", required: false });
20
+ test("kumiko-framework#2921: no argument throws — a longText field must declare a personal stance", () => {
21
+ expect(() =>
22
+ // @ts-expect-error overrides (with a personal stance) is now required
23
+ createLongTextField(),
24
+ ).toThrow(/must declare an explicit personal-data stance/);
23
25
  });
24
26
 
25
27
  test("required: true is preserved as literal in the return type", () => {
@@ -3,21 +3,84 @@
3
3
  // ../factories, exercised through the public factory functions.
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { createLongTextField, createTextField, createTimestampField } from "../factories";
6
+ import {
7
+ createLongTextField,
8
+ createNumberField,
9
+ createTextField,
10
+ createTimestampField,
11
+ } from "../factories";
7
12
 
8
13
  describe("createTextField — personal/find resolution", () => {
9
- test("no annotation at all: no PII flags, no personal/find/reason leak into the field", () => {
10
- const f = createTextField({ required: true });
11
- expect(f).toEqual({
12
- type: "text",
13
- maxLength: 200,
14
- required: true,
15
- searchable: false,
16
- sortable: false,
17
- });
18
- expect(f).not.toHaveProperty("personal");
19
- expect(f).not.toHaveProperty("find");
20
- expect(f).not.toHaveProperty("reason");
14
+ // kumiko-framework#2921 — a text field can no longer be constructed
15
+ // without declaring whose data it holds, neither at the type level nor
16
+ // at runtime (fail-closed for untyped JS callers).
17
+ test("no argument at all throws", () => {
18
+ expect(() =>
19
+ // @ts-expect-error overrides (with a personal stance) is now required
20
+ createTextField(),
21
+ ).toThrow(/must declare an explicit personal-data stance/);
22
+ });
23
+
24
+ test("options without `personal` throws", () => {
25
+ expect(() =>
26
+ // @ts-expect-error `personal` is required on the options object
27
+ createTextField({ required: true }),
28
+ ).toThrow(/createTextField\(\.\.\.\) must declare an explicit personal-data stance/);
29
+ });
30
+
31
+ test("an untyped JS caller passing null throws the same guidance instead of a raw TypeError", () => {
32
+ // Simulated untyped-boundary call — a real JS consumer has no compiler
33
+ // to reject this, so the runtime gate must catch it. Cast only here.
34
+ const call = () => createTextField(null as unknown as Parameters<typeof createTextField>[0]);
35
+ expect(call).toThrow(/must declare an explicit personal-data stance/);
36
+ });
37
+
38
+ test("an untyped JS caller passing personal: null throws the guidance message, not a raw TypeError", () => {
39
+ // Same untyped-boundary simulation, but `personal` itself is null
40
+ // instead of the whole options object — the object-branch subject
41
+ // check must not dereference `.of` on it.
42
+ const call = () =>
43
+ createTextField({ personal: null } as unknown as Parameters<typeof createTextField>[0]);
44
+ expect(call).toThrow(/must declare an explicit personal-data stance/);
45
+ });
46
+
47
+ test('personal: "self" without find throws', () => {
48
+ expect(() =>
49
+ // @ts-expect-error `find` is required alongside a named subject
50
+ createTextField({ personal: "self" }),
51
+ ).toThrow(/must declare an explicit personal-data stance/);
52
+ });
53
+
54
+ test("personal: false without a reason throws", () => {
55
+ expect(() =>
56
+ // @ts-expect-error `reason` is required alongside `personal: false`
57
+ createTextField({ personal: false }),
58
+ ).toThrow(/must declare an explicit personal-data stance/);
59
+ });
60
+
61
+ test("personal: false with a reason works", () => {
62
+ const f = createTextField({ personal: false, reason: "is_business_data" });
63
+ expect(f.allowPlaintext).toBe("is_business_data");
64
+ });
65
+
66
+ test("an invalid personal value throws and lists every valid stance", () => {
67
+ const call = () =>
68
+ createTextField({
69
+ // @ts-expect-error "yes" is not a valid personal stance
70
+ personal: "yes",
71
+ });
72
+ expect(call).toThrow(/must declare an explicit personal-data stance/);
73
+ try {
74
+ call();
75
+ throw new Error("expected createTextField to throw");
76
+ } catch (error) {
77
+ const message = String(error);
78
+ expect(message).toContain('personal: "self"');
79
+ expect(message).toContain('personal: "tenant"');
80
+ expect(message).toContain('personal: { of: "<ownerField>" }');
81
+ expect(message).toContain('personal: "ref"');
82
+ expect(message).toContain("personal: false, reason:");
83
+ }
21
84
  });
22
85
 
23
86
  test('personal: "self", find: "exact" → pii + lookupable', () => {
@@ -108,6 +171,16 @@ describe("createLongTextField — restricted find (PersonalAnnotationsLongText)"
108
171
  expect(f.lookupable).toBeUndefined();
109
172
  expect(f).not.toHaveProperty("searchable");
110
173
  });
174
+
175
+ test('find: "exact" is a createTextField-only value — createLongTextField rejects it', () => {
176
+ expect(() =>
177
+ createLongTextField({
178
+ personal: "self",
179
+ // @ts-expect-error "exact"/"fuzzy" don't exist on LongTextFindability
180
+ find: "exact",
181
+ }),
182
+ ).toThrow(/must declare an explicit personal-data stance/);
183
+ });
111
184
  });
112
185
 
113
186
  describe("createTimestampField — personal without find (PersonalAnnotationsNoFind)", () => {
@@ -127,4 +200,17 @@ describe("createTimestampField — personal without find (PersonalAnnotationsNoF
127
200
  const f = createTimestampField({ personal: false, reason: "server-generated, not user data" });
128
201
  expect(f.allowPlaintext).toBe("server-generated, not user data");
129
202
  });
203
+
204
+ // kumiko-framework#2921 only tightens text/longText — NoFind factories
205
+ // keep `personal` optional and stay callable without an argument.
206
+ test("createTimestampField() without any argument still works", () => {
207
+ const f = createTimestampField();
208
+ expect(f).toEqual({ type: "timestamp", required: false });
209
+ });
210
+
211
+ test("createNumberField() without any argument still works", () => {
212
+ const f = createNumberField();
213
+ expect(f.type).toBe("number");
214
+ expect(f).not.toHaveProperty("personal");
215
+ });
130
216
  });
@@ -213,14 +213,6 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
213
213
  console.warn(
214
214
  `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
215
215
  );
216
- } else if (!annot.subjectRef && (field.type === "text" || field.type === "longText")) {
217
- // Deprecation step towards #2810, where a text field without a
218
- // stance becomes a boot error. Only text/longText: the other field
219
- // types keep `personal` optional.
220
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
221
- console.warn(
222
- `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Text fields will require one — this warning becomes a boot error in a future release (#2810). Declare { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; "false" additionally needs { reason: "..." } stating why the value is not personal data.`,
223
- );
224
216
  }
225
217
  }
226
218
  }
@@ -88,6 +88,77 @@ function expandPersonalAnnotations<T extends PersonalOverridesInput>(
88
88
  } as Omit<T, "personal" | "find" | "reason"> & ResolvedPiiFlags;
89
89
  }
90
90
 
91
+ type TextFieldStanceFactory = "createTextField" | "createLongTextField";
92
+
93
+ // `find` is mandatory once a subject is named (types require it — see
94
+ // PersonalAnnotations / PersonalAnnotationsLongText), but the valid values
95
+ // differ per factory: longText has no lookup/search machinery.
96
+ const TEXT_FIELD_STANCE_FIND_VALUES: Record<TextFieldStanceFactory, readonly Findability[]> = {
97
+ createTextField: ["exact", "fuzzy", "none", "secret"],
98
+ createLongTextField: ["none", "secret"],
99
+ };
100
+
101
+ function textFieldStanceHint(factoryName: TextFieldStanceFactory): string {
102
+ const find = TEXT_FIELD_STANCE_FIND_VALUES[factoryName].map((f) => `"${f}"`).join(" | ");
103
+ return (
104
+ `{ personal: "self", find: ${find} }, ` +
105
+ `{ personal: "tenant", find: ${find} }, ` +
106
+ `{ personal: { of: "<ownerField>" }, find: ${find} }, ` +
107
+ `{ personal: "ref" }, or ` +
108
+ `{ personal: false, reason: "<why this is not personal data>" }`
109
+ );
110
+ }
111
+
112
+ function missingTextFieldStanceError(factoryName: TextFieldStanceFactory): Error {
113
+ return new Error(
114
+ `${factoryName}(...) must declare an explicit personal-data stance — a text field must state whose data it holds and (unless it isn't personal data) how findable it needs to stay (kumiko-framework#2921). Pass one of: ${textFieldStanceHint(factoryName)}. Run \`kumiko-guards guards --guard="Text-Field Personal-Stance Guard"\` to find remaining call sites.`,
115
+ );
116
+ }
117
+
118
+ function isNonEmptyString(value: unknown): value is string {
119
+ return typeof value === "string" && value.trim().length > 0;
120
+ }
121
+
122
+ // Fail-closed stance gate (kumiko-framework#2921) for the two text-shaped
123
+ // factories — runs before expandPersonalAnnotations so an untyped JS caller
124
+ // (no compiler to stop it) can't slip an unannotated text field past the
125
+ // type layer either. `overrides` is `unknown` here on purpose: the typed
126
+ // signature above already rejects a missing/malformed shape at compile
127
+ // time, this re-checks the same shape at the runtime boundary.
128
+ function assertTextFieldStance(factoryName: TextFieldStanceFactory, overrides: unknown): void {
129
+ if (overrides === null || typeof overrides !== "object") {
130
+ throw missingTextFieldStanceError(factoryName);
131
+ }
132
+ // @cast-boundary runtime-guard — re-validated field by field below before
133
+ // any value is trusted.
134
+ const { personal, find, reason } = overrides as PersonalOverridesInput;
135
+ if (personal === undefined || personal === null) {
136
+ throw missingTextFieldStanceError(factoryName);
137
+ }
138
+ if (personal === "ref") {
139
+ // skip: a user reference carries no content of its own, so no `find` applies
140
+ return;
141
+ }
142
+ if (personal === false) {
143
+ if (!isNonEmptyString(reason)) {
144
+ throw missingTextFieldStanceError(factoryName);
145
+ }
146
+ // skip: plaintext by declaration, `find` only applies to a named subject
147
+ return;
148
+ }
149
+ const hasValidSubject =
150
+ personal === "self" ||
151
+ personal === "tenant" ||
152
+ (typeof personal === "object" && isNonEmptyString(personal.of));
153
+ if (!hasValidSubject) {
154
+ throw missingTextFieldStanceError(factoryName);
155
+ }
156
+ const validFinds = TEXT_FIELD_STANCE_FIND_VALUES[factoryName];
157
+ if (typeof find !== "string" || !(validFinds as readonly string[]).includes(find)) {
158
+ throw missingTextFieldStanceError(factoryName);
159
+ }
160
+ }
161
+
91
162
  // Generic über `R extends true | false` (statt `boolean`) damit
92
163
  // `createTextField({ required: true })` literal `required: true` im
93
164
  // Return-Type behält. `boolean` würde widenen — EntityTable<E>'s
@@ -96,9 +167,10 @@ function expandPersonalAnnotations<T extends PersonalOverridesInput>(
96
167
  // degradieren. Default `R = false` matcht den runtime-default. Pattern
97
168
  // in jeder required-bearing factory unten.
98
169
  export function createTextField<R extends true | false = false>(
99
- overrides?: Partial<Omit<TextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
170
+ overrides: Partial<Omit<TextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
100
171
  PersonalAnnotations & { required?: R },
101
172
  ): TextFieldDef & { required: R } {
173
+ assertTextFieldStance("createTextField", overrides);
102
174
  return {
103
175
  type: "text",
104
176
  maxLength: 200,
@@ -131,9 +203,10 @@ export function createDerivedField(spec: DerivedFieldDef): DerivedFieldDef {
131
203
  }
132
204
 
133
205
  export function createLongTextField<R extends true | false = false>(
134
- overrides?: Partial<Omit<LongTextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
206
+ overrides: Partial<Omit<LongTextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
135
207
  PersonalAnnotationsLongText & { required?: R },
136
208
  ): LongTextFieldDef & { required: R } {
209
+ assertTextFieldStance("createLongTextField", overrides);
137
210
  return {
138
211
  type: "longText",
139
212
  required: false,
@@ -11,9 +11,13 @@ export function isIrreversibleEntityVerb(
11
11
  return verb === "forget" || !entity.softDelete;
12
12
  }
13
13
 
14
- // No entryHandler means the call didn't originate from a directly-dispatched
15
- // handler (job, MSP/consumer apply, seed, afterCommit hook, HTTP route
16
- // without a handler) — those are outside agent reach and stay ungated.
14
+ // The gate reads the directly dispatched entry handler; a write dispatched inside a deferred
15
+ // context (afterCommit hook, job, consumer) gets its own entryHandler and is gated too. Direct
16
+ // executor/KMS calls without one stay ungated in exactly two cases: deleting derived data
17
+ // rebuildable from a reversible source (document-ingest-foundation's forget-extract-with-file-ref
18
+ // consumer, re-created on fileRef.restored), and pipelines started by a risk "high" handler or a
19
+ // cron scan (forget cleanup, retention, tenant destroy). A deferred context can't return
20
+ // irreversibleOperationRequiresHighRisk to the agent synchronously, so the gate sits at the primitives.
17
21
  export function assertIrreversibleOperationAllowed(operation: string): void {
18
22
  const entryHandler = requestContext.get()?.entryHandler;
19
23
  // skip: no entry handler (see above) or the entry handler already resolves "high".