@cosmicdrift/kumiko-samples 0.357.0 → 0.358.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.357.0",
3
+ "version": "0.358.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.357.0",
3
+ "version": "0.358.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -4,10 +4,12 @@ import { runInOwnTransaction, type TenantDb } from "@cosmicdrift/kumiko-framewor
4
4
  import {
5
5
  createEntityExecutor,
6
6
  type HandlerContext,
7
+ type JobContext,
7
8
  type SessionUser,
8
9
  type TenantId,
9
10
  type WriteResult,
10
11
  } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
11
13
  import { eventsTable, VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
12
14
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
13
15
  import * as z from "zod";
@@ -82,17 +84,69 @@ export type BookCapUsageOptions = {
82
84
  readonly guardCurrentValue?: (currentValue: number) => void;
83
85
  };
84
86
 
85
- export function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb {
86
- if (!ctx.dbOutsideTransaction) {
87
+ export type CapCounterContext = HandlerContext | JobContext;
88
+
89
+ export type CapCounterScope = {
90
+ readonly user: SessionUser;
91
+ readonly readDb: TenantDb;
92
+ readonly writeDb: TenantDb;
93
+ };
94
+
95
+ // A tenant-mode db pins every row it writes to its own tenant; counter and reservation row would
96
+ // silently land on different tenants if that is not the user's.
97
+ function assertDbServesUser(db: TenantDb, user: SessionUser): TenantDb {
98
+ if (db.mode === "tenant" && db.tenantId !== user.tenantId) {
99
+ throw new AccessDeniedError({
100
+ message: "cap-counter: the context's database belongs to a different tenant than its user",
101
+ });
102
+ }
103
+ return db;
104
+ }
105
+
106
+ // Jobs write in place (their db is not transaction-bound); systemScope handlers reach both dbs via
107
+ // systemDb, because their ctx.db / ctx.dbOutsideTransaction are fail-closed proxies.
108
+ export function resolveCapCounterScope(
109
+ ctx: CapCounterContext,
110
+ outsideTransaction: boolean,
111
+ ): CapCounterScope {
112
+ // A job context spread into a handler-shaped one (user set) is a handler context: its user, not
113
+ // the job's systemUser, names the metered tenant.
114
+ if (!("user" in ctx) || !ctx.user) {
115
+ const jobUser = "systemUser" in ctx ? ctx.systemUser : undefined;
116
+ if (!jobUser) {
117
+ throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
118
+ }
119
+ const db = assertDbServesUser(ctx.db, jobUser);
120
+ return { user: jobUser, readDb: db, writeDb: db };
121
+ }
122
+ const { user } = ctx;
123
+ if (!user.tenantId) {
124
+ throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
125
+ }
126
+ if (ctx.systemDb) {
127
+ const readDb = ctx.systemDb.assertTenantMatch(user.tenantId);
128
+ const writeDb = outsideTransaction
129
+ ? ctx.systemDb.outsideTransaction.assertTenantMatch(user.tenantId)
130
+ : readDb;
131
+ return { user, readDb, writeDb };
132
+ }
133
+ const writeDb = outsideTransaction ? ctx.dbOutsideTransaction : ctx.db;
134
+ if (!writeDb) {
87
135
  throw new Error(
88
- "cap-counter.bookCapUsage: outsideTransaction requested but ctx.dbOutsideTransaction is undefined",
136
+ outsideTransaction
137
+ ? "cap-counter: outsideTransaction requested but ctx.dbOutsideTransaction is undefined"
138
+ : "cap-counter: ctx.db missing — run inside a handler context",
89
139
  );
90
140
  }
91
- return ctx.dbOutsideTransaction;
141
+ return {
142
+ user,
143
+ readDb: assertDbServesUser(ctx.db, user),
144
+ writeDb: assertDbServesUser(writeDb, user),
145
+ };
92
146
  }
93
147
 
94
148
  export async function bookCapUsage(
95
- ctx: HandlerContext,
149
+ ctx: CapCounterContext,
96
150
  options: BookCapUsageOptions,
97
151
  ): Promise<WriteResult> {
98
152
  return applyCapDelta(ctx, options, "add");
@@ -100,7 +154,7 @@ export async function bookCapUsage(
100
154
 
101
155
  // Gives back a reservation whose operation failed afterwards; the counter never drops below 0.
102
156
  export async function releaseCapUsage(
103
- ctx: HandlerContext,
157
+ ctx: CapCounterContext,
104
158
  options: Omit<BookCapUsageOptions, "guardCurrentValue">,
105
159
  ): Promise<WriteResult> {
106
160
  return applyCapDelta(ctx, options, "subtract");
@@ -157,18 +211,18 @@ export async function applyCapDeltaOn(
157
211
  }
158
212
 
159
213
  async function applyCapDelta(
160
- ctx: HandlerContext,
214
+ ctx: CapCounterContext,
161
215
  options: BookCapUsageOptions,
162
216
  direction: "add" | "subtract",
163
217
  ): Promise<WriteResult> {
218
+ const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
164
219
  if (options.outsideTransaction) {
165
- const outsideDb = requireOutsideTransactionDb(ctx);
166
220
  return retryCounterWriteOnVersionConflict(() =>
167
- runInOwnTransaction(outsideDb, (txDb) => applyCapDeltaOn(txDb, ctx.user, options, direction)),
221
+ runInOwnTransaction(writeDb, (txDb) => applyCapDeltaOn(txDb, user, options, direction)),
168
222
  );
169
223
  }
170
224
  return retryCounterWriteOnVersionConflict(() =>
171
- applyCapDeltaOn(ctx.db, ctx.user, options, direction),
225
+ applyCapDeltaOn(writeDb, user, options, direction),
172
226
  );
173
227
  }
174
228
 
@@ -179,21 +233,18 @@ export type MarkCapSoftWarnedOptions = {
179
233
  };
180
234
 
181
235
  export async function markCapSoftWarned(
182
- ctx: HandlerContext,
236
+ ctx: CapCounterContext,
183
237
  options: MarkCapSoftWarnedOptions,
184
238
  ): Promise<WriteResult> {
185
239
  const parsed = capPeriodSchema.parse(options);
186
- const aggregateId = capCounterAggregateId(
187
- ctx.user.tenantId,
188
- parsed.capName,
189
- parsed.periodStartIso,
190
- );
240
+ const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
241
+ const aggregateId = capCounterAggregateId(user.tenantId, parsed.capName, parsed.periodStartIso);
191
242
 
192
243
  async function attemptMark(db: TenantDb): Promise<WriteResult> {
193
244
  const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
194
245
  if (existing.length === 0) {
195
246
  throw new Error(
196
- `cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`,
247
+ `cap-counter: cannot mark-soft-warned, no counter found for tenant=${user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`,
197
248
  );
198
249
  }
199
250
  const row = existing[0];
@@ -213,16 +264,15 @@ export async function markCapSoftWarned(
213
264
  version: currentVersion,
214
265
  changes: { lastSoftWarnedAt: Temporal.Now.instant() },
215
266
  },
216
- ctx.user,
267
+ user,
217
268
  db,
218
269
  );
219
270
  }
220
271
 
221
272
  if (options.outsideTransaction) {
222
- const outsideDb = requireOutsideTransactionDb(ctx);
223
- return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptMark));
273
+ return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, attemptMark));
224
274
  }
225
- return retryCounterWriteOnVersionConflict(() => attemptMark(ctx.db));
275
+ return retryCounterWriteOnVersionConflict(() => attemptMark(writeDb));
226
276
  }
227
277
 
228
278
  export type ReadRollingCapUsageOptions = {
@@ -0,0 +1,99 @@
1
+ import type { CapCounterContext } from "./book-cap-usage.js";
2
+ import {
3
+ type CalendarCapReservation,
4
+ releaseExpiredCapReservations,
5
+ reserveCalendarCap,
6
+ settleCalendarCapReservation,
7
+ } from "./cap-reservation.js";
8
+ import {
9
+ assertBelowHardCap,
10
+ enforceCapAndMaybeNotify,
11
+ type SoftHitNotifier,
12
+ } from "./enforce-cap.js";
13
+ import type { CalendarCapDef } from "./with-cap-enforcement.js";
14
+
15
+ export type CalendarCapUsageReservationSpec = Omit<CalendarCapDef, "amount" | "notify"> & {
16
+ readonly amount: number;
17
+ readonly notify?: SoftHitNotifier;
18
+ // Default true: the reservation must commit on its own, not roll back with the caller's transaction.
19
+ readonly outsideTransaction?: boolean;
20
+ };
21
+
22
+ export type CalendarCapUsageReservation = {
23
+ readonly amount: number;
24
+ // Counter value right after this reservation was booked.
25
+ readonly counterValue: number;
26
+ // Replaces the reserved amount with the actual one; rejects a second settle and one after release.
27
+ settle(actualAmount: number): Promise<void>;
28
+ // Gives the reserved amount back; a no-op once settled.
29
+ release(): Promise<void>;
30
+ };
31
+
32
+ // Shared by withCapEnforcement and the public reservation API so both enforce identically.
33
+ export async function reserveEnforcedCalendarCap(
34
+ ctx: CapCounterContext,
35
+ cap: CalendarCapUsageReservationSpec,
36
+ ): Promise<CalendarCapReservation> {
37
+ const outsideTransaction = cap.outsideTransaction ?? true;
38
+ await releaseExpiredCapReservations(ctx, cap.capName, outsideTransaction);
39
+ if (cap.notify) {
40
+ await enforceCapAndMaybeNotify(ctx, {
41
+ capName: cap.capName,
42
+ periodStartIso: cap.periodStartIso,
43
+ limit: cap.limit,
44
+ profile: cap.profile,
45
+ notify: cap.notify,
46
+ amount: cap.amount,
47
+ markSoftWarnedOutsideTransaction: outsideTransaction,
48
+ });
49
+ }
50
+ return reserveCalendarCap(
51
+ ctx,
52
+ {
53
+ capName: cap.capName,
54
+ amount: cap.amount,
55
+ periodStartIso: cap.periodStartIso,
56
+ guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + cap.amount - 1, cap),
57
+ },
58
+ outsideTransaction,
59
+ );
60
+ }
61
+
62
+ type ReservationState = "open" | "settled" | "released";
63
+
64
+ export async function reserveCalendarCapUsage(
65
+ ctx: CapCounterContext,
66
+ spec: CalendarCapUsageReservationSpec,
67
+ ): Promise<CalendarCapUsageReservation> {
68
+ const outsideTransaction = spec.outsideTransaction ?? true;
69
+ const reservation = await reserveEnforcedCalendarCap(ctx, spec);
70
+ let state: ReservationState = "open";
71
+
72
+ return {
73
+ amount: spec.amount,
74
+ counterValue: reservation.counterValue,
75
+ async settle(actualAmount) {
76
+ if (state !== "open") {
77
+ throw new Error(`cap-counter: reservation for "${spec.capName}" is already ${state}`);
78
+ }
79
+ state = "settled";
80
+ try {
81
+ await settleCalendarCapReservation(
82
+ ctx,
83
+ reservation.reservationId,
84
+ { capName: spec.capName, periodStartIso: spec.periodStartIso, actualAmount },
85
+ outsideTransaction,
86
+ );
87
+ } catch (error) {
88
+ state = "open";
89
+ throw error;
90
+ }
91
+ },
92
+ async release() {
93
+ // skip: release after settle, or a second release, is a no-op by contract.
94
+ if (state !== "open") return;
95
+ await reservation.release();
96
+ state = "released";
97
+ },
98
+ };
99
+ }
@@ -1,9 +1,5 @@
1
1
  import { deleteMany, runInOwnTransaction, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
- import type {
3
- HandlerContext,
4
- SessionUser,
5
- WriteResult,
6
- } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
7
3
  import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
8
4
  import {
9
5
  appendEventInTenantDb,
@@ -19,9 +15,10 @@ import { rollingCapAggregateId } from "./aggregate-id.js";
19
15
  import {
20
16
  applyCapDeltaOn,
21
17
  type BookCapUsageOptions,
18
+ type CapCounterContext,
22
19
  type ReadRollingCapUsageOptions,
23
20
  readRollingCapUsage,
24
- requireOutsideTransactionDb,
21
+ resolveCapCounterScope,
25
22
  retryCounterWriteOnVersionConflict,
26
23
  retryOnStreamVersionConflict,
27
24
  } from "./book-cap-usage.js";
@@ -47,10 +44,16 @@ const NOTHING_TO_RELEASE: WriteResult = { isSuccess: true, data: {} };
47
44
  type ReservationRowDraft = Pick<CapReservationRow, "capName" | "kind" | "amount"> &
48
45
  Pick<Partial<CapReservationRow>, "periodStartIso">;
49
46
 
50
- async function insertReservationRow(txDb: TenantDb, id: string, draft: ReservationRowDraft) {
47
+ async function insertReservationRow(
48
+ txDb: TenantDb,
49
+ user: SessionUser,
50
+ id: string,
51
+ draft: ReservationRowDraft,
52
+ ) {
51
53
  const now = Temporal.Now.instant();
52
54
  await txDb.insertOne(capReservationsTable, {
53
55
  id,
56
+ tenantId: user.tenantId,
54
57
  capName: draft.capName,
55
58
  kind: draft.kind,
56
59
  periodStartIso: draft.periodStartIso ?? null,
@@ -60,9 +63,19 @@ async function insertReservationRow(txDb: TenantDb, id: string, draft: Reservati
60
63
  });
61
64
  }
62
65
 
63
- function toHandle(ctx: HandlerContext, id: string): ReservationHandle {
66
+ export type CalendarCapReservation = ReservationHandle & {
67
+ readonly reservationId: string;
68
+ // Counter value right after this reservation was booked.
69
+ readonly counterValue: number;
70
+ };
71
+
72
+ function toHandle(
73
+ ctx: CapCounterContext,
74
+ id: string,
75
+ outsideTransaction: boolean,
76
+ ): ReservationHandle {
64
77
  return {
65
- release: () => releaseCapReservation(ctx, id),
78
+ release: () => releaseCapReservation(ctx, id, outsideTransaction),
66
79
  confirmInTransaction: (confirm) => confirmCapReservation(confirm, id),
67
80
  };
68
81
  }
@@ -75,16 +88,26 @@ export type ReserveCalendarCapOptions = Required<
75
88
  // The counter increment and the reservation row commit together, so a booked amount never exists
76
89
  // without a row that can give it back.
77
90
  export async function reserveCalendarCap(
78
- ctx: HandlerContext,
91
+ ctx: CapCounterContext,
79
92
  options: ReserveCalendarCapOptions,
80
- ): Promise<ReservationHandle> {
81
- const outsideDb = requireOutsideTransactionDb(ctx);
93
+ outsideTransaction = true,
94
+ ): Promise<CalendarCapReservation> {
95
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
82
96
  const id = generateId();
97
+ // The last attempt is the one that committed, so this holds the value its increment started from.
98
+ let valueBeforeBooking = 0;
99
+ const observedOptions: ReserveCalendarCapOptions = {
100
+ ...options,
101
+ guardCurrentValue: (currentValue) => {
102
+ options.guardCurrentValue?.(currentValue);
103
+ valueBeforeBooking = currentValue;
104
+ },
105
+ };
83
106
  const reserved = await retryCounterWriteOnVersionConflict(() =>
84
- runInOwnTransaction(outsideDb, async (txDb) => {
85
- const booked = await applyCapDeltaOn(txDb, ctx.user, options, "add");
107
+ runInOwnTransaction(writeDb, async (txDb) => {
108
+ const booked = await applyCapDeltaOn(txDb, user, observedOptions, "add");
86
109
  if (!booked.isSuccess) return booked;
87
- await insertReservationRow(txDb, id, {
110
+ await insertReservationRow(txDb, user, id, {
88
111
  capName: options.capName,
89
112
  kind: "calendar",
90
113
  periodStartIso: options.periodStartIso,
@@ -94,7 +117,11 @@ export async function reserveCalendarCap(
94
117
  }),
95
118
  );
96
119
  if (!reserved.isSuccess) throw reraiseAsKumikoError(reserved.error);
97
- return toHandle(ctx, id);
120
+ return {
121
+ ...toHandle(ctx, id, outsideTransaction),
122
+ reservationId: id,
123
+ counterValue: valueBeforeBooking + options.amount,
124
+ };
98
125
  }
99
126
 
100
127
  export type ReserveRollingCapOptions = ReadRollingCapUsageOptions & {
@@ -123,28 +150,23 @@ function rollingEvent(
123
150
  // The append is version-guarded: a booker that read the usage before another one appended loses the
124
151
  // race and repeats the whole attempt, so the hard-cap check always sees the stream it appends to.
125
152
  export async function reserveRollingCap(
126
- ctx: HandlerContext,
153
+ ctx: CapCounterContext,
127
154
  options: ReserveRollingCapOptions,
155
+ outsideTransaction = true,
128
156
  ): Promise<ReservationHandle> {
129
- const outsideDb = requireOutsideTransactionDb(ctx);
157
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
130
158
  const id = generateId();
131
- const aggregateId = rollingCapAggregateId(ctx.user.tenantId, options.capName);
159
+ const aggregateId = rollingCapAggregateId(user.tenantId, options.capName);
132
160
  await retryOnStreamVersionConflict(() =>
133
- runInOwnTransaction(outsideDb, async (txDb) => {
161
+ runInOwnTransaction(writeDb, async (txDb) => {
134
162
  const version = await getStreamVersionInTenantDb(txDb, aggregateId);
135
- const usage = await readRollingCapUsage(txDb, ctx.user.tenantId, options);
163
+ const usage = await readRollingCapUsage(txDb, user.tenantId, options);
136
164
  options.guardCurrentValue?.(usage);
137
165
  await appendEventInTenantDb(
138
166
  txDb,
139
- rollingEvent(
140
- ctx.user,
141
- options.capName,
142
- options.amount,
143
- version,
144
- ROLLING_INCREMENTED_EVENT_QN,
145
- ),
167
+ rollingEvent(user, options.capName, options.amount, version, ROLLING_INCREMENTED_EVENT_QN),
146
168
  );
147
- await insertReservationRow(txDb, id, {
169
+ await insertReservationRow(txDb, user, id, {
148
170
  capName: options.capName,
149
171
  kind: "rolling",
150
172
  amount: options.amount,
@@ -152,7 +174,7 @@ export async function reserveRollingCap(
152
174
  return NOTHING_TO_RELEASE;
153
175
  }),
154
176
  );
155
- return toHandle(ctx, id);
177
+ return toHandle(ctx, id, outsideTransaction);
156
178
  }
157
179
 
158
180
  // Deleting the row takes its row lock inside the handler transaction, so a release started after a
@@ -201,45 +223,123 @@ async function giveBackReservation(
201
223
  user: SessionUser,
202
224
  id: string,
203
225
  ): Promise<WriteResult> {
204
- const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, { id });
226
+ const ownRow = { id, tenantId: user.tenantId };
227
+ const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, ownRow);
205
228
  if (!row) return NOTHING_TO_RELEASE;
206
- // Waits for a confirm still holding the row; zero rows back means that confirm committed.
207
- const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id });
208
- if (claimed.length === 0) return NOTHING_TO_RELEASE;
229
+ const claimed = await claimReservationRow(txDb, user, row);
230
+ if (!claimed) return NOTHING_TO_RELEASE;
209
231
  const given =
210
232
  row.kind === "rolling"
211
233
  ? await giveBackRolling(txDb, user, row)
212
234
  : await giveBackCalendar(txDb, user, row);
213
235
  if (!given.isSuccess) return given;
214
- await txDb.deleteMany(capReservationsTable, { id });
236
+ await txDb.deleteMany(capReservationsTable, ownRow);
215
237
  return given;
216
238
  }
217
239
 
218
- export async function releaseCapReservation(ctx: HandlerContext, id: string): Promise<void> {
219
- const outsideDb = requireOutsideTransactionDb(ctx);
240
+ // Waits for a confirm still holding the row; false means that confirm (or another release) won.
241
+ async function claimReservationRow(
242
+ txDb: TenantDb,
243
+ user: SessionUser,
244
+ row: CapReservationRow,
245
+ ): Promise<boolean> {
246
+ const claimed = await txDb.updateMany(
247
+ capReservationsTable,
248
+ { expiresAt: row.expiresAt },
249
+ { id: row.id, tenantId: user.tenantId },
250
+ );
251
+ return claimed.length > 0;
252
+ }
253
+
254
+ export async function releaseCapReservation(
255
+ ctx: CapCounterContext,
256
+ id: string,
257
+ outsideTransaction = true,
258
+ ): Promise<void> {
259
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
220
260
  const released = await retryOnStreamVersionConflict(() =>
221
261
  retryCounterWriteOnVersionConflict(() =>
222
- runInOwnTransaction(outsideDb, (txDb) => giveBackReservation(txDb, ctx.user, id)),
262
+ runInOwnTransaction(writeDb, (txDb) => giveBackReservation(txDb, user, id)),
223
263
  ),
224
264
  );
225
265
  if (!released.isSuccess) throw reraiseAsKumikoError(released.error);
226
266
  }
227
267
 
268
+ export type SettleCalendarCapOptions = {
269
+ readonly capName: string;
270
+ readonly periodStartIso: string;
271
+ readonly actualAmount: number;
272
+ };
273
+
274
+ function bookCalendarDelta(
275
+ txDb: TenantDb,
276
+ user: SessionUser,
277
+ period: Pick<SettleCalendarCapOptions, "capName" | "periodStartIso">,
278
+ delta: number,
279
+ ): Promise<WriteResult> {
280
+ if (delta === 0) return Promise.resolve({ isSuccess: true, data: {} });
281
+ return applyCapDeltaOn(
282
+ txDb,
283
+ user,
284
+ { ...period, amount: Math.abs(delta) },
285
+ delta > 0 ? "add" : "subtract",
286
+ );
287
+ }
288
+
289
+ // Replaces the reserved amount by the actual one in a single transaction. When the row is already
290
+ // gone (swept or released), the reserved amount was given back, so the full actual amount is booked.
291
+ export async function settleCalendarCapReservation(
292
+ ctx: CapCounterContext,
293
+ id: string,
294
+ options: SettleCalendarCapOptions,
295
+ outsideTransaction = true,
296
+ ): Promise<void> {
297
+ if (!Number.isInteger(options.actualAmount) || options.actualAmount < 0) {
298
+ throw new Error(
299
+ `cap-counter: settle amount must be a non-negative integer, got ${options.actualAmount}`,
300
+ );
301
+ }
302
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
303
+ const settled = await retryCounterWriteOnVersionConflict(() =>
304
+ runInOwnTransaction(writeDb, async (txDb) => {
305
+ const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, {
306
+ id,
307
+ tenantId: user.tenantId,
308
+ });
309
+ const claimed = row ? await claimReservationRow(txDb, user, row) : false;
310
+ if (!row || !claimed) {
311
+ return bookCalendarDelta(txDb, user, options, options.actualAmount);
312
+ }
313
+ const booked = await bookCalendarDelta(
314
+ txDb,
315
+ user,
316
+ options,
317
+ options.actualAmount - row.amount,
318
+ );
319
+ if (!booked.isSuccess) return booked;
320
+ await txDb.deleteMany(capReservationsTable, { id, tenantId: user.tenantId });
321
+ return booked;
322
+ }),
323
+ );
324
+ if (!settled.isSuccess) throw reraiseAsKumikoError(settled.error);
325
+ }
326
+
228
327
  // A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
229
328
  // capacity stays counted.
230
329
  export async function releaseExpiredCapReservations(
231
- ctx: HandlerContext,
330
+ ctx: CapCounterContext,
232
331
  capName: string,
332
+ outsideTransaction = true,
233
333
  ): Promise<void> {
234
- const outsideDb = requireOutsideTransactionDb(ctx);
235
- const expired = await outsideDb.selectMany<Pick<CapReservationRow, "id">>(capReservationsTable, {
236
- tenantId: ctx.user.tenantId,
334
+ const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
335
+ const expired = await writeDb.selectMany<Pick<CapReservationRow, "id">>(capReservationsTable, {
336
+ tenantId: user.tenantId,
237
337
  capName,
238
338
  expiresAt: { lt: Temporal.Now.instant() },
239
339
  });
240
340
  for (const { id } of expired) {
241
341
  try {
242
- await releaseCapReservation(ctx, id);
342
+ await releaseCapReservation(ctx, id, outsideTransaction);
243
343
  } catch (error) {
244
344
  ctx.log?.error("releasing an expired cap reservation failed", { id, capName, error });
245
345
  }
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.358.0",
4
+ "type": "improvement",
5
+ "title": "Reserve and settle cap usage outside the dispatcher",
6
+ "detail": "reserveCalendarCapUsage(ctx, { capName, periodStartIso, limit, profile, amount, notify? }) runs the same sweep, soft-hit notification and guarded hard-cap reservation as withCapEnforcement and returns a handle. settle(actualAmount) replaces the reserved amount by the actual one in one transaction (and books the full actual amount if the reservation was swept or released meanwhile); release() gives it back. Parallel callers cannot exceed the hard cap. It works with a HandlerContext (also r.systemScope() handlers) and a JobContext. Pattern: `const r = await reserveCalendarCapUsage(ctx, {...}); try { ...; await r.settle(actual) } catch (e) { await r.release(); throw e }`. bookCapUsage, markCapSoftWarned, releaseCapUsage and the enforceCap family accept a JobContext as well.",
7
+ "migration": "withCapEnforcement and bookCapUsage now work on r.systemScope() handlers; before, they failed on the fail-closed ctx.db. Reservation rows carry the tenant of ctx.user explicitly, and a context whose tenant-mode db belongs to another tenant than ctx.user is rejected with access_denied. enforceCap now filters the counter by ctx.user.tenantId, so a system-mode db no longer reads another tenant's counter."
8
+ },
2
9
  {
3
10
  "version": "0.352.0",
4
11
  "type": "breaking",
@@ -1,7 +1,12 @@
1
- import { createEntityExecutor, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { KumikoError, reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
- import { markCapSoftWarned, readRollingCapUsage } from "./book-cap-usage.js";
4
+ import {
5
+ type CapCounterContext,
6
+ markCapSoftWarned,
7
+ readRollingCapUsage,
8
+ resolveCapCounterScope,
9
+ } from "./book-cap-usage.js";
5
10
  import { capCounterEntity } from "./entity.js";
6
11
 
7
12
  const { table } = createEntityExecutor("cap-counter", capCounterEntity);
@@ -95,7 +100,7 @@ export function assertBelowHardCap(
95
100
  * level (e.g. uniqueness-index on apps.tenantId+slot-number).
96
101
  */
97
102
  export async function enforceCap(
98
- ctx: HandlerContext,
103
+ ctx: CapCounterContext,
99
104
  options: {
100
105
  readonly capName: string;
101
106
  readonly periodStartIso: string;
@@ -106,9 +111,7 @@ export async function enforceCap(
106
111
  readonly amount?: number;
107
112
  },
108
113
  ): Promise<EnforceCapResult> {
109
- if (!ctx.db) {
110
- throw new Error("cap-counter.enforceCap: ctx.db missing — run inside a handler context");
111
- }
114
+ const { user, readDb } = resolveCapCounterScope(ctx, false);
112
115
 
113
116
  const amount = options.amount ?? 1;
114
117
  if (!Number.isInteger(amount) || amount < 1) {
@@ -120,9 +123,9 @@ export async function enforceCap(
120
123
  const tolerance = CAP_TOLERANCES[options.profile];
121
124
  const softThreshold = options.limit * tolerance.soft;
122
125
 
123
- const rows = await ctx.db.selectMany(
126
+ const rows = await readDb.selectMany(
124
127
  table,
125
- { capName: options.capName, periodStart: options.periodStartIso },
128
+ { tenantId: user.tenantId, capName: options.capName, periodStart: options.periodStartIso },
126
129
  { limit: 1 },
127
130
  );
128
131
 
@@ -170,7 +173,7 @@ export async function enforceCap(
170
173
  * vorgezogen.
171
174
  */
172
175
  export async function enforceRollingCap(
173
- ctx: HandlerContext,
176
+ ctx: CapCounterContext,
174
177
  options: {
175
178
  readonly capName: string;
176
179
  readonly windowDays: number;
@@ -178,20 +181,13 @@ export async function enforceRollingCap(
178
181
  readonly profile: CapToleranceProfileName;
179
182
  },
180
183
  ): Promise<EnforceCapResult> {
181
- if (!ctx.db) {
182
- throw new Error("cap-counter.enforceRollingCap: ctx.db missing — run inside a handler context");
183
- }
184
- if (!ctx.user?.tenantId) {
185
- throw new Error(
186
- "cap-counter.enforceRollingCap: ctx.user.tenantId missing — required to compute aggregate-id",
187
- );
188
- }
184
+ const { user, readDb } = resolveCapCounterScope(ctx, false);
189
185
 
190
186
  const tolerance = CAP_TOLERANCES[options.profile];
191
187
  const softThreshold = options.limit * tolerance.soft;
192
188
  const hardThreshold = options.limit * tolerance.hard;
193
189
 
194
- const value = await readRollingCapUsage(ctx.db, ctx.user.tenantId, {
190
+ const value = await readRollingCapUsage(readDb, user.tenantId, {
195
191
  capName: options.capName,
196
192
  windowDays: options.windowDays,
197
193
  });
@@ -295,7 +291,7 @@ export type SoftHitNotifier = (info: {
295
291
  * Email-Notification).
296
292
  */
297
293
  export async function enforceCapAndMaybeNotify(
298
- ctx: HandlerContext,
294
+ ctx: CapCounterContext,
299
295
  options: {
300
296
  readonly capName: string;
301
297
  readonly periodStartIso: string;
@@ -315,16 +311,11 @@ export async function enforceCapAndMaybeNotify(
315
311
  });
316
312
 
317
313
  if (result.state === "soft-hit" && result.crossed) {
318
- if (!ctx.user?.tenantId) {
319
- throw new Error(
320
- "cap-counter.enforceCapAndMaybeNotify: ctx.user.tenantId missing — required for notification",
321
- );
322
- }
323
314
  await options.notify({
324
315
  capName: options.capName,
325
316
  value: result.value,
326
317
  limit: options.limit,
327
- tenantId: ctx.user.tenantId,
318
+ tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
328
319
  });
329
320
  // Flip the soft-warned flag so the same period doesn't re-notify.
330
321
  const marked = await markCapSoftWarned(ctx, {
@@ -349,7 +340,7 @@ export async function enforceCapAndMaybeNotify(
349
340
  * Tag/Stunde auf (Hourly-Cron statt pro Request).
350
341
  */
351
342
  export async function enforceRollingCapAndMaybeNotify(
352
- ctx: HandlerContext,
343
+ ctx: CapCounterContext,
353
344
  options: {
354
345
  readonly capName: string;
355
346
  readonly windowDays: number;
@@ -366,16 +357,11 @@ export async function enforceRollingCapAndMaybeNotify(
366
357
  });
367
358
 
368
359
  if (result.state === "soft-hit") {
369
- if (!ctx.user?.tenantId) {
370
- throw new Error(
371
- "cap-counter.enforceRollingCapAndMaybeNotify: ctx.user.tenantId missing — required for notification",
372
- );
373
- }
374
360
  await options.notify({
375
361
  capName: options.capName,
376
362
  value: result.value,
377
363
  limit: options.limit,
378
- tenantId: ctx.user.tenantId,
364
+ tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
379
365
  });
380
366
  }
381
367
 
@@ -9,6 +9,11 @@ export {
9
9
  type ReadRollingCapUsageOptions,
10
10
  readRollingCapUsage,
11
11
  } from "./book-cap-usage.js";
12
+ export {
13
+ type CalendarCapUsageReservation,
14
+ type CalendarCapUsageReservationSpec,
15
+ reserveCalendarCapUsage,
16
+ } from "./calendar-cap-usage-reservation.js";
12
17
  export {
13
18
  CAP_COUNTER_FEATURE,
14
19
  CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
@@ -29,15 +29,11 @@ import type {
29
29
  WriteEvent,
30
30
  WriteHandlerDef,
31
31
  } from "@cosmicdrift/kumiko-framework/engine";
32
- import {
33
- releaseExpiredCapReservations,
34
- reserveCalendarCap,
35
- reserveRollingCap,
36
- } from "./cap-reservation.js";
32
+ import { reserveEnforcedCalendarCap } from "./calendar-cap-usage-reservation.js";
33
+ import { releaseExpiredCapReservations, reserveRollingCap } from "./cap-reservation.js";
37
34
  import {
38
35
  assertBelowHardCap,
39
36
  type CapToleranceProfileName,
40
- enforceCapAndMaybeNotify,
41
37
  enforceRollingCapAndMaybeNotify,
42
38
  type SoftHitNotifier,
43
39
  } from "./enforce-cap.js";
@@ -93,25 +89,7 @@ export function withCapEnforcement(
93
89
  handler.reserveBeforeTransaction,
94
90
  async (event, ctx) => {
95
91
  const cap = await capResolver(event, ctx);
96
-
97
- await releaseExpiredCapReservations(ctx, cap.capName);
98
- await enforceCapAndMaybeNotify(ctx, {
99
- capName: cap.capName,
100
- periodStartIso: cap.periodStartIso,
101
- limit: cap.limit,
102
- profile: cap.profile,
103
- notify: cap.notify,
104
- markSoftWarnedOutsideTransaction: true,
105
- ...(cap.amount !== undefined && { amount: cap.amount }),
106
- });
107
-
108
- const amount = cap.amount ?? 1;
109
- return reserveCalendarCap(ctx, {
110
- capName: cap.capName,
111
- amount,
112
- periodStartIso: cap.periodStartIso,
113
- guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
114
- });
92
+ return reserveEnforcedCalendarCap(ctx, { ...cap, amount: cap.amount ?? 1 });
115
93
  },
116
94
  ),
117
95
  };
@@ -325,7 +325,6 @@ async function sweepDerivedSubjectData(
325
325
  if (ctx.registry.features.has("notes-history")) {
326
326
  await eraseNoteBodiesReachingSubject({
327
327
  db: crossTenantSubjectRunner,
328
- registry: ctx.registry,
329
328
  subject,
330
329
  erasedBy: audit.erasedBy,
331
330
  requestId: audit.requestId,
@@ -14,6 +14,7 @@ import {
14
14
  KeyErasedError,
15
15
  type LocalKeyKmsAdapter,
16
16
  PII_ERASED_SENTINEL,
17
+ type SubjectId,
17
18
  } from "@cosmicdrift/kumiko-framework/crypto";
18
19
  import { acquireNamespacedAdvisoryLock, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
19
20
  import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
@@ -71,6 +72,15 @@ const DEFAULT_CACHE_MAX_ENTRIES = 10_000;
71
72
  // to DB-lookup if actually needed.
72
73
  const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
73
74
 
75
+ // Run rows hang on SYSTEM_TENANT_ID and a null trigger is a cron/system run;
76
+ // job-run-table.ts declares whenAbsent: "tenant" for exactly that case, so the
77
+ // logger must resolve the same subject the entity chain would (fw#3654).
78
+ function jobRunSubject(triggeredById: string | null): SubjectId {
79
+ return triggeredById === null
80
+ ? { kind: "tenant", tenantId: SYSTEM_TENANT_ID }
81
+ : { kind: "user", userId: triggeredById };
82
+ }
83
+
74
84
  // Same per-subject encryption as encryptFailureError/encryptStartedPayload,
75
85
  // applied to a single value — shared so payload/message/error all go
76
86
  // through the identical erased-key fallback. A key erased right before (or
@@ -81,14 +91,14 @@ const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
81
91
  // whole run row.
82
92
  async function encryptOrSentinel(
83
93
  kms: LocalKeyKmsAdapter,
84
- subjectUserId: string,
94
+ subject: SubjectId,
85
95
  value: string,
86
96
  field: string,
87
97
  ): Promise<string> {
88
98
  try {
89
99
  return await encryptPiiValueForSubject(
90
100
  kms,
91
- { kind: "user", userId: subjectUserId },
101
+ subject,
92
102
  value,
93
103
  { requestId: "jobs:job-run-logger" },
94
104
  field,
@@ -104,20 +114,20 @@ async function encryptOrSentinel(
104
114
  // jobRunLogsTable — that table is unmanaged/direct-write, so there is no
105
115
  // event-piiFields catalog to lean on here either. "message" is the AAD
106
116
  // field name and must match the field passed to decryptStoredPii on read
107
- // (detail.query.ts) or decrypt fails loud. Same skip rules as the payload:
108
- // null subject (system/cron runs) and absent KMS (rollout mode) both stay
109
- // plaintext. Bounded concurrency (#2307) — unbounded Promise.all would fire
110
- // one getOrCreateDek per log line at once against the KMS adapter's pool.
117
+ // (detail.query.ts) or decrypt fails loud. Same skip rule as the payload:
118
+ // absent KMS (rollout mode) stays plaintext. Bounded concurrency (#2307) —
119
+ // unbounded Promise.all would fire one getOrCreateDek per log line at once
120
+ // against the KMS adapter's pool.
111
121
  async function encryptLogMessages<T extends { readonly message: string }>(
112
122
  logs: readonly T[],
113
123
  triggeredById: string | null,
114
124
  ): Promise<T[]> {
115
- if (triggeredById === null) return [...logs];
116
125
  const kms = configuredPiiSubjectKms();
117
126
  if (!kms) return [...logs];
127
+ const subject = jobRunSubject(triggeredById);
118
128
  return mapWithConcurrency(logs, KMS_POOL_CONCURRENCY, async (log) => ({
119
129
  ...log,
120
- message: await encryptOrSentinel(kms, triggeredById, log.message, "message"),
130
+ message: await encryptOrSentinel(kms, subject, log.message, "message"),
121
131
  }));
122
132
  }
123
133
 
@@ -131,29 +141,27 @@ export async function encryptFailureError(
131
141
  error: string,
132
142
  triggeredById: string | null,
133
143
  ): Promise<string> {
134
- if (triggeredById === null) return error;
135
144
  const kms = configuredPiiSubjectKms();
136
145
  if (!kms) return error;
137
- return encryptOrSentinel(kms, triggeredById, error, "error");
146
+ return encryptOrSentinel(kms, jobRunSubject(triggeredById), error, "error");
138
147
  }
139
148
 
140
149
  // The run-started payload can carry arbitrary user data; triggeredById
141
150
  // names its owning user. No event-PII catalog involved (#2243 removed the
142
151
  // jobRun r.defineEvent registrations, so there is nothing to catalog) —
143
152
  // the subject is known statically, so we encrypt directly. A null subject
144
- // (system cron runs, recipient-less triggers) stays plaintext: there is no
145
- // user key to shred, mirroring the previous event-pii catalog's own skip
146
- // rule. Absent KMS adapter stays plaintext too (rollout mode, unchanged).
153
+ // (system cron runs, recipient-less triggers) encrypts under the system
154
+ // tenant key. Absent KMS adapter stays plaintext (rollout mode, unchanged).
147
155
  // A key already erased by the time the trigger lands (#2307) must not fail
148
156
  // the whole run-start write — sentinel like the log/error paths.
149
157
  async function encryptStartedPayload(
150
158
  payload: string | null,
151
159
  triggeredById: string | null,
152
160
  ): Promise<string | null> {
153
- if (payload === null || triggeredById === null) return payload;
161
+ if (payload === null) return payload;
154
162
  const kms = configuredPiiSubjectKms();
155
163
  if (!kms) return payload;
156
- return encryptOrSentinel(kms, triggeredById, payload, "payload");
164
+ return encryptOrSentinel(kms, jobRunSubject(triggeredById), payload, "payload");
157
165
  }
158
166
 
159
167
  // pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
@@ -46,11 +46,18 @@ export const jobRunEntity = createEntity({
46
46
  status: createTextField({ required: true, personal: false, reason: "system_metadata" }),
47
47
  // The run-started payload can carry arbitrary caller-supplied data,
48
48
  // encrypted by job-run-logger.ts under the triggering user's DEK (see
49
- // jobs-pii-kms.integration.test.ts) — same subject as `triggeredById`.
50
- payload: createTextField({ personal: { of: "triggeredById" }, find: "none" }),
49
+ // jobs-pii-kms.integration.test.ts) — same subject as `triggeredById`;
50
+ // cron runs have none and use the system tenant key (whenAbsent).
51
+ payload: createTextField({
52
+ personal: { of: "triggeredById", whenAbsent: "tenant" },
53
+ find: "none",
54
+ }),
51
55
  // Failure messages can echo payload content back — same subject/DEK as
52
56
  // `payload` (job-run-logger.ts encryptFailureError).
53
- error: createTextField({ personal: { of: "triggeredById" }, find: "none" }),
57
+ error: createTextField({
58
+ personal: { of: "triggeredById", whenAbsent: "tenant" },
59
+ find: "none",
60
+ }),
54
61
  attempt: createNumberField({ required: true, default: 1, integer: true }),
55
62
  startedAt: createTimestampField({ required: true }),
56
63
  finishedAt: createTimestampField(),
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.358.0",
4
+ "type": "fix",
5
+ "title": "forget-subject shreds note bodies even when the host entity has anonymize or blockDelete retention",
6
+ "detail": "Note bodies that @-mention the forgotten subject or hang on it as host entity were skipped when the host entity's retention strategy was anonymize or blockDelete, so the note text stayed readable while the host's own PII fields were anonymized. The note text is free-form PII, and a retention strategy protects the row, not its content, so these bodies are now always crypto-shredded. This applies to the manual `forget-subject` command and to the automated Art. 17 delete hook."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "breaking",
@@ -77,9 +77,10 @@ export function createNoteEntryEntity(
77
77
  }),
78
78
  // Stamped once at write time, never re-resolved later — the history
79
79
  // needs the name as it was then, not whatever the account is called now.
80
+ // whenAbsent: authorless notes (system-written) encrypt under the tenant key.
80
81
  authorName: createTextField({
81
82
  maxLength: 200,
82
- personal: { of: "authorId" },
83
+ personal: { of: "authorId", whenAbsent: "tenant" },
83
84
  find: "none",
84
85
  }),
85
86
  body: createLongTextField({
@@ -8,18 +8,11 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
8
  import { configuredPiiSubjectKms, type SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
9
9
  import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
10
10
  import type {
11
- Registry,
12
11
  TenantId,
13
12
  UserDataDeleteHook,
14
13
  UserDataExportHook,
15
14
  } from "@cosmicdrift/kumiko-framework/engine";
16
- import {
17
- type RetentionPresetKey,
18
- resolveRetentionPolicyForTenant,
19
- resolveTenantRetentionPreset,
20
- } from "../data-retention/index.js";
21
15
  import { noteEntryTable, noteMentionTable } from "../notes-history/index.js";
22
- import { policyToStrategy } from "../user-data-rights/index.js";
23
16
 
24
17
  // note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
25
18
  // per-user content, so the export filters by authorId directly.
@@ -84,17 +77,15 @@ async function selectNotesReachingSubject(
84
77
  // @-mentions the subject (`note-mention`, fw#2787) or hangs on it as host.
85
78
  // Free-text mentions are NOT detected — the documented gap in entity.ts.
86
79
  //
87
- // Per reached note the HOST entity's retention strategy (entityType, resolved
88
- // for the note's own tenant — the manual forget-subject path runs cross-tenant)
89
- // is consulted first: anonymize/blockDelete win over erasure, same rule the
90
- // automated forget-cleanup applies to every other entity.
80
+ // Reached bodies are shredded regardless of the host entity's retention strategy:
81
+ // the note text is free-form PII, and a host's anonymize/blockDelete protects the
82
+ // row, not its content, just like the host's own PII fields get anonymized.
91
83
  //
92
84
  // Without a mounted KMS this erases nothing: record-owned fields fall back to
93
85
  // plaintext framework-wide (see pii-field-encryption.ts), so apps that need
94
86
  // Art. 17 coverage must mount one.
95
87
  export async function eraseNoteBodiesReachingSubject(args: {
96
88
  readonly db: DbRunner | TenantDb;
97
- readonly registry: Registry;
98
89
  readonly subject: SubjectId;
99
90
  readonly erasedBy: string;
100
91
  readonly requestId: string;
@@ -104,36 +95,7 @@ export async function eraseNoteBodiesReachingSubject(args: {
104
95
  if (!kms) return;
105
96
 
106
97
  const notes = await selectNotesReachingSubject(args.db, args.subject);
107
- const dataRetentionMounted = args.registry.features.has("data-retention");
108
- const presetByTenant = new Map<string, RetentionPresetKey | null>();
109
- const strategyByTenantAndEntityType = new Map<string, ReturnType<typeof policyToStrategy>>();
110
98
  for (const note of notes) {
111
- const strategyKey = `${note.tenantId}:${note.entityType}`;
112
- let strategy = strategyByTenantAndEntityType.get(strategyKey);
113
- if (strategy === undefined) {
114
- let tenantPreset = presetByTenant.get(note.tenantId);
115
- if (tenantPreset === undefined) {
116
- tenantPreset = await resolveTenantRetentionPreset({
117
- db: args.db,
118
- registry: args.registry,
119
- tenantId: note.tenantId,
120
- });
121
- presetByTenant.set(note.tenantId, tenantPreset);
122
- }
123
- const hostPolicy = await resolveRetentionPolicyForTenant({
124
- db: args.db,
125
- registry: args.registry,
126
- tenantId: note.tenantId,
127
- entityName: note.entityType,
128
- preloadedTenantPreset: tenantPreset,
129
- // Without data-retention there is no override table to read.
130
- ...(!dataRetentionMounted && { preloadedOverride: null }),
131
- });
132
- strategy = policyToStrategy(hostPolicy.policy?.strategy ?? null);
133
- strategyByTenantAndEntityType.set(strategyKey, strategy);
134
- }
135
- if (strategy === "anonymize") continue;
136
-
137
99
  await kms.eraseKey(
138
100
  { kind: "record", entity: "note-entry", id: note.id },
139
101
  {
@@ -148,7 +110,6 @@ export async function eraseNoteBodiesReachingSubject(args: {
148
110
  export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
149
111
  await eraseNoteBodiesReachingSubject({
150
112
  db: ctx.db,
151
- registry: ctx.registry,
152
113
  subject: { kind: "user", userId: ctx.userId },
153
114
  erasedBy: ctx.userId,
154
115
  requestId: "notes-history-user-data:forget-reached-notes",
@@ -17,7 +17,7 @@ export { eraseNoteBodiesReachingSubject } from "./hooks.js";
17
17
 
18
18
  export const notesHistoryUserDataFeature = defineFeature("notes-history-user-data", (r) => {
19
19
  r.describe(
20
- "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
20
+ "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (regardless of the host entity's retention strategy: the note text is free PII, the host's anonymize/blockDelete protects the row, not the content), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
21
21
  );
22
22
  // user-data-rights is the hard dependency (EXT_USER_DATA host). notes-history
23
23
  // is OPTIONAL: if it's mounted toggleable(default=false), a hard r.requires
@@ -153,6 +153,7 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
153
153
  legendTotals: false,
154
154
  initialWindow: "from-today",
155
155
  markerLegend: "legend",
156
+ markerLabels: true,
156
157
  markerKinds: {
157
158
  extra: { tone: "active", label: "widgets:dashboard:kind-extra" },
158
159
  payoff: { tone: "positive", label: "widgets:dashboard:kind-payoff" },
@@ -1954,7 +1954,7 @@
1954
1954
  },
1955
1955
  {
1956
1956
  "name": "notes-history-user-data",
1957
- "description": "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
1957
+ "description": "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (regardless of the host entity's retention strategy: the note text is free PII, the host's anonymize/blockDelete protects the row, not the content), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
1958
1958
  "toggleableDefault": null,
1959
1959
  "requires": [
1960
1960
  "user-data-rights"