@cosmicdrift/kumiko-framework 0.354.1 → 0.355.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 (40) hide show
  1. package/dist/db/entity-field-encryption.d.ts +10 -1
  2. package/dist/db/entity-field-encryption.js +31 -2
  3. package/dist/db/event-store-executor-context.d.ts +8 -2
  4. package/dist/db/event-store-executor-context.js +41 -9
  5. package/dist/db/event-store-executor-read.js +7 -4
  6. package/dist/db/event-store-executor-write.js +95 -56
  7. package/dist/engine/build-app-schema.js +32 -5
  8. package/dist/engine/cross-tenant.d.ts +2 -1
  9. package/dist/engine/cross-tenant.js +18 -14
  10. package/dist/engine/entity-handlers.js +17 -16
  11. package/dist/engine/factories.d.ts +2 -0
  12. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  13. package/dist/engine/feature-entity-handlers.js +1 -0
  14. package/dist/engine/index.d.ts +1 -1
  15. package/dist/engine/index.js +1 -1
  16. package/dist/engine/registry-validate.d.ts +7 -1
  17. package/dist/engine/registry-validate.js +82 -2
  18. package/dist/engine/registry.js +2 -1
  19. package/dist/engine/types/index.d.ts +1 -1
  20. package/dist/errors/reasons.d.ts +1 -0
  21. package/dist/errors/reasons.js +2 -0
  22. package/dist/event-store/snapshot.d.ts +1 -0
  23. package/dist/event-store/snapshot.js +12 -3
  24. package/dist/jobs/job-runner.js +32 -17
  25. package/dist/observability/escape-hatch-report.d.ts +5 -2
  26. package/dist/observability/escape-hatch-report.js +52 -22
  27. package/dist/observability/index.d.ts +1 -1
  28. package/dist/observability/index.js +1 -1
  29. package/dist/pipeline/dispatch-query.js +4 -0
  30. package/dist/pipeline/dispatch-shared.js +33 -6
  31. package/dist/pipeline/dispatch-stream.js +4 -0
  32. package/dist/pipeline/dispatcher-utils.d.ts +4 -0
  33. package/dist/pipeline/dispatcher-utils.js +10 -0
  34. package/dist/pipeline/dispatcher.js +7 -7
  35. package/dist/pipeline/index.d.ts +1 -1
  36. package/dist/pipeline/index.js +1 -1
  37. package/dist/pipeline/pre-handler-gates.js +4 -0
  38. package/dist/ui-types/index.d.ts +1 -1
  39. package/package.json +4 -4
  40. package/src/changes.json +30 -0
@@ -1,4 +1,5 @@
1
1
  import type { EntityDefinition } from "../engine/types/index.js";
2
+ import { type Logger } from "../logging/index.js";
2
3
  import type { EnvelopeCipher } from "../secrets/envelope-cipher.js";
3
4
  export declare function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySet<string>;
4
5
  export declare function isSensitiveLabelField(entity: EntityDefinition, field: string): boolean;
@@ -6,7 +7,15 @@ export declare function hasSearchablePlaintext(entity: EntityDefinition, field:
6
7
  export declare function encryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher, opts?: {
7
8
  onlyKeys?: Iterable<string>;
8
9
  }): Promise<Record<string, unknown>>;
9
- export declare function decryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher): Promise<Record<string, unknown>>;
10
+ export declare function logUndecryptableField(info: {
11
+ entityName: string | undefined;
12
+ rowId: unknown;
13
+ field: string;
14
+ error: unknown;
15
+ }, logger?: Logger): void;
16
+ export declare function decryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher, opts?: {
17
+ onFieldError?: (field: string, error: unknown) => void;
18
+ }): Promise<Record<string, unknown>>;
10
19
  export declare function configureEntityFieldEncryption(cipher: EnvelopeCipher | undefined): void;
11
20
  export declare function resolveEntityFieldEncryption(): EnvelopeCipher;
12
21
  export declare function configuredEntityFieldEncryption(): EnvelopeCipher | undefined;
@@ -4,6 +4,7 @@
4
4
  // single-key ciphertexts (pre-envelope ENCRYPTION_KEY era) readable via
5
5
  // the cipher's legacy fallback until re-encrypted.
6
6
  import { collectPiiSubjectFields } from "../crypto/index.js";
7
+ import { createLogger } from "../logging/index.js";
7
8
  import { createEnvMasterKeyProvider } from "../secrets/env-master-key-provider.js";
8
9
  export function collectEncryptedFieldNames(entity) {
9
10
  const names = new Set();
@@ -60,7 +61,25 @@ export async function encryptEntityFieldValues(row, encryptedFields, cipher, opt
60
61
  }
61
62
  return out;
62
63
  }
63
- export async function decryptEntityFieldValues(row, encryptedFields, cipher) {
64
+ let undecryptableFieldLogger;
65
+ function defaultUndecryptableFieldLogger() {
66
+ undecryptableFieldLogger ??= createLogger();
67
+ return undecryptableFieldLogger;
68
+ }
69
+ // Logs identifiers and the error class only: ciphertext, key material and the
70
+ // cipher's error message (which may echo envelope content) never reach the log.
71
+ export function logUndecryptableField(info, logger = defaultUndecryptableFieldLogger()) {
72
+ logger.error("encrypted field could not be decrypted; returned as null", {
73
+ entity: info.entityName,
74
+ rowId: info.rowId,
75
+ field: info.field,
76
+ errorName: info.error instanceof Error ? info.error.name : typeof info.error,
77
+ });
78
+ }
79
+ // With `onFieldError` a field that fails to decrypt (wrong key, corrupt
80
+ // envelope) reads as null and is reported instead of throwing, so one bad row
81
+ // cannot fail a whole list. Without it decrypt stays fail-loud (write paths).
82
+ export async function decryptEntityFieldValues(row, encryptedFields, cipher, opts) {
64
83
  if (encryptedFields.size === 0)
65
84
  return row;
66
85
  const out = { ...row };
@@ -71,7 +90,17 @@ export async function decryptEntityFieldValues(row, encryptedFields, cipher) {
71
90
  continue;
72
91
  if (typeof value !== "string")
73
92
  continue;
74
- out[name] = await cipher.decrypt(value, scope);
93
+ if (!opts?.onFieldError) {
94
+ out[name] = await cipher.decrypt(value, scope);
95
+ continue;
96
+ }
97
+ try {
98
+ out[name] = await cipher.decrypt(value, scope);
99
+ }
100
+ catch (error) {
101
+ out[name] = null;
102
+ opts.onFieldError(name, error);
103
+ }
75
104
  }
76
105
  return out;
77
106
  }
@@ -9,11 +9,15 @@ import type { EventStoreExecutorOptions } from "./event-store-executor.js";
9
9
  import type { TenantDb } from "./tenant-db.js";
10
10
  export type Table = TableColumns<any>;
11
11
  export declare function buildFilterWhere(field: string, op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in", value: unknown): WhereObject | null;
12
+ export declare function ownershipSubject(user: SessionUser, streamTenantId: TenantId | undefined): SessionUser;
12
13
  export declare function isForeignTenantOnGlobalEntity(entity: EntityDefinition, tenantIdValue: unknown): boolean;
13
14
  export declare function tryMapUniqueViolation(e: unknown, entityName: string): WriteFailure | null;
14
15
  export declare function buildEventMetadata(user: SessionUser): EventMetadata;
15
16
  export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
16
17
  export declare function entityEventName(entityName: string, verb: EntityLifecycleVerb): string;
18
+ export type DecryptForReadOptions = {
19
+ readonly degradeUndecryptable?: (field: string) => void;
20
+ };
17
21
  export type ExecutorContext = {
18
22
  readonly table: Table;
19
23
  readonly entity: EntityDefinition;
@@ -21,7 +25,8 @@ export type ExecutorContext = {
21
25
  readonly entityCache?: EntityCache;
22
26
  readonly searchAdapter?: SearchAdapter;
23
27
  readonly softDelete: boolean;
24
- readonly streamTenantFor: (user: SessionUser) => TenantId;
28
+ readonly streamTenantFor: (user: SessionUser, requestedStreamTenantId?: TenantId) => TenantId;
29
+ readonly streamTenantOverrideFailure: (db: TenantDb, row: Record<string, unknown>, requestedStreamTenantId: TenantId | undefined) => WriteFailure | undefined;
25
30
  readonly idFilter: (id: EntityId) => WhereObject;
26
31
  readonly loadById: (id: EntityId, db: TenantDb) => Promise<Record<string, unknown> | null>;
27
32
  readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
@@ -53,8 +58,9 @@ export type ExecutorContext = {
53
58
  readonly encryptForStorage: (row: Record<string, unknown>, user: SessionUser, opts?: {
54
59
  onlyKeys?: Iterable<string>;
55
60
  subjectSource?: Record<string, unknown>;
61
+ keyTenantId?: TenantId | undefined;
56
62
  }) => Promise<Record<string, unknown>>;
57
- readonly decryptForRead: (row: Record<string, unknown>) => Promise<Record<string, unknown>>;
63
+ readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
58
64
  readonly applyDefaults: (payload: Record<string, unknown>) => Record<string, unknown>;
59
65
  readonly stripSensitive: (payload: Record<string, unknown> | undefined) => Record<string, unknown>;
60
66
  };
@@ -4,11 +4,11 @@ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues
4
4
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
5
5
  import { shiftParams } from "../engine/ownership.js";
6
6
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
7
- import { InternalError, subjectErasedConflict, UniqueViolationError, writeFailure, } from "../errors/index.js";
7
+ import { AccessDeniedError, InternalError, subjectErasedConflict, UniqueViolationError, writeFailure, } from "../errors/index.js";
8
8
  import { ArchivedStreamError, isStreamArchived } from "../event-store/index.js";
9
9
  import { assertUnreachable } from "../utils/index.js";
10
10
  import { rehydrateCompoundTypes } from "./compound-types.js";
11
- import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
11
+ import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
12
12
  import { constraintOf, isUniqueViolation } from "./pg-error.js";
13
13
  import { toSnakeCase } from "./table-builder.js";
14
14
  import { tenantDbRunner } from "./tenant-db-runner.js";
@@ -39,6 +39,13 @@ export function buildFilterWhere(field, op, value) {
39
39
  return assertUnreachable(op, "filter op");
40
40
  }
41
41
  }
42
+ // Tenant-based write-ownership rules (from("user:tenantId", "tenantId")) compare the caller's
43
+ // tenant with the row's. An explicit cross-tenant grant (streamTenantId, checked against the
44
+ // loaded row) is evaluated as the operator acting inside the target tenant. Only this
45
+ // evaluation sees the target tenant: stream actor, metadata and PII keys keep the real identity.
46
+ export function ownershipSubject(user, streamTenantId) {
47
+ return streamTenantId === undefined ? user : { ...user, tenantId: streamTenantId };
48
+ }
42
49
  // Returns the scalar default of a field, or undefined if the field's type
43
50
  // doesn't carry a default or no default was declared. Only scalar types
44
51
  // (text/number/boolean/select) support creation-time defaults — money/date/
@@ -110,7 +117,24 @@ export function buildExecutorContext(table, entity, options) {
110
117
  // user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
111
118
  // there. Everything else stays on the caller's tenant (byte-identical to the
112
119
  // old hardcoded user.tenantId). Single source of truth for the stream key.
113
- const streamTenantFor = (user) => entity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
120
+ const streamTenantFor = (user, requestedStreamTenantId) => entity.systemStream ? SYSTEM_TENANT_ID : (requestedStreamTenantId ?? user.tenantId);
121
+ // Only a system-mode db (r.systemScope() / acknowledged cross-tenant) may address another
122
+ // tenant's stream, and only the tenant of the row it just loaded.
123
+ const streamTenantOverrideFailure = (db, row, requestedStreamTenantId) => {
124
+ if (requestedStreamTenantId === undefined)
125
+ return undefined;
126
+ if (db.mode !== "system") {
127
+ return writeFailure(new AccessDeniedError({
128
+ message: `${entityName}: streamTenantId requires a system-mode db`,
129
+ }));
130
+ }
131
+ if (row["tenantId"] !== requestedStreamTenantId) {
132
+ return writeFailure(new AccessDeniedError({
133
+ message: `${entityName}: streamTenantId does not match the tenant of the addressed row`,
134
+ }));
135
+ }
136
+ return undefined;
137
+ };
114
138
  // idType default (undefined) is now "uuid" — the ES-pivot made UUID the
115
139
  // only valid aggregate-id type. Explicit `idType: "serial"` is the only
116
140
  // shape that's incompatible with the event-store and still rejected.
@@ -156,10 +180,10 @@ export function buildExecutorContext(table, entity, options) {
156
180
  function piiKms() {
157
181
  return options.kms ?? configuredPiiSubjectKms();
158
182
  }
159
- function kmsContextFor(user) {
183
+ function kmsContextFor(user, keyTenantId) {
160
184
  return {
161
185
  requestId: requestContext.get()?.requestId ?? "event-store-executor",
162
- ...(user && { tenantId: user.tenantId, userId: String(user.id) }),
186
+ ...(user && { tenantId: keyTenantId ?? user.tenantId, userId: String(user.id) }),
163
187
  };
164
188
  }
165
189
  // Async on purpose: the envelope cipher wraps/unwraps DEKs via the
@@ -176,8 +200,8 @@ export function buildExecutorContext(table, entity, options) {
176
200
  const kms = piiKms();
177
201
  if (hasPiiFields && kms) {
178
202
  try {
179
- out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
180
- tenantId: user.tenantId,
203
+ out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user, opts?.keyTenantId), {
204
+ tenantId: opts?.keyTenantId ?? user.tenantId,
181
205
  entityName,
182
206
  ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
183
207
  ...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
@@ -198,14 +222,21 @@ export function buildExecutorContext(table, entity, options) {
198
222
  // unwrap before envelope decrypt, or the envelope cipher chokes on a
199
223
  // still-PII-wrapped string (auth-mfa.totpSecret/recoveryCodes combine both
200
224
  // markers; see pii-subject-encryption.integration.test.ts).
201
- async function decryptForRead(row) {
225
+ async function decryptForRead(row, opts) {
202
226
  let out = row;
203
227
  const kms = piiKms();
204
228
  if (hasPiiFields && kms) {
205
229
  out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
206
230
  }
207
231
  if (hasEncryptedFields) {
208
- out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher());
232
+ out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher(), opts?.degradeUndecryptable
233
+ ? {
234
+ onFieldError: (field, error) => {
235
+ logUndecryptableField({ entityName, rowId: row["id"], field, error });
236
+ opts.degradeUndecryptable?.(field);
237
+ },
238
+ }
239
+ : undefined);
209
240
  }
210
241
  return out;
211
242
  }
@@ -384,6 +415,7 @@ export function buildExecutorContext(table, entity, options) {
384
415
  searchAdapter,
385
416
  softDelete,
386
417
  streamTenantFor,
418
+ streamTenantOverrideFailure,
387
419
  idFilter,
388
420
  loadById,
389
421
  assertStreamWritable,
@@ -664,7 +664,7 @@ export function createReadVerbs(ctx) {
664
664
  // field (ciphertext leaked to the caller).
665
665
  const tableInfo = extractTableInfo(table);
666
666
  const encryptedRows = rawRows.map((r) => rehydrateCompoundTypes(coerceRow(r, tableInfo), entity));
667
- const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
667
+ const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r, { degradeUndecryptable: () => { } })));
668
668
  // Stream version is authoritative (see detail()): ctx.appendEvent bumps the
669
669
  // stream without touching row.version, so the raw row version would hand
670
670
  // clients a stale optimistic-lock base. Rows without events keep theirs.
@@ -744,7 +744,7 @@ export function createReadVerbs(ctx) {
744
744
  // Cached rows are stored re-encrypted (see the `set` below) so an
745
745
  // `encrypted` field's plaintext never sits in a second at-rest
746
746
  // store (Redis) the field-encryption feature doesn't cover.
747
- return withStreamVersion(await decryptForRead(cached), streamVersion);
747
+ return withStreamVersion(await decryptForRead(cached, { degradeUndecryptable: () => { } }), streamVersion);
748
748
  }
749
749
  }
750
750
  }
@@ -755,9 +755,12 @@ export function createReadVerbs(ctx) {
755
755
  // Same coerce-before-rehydrate/decrypt ordering as list() above — raw
756
756
  // is snake_case (raw SQL); coerceRow maps it to camelCase.
757
757
  const rowInfo = extractTableInfo(table);
758
- const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity));
758
+ const undecryptableFields = [];
759
+ const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity), { degradeUndecryptable: (field) => undecryptableFields.push(field) });
759
760
  const result = withStreamVersion(coerced, snapshot.streamVersion);
760
- if (entityCache && entityName) {
761
+ // A degraded row must not be cached: re-encrypting its null would
762
+ // replay the missing value on later cache hits without any error log.
763
+ if (entityCache && entityName && undecryptableFields.length === 0) {
761
764
  await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(result, user));
762
765
  }
763
766
  return result;
@@ -11,7 +11,7 @@ import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline
11
11
  import { generateId } from "../utils/index.js";
12
12
  import { applyEntityEvent } from "./apply-entity-event.js";
13
13
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
14
- import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, tryMapUniqueViolation, } from "./event-store-executor-context.js";
14
+ import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
15
15
  import { projectionRegistryOf } from "./projection-registry-binding.js";
16
16
  import { runInSavepointIfSupported } from "./query.js";
17
17
  import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
@@ -118,12 +118,43 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action,
118
118
  }
119
119
  }
120
120
  export function createWriteVerbs(ctx) {
121
- const { table, entity, entityName, entityCache, softDelete, streamTenantFor, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
121
+ const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
122
122
  const entityInstructionFieldNames = instructionFieldNames(entity);
123
+ const eventVersion = entity.eventVersion ?? 1;
123
124
  let insertSchema;
124
125
  let updateSchema;
125
126
  const createOutputSchema = () => (insertSchema ??= buildInsertSchema(entity));
126
127
  const updateOutputSchema = () => (updateSchema ??= buildUpdateSchema(entity));
128
+ // Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
129
+ // selectMany(runner, ...) did not — any caller could un-delete a foreign
130
+ // tenant's row by id. "system"-mode dbs (r.systemScope() / escapeHatch
131
+ // handlers) still read unfiltered. No isDeleted filter in the read: restore
132
+ // targets exactly the soft-deleted row.
133
+ async function loadSoftDeletedRow(db, id, streamTenantId) {
134
+ const row = await db.fetchOne(table, { id });
135
+ if (!row)
136
+ return { failure: writeFailure(new NotFoundError(entityName, id)) };
137
+ const streamTenantMismatch = streamTenantOverrideFailure(db, row, streamTenantId);
138
+ if (streamTenantMismatch)
139
+ return { failure: streamTenantMismatch };
140
+ if (!row["isDeleted"]) {
141
+ return {
142
+ failure: writeFailure(new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" })),
143
+ };
144
+ }
145
+ return { row: row };
146
+ }
147
+ async function expectedStreamVersion(db, id, streamTenant, expect) {
148
+ const snapshot = await loadExpectSnapshot(db, id, streamTenant, Object.keys(expect));
149
+ const row = snapshot.row;
150
+ if (!row)
151
+ return writeFailure(new PreconditionFailedError({ entityId: id, field: "id" }));
152
+ const mismatch = Object.entries(expect).find(([key, expected]) => row[key] !== expected);
153
+ if (mismatch) {
154
+ return writeFailure(new PreconditionFailedError({ entityId: id, field: mismatch[0] }));
155
+ }
156
+ return snapshot.streamVersion;
157
+ }
127
158
  return {
128
159
  async create(payload, user, db, options) {
129
160
  if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
@@ -218,6 +249,7 @@ export function createWriteVerbs(ctx) {
218
249
  tenantId: streamTenantFor(user),
219
250
  expectedVersion: 0,
220
251
  type: entityEventName(entityName, "created"),
252
+ eventVersion,
221
253
  payload: flatData,
222
254
  metadata: buildEventMetadata(user),
223
255
  }));
@@ -309,6 +341,10 @@ export function createWriteVerbs(ctx) {
309
341
  const previous = await loadById(payload.id, db);
310
342
  if (!previous)
311
343
  return writeFailure(new NotFoundError(entityName, payload.id));
344
+ const streamTenantId = updateOptions?.streamTenantId;
345
+ const streamTenantMismatch = streamTenantOverrideFailure(db, previous, streamTenantId);
346
+ if (streamTenantMismatch)
347
+ return streamTenantMismatch;
312
348
  const preSaveResult = await runPreSave(updateOptions?.preSave, payload.changes, previous, false, entityName, "update", updateOutputSchema);
313
349
  if ("failure" in preSaveResult)
314
350
  return preSaveResult.failure;
@@ -322,7 +358,7 @@ export function createWriteVerbs(ctx) {
322
358
  // prevents the attack where role A passes old, role B passes new and
323
359
  // aggregation would wrongly allow a row-grab.
324
360
  const mergedNew = { ...previous, ...changes };
325
- if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
361
+ if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, previous, mergedNew)) {
326
362
  return writeFailure(new UnprocessableError("ownership_denied", {
327
363
  i18nKey: "errors.ownershipDenied",
328
364
  details: {
@@ -350,7 +386,7 @@ export function createWriteVerbs(ctx) {
350
386
  // for a checked field is still evaluated against the full post-hook
351
387
  // row (`changes` merged onto `previous`) — an ownership rule can
352
388
  // reference a column only a hook populates (kumiko-framework#1672).
353
- const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, user, previous, changes);
389
+ const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, ownershipSubject(user, streamTenantId), previous, changes);
354
390
  if (fieldDeniedUpdate) {
355
391
  return writeFailure(new UnprocessableError("ownership_denied", {
356
392
  i18nKey: "errors.ownershipDenied",
@@ -367,7 +403,8 @@ export function createWriteVerbs(ctx) {
367
403
  const unavailableOnUpdate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
368
404
  if (unavailableOnUpdate)
369
405
  return unavailableOnUpdate;
370
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
406
+ const streamTenant = streamTenantFor(user, streamTenantId);
407
+ await assertStreamWritable(db, payload.id, streamTenant);
371
408
  // Stream-version is authoritative, not row.version. `ctx.appendEvent`
372
409
  // can bump the stream between CRUD writes (domain event on the same
373
410
  // aggregate); a stale row.version here would make the next CRUD write
@@ -376,22 +413,12 @@ export function createWriteVerbs(ctx) {
376
413
  //
377
414
  // With `expect:` the version comes from loadExpectSnapshot's combined
378
415
  // query instead; see its comment for why.
379
- let currentVersion;
380
- if (updateOptions?.expect) {
381
- const expectKeys = Object.keys(updateOptions.expect);
382
- const snapshot = await loadExpectSnapshot(db, payload.id, streamTenantFor(user), expectKeys);
383
- if (!snapshot.row) {
384
- return writeFailure(new PreconditionFailedError({ entityId: payload.id, field: "id" }));
385
- }
386
- const mismatch = Object.entries(updateOptions.expect).find(([key, expected]) => snapshot.row?.[key] !== expected);
387
- if (mismatch) {
388
- return writeFailure(new PreconditionFailedError({ entityId: payload.id, field: mismatch[0] }));
389
- }
390
- currentVersion = snapshot.streamVersion;
391
- }
392
- else {
393
- currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
394
- }
416
+ const expect = updateOptions?.expect;
417
+ const currentVersion = expect
418
+ ? await expectedStreamVersion(db, payload.id, streamTenant, expect)
419
+ : await getStreamVersion(runner, String(payload.id), streamTenant);
420
+ if (typeof currentVersion !== "number")
421
+ return currentVersion;
395
422
  if (!updateOptions?.skipOptimisticLock) {
396
423
  if (payload.version === undefined) {
397
424
  return writeFailure(new FrameworkVersionConflict({
@@ -419,6 +446,7 @@ export function createWriteVerbs(ctx) {
419
446
  const flatChanges = await encryptForStorage(flatChangesPlain, user, {
420
447
  onlyKeys: Object.keys(submittedChanges),
421
448
  subjectSource: mergedNew,
449
+ keyTenantId: streamTenantId,
422
450
  });
423
451
  // The event payload carries BOTH `changes` (what the user asked for) AND
424
452
  // `previous` (the pre-update row). Cross-aggregate projections need the
@@ -429,16 +457,19 @@ export function createWriteVerbs(ctx) {
429
457
  // re-encrypt it before it's persisted so plaintext of pii/encrypted
430
458
  // fields doesn't land in the immutable log (flatChanges is already
431
459
  // ciphertext from encryptForStorage above).
432
- const encryptedPrevious = await encryptForStorage(previous, user);
460
+ const encryptedPrevious = await encryptForStorage(previous, user, {
461
+ keyTenantId: streamTenantId,
462
+ });
433
463
  // Savepoint-scoped — see the create() append() above for why:
434
464
  // confines a losing writer's unique-violation to a nested scope
435
465
  // instead of poisoning the whole outer transaction.
436
466
  const event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
437
467
  aggregateId: String(payload.id),
438
468
  aggregateType: entityName,
439
- tenantId: streamTenantFor(user),
469
+ tenantId: streamTenant,
440
470
  expectedVersion: currentVersion,
441
471
  type: entityEventName(entityName, "updated"),
472
+ eventVersion,
442
473
  payload: {
443
474
  changes: flatChanges,
444
475
  previous: encryptedPrevious,
@@ -472,7 +503,7 @@ export function createWriteVerbs(ctx) {
472
503
  const row = result.row;
473
504
  const data = await decryptForRead(rehydrateCompoundTypes(row, entity));
474
505
  if (entityCache && entityName) {
475
- await entityCache.del(user.tenantId, entityName, payload.id);
506
+ await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
476
507
  }
477
508
  const echoEvent = {
478
509
  ...event,
@@ -514,7 +545,7 @@ export function createWriteVerbs(ctx) {
514
545
  throw e;
515
546
  }
516
547
  },
517
- async delete(payload, user, db) {
548
+ async delete(payload, user, db, options) {
518
549
  if (isIrreversibleEntityVerb("delete", entity)) {
519
550
  assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
520
551
  }
@@ -522,11 +553,15 @@ export function createWriteVerbs(ctx) {
522
553
  const existing = await loadById(payload.id, db);
523
554
  if (!existing)
524
555
  return writeFailure(new NotFoundError(entityName, payload.id));
556
+ const streamTenantId = options?.streamTenantId;
557
+ const streamTenantMismatch = streamTenantOverrideFailure(db, existing, streamTenantId);
558
+ if (streamTenantMismatch)
559
+ return streamTenantMismatch;
525
560
  // H.2 — entity-level write-ownership on delete. Only the pre-delete
526
561
  // row matters (there's no "new" row for a delete); passing existing
527
562
  // twice to userCanWriteFieldRow makes the Straddle check trivial
528
563
  // (same row on both sides) while keeping the multi-role-atomic shape.
529
- if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
564
+ if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
530
565
  return writeFailure(new UnprocessableError("ownership_denied", {
531
566
  i18nKey: "errors.ownershipDenied",
532
567
  details: {
@@ -538,9 +573,9 @@ export function createWriteVerbs(ctx) {
538
573
  },
539
574
  }));
540
575
  }
541
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
576
+ await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
542
577
  // Stream-version authoritative (see update() for rationale).
543
- const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
578
+ const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
544
579
  // Deletes carry the full pre-delete row as `previous`. That's what
545
580
  // projections and downstream consumers need to reverse any aggregates —
546
581
  // a `{}`-payload delete would make cross-aggregate projections impossible
@@ -552,10 +587,13 @@ export function createWriteVerbs(ctx) {
552
587
  event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
553
588
  aggregateId: String(payload.id),
554
589
  aggregateType: entityName,
555
- tenantId: streamTenantFor(user),
590
+ tenantId: streamTenantFor(user, streamTenantId),
556
591
  expectedVersion: currentVersion,
557
592
  type: entityEventName(entityName, "deleted"),
558
- payload: { previous: await encryptForStorage(existing, user) },
593
+ eventVersion,
594
+ payload: {
595
+ previous: await encryptForStorage(existing, user, { keyTenantId: streamTenantId }),
596
+ },
559
597
  metadata: buildEventMetadata(user),
560
598
  }));
561
599
  }
@@ -578,7 +616,7 @@ export function createWriteVerbs(ctx) {
578
616
  return writeFailure(new InternalError({ message: "projection delete: applyEntityEvent skipped" }));
579
617
  }
580
618
  if (entityCache && entityName) {
581
- await entityCache.del(user.tenantId, entityName, payload.id);
619
+ await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
582
620
  }
583
621
  const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
584
622
  await projectEntityEvent(db, runner, echoEvent);
@@ -597,15 +635,19 @@ export function createWriteVerbs(ctx) {
597
635
  // hard-deletes the row regardless of softDelete — and, being an auto-verb,
598
636
  // the erasure replays on rebuild (created → forgotten → row gone). Loads
599
637
  // without the isDeleted filter so trashed (soft-deleted) rows are erased too.
600
- async forget(payload, user, db) {
638
+ async forget(payload, user, db, options) {
601
639
  assertIrreversibleOperationAllowed(`${entityName}.forget`);
602
640
  const runner = tenantDbRunner(db);
603
641
  const raw = await db.fetchOne(table, { id: payload.id });
604
642
  if (!raw)
605
643
  return writeFailure(new NotFoundError(entityName, payload.id));
644
+ const streamTenantId = options?.streamTenantId;
645
+ const streamTenantMismatch = streamTenantOverrideFailure(db, raw, streamTenantId);
646
+ if (streamTenantMismatch)
647
+ return streamTenantMismatch;
606
648
  const existing = await decryptForRead(rehydrateCompoundTypes(raw, entity));
607
649
  if (!isFrameworkSystemUser(user) &&
608
- !userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
650
+ !userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
609
651
  return writeFailure(new UnprocessableError("ownership_denied", {
610
652
  i18nKey: "errors.ownershipDenied",
611
653
  details: {
@@ -617,19 +659,22 @@ export function createWriteVerbs(ctx) {
617
659
  },
618
660
  }));
619
661
  }
620
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
621
- const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
662
+ await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
663
+ const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
622
664
  let event;
623
665
  try {
624
666
  event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
625
667
  aggregateId: String(payload.id),
626
668
  aggregateType: entityName,
627
- tenantId: streamTenantFor(user),
669
+ tenantId: streamTenantFor(user, streamTenantId),
628
670
  expectedVersion: currentVersion,
629
671
  type: entityEventName(entityName, "forgotten"),
672
+ eventVersion,
630
673
  // Re-encrypt like delete(): `existing` came decrypted from loadById —
631
674
  // plaintext must not land in the immutable log, least of all on forget.
632
- payload: { previous: await encryptForStorage(existing, user) },
675
+ payload: {
676
+ previous: await encryptForStorage(existing, user, { keyTenantId: streamTenantId }),
677
+ },
633
678
  metadata: buildEventMetadata(user),
634
679
  }));
635
680
  }
@@ -651,7 +696,7 @@ export function createWriteVerbs(ctx) {
651
696
  return writeFailure(new InternalError({ message: "projection forget: applyEntityEvent skipped" }));
652
697
  }
653
698
  if (entityCache && entityName) {
654
- await entityCache.del(user.tenantId, entityName, payload.id);
699
+ await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
655
700
  }
656
701
  const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
657
702
  await projectEntityEvent(db, runner, echoEvent);
@@ -666,29 +711,22 @@ export function createWriteVerbs(ctx) {
666
711
  },
667
712
  };
668
713
  },
669
- async restore(payload, user, db) {
714
+ async restore(payload, user, db, options) {
670
715
  if (!softDelete) {
671
716
  return writeFailure(new UnprocessableError("soft_delete_not_enabled", {
672
717
  i18nKey: "errors.softDeleteNotEnabled",
673
718
  }));
674
719
  }
675
720
  const runner = tenantDbRunner(db);
676
- // Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
677
- // selectMany(runner, ...) did not — any caller could un-delete a foreign
678
- // tenant's row by id. "system"-mode dbs (r.systemScope() / escapeHatch
679
- // handlers) still read unfiltered. No isDeleted filter here: restore
680
- // targets exactly the soft-deleted row.
681
- const row = await db.fetchOne(table, { id: payload.id });
682
- if (!row)
683
- return writeFailure(new NotFoundError(entityName, payload.id));
684
- const data = row;
685
- if (!data["isDeleted"]) {
686
- return writeFailure(new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" }));
687
- }
721
+ const streamTenantId = options?.streamTenantId;
722
+ const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
723
+ if ("failure" in target)
724
+ return target.failure;
725
+ const data = target.row;
688
726
  // H.2 — entity-level write-ownership on restore. Same shape as delete:
689
727
  // only the stored row matters. Stored row carries pre-soft-delete
690
728
  // teamId/... fields, so the ownership predicate still applies cleanly.
691
- if (!userCanWriteFieldRow(user, entity.access?.write, data, data)) {
729
+ if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, data, data)) {
692
730
  return writeFailure(new UnprocessableError("ownership_denied", {
693
731
  i18nKey: "errors.ownershipDenied",
694
732
  details: {
@@ -700,9 +738,9 @@ export function createWriteVerbs(ctx) {
700
738
  },
701
739
  }));
702
740
  }
703
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
741
+ await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
704
742
  // Stream-version authoritative (see update() for rationale).
705
- const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
743
+ const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
706
744
  // Restore carries the soft-deleted snapshot as `previous` — mirror of
707
745
  // delete for symmetry. Projections that decremented on delete use
708
746
  // `previous` to re-increment on restore without re-querying the entity
@@ -713,9 +751,10 @@ export function createWriteVerbs(ctx) {
713
751
  event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
714
752
  aggregateId: String(payload.id),
715
753
  aggregateType: entityName,
716
- tenantId: streamTenantFor(user),
754
+ tenantId: streamTenantFor(user, streamTenantId),
717
755
  expectedVersion: currentVersion,
718
756
  type: entityEventName(entityName, "restored"),
757
+ eventVersion,
719
758
  payload: { previous: data },
720
759
  metadata: buildEventMetadata(user),
721
760
  }));
@@ -752,7 +791,7 @@ export function createWriteVerbs(ctx) {
752
791
  }
753
792
  const restored = restoreResult.row;
754
793
  if (entityCache && entityName) {
755
- await entityCache.del(user.tenantId, entityName, payload.id);
794
+ await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
756
795
  }
757
796
  // Read-side auto-convert for compound types, same as update/list.
758
797
  // decryptForRead matches create/update/list/detail: the caller-facing