@cosmicdrift/kumiko-framework 0.395.0 → 0.397.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 (35) hide show
  1. package/agents/examples/ownership.md +3 -3
  2. package/dist/crypto/index.d.ts +1 -0
  3. package/dist/crypto/index.js +1 -0
  4. package/dist/crypto/pii-ciphertext-corrupt.d.ts +8 -0
  5. package/dist/crypto/pii-ciphertext-corrupt.js +18 -0
  6. package/dist/crypto/pii-field-encryption.js +33 -11
  7. package/dist/db/entity-column-meta.js +1 -1
  8. package/dist/db/event-store-executor-context.d.ts +2 -0
  9. package/dist/db/event-store-executor-context.js +17 -0
  10. package/dist/db/event-store-executor-read-paging.js +7 -7
  11. package/dist/db/event-store-executor-read-sort.d.ts +4 -1
  12. package/dist/db/event-store-executor-read-sort.js +19 -4
  13. package/dist/db/event-store-executor-write.js +24 -1
  14. package/dist/db/queries/backfill-pii.d.ts +2 -0
  15. package/dist/db/queries/backfill-pii.js +2 -2
  16. package/dist/db/queries/event-store-payload-scrub.d.ts +12 -0
  17. package/dist/db/queries/event-store-payload-scrub.js +20 -0
  18. package/dist/engine/entity-handlers.js +9 -2
  19. package/dist/engine/factories.js +1 -1
  20. package/dist/engine/schema-builder.js +4 -2
  21. package/dist/files/feature.d.ts +1 -0
  22. package/dist/files/feature.js +16 -3
  23. package/dist/files/file-ref-entity.d.ts +1 -0
  24. package/dist/files/file-ref-table.d.ts +1 -0
  25. package/dist/files/sweep-stale-pending-file-refs.d.ts +10 -0
  26. package/dist/files/sweep-stale-pending-file-refs.js +46 -0
  27. package/dist/migrations/event-payload-scrub.d.ts +31 -0
  28. package/dist/migrations/event-payload-scrub.js +132 -0
  29. package/dist/migrations/index.d.ts +1 -0
  30. package/dist/migrations/index.js +2 -0
  31. package/dist/migrations/pii-event-backfill.d.ts +10 -1
  32. package/dist/migrations/pii-event-backfill.js +18 -9
  33. package/dist/pipeline/dispatch-write.js +3 -0
  34. package/package.json +4 -4
  35. package/src/changes.json +43 -0
@@ -203,14 +203,14 @@ describe("entity-level write: create/update/delete blocked on foreign rows", ()
203
203
  });
204
204
 
205
205
  test("TeamMember eng CANNOT update the ops row (not visible → not modifiable)", async () => {
206
- // The row exists but entity-level ownership rejects the update.
207
- // version is guessed — doesn't matter, the ownership check fires first.
206
+ // The row exists, but a row the caller cannot read answers like a missing
207
+ // one, so the write does not reveal it. The guessed version never matters.
208
208
  const err = await stack.http.writeErr(
209
209
  "contracts:write:contract:update",
210
210
  { id: opsRowId, version: 1, changes: { propA: "hack" } },
211
211
  teamMemberEng,
212
212
  );
213
- expectErrorIncludes(err, "ownership_denied");
213
+ expectErrorIncludes(err, "not_found");
214
214
  });
215
215
 
216
216
  test("TeamMember eng CANNOT move their row to another team (teamId rewrite blocked)", async () => {
@@ -7,5 +7,6 @@ export { KeyAlreadyExistsError, KeyErasedError, type KmsAdapter, type KmsContext
7
7
  export { ENCRYPTION_UNAVAILABLE_CODE, EncryptionUnavailableError } from "./kms-outage.ts";
8
8
  export { type KmsWiring, requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.ts";
9
9
  export { createPgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.ts";
10
+ export { PII_CIPHERTEXT_CORRUPT_CODE, PiiCiphertextCorruptError, } from "./pii-ciphertext-corrupt.ts";
10
11
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.ts";
11
12
  export { collectPiiEncryptedFields, collectPiiSubjectFields, isRecordKeyedField, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.ts";
@@ -7,5 +7,6 @@ export { KeyAlreadyExistsError, KeyErasedError, subjectIdSchema, subjectIdToKey,
7
7
  export { ENCRYPTION_UNAVAILABLE_CODE, EncryptionUnavailableError } from "./kms-outage.js";
8
8
  export { requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.js";
9
9
  export { createPgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
10
+ export { PII_CIPHERTEXT_CORRUPT_CODE, PiiCiphertextCorruptError, } from "./pii-ciphertext-corrupt.js";
10
11
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
11
12
  export { collectPiiEncryptedFields, collectPiiSubjectFields, isRecordKeyedField, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
@@ -0,0 +1,8 @@
1
+ import { KumikoError } from "../errors/kumiko-error.ts";
2
+ export declare const PII_CIPHERTEXT_CORRUPT_CODE = "pii_ciphertext_corrupt";
3
+ export declare class PiiCiphertextCorruptError extends KumikoError {
4
+ readonly field: string;
5
+ readonly code = "pii_ciphertext_corrupt";
6
+ readonly httpStatus = 500;
7
+ constructor(field: string, cause?: Error);
8
+ }
@@ -0,0 +1,18 @@
1
+ import { KumikoError } from "../errors/kumiko-error.js";
2
+ export const PII_CIPHERTEXT_CORRUPT_CODE = "pii_ciphertext_corrupt";
3
+ // The stored value does not decrypt under its subject's key although the key store answered:
4
+ // damaged bytes, a value copied into another field or subject (the AAD binds both), or a value
5
+ // written under another key. A retry fails the same way, unlike EncryptionUnavailableError.
6
+ export class PiiCiphertextCorruptError extends KumikoError {
7
+ field;
8
+ code = PII_CIPHERTEXT_CORRUPT_CODE;
9
+ httpStatus = 500;
10
+ constructor(field, cause) {
11
+ super({
12
+ message: `the stored PII value of field "${field}" does not decrypt under its subject key`,
13
+ i18nKey: "errors.internal",
14
+ ...(cause && { cause }),
15
+ });
16
+ this.field = field;
17
+ }
18
+ }
@@ -18,6 +18,7 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
18
18
  import { assertIrreversibleOperationAllowed } from "../pipeline/irreversible-operation-gate.js";
19
19
  import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, PII_ERASED_SENTINEL, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
20
20
  import { classifyKmsOutage } from "./kms-outage.js";
21
+ import { PiiCiphertextCorruptError } from "./pii-ciphertext-corrupt.js";
21
22
  import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_CIPHERTEXT_PREFIX_V1, } from "./pii-ciphertext-format.js";
22
23
  import { resolveSubjectForField } from "./subject-resolver.js";
23
24
  // Spec value (crypto-shredding.md) — renderers show it verbatim.
@@ -58,14 +59,35 @@ function parseCiphertext(value) {
58
59
  };
59
60
  }
60
61
  function decryptValue(dek, blob, subject, field, hasAad, isJson) {
61
- const iv = blob.subarray(0, IV_LENGTH);
62
- const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
63
- const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
64
- const decipher = createDecipheriv("aes-256-gcm", dek, iv);
65
- if (hasAad)
66
- decipher.setAAD(buildAad(subject, field, isJson));
67
- decipher.setAuthTag(tag);
68
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
62
+ try {
63
+ const iv = blob.subarray(0, IV_LENGTH);
64
+ const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
65
+ const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
66
+ const decipher = createDecipheriv("aes-256-gcm", dek, iv);
67
+ if (hasAad)
68
+ decipher.setAAD(buildAad(subject, field, isJson));
69
+ decipher.setAuthTag(tag);
70
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
71
+ }
72
+ catch (e) {
73
+ throw new PiiCiphertextCorruptError(field, e instanceof Error ? e : undefined);
74
+ }
75
+ }
76
+ function parseCiphertextOf(value, field) {
77
+ try {
78
+ return parseCiphertext(value);
79
+ }
80
+ catch (e) {
81
+ throw new PiiCiphertextCorruptError(field, e instanceof Error ? e : undefined);
82
+ }
83
+ }
84
+ function parseDecryptedJson(plain, field) {
85
+ try {
86
+ return JSON.parse(plain);
87
+ }
88
+ catch (e) {
89
+ throw new PiiCiphertextCorruptError(field, e instanceof Error ? e : undefined);
90
+ }
69
91
  }
70
92
  // First write to a PII field of a new subject creates the key implicitly
71
93
  // (user-signup / tenant-create need no extra hook). The AlreadyExists catch
@@ -118,7 +140,7 @@ export async function encryptPiiJsonValueForSubject(kms, subject, value, kmsCtx,
118
140
  export async function decryptPiiValueForSubject(kms, value, kmsCtx, field) {
119
141
  if (!isPiiCiphertext(value))
120
142
  return value;
121
- const { subject, blob, hasAad, isJson } = parseCiphertext(value);
143
+ const { subject, blob, hasAad, isJson } = parseCiphertextOf(value, field);
122
144
  if (isJson) {
123
145
  throw new Error(`PII field "${field}" holds a jsonb ciphertext — decrypt it via decryptPiiFieldValues`);
124
146
  }
@@ -193,11 +215,11 @@ export async function decryptPiiFieldValues(row, piiFields, kms, kmsCtx) {
193
215
  // work during rollout).
194
216
  if (!isPiiCiphertext(value))
195
217
  continue;
196
- const { subject, blob, hasAad, isJson } = parseCiphertext(value);
218
+ const { subject, blob, hasAad, isJson } = parseCiphertextOf(value, name);
197
219
  try {
198
220
  const key = await kms.getKey(subject, kmsCtx);
199
221
  const plain = decryptValue(key, blob, subject, name, hasAad, isJson);
200
- const decoded = isJson ? JSON.parse(plain) : plain;
222
+ const decoded = isJson ? parseDecryptedJson(plain, name) : plain;
201
223
  out[name] = decoded;
202
224
  }
203
225
  catch (e) {
@@ -90,7 +90,7 @@ const COLUMN_META_EMITTERS = {
90
90
  reference: referenceColumnMeta,
91
91
  money: moneyColumnMeta,
92
92
  embedded: (snake, field) => jsonbColumn(snake, field.multiple === true ? EMPTY_JSONB_ARRAY : EMPTY_JSONB_OBJECT),
93
- jsonb: (snake) => jsonbColumn(snake, EMPTY_JSONB_OBJECT),
93
+ jsonb: (snake, field) => jsonbColumn(snake, field.emptyValue === "array" ? EMPTY_JSONB_ARRAY : EMPTY_JSONB_OBJECT),
94
94
  // Real PG `date` — Temporal.PlainDate, no time-of-day/timezone component.
95
95
  date: (snake, field) => requiredColumn(snake, "date", field),
96
96
  timestamp: (snake, field) => requiredColumn(snake, "timestamptz", field),
@@ -1,3 +1,4 @@
1
+ import type { ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-store-executor-types";
1
2
  import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
2
3
  import type { EntityId } from "@cosmicdrift/kumiko-types/identifiers";
3
4
  import type { ErasedSubjectHandling } from "../crypto/pii-field-encryption.ts";
@@ -44,6 +45,7 @@ export type ExecutorContext = {
44
45
  readonly owned: boolean;
45
46
  readonly streamVersion: number;
46
47
  }>;
48
+ readonly isRowReadable: (id: EntityId, user: SessionUser, db: TenantDb, parentVisibility: ParentVisibilityOption | undefined) => Promise<boolean>;
47
49
  readonly loadDetailSnapshot: (db: TenantDb, idWhere: WhereObject, ownership: {
48
50
  kind: "pass";
49
51
  } | {
@@ -10,6 +10,7 @@ import { isStreamArchived } from "../event-store/index.js";
10
10
  import { assertUnreachable } from "../utils/index.js";
11
11
  import { rehydrateCompoundTypes } from "./compound-types.js";
12
12
  import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
13
+ import { resolveReadOwnership } from "./event-store-executor-read-where.js";
13
14
  import { constraintOf, isUniqueViolation } from "./pg-error.js";
14
15
  import { toSnakeCase } from "./table-builder.js";
15
16
  import { columnHandleName, schemaTableName } from "./table-introspection.js";
@@ -355,6 +356,21 @@ export function buildExecutorContext(table, entity, options) {
355
356
  const version = first["__streamVersion"];
356
357
  return { owned: true, streamVersion: typeof version === "number" ? version : 0 };
357
358
  }
359
+ // Same read predicate detail applies (access.read plus the parent-ref gate), evaluated
360
+ // for one row. The row's own isDeleted is not filtered because restore targets a
361
+ // soft-deleted row; includeDeleted below only concerns the parent host. Writers use it
362
+ // to treat an unreadable row as nonexistent.
363
+ async function isRowReadable(id, user, db, parentVisibility) {
364
+ const ownership = resolveReadOwnership({ entity, table }, String(schemaTableName(table)), user, db, parentVisibility, { includeDeleted: false, narrowTypes: undefined });
365
+ if (ownership.kind === "pass")
366
+ return true;
367
+ if (ownership.kind === "empty")
368
+ return false;
369
+ const tableName = String(schemaTableName(table));
370
+ const { whereParts, params } = buildRawIdWhere(db, { id }, ownership);
371
+ const rows = await executeRawQueryRead(tenantDbRunner(db), `SELECT 1 AS "__readable" FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`, params);
372
+ return rows.length > 0;
373
+ }
358
374
  // Row and MAX(version) in ONE statement: two reads can pair a post-write
359
375
  // version with a pre-write row, and update({version: detail.version}) would
360
376
  // then pass the optimistic lock and overwrite the newer write (lost update).
@@ -436,6 +452,7 @@ export function buildExecutorContext(table, entity, options) {
436
452
  loadById,
437
453
  assertStreamWritable,
438
454
  probeOwnershipWithStreamVersion,
455
+ isRowReadable,
439
456
  loadDetailSnapshot,
440
457
  loadExpectSnapshot,
441
458
  encryptForStorage,
@@ -1,6 +1,6 @@
1
1
  import { UnprocessableError } from "../errors/index.js";
2
2
  import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
3
- import { renderSortSql } from "./event-store-executor-read-sort.js";
3
+ import { renderSortSql, sortsNullsFirst } from "./event-store-executor-read-sort.js";
4
4
  import { executeRawQueryRead } from "./queries/raw-sql.js";
5
5
  import { physicalColumnName } from "./table-builder.js";
6
6
  // Defense-in-depth pagination guard. The handler boundary (entityListSchema)
@@ -48,13 +48,13 @@ function toCursorSortText(value) {
48
48
  }
49
49
  return undefined;
50
50
  }
51
- // Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
52
- // null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
53
- // would change the visible order and cost the sort its index.
54
- function keysetBoundarySql(sortCol, idCol, cursor, descending, params) {
51
+ // Keyset boundary for `ORDER BY <sort> <dir> [NULLS FIRST|LAST], id ASC`. Without a declared
52
+ // `sortNulls` Postgres' default applies (ASC → NULLS LAST, DESC → NULLS FIRST), which keeps the
53
+ // sort on a plain index; a declared one may need an index with the same NULLS order.
54
+ function keysetBoundarySql(sortCol, idCol, cursor, order, params) {
55
55
  params.push(cursor.id);
56
56
  const afterId = `${idCol} > $${params.length}`;
57
- const nullsComeFirst = descending;
57
+ const { descending, nullsComeFirst } = order;
58
58
  if (cursor.sortValue === null) {
59
59
  return nullsComeFirst
60
60
  ? `(${sortCol} IS NOT NULL OR ${afterId})`
@@ -75,7 +75,7 @@ export function appendCursorBoundary(where, encodedCursor, sort) {
75
75
  where.whereSql.push(`${where.colSql("id")} > $${where.params.length}`);
76
76
  }
77
77
  else {
78
- where.whereSql.push(keysetBoundarySql(renderSortSql(sort, where), where.colSql("id"), { id: cursor.id, sortValue: cursor.sortValue }, sort.descending, where.params));
78
+ where.whereSql.push(keysetBoundarySql(renderSortSql(sort, where), where.colSql("id"), { id: cursor.id, sortValue: cursor.sortValue }, { descending: sort.descending, nullsComeFirst: sortsNullsFirst(sort) }, where.params));
79
79
  }
80
80
  }
81
81
  export function encodeNextCursor(cursorId, sort, lastRaw, lastSortLabel, table) {
@@ -1,4 +1,4 @@
1
- import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
1
+ import type { EntityDefinition, SortNulls } from "@cosmicdrift/kumiko-types/fields";
2
2
  import { type OwnershipClause } from "../engine/ownership.ts";
3
3
  import type { SessionUser } from "../engine/types/index.ts";
4
4
  import type { EventStoreExecutor } from "./event-store-executor.ts";
@@ -13,15 +13,18 @@ export type ListSort = {
13
13
  readonly kind: "column";
14
14
  readonly field: string;
15
15
  readonly descending: boolean;
16
+ readonly nulls: SortNulls | undefined;
16
17
  } | {
17
18
  readonly kind: "referenceLabel";
18
19
  readonly field: string;
19
20
  readonly descending: boolean;
21
+ readonly nulls: SortNulls | undefined;
20
22
  readonly labelSql: (params: unknown[]) => string;
21
23
  };
22
24
  export type SortedListSort = Exclude<ListSort, {
23
25
  readonly kind: "unsorted";
24
26
  }>;
27
+ export declare function sortsNullsFirst(sort: SortedListSort): boolean;
25
28
  export declare function resolveListSort(request: {
26
29
  readonly payload: Pick<ListPayload, "sort" | "sortDirection">;
27
30
  readonly table: Table;
@@ -7,6 +7,19 @@ import { schemaTableName } from "./table-introspection.js";
7
7
  // Projected alongside the row when a reference sort is active, so the keyset
8
8
  // cursor carries the label that was ordered by instead of the FK's UUID.
9
9
  const SORT_LABEL_ALIAS = "__kumiko_sort_label";
10
+ function declaredSortNulls(entity, field) {
11
+ const def = entity.fields[field];
12
+ return def !== undefined && "sortNulls" in def ? def.sortNulls : undefined;
13
+ }
14
+ // Postgres' own null order without an explicit NULLS clause: last ascending, first descending.
15
+ export function sortsNullsFirst(sort) {
16
+ return sort.nulls === undefined ? sort.descending : sort.nulls === "first";
17
+ }
18
+ function nullsClause(sort) {
19
+ if (sort.nulls === undefined)
20
+ return "";
21
+ return sort.nulls === "first" ? " NULLS FIRST" : " NULLS LAST";
22
+ }
10
23
  // Resolve a `sortable: true` reference field's label inline for
11
24
  // ORDER BY. A correlated scalar subquery, not a join: the result set keeps one
12
25
  // row per entity row, the read model stays untouched, and the lookup rides the
@@ -80,8 +93,10 @@ export function resolveListSort(request) {
80
93
  if (field === undefined)
81
94
  return { kind: "unsorted" };
82
95
  const descending = payload.sortDirection === "desc";
83
- if (entity.fields[field]?.type !== "reference")
84
- return { kind: "column", field, descending };
96
+ const nulls = declaredSortNulls(entity, field);
97
+ if (entity.fields[field]?.type !== "reference") {
98
+ return { kind: "column", field, descending, nulls };
99
+ }
85
100
  // A reference column stores a UUID, so it is never sorted by
86
101
  // directly: either the field opted into `sortable` and we order by the
87
102
  // target row's label, or the sort is dropped to plain id order.
@@ -91,7 +106,7 @@ export function resolveListSort(request) {
91
106
  : undefined;
92
107
  return labelSql === undefined
93
108
  ? { kind: "unsorted" }
94
- : { kind: "referenceLabel", field, descending, labelSql };
109
+ : { kind: "referenceLabel", field, descending, nulls, labelSql };
95
110
  }
96
111
  export function renderSortSql(sort, where) {
97
112
  return sort.kind === "referenceLabel" ? sort.labelSql(where.params) : where.colSql(sort.field);
@@ -102,7 +117,7 @@ export function renderListOrderBy(sort, where) {
102
117
  return { orderByClause: ` ORDER BY ${idAscending}`, sortLabelProjection: "" };
103
118
  }
104
119
  const sortSql = renderSortSql(sort, where);
105
- const direction = sort.descending ? "DESC" : "ASC";
120
+ const direction = `${sort.descending ? "DESC" : "ASC"}${nullsClause(sort)}`;
106
121
  if (sort.kind === "column") {
107
122
  return {
108
123
  orderByClause: ` ORDER BY ${sortSql} ${direction}, ${idAscending}`,
@@ -24,8 +24,17 @@ import { tenantDbWriteRunner } from "./tenant-db-runner.js";
24
24
  // store-executor, split out of event-store-executor.ts. They read an explicit
25
25
  // ExecutorContext instead of capturing the factory's local scope directly.
26
26
  export function createWriteVerbs(ctx) {
27
- const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, ownerTransitionRekeys, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
27
+ const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, streamTenantCreateFailure, encryptForStorage, decryptForRead, ownerTransitionRekeys, applyDefaults, stripSensitive, loadById, assertStreamWritable, isRowReadable, loadExpectSnapshot, } = ctx;
28
28
  const entityInstructionFieldNames = instructionFieldNames(entity);
29
+ // A row the caller cannot read is reported as missing, like detail does, so a write
30
+ // can neither change it nor echo its content back. Framework system writes skip the
31
+ // check as they do for forget: their role never appears in a per-role read map.
32
+ async function unreadableRowFailure(id, user, db, parentVisibility) {
33
+ if (isFrameworkSystemUser(user) || (await isRowReadable(id, user, db, parentVisibility))) {
34
+ return undefined;
35
+ }
36
+ return writeFailure(new NotFoundError(entityName, id));
37
+ }
29
38
  // update and delete funnel through here, so custom handlers and nested writes cannot
30
39
  // bypass the lock whatever they return. Setting the lock (unlocked row) passes.
31
40
  // Framework system writes that name a writePurpose (retention, erasure) are exempt:
@@ -487,6 +496,9 @@ export function createWriteVerbs(ctx) {
487
496
  });
488
497
  if (!previous)
489
498
  return writeFailure(new NotFoundError(entityName, payload.id));
499
+ const unreadable = await unreadableRowFailure(payload.id, user, db, updateOptions?.parentVisibility);
500
+ if (unreadable)
501
+ return unreadable;
490
502
  const streamTenantId = updateOptions?.streamTenantId;
491
503
  const streamTenantMismatch = streamTenantOverrideFailure(db, previous, streamTenantId);
492
504
  if (streamTenantMismatch)
@@ -561,6 +573,9 @@ export function createWriteVerbs(ctx) {
561
573
  const existing = await loadById(payload.id, db);
562
574
  if (!existing)
563
575
  return writeFailure(new NotFoundError(entityName, payload.id));
576
+ const unreadable = await unreadableRowFailure(payload.id, user, db, options?.parentVisibility);
577
+ if (unreadable)
578
+ return unreadable;
564
579
  const streamTenantId = options?.streamTenantId;
565
580
  const streamTenantMismatch = streamTenantOverrideFailure(db, existing, streamTenantId);
566
581
  if (streamTenantMismatch)
@@ -645,6 +660,9 @@ export function createWriteVerbs(ctx) {
645
660
  const raw = await db.fetchOne(table, { id: payload.id });
646
661
  if (!raw)
647
662
  return writeFailure(new NotFoundError(entityName, payload.id));
663
+ const unreadable = await unreadableRowFailure(payload.id, user, db, options?.parentVisibility);
664
+ if (unreadable)
665
+ return unreadable;
648
666
  const streamTenantId = options?.streamTenantId;
649
667
  const streamTenantMismatch = streamTenantOverrideFailure(db, raw, streamTenantId);
650
668
  if (streamTenantMismatch)
@@ -716,6 +734,11 @@ export function createWriteVerbs(ctx) {
716
734
  }
717
735
  const runner = tenantDbWriteRunner(db, "ctx.db executor restore");
718
736
  const streamTenantId = options?.streamTenantId;
737
+ // Before loadSoftDeletedRow: its not_deleted answer would confirm that an unreadable
738
+ // live row exists.
739
+ const unreadable = await unreadableRowFailure(payload.id, user, db, options?.parentVisibility);
740
+ if (unreadable)
741
+ return unreadable;
719
742
  const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
720
743
  if ("failure" in target)
721
744
  return target.failure;
@@ -40,3 +40,5 @@ export type PiiBackfillBatchOptions = PiiBackfillOptions & {
40
40
  };
41
41
  export declare function backfillEventPiiEncryption(db: DbRunner, registry: Registry, options?: PiiBackfillOptions): Promise<PiiBackfillResult>;
42
42
  export declare function backfillEventPiiEncryptionBatch(db: DbRunner, registry: Registry, options?: PiiBackfillBatchOptions): Promise<PiiBackfillBatchResult>;
43
+ export declare function isLifecycleEventOf(eventType: string, aggregateType: string): boolean;
44
+ export declare function lifecycleSections(payload: Record<string, unknown>): Record<string, unknown>[];
@@ -611,7 +611,7 @@ function projectionRowToCamel(entity, row) {
611
611
  }
612
612
  return camel;
613
613
  }
614
- function isLifecycleEventOf(eventType, aggregateType) {
614
+ export function isLifecycleEventOf(eventType, aggregateType) {
615
615
  if (!eventType.startsWith(`${aggregateType}.`))
616
616
  return false;
617
617
  const verb = eventType.slice(aggregateType.length + 1);
@@ -620,7 +620,7 @@ function isLifecycleEventOf(eventType, aggregateType) {
620
620
  // created events carry the fields flat; updated carries { changes, previous };
621
621
  // deleted/forgotten/restored carry { previous }. Returned sections are the
622
622
  // mutable objects INSIDE the payload clone — encryptField writes in place.
623
- function lifecycleSections(payload) {
623
+ export function lifecycleSections(payload) {
624
624
  const sections = [];
625
625
  if (isRecord(payload["changes"]))
626
626
  sections.push(payload["changes"]);
@@ -0,0 +1,12 @@
1
+ import type { DbRunner } from "../connection.ts";
2
+ export type ScrubEventRow = {
3
+ readonly id: bigint | string;
4
+ readonly aggregate_id: string;
5
+ readonly aggregate_type: string;
6
+ readonly tenant_id: string;
7
+ readonly type: string;
8
+ readonly payload: Record<string, unknown>;
9
+ };
10
+ export declare function selectEventsOfTypeAfter(db: DbRunner, eventType: string, afterEventId: string, limit: number): Promise<readonly ScrubEventRow[]>;
11
+ export declare function replaceEventPayload(db: DbRunner, eventId: bigint | string, payload: Record<string, unknown>): Promise<void>;
12
+ export declare function deleteSnapshotsOfAggregates(db: DbRunner, aggregateIds: readonly string[]): Promise<number>;
@@ -0,0 +1,20 @@
1
+ import { asRawClient } from "../query.js";
2
+ import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
3
+ export function selectEventsOfTypeAfter(db, eventType, afterEventId, limit) {
4
+ return asRawClient(db).unsafe(`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
5
+ FROM "kumiko_events"
6
+ WHERE "type" = $1 AND "id" > $2::bigint
7
+ ORDER BY "id" ASC
8
+ LIMIT $3`, [eventType, afterEventId, limit]);
9
+ }
10
+ export async function replaceEventPayload(db, eventId, payload) {
11
+ await asRawClient(db).unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
12
+ payload,
13
+ eventId,
14
+ ]);
15
+ }
16
+ // Snapshots may cache the replaced values of the streams; the next snapshotting load recreates them.
17
+ export async function deleteSnapshotsOfAggregates(db, aggregateIds) {
18
+ const deleted = await asRawClient(db).unsafe(`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`, [toPostgresArrayLiteral([...aggregateIds])]);
19
+ return deleted.length;
20
+ }
@@ -289,6 +289,7 @@ export function defineEntityWriteHandler(name, entity, options) {
289
289
  // they rely on to intentionally force a fresh event/ciphertext.
290
290
  return executor.update(payload, event.user, db, {
291
291
  streamTenantId,
292
+ parentVisibility: { entities: ctx.registry.getAllEntities() },
292
293
  skipUnchanged: true,
293
294
  preSave: runPreSave &&
294
295
  ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
@@ -301,7 +302,10 @@ export function defineEntityWriteHandler(name, entity, options) {
301
302
  const db = dbFor(ctx);
302
303
  const payload = event.payload; // @cast-boundary engine-payload
303
304
  const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
304
- return executor.delete(payload, event.user, db, { streamTenantId });
305
+ return executor.delete(payload, event.user, db, {
306
+ streamTenantId,
307
+ parentVisibility: { entities: ctx.registry.getAllEntities() },
308
+ });
305
309
  };
306
310
  break;
307
311
  case "restore":
@@ -310,7 +314,10 @@ export function defineEntityWriteHandler(name, entity, options) {
310
314
  const db = dbFor(ctx);
311
315
  const payload = event.payload; // @cast-boundary engine-payload
312
316
  const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
313
- return executor.restore(payload, event.user, db, { streamTenantId });
317
+ return executor.restore(payload, event.user, db, {
318
+ streamTenantId,
319
+ parentVisibility: { entities: ctx.registry.getAllEntities() },
320
+ });
314
321
  };
315
322
  break;
316
323
  default:
@@ -277,7 +277,7 @@ export function createEmbeddedListField(schema, overrides) {
277
277
  };
278
278
  }
279
279
  // Free-form jsonb column — see the `JsonbFieldDef` documentation. Schema-less,
280
- // defaults to `{}`, and is NOT NULL. Primarily used by the custom-fields
280
+ // defaults to `{}` (`[]` with `emptyValue: "array"`), and is NOT NULL. Primarily used by the custom-fields
281
281
  // bundle for the host entity's `customFields` column; other valid uses include
282
282
  // tenant configuration blobs, AI-inferred metadata, and future tag arrays.
283
283
  export function createJsonbField(overrides) {
@@ -315,8 +315,10 @@ export function fieldToZod(field, currencies, opts = {}) {
315
315
  return embeddedFieldToZod(field);
316
316
  case "jsonb":
317
317
  // Free-form jsonb — keys are tenant-/runtime-defined. Validation
318
- // passes through any plain object.
319
- return z.record(z.string(), z.unknown());
318
+ // passes through any plain object, or any array for emptyValue "array".
319
+ return field.emptyValue === "array"
320
+ ? z.array(z.unknown())
321
+ : z.record(z.string(), z.unknown());
320
322
  case "date":
321
323
  return withDateBounds(z.string().date(), field.min, field.max);
322
324
  case "timestamp":
@@ -7,6 +7,7 @@ export type FilesFeatureOptions = {
7
7
  readonly unclaimedUploadReaders?: readonly string[];
8
8
  readonly maxUploadSize?: string;
9
9
  readonly uploadSweepGraceHours?: number;
10
+ readonly pendingUploadGraceHours?: number;
10
11
  };
11
12
  export declare const FILES_FEATURE: "files";
12
13
  export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
@@ -2,6 +2,7 @@ import { defineFeature } from "../engine/index.js";
2
2
  import { bindReferencedFilesWrite } from "./bind-referenced-files.write.js";
3
3
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
4
4
  import { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.js";
5
+ import { sweepStalePendingFileRefs } from "./sweep-stale-pending-file-refs.js";
5
6
  import { fileUploadLedgerTable } from "./upload-ledger.js";
6
7
  export { fileRefEntity } from "./file-ref-entity.js";
7
8
  // `fileRef` is a regular ES entity. Uploads and deletes go through the standard
@@ -15,14 +16,18 @@ export { fileRefEntity } from "./file-ref-entity.js";
15
16
  // rebuildProjection. It lives in the framework next to file-routes and
16
17
  // fileRefsTable; bundled-features/files only re-exports it.
17
18
  export const FILES_FEATURE = "files";
18
- function assertValidSweepGraceHours(hours) {
19
+ function assertValidSweepGraceHours(option, hours) {
19
20
  if (!Number.isInteger(hours) || hours < MIN_UPLOAD_SWEEP_GRACE_HOURS) {
20
- throw new Error(`createFilesFeature: uploadSweepGraceHours must be a whole number of hours >= ${MIN_UPLOAD_SWEEP_GRACE_HOURS}, got ${String(hours)}`);
21
+ throw new Error(`createFilesFeature: ${option} must be a whole number of hours >= ${MIN_UPLOAD_SWEEP_GRACE_HOURS}, got ${String(hours)}`);
21
22
  }
22
23
  }
23
24
  export function createFilesFeature(options = {}) {
24
25
  const uploadSweepGraceHours = options.uploadSweepGraceHours ?? DEFAULT_UPLOAD_SWEEP_GRACE_HOURS;
25
- assertValidSweepGraceHours(uploadSweepGraceHours);
26
+ assertValidSweepGraceHours("uploadSweepGraceHours", uploadSweepGraceHours);
27
+ const { pendingUploadGraceHours } = options;
28
+ if (pendingUploadGraceHours !== undefined) {
29
+ assertValidSweepGraceHours("pendingUploadGraceHours", pendingUploadGraceHours);
30
+ }
26
31
  return defineFeature(FILES_FEATURE, (r) => {
27
32
  r.describe("Exposes the `fileRef` entity from the framework core so that uploaded files — tracked in the `file_refs` table by `createFileRoutes` — participate in cross-feature hooks: `user-data-rights-defaults` automatically includes file blobs in GDPR exports and forget flows, and tenant-lifecycle cleanup deletes all refs on tenant destroy. Upload/download routes are registered by the server bootstrap when a `file-provider-*` feature is mounted; this feature carries their access/size policy via `createFilesFeature(opts?)`.");
28
33
  r.uiHints({
@@ -46,6 +51,14 @@ export function createFilesFeature(options = {}) {
46
51
  graceHours: uploadSweepGraceHours,
47
52
  log: ctx.obs.log,
48
53
  });
54
+ if (pendingUploadGraceHours !== undefined) {
55
+ await sweepStalePendingFileRefs({
56
+ db: ctx.db,
57
+ now: ctx.tz.now(),
58
+ graceHours: pendingUploadGraceHours,
59
+ log: ctx.obs.log,
60
+ });
61
+ }
49
62
  },
50
63
  });
51
64
  return { routeOptions: options };
@@ -14,6 +14,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
14
14
  readonly description?: string;
15
15
  readonly required?: boolean;
16
16
  readonly sortable?: boolean;
17
+ readonly sortNulls?: import("@cosmicdrift/kumiko-types/fields").SortNulls;
17
18
  readonly filterable?: boolean;
18
19
  readonly sensitive?: boolean;
19
20
  readonly default?: number;
@@ -13,6 +13,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
13
13
  readonly description?: string;
14
14
  readonly required?: boolean;
15
15
  readonly sortable?: boolean;
16
+ readonly sortNulls?: import("@cosmicdrift/kumiko-types/fields").SortNulls;
16
17
  readonly filterable?: boolean;
17
18
  readonly sensitive?: boolean;
18
19
  readonly default?: number;
@@ -0,0 +1,10 @@
1
+ import type { Logger } from "@cosmicdrift/kumiko-types/logger";
2
+ import type { TenantDb } from "../db/tenant-db.ts";
3
+ export declare const STALE_PENDING_FILE_REF_SWEEP_BATCH = 500;
4
+ export declare function sweepStalePendingFileRefs(args: {
5
+ readonly db: TenantDb;
6
+ readonly now: Temporal.Instant;
7
+ readonly graceHours: number;
8
+ readonly log: Pick<Logger, "warn"> | undefined;
9
+ readonly pageSize?: number;
10
+ }): Promise<number>;
@@ -0,0 +1,46 @@
1
+ import { selectMany } from "../db/query.js";
2
+ import { createSystemUser, SYSTEM_USER_ID } from "../engine/system-user.js";
3
+ import { getFileRefExecutor } from "./create-file-ref.js";
4
+ import { fileRefsTable } from "./file-ref-table.js";
5
+ export const STALE_PENDING_FILE_REF_SWEEP_BATCH = 500;
6
+ // A pending upload names its target field (entityType set) but no record yet. Uploads without a
7
+ // target field (inbox, dropzone) and files the system generated are kept on purpose.
8
+ function isStalePendingUpload(row) {
9
+ return row.entityType !== null && row.insertedById !== SYSTEM_USER_ID;
10
+ }
11
+ // Soft-deletes pending uploads whose record never came, through the fileRef executor, so the
12
+ // soft-delete cleanup later removes their bytes like those of any deleted file.
13
+ export async function sweepStalePendingFileRefs(args) {
14
+ const { db, now, graceHours, log } = args;
15
+ const pageSize = args.pageSize ?? STALE_PENDING_FILE_REF_SWEEP_BATCH;
16
+ const cutoff = now.subtract({ hours: graceHours });
17
+ const executor = getFileRefExecutor();
18
+ const user = createSystemUser(db.tenantId);
19
+ let swept = 0;
20
+ let cursor;
21
+ for (;;) {
22
+ const rows = await selectMany(db, fileRefsTable, {
23
+ entityId: null,
24
+ isDeleted: false,
25
+ insertedAt: { lt: cutoff },
26
+ ...(cursor !== undefined && { id: { gt: cursor } }),
27
+ }, {
28
+ orderBy: { col: "id", direction: "asc" },
29
+ fields: ["id", "entityType", "insertedById"],
30
+ limit: pageSize,
31
+ });
32
+ const lastRow = rows[rows.length - 1];
33
+ // skip: an empty page means every due row has been visited
34
+ if (lastRow === undefined)
35
+ return swept;
36
+ cursor = lastRow.id;
37
+ for (const row of rows.filter(isStalePendingUpload)) {
38
+ const removed = await executor.delete({ id: row.id }, user, db);
39
+ if (removed.isSuccess) {
40
+ swept++;
41
+ continue;
42
+ }
43
+ log?.warn(`[pending-upload-sweep] pending fileRef ${row.id} not deleted, retried next run: ${removed.error.code}`);
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,31 @@
1
+ import type { DbConnection } from "../db/connection.ts";
2
+ import type { Registry } from "../engine/types/index.ts";
3
+ import type { Logger } from "../logging/types.ts";
4
+ import { type PiiRebuildRun } from "./pii-event-backfill.ts";
5
+ export type EventScrubCandidate = {
6
+ readonly eventId: string;
7
+ readonly eventType: string;
8
+ readonly aggregateId: string;
9
+ readonly aggregateType: string;
10
+ readonly tenantId: string;
11
+ readonly field: string;
12
+ readonly payload: Readonly<Record<string, unknown>>;
13
+ };
14
+ export type ScrubEventPayloadOptions = {
15
+ readonly eventType: string;
16
+ readonly fields: readonly string[];
17
+ readonly reason: string;
18
+ readonly shouldScrub?: (value: unknown, candidate: EventScrubCandidate) => boolean | Promise<boolean>;
19
+ readonly dryRun?: boolean;
20
+ readonly batchSize?: number;
21
+ readonly logger?: Pick<Logger, "error"> & Partial<Pick<Logger, "warn" | "debug">>;
22
+ };
23
+ export type ScrubEventPayloadResult = {
24
+ readonly scannedEvents: number;
25
+ readonly scrubbedEvents: number;
26
+ readonly scrubbedFields: number;
27
+ readonly deletedSnapshots: number;
28
+ readonly queuedTables: readonly string[];
29
+ readonly rebuild: PiiRebuildRun | null;
30
+ };
31
+ export declare function scrubEventPayloadFields(db: DbConnection, registry: Registry, options: ScrubEventPayloadOptions): Promise<ScrubEventPayloadResult>;
@@ -0,0 +1,132 @@
1
+ // Replaces chosen payload fields of stored events with PII_ERASED_SENTINEL and rebuilds the
2
+ // projections fed by them. For personal data that crypto-shredding cannot reach: plaintext from
3
+ // before the field was declared personal without a subject left to erase, or a ciphertext that no
4
+ // longer decrypts. A read-model UPDATE alone would come back with the next rebuild.
5
+ import { PII_ERASED_SENTINEL } from "../crypto/pii-field-encryption.js";
6
+ import { isLifecycleEventOf, lifecycleSections } from "../db/queries/backfill-pii.js";
7
+ import { deleteSnapshotsOfAggregates, replaceEventPayload, selectEventsOfTypeAfter, } from "../db/queries/event-store-payload-scrub.js";
8
+ import { runInSystemRowLevelSecurityScope } from "../db/row-level-security-scope.js";
9
+ import { withoutStatementTimeout } from "../db/statement-timeout.js";
10
+ import { assertReason } from "../engine/escape-hatch-reason.js";
11
+ import { createFallbackLogger } from "../logging/utils.js";
12
+ import { createPendingRebuildsTable } from "./pending-rebuilds.js";
13
+ import { affectedProjectionTables, ensureInfraTable, queueRebuildTables, rebuildQueuedTables, } from "./pii-event-backfill.js";
14
+ const EVENT_SCRUB_MIGRATION_PREFIX = "event-scrub:";
15
+ const DEFAULT_BATCH_SIZE = 500;
16
+ function hasValue(value) {
17
+ return value !== undefined && value !== null && value !== PII_ERASED_SENTINEL;
18
+ }
19
+ function payloadSections(row, payload) {
20
+ return isLifecycleEventOf(row.type, row.aggregate_type) ? lifecycleSections(payload) : [payload];
21
+ }
22
+ async function scrubPayload(row, options) {
23
+ const payload = structuredClone(row.payload);
24
+ let scrubbed = 0;
25
+ for (const section of payloadSections(row, payload)) {
26
+ for (const field of options.fields) {
27
+ const value = section[field];
28
+ if (!hasValue(value))
29
+ continue;
30
+ const candidate = {
31
+ eventId: String(row.id),
32
+ eventType: row.type,
33
+ aggregateId: row.aggregate_id,
34
+ aggregateType: row.aggregate_type,
35
+ tenantId: row.tenant_id,
36
+ field,
37
+ payload: row.payload,
38
+ };
39
+ if (options.shouldScrub && !(await options.shouldScrub(value, candidate)))
40
+ continue;
41
+ section[field] = PII_ERASED_SENTINEL;
42
+ scrubbed++;
43
+ }
44
+ }
45
+ return { payload, scrubbed };
46
+ }
47
+ // One transaction per batch: payloads, the snapshots that may cache the old values and the
48
+ // rebuild queue move together, so an abort leaves no scrubbed stream with a stale snapshot.
49
+ async function scrubOneBatch(db, registry, args) {
50
+ const { options, totals } = args;
51
+ return db.begin(async (tx) => {
52
+ const rows = await selectEventsOfTypeAfter(tx, options.eventType, args.afterEventId, args.batchSize);
53
+ const last = rows[rows.length - 1];
54
+ if (last === undefined)
55
+ return { lastEventId: null, scannedAll: true };
56
+ const touchedAggregates = new Set();
57
+ const touchedAggregateTypes = new Set();
58
+ for (const row of rows) {
59
+ totals.scannedEvents++;
60
+ const { payload, scrubbed } = await scrubPayload(row, options);
61
+ if (scrubbed === 0)
62
+ continue;
63
+ totals.scrubbedEvents++;
64
+ totals.scrubbedFields += scrubbed;
65
+ if (options.dryRun)
66
+ continue;
67
+ await replaceEventPayload(tx, row.id, payload);
68
+ touchedAggregates.add(row.aggregate_id);
69
+ touchedAggregateTypes.add(row.aggregate_type);
70
+ }
71
+ if (touchedAggregates.size > 0) {
72
+ totals.deletedSnapshots += await deleteSnapshotsOfAggregates(tx, [...touchedAggregates]);
73
+ const tables = affectedProjectionTables(registry, touchedAggregateTypes, new Set([options.eventType]));
74
+ await queueRebuildTables(tx, tables, args.migrationId);
75
+ for (const table of tables)
76
+ args.queuedTables.add(table);
77
+ }
78
+ return { lastEventId: String(last.id), scannedAll: rows.length < args.batchSize };
79
+ });
80
+ }
81
+ async function scrubAllBatches(db, registry, options) {
82
+ const log = createFallbackLogger("event-payload-scrub", options.logger);
83
+ if (!options.dryRun) {
84
+ await ensureInfraTable(db, "kumiko_pending_rebuilds", createPendingRebuildsTable);
85
+ }
86
+ const migrationId = `${EVENT_SCRUB_MIGRATION_PREFIX}${crypto.randomUUID()}`;
87
+ const totals = {
88
+ scannedEvents: 0,
89
+ scrubbedEvents: 0,
90
+ scrubbedFields: 0,
91
+ deletedSnapshots: 0,
92
+ };
93
+ const queuedTables = new Set();
94
+ let afterEventId = "0";
95
+ for (;;) {
96
+ const batch = await scrubOneBatch(db, registry, {
97
+ options,
98
+ afterEventId,
99
+ batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
100
+ migrationId,
101
+ totals,
102
+ queuedTables,
103
+ });
104
+ if (batch.lastEventId === null)
105
+ break;
106
+ afterEventId = batch.lastEventId;
107
+ if (batch.scannedAll)
108
+ break;
109
+ }
110
+ const rebuild = queuedTables.size === 0
111
+ ? null
112
+ : await rebuildQueuedTables(db, registry, {
113
+ migrationIdPrefix: migrationId,
114
+ signal: undefined,
115
+ log,
116
+ failureMessage: "Projection rebuild after an event payload scrub failed; the tables stay queued in kumiko_pending_rebuilds",
117
+ });
118
+ log.debug?.("Event payload scrub finished", {
119
+ eventType: options.eventType,
120
+ fields: options.fields,
121
+ reason: options.reason,
122
+ ...totals,
123
+ });
124
+ return { ...totals, queuedTables: [...queuedTables], rebuild };
125
+ }
126
+ export function scrubEventPayloadFields(db, registry, options) {
127
+ assertReason("scrubEventPayloadFields", options.reason);
128
+ if (options.fields.length === 0) {
129
+ throw new Error("scrubEventPayloadFields: name at least one payload field to scrub");
130
+ }
131
+ return runInSystemRowLevelSecurityScope("event payload scrub rewrites one event type across all tenants", () => withoutStatementTimeout(db, (unboundedDb) => scrubAllBatches(unboundedDb, registry, options)));
132
+ }
@@ -1,3 +1,4 @@
1
+ export { type EventScrubCandidate, type ScrubEventPayloadOptions, type ScrubEventPayloadResult, scrubEventPayloadFields, } from "./event-payload-scrub.ts";
1
2
  export { assertKumikoSchemaCurrent, SchemaDriftError, } from "./kumiko-drift.ts";
2
3
  export { enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, reportPendingRebuildRun, runPendingRebuilds, } from "./pending-rebuilds.ts";
3
4
  export { runPiiEventBackfill } from "./pii-event-backfill.ts";
@@ -1,4 +1,6 @@
1
1
  // Drizzle-free gate (kumiko/migrations system) — the canonical boot-gate.
2
+ // Erases chosen payload fields of stored events and rebuilds the projections fed by them.
3
+ export { scrubEventPayloadFields, } from "./event-payload-scrub.js";
2
4
  export { assertKumikoSchemaCurrent, SchemaDriftError, } from "./kumiko-drift.js";
3
5
  // Persistente Pending-Rebuild-Queue (survives Rebuild-Failures + Crashes).
4
6
  export { enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, reportPendingRebuildRun, runPendingRebuilds, } from "./pending-rebuilds.js";
@@ -1,9 +1,10 @@
1
- import type { DbConnection } from "../db/connection.ts";
1
+ import type { DbConnection, DbRunner } from "../db/connection.ts";
2
2
  import { type PiiBackfillFailure } from "../db/queries/backfill-pii.ts";
3
3
  import type { Registry } from "../engine/types/index.ts";
4
4
  import type { Logger } from "../logging/types.ts";
5
5
  export declare const piiBackfillStateTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
6
6
  export declare function createPiiBackfillStateTable(db: DbConnection): Promise<void>;
7
+ export declare function ensureInfraTable(db: DbConnection, tableName: string, create: (db: DbConnection) => Promise<void>): Promise<void>;
7
8
  export declare function piiAnnotationEntries(registry: Registry): readonly string[];
8
9
  export declare function piiAnnotationFingerprint(registry: Registry): string;
9
10
  export type PiiEventBackfillOptions = {
@@ -38,4 +39,12 @@ export type PiiEventBackfillResult = {
38
39
  readonly queuedTables: readonly string[];
39
40
  readonly rebuild: PiiRebuildRun | null;
40
41
  };
42
+ export declare function queueRebuildTables(db: DbRunner, tableNames: readonly string[], migrationId: string): Promise<void>;
43
+ export declare function affectedProjectionTables(registry: Registry, touchedAggregateTypes: ReadonlySet<string>, touchedEventTypes: ReadonlySet<string>): readonly string[];
41
44
  export declare function runPiiEventBackfill(db: DbConnection, registry: Registry, options?: PiiEventBackfillOptions): Promise<PiiEventBackfillResult>;
45
+ export declare function rebuildQueuedTables(db: DbConnection, registry: Registry, options: {
46
+ readonly migrationIdPrefix: string;
47
+ readonly signal: AbortSignal | undefined;
48
+ readonly log: Pick<Logger, "error" | "warn">;
49
+ readonly failureMessage: string;
50
+ }): Promise<PiiRebuildRun | null>;
@@ -56,7 +56,7 @@ export async function createPiiBackfillStateTable(db) {
56
56
  }
57
57
  // Replicas boot concurrently: two of them can both pass the tableExists gate and
58
58
  // one CREATE then fails. The table existing afterwards means the other won.
59
- async function ensureInfraTable(db, tableName, create) {
59
+ export async function ensureInfraTable(db, tableName, create) {
60
60
  try {
61
61
  await create(db);
62
62
  }
@@ -86,12 +86,12 @@ export function piiAnnotationFingerprint(registry) {
86
86
  .update([...piiAnnotationEntries(registry), PII_BACKFILL_TRANSFORM_VERSION].join("\n"))
87
87
  .digest("hex");
88
88
  }
89
- async function queueRebuildTables(db, tableNames, migrationId) {
89
+ export async function queueRebuildTables(db, tableNames, migrationId) {
90
90
  for (const tableName of tableNames) {
91
91
  await upsertOnConflict(db, pendingRebuildsTable, { tableName, migrationId }, { conflictKeys: ["tableName"], update: { migrationId } });
92
92
  }
93
93
  }
94
- function affectedProjectionTables(registry, touchedAggregateTypes, touchedEventTypes) {
94
+ export function affectedProjectionTables(registry, touchedAggregateTypes, touchedEventTypes) {
95
95
  const tables = new Set();
96
96
  for (const [name, def] of registry.getAllProjections()) {
97
97
  const sources = [
@@ -253,7 +253,14 @@ async function backfillPiiEvents(db, registry, options) {
253
253
  };
254
254
  const aborted = await scanAllBatches(ctx, progress, options.signal, initialState?.failedEventIds ?? []);
255
255
  reportRunOutcome(log, progress);
256
- const rebuild = aborted ? null : await rebuildQueuedTables(db, registry, options.signal, log);
256
+ const rebuild = aborted
257
+ ? null
258
+ : await rebuildQueuedTables(db, registry, {
259
+ migrationIdPrefix: PII_BACKFILL_MIGRATION_PREFIX,
260
+ signal: options.signal,
261
+ log,
262
+ failureMessage: "Projection rebuild after PII backfill failed; tables stay queued and are retried on the next boot",
263
+ });
257
264
  const queuedTables = [...progress.queuedTables];
258
265
  log.debug("PII event backfill finished", {
259
266
  mode,
@@ -281,8 +288,11 @@ function maxBigInt(a, b) {
281
288
  // Only our own queue rows: runPendingRebuilds would drain the whole queue and
282
289
  // silently clear entries this process cannot map (a worker with a narrower
283
290
  // feature set would swallow rebuilds queued by the API pod or by schema apply).
284
- async function rebuildQueuedTables(db, registry, signal, log) {
285
- const ownRows = (await listPendingRebuildRows(db)).filter((row) => row.migrationId.startsWith(PII_BACKFILL_MIGRATION_PREFIX));
291
+ // Rebuilds the projections of the tables queued under the run's migration id prefix; a failed one
292
+ // stays queued.
293
+ export async function rebuildQueuedTables(db, registry, options) {
294
+ const { signal, log } = options;
295
+ const ownRows = (await listPendingRebuildRows(db)).filter((row) => row.migrationId.startsWith(options.migrationIdPrefix));
286
296
  if (ownRows.length === 0)
287
297
  return null;
288
298
  const tableToProjection = buildProjectionTableIndex(registry);
@@ -317,8 +327,7 @@ async function rebuildQueuedTables(db, registry, signal, log) {
317
327
  failed.push({ projection, error: e instanceof Error ? e.message : String(e) });
318
328
  }
319
329
  }
320
- if (failed.length > 0) {
321
- log.error("Projection rebuild after PII backfill failed; tables stay queued and are retried on the next boot", { failed });
322
- }
330
+ if (failed.length > 0)
331
+ log.error(options.failureMessage, { failed });
323
332
  return { rebuilt, failed, skippedTables };
324
333
  }
@@ -465,6 +465,9 @@ async function guardFieldTransition(ctx, args) {
465
465
  }
466
466
  const currentValue = row[fieldName] ??
467
467
  row[toSnakeCase(fieldName)]; // @cast-boundary engine-bridge
468
+ // skip: an unchanged value is no transition (an edit form resends the current status)
469
+ if (currentValue === newValue)
470
+ return;
468
471
  guardTransition(getTransitions(ctx, { entityName, fieldName, map: transitionMap }), currentValue, newValue);
469
472
  }
470
473
  // The handler itself plus the lifecycle pipeline run under the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.395.0",
3
+ "version": "0.397.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -203,7 +203,7 @@
203
203
  "./package.json": "./package.json"
204
204
  },
205
205
  "dependencies": {
206
- "@cosmicdrift/kumiko-types": "0.395.0",
206
+ "@cosmicdrift/kumiko-types": "0.397.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "hono": "^4.13.1",
209
209
  "i18next": "^26.1.0",
@@ -225,9 +225,9 @@
225
225
  }
226
226
  },
227
227
  "devDependencies": {
228
- "@cosmicdrift/kumiko-testing": "0.395.0",
228
+ "@cosmicdrift/kumiko-testing": "0.397.0",
229
229
  "bun-types": "^1.4.4",
230
- "fflate": "^0.8.2",
230
+ "fflate": "^0.8.3",
231
231
  "meilisearch": "^0.58.0",
232
232
  "pino-pretty": "^13.1.3"
233
233
  },
package/src/changes.json CHANGED
@@ -1,4 +1,47 @@
1
1
  [
2
+ {
3
+ "version": "0.397.0",
4
+ "type": "improvement",
5
+ "title": "Scrub primitive for event payload fields",
6
+ "detail": "Plaintext written before a field was declared personal without a subject to erase, or a ciphertext that no longer decrypts, stayed in `kumiko_events`, and the only way out was a raw `UPDATE` on the event store, which the event-store-writes guard rejects. The scrub walks the events of one type in batches, reaches the `changes` and `previous` sections of entity lifecycle events, lets `shouldScrub(value, candidate)` narrow the values (for example to those that throw `PiiCiphertextCorruptError`), drops the snapshots of touched streams and queues the affected projection and multi-stream projection tables in `kumiko_pending_rebuilds` in the same transaction, then rebuilds them; a failed rebuild stays queued. `reason` is required and placeholders are refused; `dryRun` only counts."
7
+ },
8
+ {
9
+ "version": "0.397.0",
10
+ "type": "improvement",
11
+ "title": "jsonb fields can default to an empty array",
12
+ "detail": "A jsonb field that stores a list had the fixed column default `{}`, so a row that never wrote it read an object while every other row read an array, and the generated write schema only accepted plain objects. `emptyValue: \"array\"` switches the column default to `[]` and the write schema to an array; the default stays `\"object\"`. The schema migration renders `ALTER COLUMN ... SET DEFAULT '[]'::jsonb`; existing rows that never wrote the field get `[]` with the next rebuild of the projection."
13
+ },
14
+ {
15
+ "version": "0.397.0",
16
+ "type": "improvement",
17
+ "title": "Abandoned form uploads can expire",
18
+ "detail": "An upload sent with entityType and fieldName but without a record stays pending until a save binds it. When the form was abandoned, the fileRef row and its bytes stayed forever without an owner that deletes them. With `pendingUploadGraceHours` the `files:job:sweep-orphaned-uploads` job soft-deletes such rows through the fileRef executor once they are older than the grace period. Uploads without a target field (inbox, dropzone) and files the system user created are kept. It is opt-in because an app that stores fileRef ids without binding them would lose those files."
19
+ },
20
+ {
21
+ "version": "0.397.0",
22
+ "type": "improvement",
23
+ "title": "Corrupt PII ciphertext has its own error type",
24
+ "detail": "`decryptPiiFieldValues`, `decryptPiiValueForSubject` and every read path built on them threw Node's generic \"unable to authenticate data\" error for a failed GCM auth tag, the same shape as any other failure. Now a failed auth tag, an AAD mismatch (value copied into another field or subject), a malformed ciphertext and an undecodable jsonb plaintext throw `PiiCiphertextCorruptError` with the field name; it is a KumikoError, so it keeps its code through the dispatcher. An export can blank one such row and still fail loud on an outage, which keeps its own `EncryptionUnavailableError`."
25
+ },
26
+ {
27
+ "version": "0.397.0",
28
+ "type": "improvement",
29
+ "title": "sortNulls places rows without a value in list sorting",
30
+ "detail": "Without the option the list keeps Postgres' order (nulls last ascending, first descending), which a plain index serves. With `sortNulls` the list query adds `NULLS FIRST` or `NULLS LAST` to the order and the keyset cursor follows it, so paging across the rows without a value stays complete. A sort that must stay on an index needs an index with the same NULLS order."
31
+ },
32
+ {
33
+ "version": "0.397.0",
34
+ "type": "fix",
35
+ "title": "Transition guard ignores an unchanged state value",
36
+ "detail": "An update whose `changes` carry the state field with the value the row already has is no transition, so the guard skips it instead of rejecting it as a self-transition that the map does not list. Before, a rename on a `sold` or `finished` row failed as soon as the client sent the current status along."
37
+ },
38
+ {
39
+ "version": "0.396.0",
40
+ "type": "breaking",
41
+ "title": "Entity writes on a row the caller cannot read fail with not_found",
42
+ "detail": "update, delete, restore and forget only checked access.write, so on an entity with an owner access.read rule and no access.write any tenant member with handler access could change or delete another member's row, and delete returned the decrypted row. They now apply the same access.read rule and parentRef host-visibility gate as detail and answer not_found, so the row's existence stays hidden. Framework system writes are exempt. A write the caller could not read but that failed the write rule before now reports not_found instead of ownership_denied.",
43
+ "migration": "Writes on rows the caller cannot read now fail with not_found; give such writers read access or run them as system. Custom handlers that call the executor directly get the parentRef gate by passing parentVisibility: { entities: ctx.registry.getAllEntities() }, as the generated handlers do."
44
+ },
2
45
  {
3
46
  "version": "0.395.0",
4
47
  "type": "fix",