@fullwell/fullwell 1.1.11 → 1.1.13

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.
@@ -12,7 +12,7 @@ import {
12
12
  stat,
13
13
  unlink,
14
14
  } from "node:fs/promises";
15
- import { randomUUID } from "node:crypto";
15
+ import { createHash, randomUUID } from "node:crypto";
16
16
  import { homedir } from "node:os";
17
17
  import path from "node:path";
18
18
  import { pathToFileURL } from "node:url";
@@ -22,9 +22,30 @@ const MAX_DOCUMENT_BYTES = 16 * 1024 * 1024;
22
22
  const MAX_JSON_DEPTH = 64;
23
23
  const MAX_JSON_NODES = 250_000;
24
24
  const LOCK_STALE_MS = 5 * 60 * 1000;
25
+ const LOCK_WAIT_MS = 1_000;
26
+ const LOCK_RETRY_MS = 20;
27
+ const MAX_MEAL_PROPOSALS = 2_000;
28
+ const MAX_MEAL_REVIEW_EVENTS = 2_000;
29
+ const MAX_MEAL_WITHDRAWAL_EVENTS = MAX_MEAL_PROPOSALS;
30
+ const MAX_MEAL_EVENTS = MAX_MEAL_REVIEW_EVENTS + MAX_MEAL_WITHDRAWAL_EVENTS;
31
+ const MAX_MEAL_IDEMPOTENCY_RECEIPTS = 6_000;
32
+ const MAX_MEAL_PROPOSALS_PER_SLOT = 48;
33
+ const MAX_MEAL_PROPOSALS_PER_WEEK = 500;
34
+ const MAX_MEAL_REVIEW_EVENTS_PER_WEEK = 500;
35
+ const MAX_MEAL_WITHDRAWAL_EVENTS_PER_WEEK = MAX_MEAL_PROPOSALS_PER_WEEK;
36
+ const MAX_MEAL_WEEKS = 520;
37
+ const IANA_TIME_ZONES = new Set(["UTC", ...Intl.supportedValuesOf("timeZone")]);
25
38
  const LOCAL_ID_PATTERN = /^lcl_[0-9a-f]{32}$/;
26
39
  const FULLWELL_ID_PATTERN = /^(?:usr|hsh)_[0-9a-z]{16,64}$/;
27
40
  const HEAD_PATTERN = /^[0-9a-f]{40,64}$/;
41
+ const ITEM_ID_PATTERN = /^itm_[0-9a-z]{16,64}$/;
42
+ const EVIDENCE_ID_PATTERN = /^evd_[0-9a-z]{16,64}$/;
43
+ const MEAL_PROPOSAL_ID_PATTERN = /^mlp_[0-9a-z]{16,64}$/;
44
+ const MEAL_EVENT_ID_PATTERN = /^mle_[0-9a-z]{16,64}$/;
45
+ const MEAL_PROFILE_RECEIPT_ID_PATTERN = /^mlr_[0-9a-f]{32}$/;
46
+ const LOCAL_RECIPE_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
47
+ const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/;
48
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
28
49
  const FORBIDDEN_JOURNAL_KEYS = new Set([
29
50
  "access_token",
30
51
  "authorization",
@@ -69,6 +90,20 @@ function assertExactKeys(value, allowed, label) {
69
90
  }
70
91
  }
71
92
 
93
+ function canonicalJson(value) {
94
+ if (Array.isArray(value)) return value.map(canonicalJson);
95
+ if (!isPlainObject(value)) return value;
96
+ return Object.fromEntries(
97
+ Object.entries(value)
98
+ .sort(([left], [right]) => left.localeCompare(right))
99
+ .map(([key, child]) => [key, canonicalJson(child)]),
100
+ );
101
+ }
102
+
103
+ function stableJson(value) {
104
+ return JSON.stringify(canonicalJson(value));
105
+ }
106
+
72
107
  function assertRevision(value, label = "expected_revision") {
73
108
  if (!Number.isSafeInteger(value) || value < 1) fail("VALIDATION_FAILED", `${label} must be a positive integer`);
74
109
  return value;
@@ -91,14 +126,436 @@ function assertDate(value, label) {
91
126
  return value;
92
127
  }
93
128
 
129
+ /** Detects ASCII control bytes, with optional space rejection for URL inputs. */
130
+ export function hasForbiddenAscii(value, forbidSpace = false) {
131
+ for (const character of value) {
132
+ const code = character.codePointAt(0);
133
+ if (code <= 31 || code === 127 || (forbidSpace && code === 32)) return true;
134
+ }
135
+ return false;
136
+ }
137
+
138
+ function assertBoundedText(value, label, maximum, { nullable = false } = {}) {
139
+ if (nullable && value === null) return null;
140
+ if (typeof value !== "string"
141
+ || value.length < 1
142
+ || value.length > maximum
143
+ || !value.isWellFormed()
144
+ || value.trim() !== value
145
+ || hasForbiddenAscii(value)) {
146
+ fail("VALIDATION_FAILED", `${label} must be trimmed text of at most ${maximum} characters`);
147
+ }
148
+ return value;
149
+ }
150
+
151
+ function assertHouseholdName(value) {
152
+ if (typeof value !== "string" || !value.isWellFormed() || hasForbiddenAscii(value)) {
153
+ fail("VALIDATION_FAILED", "household_name must be trimmed text of at most 120 characters");
154
+ }
155
+ return assertBoundedText(value.trim(), "household_name", 120);
156
+ }
157
+
158
+ function assertIsoDateTime(value, label) {
159
+ if (typeof value !== "string"
160
+ || !/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value)
161
+ || !Number.isFinite(Date.parse(value))) {
162
+ fail("VALIDATION_FAILED", `${label} must be an ISO date-time`);
163
+ }
164
+ return value;
165
+ }
166
+
167
+ function assertIsoDate(value, label) {
168
+ if (typeof value !== "string" || !ISO_DATE_PATTERN.test(value)) fail("VALIDATION_FAILED", `${label} must be an ISO date`);
169
+ const parsed = new Date(`${value}T00:00:00.000Z`);
170
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) fail("VALIDATION_FAILED", `${label} is invalid`);
171
+ return value;
172
+ }
173
+
174
+ function assertMonday(value, label = "week_start") {
175
+ const date = assertIsoDate(value, label);
176
+ if (new Date(`${date}T00:00:00.000Z`).getUTCDay() !== 1) fail("VALIDATION_FAILED", `${label} must be a Monday`);
177
+ return date;
178
+ }
179
+
180
+ function assertMealDate(weekStart, value) {
181
+ const mealDate = assertIsoDate(value, "meal_date");
182
+ const start = Date.parse(`${weekStart}T00:00:00.000Z`);
183
+ const offset = (Date.parse(`${mealDate}T00:00:00.000Z`) - start) / 86_400_000;
184
+ if (!Number.isInteger(offset) || offset < 0 || offset > 6) fail("VALIDATION_FAILED", "meal_date must fall within week_start");
185
+ return mealDate;
186
+ }
187
+
188
+ function assertIanaTimeZone(value) {
189
+ if (typeof value !== "string" || value.length > 100 || !IANA_TIME_ZONES.has(value)) {
190
+ fail("VALIDATION_FAILED", "time_zone is invalid");
191
+ }
192
+ return value;
193
+ }
194
+
195
+ function assertIdempotencyKey(value) {
196
+ if (typeof value !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(value)) fail("VALIDATION_FAILED", "idempotency_key is invalid");
197
+ return value;
198
+ }
199
+
200
+ function assertLocalActor(label) {
201
+ return { kind: "local", label: assertBoundedText(label, "actor_label", 80) };
202
+ }
203
+
204
+ function assertHttpsUrl(value, label, { nullable = false } = {}) {
205
+ if (nullable && value === null) return null;
206
+ if (typeof value !== "string" || value.length > 2_048 || hasForbiddenAscii(value, true)) {
207
+ fail("VALIDATION_FAILED", `${label} must be a bounded HTTPS URL`);
208
+ }
209
+ let parsed;
210
+ try {
211
+ parsed = new URL(value);
212
+ } catch {
213
+ fail("VALIDATION_FAILED", `${label} must be a valid HTTPS URL`);
214
+ }
215
+ if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") {
216
+ fail("VALIDATION_FAILED", `${label} must be a credential-free HTTPS URL`);
217
+ }
218
+ return parsed.toString();
219
+ }
220
+
221
+ function assertUniqueTextList(value, label, maximumItems, maximumText) {
222
+ if (!Array.isArray(value) || value.length > maximumItems) fail("VALIDATION_FAILED", `${label} has too many values`);
223
+ const parsed = value.map((entry, index) => assertBoundedText(entry, `${label}[${index}]`, maximumText));
224
+ const normalized = parsed.map((entry) => entry.toLocaleLowerCase());
225
+ if (new Set(normalized).size !== normalized.length) fail("VALIDATION_FAILED", `${label} values must be unique`);
226
+ return parsed;
227
+ }
228
+
229
+ function assertMealConstraints(value) {
230
+ if (!isPlainObject(value) || typeof value.status !== "string") fail("VALIDATION_FAILED", "constraints must be an object");
231
+ if (value.status === "unresolved") {
232
+ assertExactKeys(value, new Set(["status"]), "constraints");
233
+ return { status: "unresolved" };
234
+ }
235
+ if (value.status === "confirmed_none") {
236
+ assertExactKeys(value, new Set(["status", "time_zone", "reviewed_at"]), "constraints");
237
+ return {
238
+ status: "confirmed_none",
239
+ time_zone: assertIanaTimeZone(value.time_zone),
240
+ reviewed_at: assertIsoDateTime(value.reviewed_at, "constraints.reviewed_at"),
241
+ };
242
+ }
243
+ if (value.status === "recorded") {
244
+ assertExactKeys(value, new Set([
245
+ "status",
246
+ "time_zone",
247
+ "allergy_labels",
248
+ "sensitivity_labels",
249
+ "reviewed_at",
250
+ ]), "constraints");
251
+ const allergyLabels = assertUniqueTextList(value.allergy_labels, "allergy_labels", 30, 120);
252
+ const sensitivityLabels = assertUniqueTextList(value.sensitivity_labels, "sensitivity_labels", 30, 120);
253
+ const combined = [...allergyLabels, ...sensitivityLabels].map((entry) => entry.toLocaleLowerCase());
254
+ if (combined.length === 0 || new Set(combined).size !== combined.length) {
255
+ fail("VALIDATION_FAILED", "recorded constraints require unique allergy or sensitivity labels");
256
+ }
257
+ return {
258
+ status: "recorded",
259
+ time_zone: assertIanaTimeZone(value.time_zone),
260
+ allergy_labels: allergyLabels,
261
+ sensitivity_labels: sensitivityLabels,
262
+ reviewed_at: assertIsoDateTime(value.reviewed_at, "constraints.reviewed_at"),
263
+ };
264
+ }
265
+ fail("VALIDATION_FAILED", "constraints status is unsupported");
266
+ }
267
+
268
+ function assertMealSlot(value) {
269
+ if (!isPlainObject(value) || typeof value.kind !== "string") fail("VALIDATION_FAILED", "slot must be an object");
270
+ if (["breakfast", "lunch", "dinner", "snack"].includes(value.kind)) {
271
+ assertExactKeys(value, new Set(["kind"]), "slot");
272
+ return { kind: value.kind };
273
+ }
274
+ if (value.kind === "custom") {
275
+ assertExactKeys(value, new Set(["kind", "label"]), "slot");
276
+ return { kind: "custom", label: assertBoundedText(value.label, "slot.label", 80) };
277
+ }
278
+ fail("VALIDATION_FAILED", "slot kind is unsupported");
279
+ }
280
+
281
+ function mealSlotKey(slot) {
282
+ return slot.kind === "custom" ? `custom:${slot.label}` : slot.kind;
283
+ }
284
+
285
+ function assertOpaqueId(value, pattern, label) {
286
+ if (typeof value !== "string" || !pattern.test(value)) fail("VALIDATION_FAILED", `${label} is invalid`);
287
+ return value;
288
+ }
289
+
290
+ function assertLocalMealSource(value) {
291
+ if (!isPlainObject(value) || typeof value.kind !== "string") fail("VALIDATION_FAILED", "source must be an object");
292
+ if (value.kind === "freeform") {
293
+ assertExactKeys(value, new Set(["kind", "title"]), "source");
294
+ return { kind: "freeform", title: assertBoundedText(value.title, "source.title", 300) };
295
+ }
296
+ if (value.kind === "journal_recipe") {
297
+ assertExactKeys(value, new Set(["kind", "item_id", "item_revision", "liked_evidence_ids"]), "source");
298
+ if (!Array.isArray(value.liked_evidence_ids) || value.liked_evidence_ids.length < 1 || value.liked_evidence_ids.length > 100) {
299
+ fail("VALIDATION_FAILED", "source.liked_evidence_ids must contain between 1 and 100 IDs");
300
+ }
301
+ const evidenceIds = value.liked_evidence_ids.map((entry) => assertOpaqueId(entry, EVIDENCE_ID_PATTERN, "liked evidence ID"));
302
+ if (new Set(evidenceIds).size !== evidenceIds.length) fail("VALIDATION_FAILED", "source.liked_evidence_ids must be unique");
303
+ return {
304
+ kind: "journal_recipe",
305
+ item_id: assertOpaqueId(value.item_id, ITEM_ID_PATTERN, "source.item_id"),
306
+ item_revision: assertOpaqueId(value.item_revision, LOCAL_RECIPE_DIGEST_PATTERN, "source.item_revision"),
307
+ liked_evidence_ids: evidenceIds,
308
+ };
309
+ }
310
+ if (value.kind === "external_recipe") {
311
+ assertExactKeys(value, new Set(["kind", "title", "canonical_url", "site_name", "discovered_at"]), "source");
312
+ return {
313
+ kind: "external_recipe",
314
+ title: assertBoundedText(value.title, "source.title", 300),
315
+ canonical_url: assertHttpsUrl(value.canonical_url, "source.canonical_url"),
316
+ site_name: assertBoundedText(value.site_name, "source.site_name", 200),
317
+ discovered_at: assertIsoDateTime(value.discovered_at, "source.discovered_at"),
318
+ };
319
+ }
320
+ fail("VALIDATION_FAILED", "source kind is unsupported");
321
+ }
322
+
323
+ function localRecipeContentDigest(item) {
324
+ return `sha256:${createHash("sha256").update(stableJson(item)).digest("hex")}`;
325
+ }
326
+
327
+ function validateLocalMealProposalSource(source, journal) {
328
+ if (source.kind !== "journal_recipe") return;
329
+ const item = Array.isArray(journal.items)
330
+ ? journal.items.find((candidate) => isPlainObject(candidate) && candidate.id === source.item_id)
331
+ : undefined;
332
+ if (item === undefined || item.kind !== "recipe") {
333
+ fail("VALIDATION_FAILED", "a journal recipe proposal must cite an existing recipe");
334
+ }
335
+ if (localRecipeContentDigest(item) !== source.item_revision) {
336
+ fail("LOCAL_RECIPE_REVISION_CONFLICT", "the cited recipe revision is no longer current");
337
+ }
338
+ if (item.liked !== "yes" || !Array.isArray(item.evidence_ids)) {
339
+ fail("VALIDATION_FAILED", "a liked-recipe proposal requires current Liked evidence");
340
+ }
341
+ const itemEvidenceIds = new Set(item.evidence_ids);
342
+ const evidence = Array.isArray(journal.evidence) ? journal.evidence : [];
343
+ const invalidEvidence = source.liked_evidence_ids.some((id) => {
344
+ const cited = evidence.find((candidate) => isPlainObject(candidate) && candidate.id === id);
345
+ return !itemEvidenceIds.has(id)
346
+ || cited?.kind !== "user_confirmation"
347
+ || cited.confirmation?.subject !== "recipe_preference"
348
+ || cited.confirmation.recipe_item_id !== item.id
349
+ || cited.confirmation.preference !== "liked";
350
+ });
351
+ if (invalidEvidence) fail("VALIDATION_FAILED", "Liked evidence must be a cited user confirmation");
352
+ }
353
+
354
+ function assertMealPlanningState(value) {
355
+ if (!isPlainObject(value)) fail("VALIDATION_FAILED", "journal.meal_planning must be an object");
356
+ assertExactKeys(value, new Set(["schema_version", "profile", "proposals", "events", "idempotency"]), "journal.meal_planning");
357
+ if (value.schema_version !== 1) fail("VALIDATION_FAILED", "journal.meal_planning schema version is unsupported");
358
+ if (!isPlainObject(value.profile)) fail("VALIDATION_FAILED", "journal.meal_planning.profile must be an object");
359
+ assertExactKeys(value.profile, new Set([
360
+ "schema_version",
361
+ "revision",
362
+ "constraints",
363
+ "updated_at",
364
+ "updated_by",
365
+ ]), "journal.meal_planning.profile");
366
+ if (value.profile.schema_version !== 1) fail("VALIDATION_FAILED", "meal-planning profile schema version is unsupported");
367
+ assertRevision(value.profile.revision, "meal-planning profile revision");
368
+ assertMealConstraints(value.profile.constraints);
369
+ assertIsoDateTime(value.profile.updated_at, "meal-planning profile updated_at");
370
+ if (!isPlainObject(value.profile.updated_by)) fail("VALIDATION_FAILED", "meal-planning profile updated_by must be an object");
371
+ assertExactKeys(value.profile.updated_by, new Set(["kind", "label"]), "meal-planning profile updated_by");
372
+ if (value.profile.updated_by.kind !== "local") fail("VALIDATION_FAILED", "meal-planning profile actor must be local");
373
+ assertBoundedText(value.profile.updated_by.label, "meal-planning profile actor label", 80);
374
+
375
+ if (!Array.isArray(value.proposals) || value.proposals.length > MAX_MEAL_PROPOSALS) {
376
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposals");
377
+ }
378
+ if (!Array.isArray(value.events) || value.events.length > MAX_MEAL_EVENTS) {
379
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many events");
380
+ }
381
+ if (!Array.isArray(value.idempotency) || value.idempotency.length > MAX_MEAL_IDEMPOTENCY_RECEIPTS) {
382
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many idempotency receipts");
383
+ }
384
+
385
+ const proposalIds = new Set();
386
+ const proposalsByWeek = new Map();
387
+ const proposalsBySlot = new Map();
388
+ for (const proposal of value.proposals) {
389
+ assertStoredMealProposal(proposal);
390
+ if (proposalIds.has(proposal.id)) fail("VALIDATION_FAILED", "meal-planning proposal IDs must be unique");
391
+ proposalIds.add(proposal.id);
392
+ proposalsByWeek.set(proposal.week_start, (proposalsByWeek.get(proposal.week_start) ?? 0) + 1);
393
+ const slotKey = `${proposal.week_start}:${proposal.meal_date}:${mealSlotKey(proposal.slot)}`;
394
+ proposalsBySlot.set(slotKey, (proposalsBySlot.get(slotKey) ?? 0) + 1);
395
+ }
396
+ if ([...proposalsByWeek.values()].some((count) => count > MAX_MEAL_PROPOSALS_PER_WEEK)) {
397
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposals for one week");
398
+ }
399
+ if ([...proposalsBySlot.values()].some((count) => count > MAX_MEAL_PROPOSALS_PER_SLOT)) {
400
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposals for one meal slot");
401
+ }
402
+
403
+ const eventIds = new Set();
404
+ const reviewEventsByWeek = new Map();
405
+ const withdrawalEventsByWeek = new Map();
406
+ let reviewEventCount = 0;
407
+ let withdrawalEventCount = 0;
408
+ for (const event of value.events) {
409
+ assertStoredMealEvent(event);
410
+ if (eventIds.has(event.id)) fail("VALIDATION_FAILED", "meal-planning event IDs must be unique");
411
+ eventIds.add(event.id);
412
+ const byWeek = event.kind === "constraints_reviewed" ? reviewEventsByWeek : withdrawalEventsByWeek;
413
+ byWeek.set(event.week_start, (byWeek.get(event.week_start) ?? 0) + 1);
414
+ if (event.kind === "constraints_reviewed") reviewEventCount += 1;
415
+ else withdrawalEventCount += 1;
416
+ }
417
+ if (reviewEventCount > MAX_MEAL_REVIEW_EVENTS || withdrawalEventCount > MAX_MEAL_WITHDRAWAL_EVENTS) {
418
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many events");
419
+ }
420
+ if ([...reviewEventsByWeek.values()].some((count) => count > MAX_MEAL_REVIEW_EVENTS_PER_WEEK)
421
+ || [...withdrawalEventsByWeek.values()].some((count) => count > MAX_MEAL_WITHDRAWAL_EVENTS_PER_WEEK)) {
422
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many events of one kind for one week");
423
+ }
424
+ const weeks = new Set([...proposalsByWeek.keys(), ...reviewEventsByWeek.keys(), ...withdrawalEventsByWeek.keys()]);
425
+ if (weeks.size > MAX_MEAL_WEEKS) {
426
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many weeks");
427
+ }
428
+ for (const proposal of value.proposals) {
429
+ const review = value.events.find(({ id }) => id === proposal.constraint_review_event_id);
430
+ if (review?.kind !== "constraints_reviewed"
431
+ || review.week_start !== proposal.week_start
432
+ || review.constraint_revision !== proposal.constraint_revision) {
433
+ fail("VALIDATION_FAILED", "meal proposal does not reference its matching weekly constraint review");
434
+ }
435
+ if (proposal.constraint_revision > value.profile.revision) {
436
+ fail("VALIDATION_FAILED", "meal proposal references a future constraint revision");
437
+ }
438
+ }
439
+ for (const event of value.events) {
440
+ if (event.kind === "constraints_reviewed" && event.constraint_revision > value.profile.revision) {
441
+ fail("VALIDATION_FAILED", "meal-plan event references a future constraint revision");
442
+ }
443
+ if (event.kind === "proposal_withdrawn"
444
+ && !value.proposals.some(({ id, week_start: weekStart }) => id === event.proposal_id && weekStart === event.week_start)) {
445
+ fail("VALIDATION_FAILED", "withdrawal event does not reference a proposal in its week");
446
+ }
447
+ }
448
+ const receiptKeys = new Set();
449
+ for (const receipt of value.idempotency) {
450
+ if (!isPlainObject(receipt)) fail("VALIDATION_FAILED", "meal-planning idempotency receipt must be an object");
451
+ assertExactKeys(receipt, new Set(["key", "kind", "fingerprint", "entity_id"]), "meal-planning idempotency receipt");
452
+ assertIdempotencyKey(receipt.key);
453
+ if (!["meal_planning_profile", "constraints_reviewed", "meal_proposal", "meal_plan_event"].includes(receipt.kind)) {
454
+ fail("VALIDATION_FAILED", "meal-planning idempotency receipt kind is unsupported");
455
+ }
456
+ if (typeof receipt.fingerprint !== "string" || !/^[0-9a-f]{64}$/.test(receipt.fingerprint)) {
457
+ fail("VALIDATION_FAILED", "meal-planning idempotency fingerprint is invalid");
458
+ }
459
+ const expectedPattern = receipt.kind === "meal_planning_profile"
460
+ ? MEAL_PROFILE_RECEIPT_ID_PATTERN
461
+ : receipt.kind === "meal_proposal"
462
+ ? MEAL_PROPOSAL_ID_PATTERN
463
+ : MEAL_EVENT_ID_PATTERN;
464
+ assertOpaqueId(receipt.entity_id, expectedPattern, "meal-planning idempotency entity_id");
465
+ if (receipt.kind !== "meal_planning_profile") {
466
+ const entity = receipt.kind === "meal_proposal"
467
+ ? value.proposals.find(({ id }) => id === receipt.entity_id)
468
+ : value.events.find(({ id }) => id === receipt.entity_id);
469
+ if (entity === undefined
470
+ || (receipt.kind === "constraints_reviewed" && entity.kind !== "constraints_reviewed")
471
+ || (receipt.kind === "meal_plan_event" && entity.kind !== "proposal_withdrawn")) {
472
+ fail("VALIDATION_FAILED", "meal-planning idempotency receipt has no matching entity");
473
+ }
474
+ }
475
+ if (receiptKeys.has(receipt.key)) fail("VALIDATION_FAILED", "meal-planning idempotency keys must be unique");
476
+ receiptKeys.add(receipt.key);
477
+ }
478
+ return value;
479
+ }
480
+
481
+ function assertStoredMealProposal(value) {
482
+ if (!isPlainObject(value)) fail("VALIDATION_FAILED", "meal proposal must be an object");
483
+ assertExactKeys(value, new Set([
484
+ "id",
485
+ "week_start",
486
+ "meal_date",
487
+ "slot",
488
+ "proposed_by",
489
+ "source",
490
+ "servings",
491
+ "notes",
492
+ "constraint_revision",
493
+ "constraint_review_event_id",
494
+ "compatibility",
495
+ "compatibility_caveat",
496
+ "created_at",
497
+ "schema_version",
498
+ ]), "meal proposal");
499
+ if (value.schema_version !== 1) fail("VALIDATION_FAILED", "meal proposal schema version is unsupported");
500
+ assertOpaqueId(value.id, MEAL_PROPOSAL_ID_PATTERN, "meal proposal ID");
501
+ const weekStart = assertMonday(value.week_start);
502
+ assertMealDate(weekStart, value.meal_date);
503
+ assertMealSlot(value.slot);
504
+ if (!isPlainObject(value.proposed_by) || value.proposed_by.kind !== "local") fail("VALIDATION_FAILED", "local meal proposal actor is invalid");
505
+ assertExactKeys(value.proposed_by, new Set(["kind", "label"]), "meal proposal actor");
506
+ assertBoundedText(value.proposed_by.label, "meal proposal actor label", 80);
507
+ assertLocalMealSource(value.source);
508
+ if (value.servings !== null && (!Number.isSafeInteger(value.servings) || value.servings < 1 || value.servings > 100)) {
509
+ fail("VALIDATION_FAILED", "servings must be null or an integer from 1 to 100");
510
+ }
511
+ assertBoundedText(value.notes, "notes", 1_000, { nullable: true });
512
+ assertRevision(value.constraint_revision, "constraint_revision");
513
+ assertOpaqueId(value.constraint_review_event_id, MEAL_EVENT_ID_PATTERN, "constraint_review_event_id");
514
+ if (!["appears_compatible", "incomplete_evidence", "needs_recheck"].includes(value.compatibility)) {
515
+ fail("VALIDATION_FAILED", "compatibility is unsupported");
516
+ }
517
+ assertBoundedText(value.compatibility_caveat, "compatibility_caveat", 1_000);
518
+ assertIsoDateTime(value.created_at, "meal proposal created_at");
519
+ }
520
+
521
+ function assertStoredMealEvent(value) {
522
+ if (!isPlainObject(value) || typeof value.kind !== "string") fail("VALIDATION_FAILED", "meal-plan event must be an object");
523
+ const common = ["id", "kind", "week_start", "actor", "occurred_at", "schema_version"];
524
+ const specific = value.kind === "constraints_reviewed"
525
+ ? ["constraint_revision"]
526
+ : value.kind === "proposal_withdrawn"
527
+ ? ["proposal_id", "reason"]
528
+ : [];
529
+ if (specific.length === 0) fail("VALIDATION_FAILED", "meal-plan event kind is unsupported");
530
+ assertExactKeys(value, new Set([...common, ...specific]), "meal-plan event");
531
+ if (value.schema_version !== 1) fail("VALIDATION_FAILED", "meal-plan event schema version is unsupported");
532
+ assertOpaqueId(value.id, MEAL_EVENT_ID_PATTERN, "meal-plan event ID");
533
+ assertMonday(value.week_start);
534
+ if (!isPlainObject(value.actor) || value.actor.kind !== "local") fail("VALIDATION_FAILED", "meal-plan event actor is invalid");
535
+ assertExactKeys(value.actor, new Set(["kind", "label"]), "meal-plan event actor");
536
+ assertBoundedText(value.actor.label, "meal-plan event actor label", 80);
537
+ assertIsoDateTime(value.occurred_at, "meal-plan event occurred_at");
538
+ if (value.kind === "constraints_reviewed") assertRevision(value.constraint_revision, "constraint_revision");
539
+ if (value.kind === "proposal_withdrawn") {
540
+ assertOpaqueId(value.proposal_id, MEAL_PROPOSAL_ID_PATTERN, "proposal_id");
541
+ assertBoundedText(value.reason, "reason", 500, { nullable: true });
542
+ }
543
+ }
544
+
94
545
  function assertJournal(value) {
95
546
  if (!isPlainObject(value)) fail("VALIDATION_FAILED", "journal must be an object");
547
+ if (value.household !== undefined) {
548
+ if (!isPlainObject(value.household)) fail("VALIDATION_FAILED", "journal household must be an object");
549
+ assertExactKeys(value.household, new Set(["display_name"]), "journal household");
550
+ assertBoundedText(value.household.display_name, "household display name", 120);
551
+ }
96
552
  if (Array.isArray(value.evidence) && value.evidence.length > 10_000) {
97
553
  fail("LOCAL_HOUSEHOLD_TOO_LARGE", "journal exceeds 10,000 evidence records");
98
554
  }
99
555
  if (Array.isArray(value.items) && value.items.length > 10_000) {
100
556
  fail("LOCAL_HOUSEHOLD_TOO_LARGE", "journal exceeds 10,000 items");
101
557
  }
558
+ if (value.meal_planning !== undefined) assertMealPlanningState(value.meal_planning);
102
559
  const pending = [{ value, depth: 0 }];
103
560
  let nodes = 0;
104
561
  while (pending.length > 0) {
@@ -182,9 +639,18 @@ function parseDocument(value) {
182
639
 
183
640
  function parseRequest(input) {
184
641
  if (!isPlainObject(input) || typeof input.operation !== "string") fail("VALIDATION_FAILED", "request must include an operation");
185
- if (["initialize", "load"].includes(input.operation)) {
186
- assertExactKeys(input, new Set(["operation"]), `${input.operation} request`);
187
- return { operation: input.operation };
642
+ if (input.operation === "initialize") {
643
+ assertExactKeys(input, new Set(["operation", "household_name"]), "initialize request");
644
+ return {
645
+ operation: "initialize",
646
+ household_name: input.household_name === undefined
647
+ ? undefined
648
+ : assertHouseholdName(input.household_name),
649
+ };
650
+ }
651
+ if (input.operation === "load") {
652
+ assertExactKeys(input, new Set(["operation"]), "load request");
653
+ return { operation: "load" };
188
654
  }
189
655
  if (input.operation === "save") {
190
656
  assertExactKeys(input, new Set(["operation", "expected_revision", "journal"]), "save request");
@@ -194,6 +660,117 @@ function parseRequest(input) {
194
660
  journal: assertJournal(input.journal),
195
661
  };
196
662
  }
663
+ if (input.operation === "rename_household") {
664
+ assertExactKeys(input, new Set(["operation", "expected_revision", "household_name"]), "rename_household request");
665
+ return {
666
+ operation: "rename_household",
667
+ expected_revision: assertRevision(input.expected_revision),
668
+ household_name: assertHouseholdName(input.household_name),
669
+ };
670
+ }
671
+ if (input.operation === "save_meal_planning_profile") {
672
+ assertExactKeys(input, new Set([
673
+ "operation",
674
+ "expected_revision",
675
+ "idempotency_key",
676
+ "actor_label",
677
+ "constraints",
678
+ ]), "save_meal_planning_profile request");
679
+ const constraints = assertMealConstraints(input.constraints);
680
+ if (constraints.status === "unresolved") fail("VALIDATION_FAILED", "meal-planning constraints must record an explicit answer");
681
+ return {
682
+ operation: "save_meal_planning_profile",
683
+ expected_revision: assertRevision(input.expected_revision),
684
+ idempotency_key: assertIdempotencyKey(input.idempotency_key),
685
+ actor: assertLocalActor(input.actor_label),
686
+ constraints,
687
+ };
688
+ }
689
+ if (input.operation === "review_meal_constraints") {
690
+ assertExactKeys(input, new Set([
691
+ "operation",
692
+ "expected_revision",
693
+ "idempotency_key",
694
+ "actor_label",
695
+ "week_start",
696
+ "constraint_revision",
697
+ ]), "review_meal_constraints request");
698
+ return {
699
+ operation: "review_meal_constraints",
700
+ expected_revision: assertRevision(input.expected_revision),
701
+ idempotency_key: assertIdempotencyKey(input.idempotency_key),
702
+ actor: assertLocalActor(input.actor_label),
703
+ week_start: assertMonday(input.week_start),
704
+ constraint_revision: assertRevision(input.constraint_revision, "constraint_revision"),
705
+ };
706
+ }
707
+ if (input.operation === "append_meal_proposal") {
708
+ assertExactKeys(input, new Set([
709
+ "operation",
710
+ "expected_revision",
711
+ "idempotency_key",
712
+ "actor_label",
713
+ "week_start",
714
+ "meal_date",
715
+ "slot",
716
+ "source",
717
+ "servings",
718
+ "notes",
719
+ "constraint_revision",
720
+ "constraint_review_event_id",
721
+ "compatibility",
722
+ "compatibility_caveat",
723
+ ]), "append_meal_proposal request");
724
+ const weekStart = assertMonday(input.week_start);
725
+ if (input.servings !== null && (!Number.isSafeInteger(input.servings) || input.servings < 1 || input.servings > 100)) {
726
+ fail("VALIDATION_FAILED", "servings must be null or an integer from 1 to 100");
727
+ }
728
+ if (!["appears_compatible", "incomplete_evidence", "needs_recheck"].includes(input.compatibility)) {
729
+ fail("VALIDATION_FAILED", "compatibility is unsupported");
730
+ }
731
+ return {
732
+ operation: "append_meal_proposal",
733
+ expected_revision: assertRevision(input.expected_revision),
734
+ idempotency_key: assertIdempotencyKey(input.idempotency_key),
735
+ actor: assertLocalActor(input.actor_label),
736
+ week_start: weekStart,
737
+ meal_date: assertMealDate(weekStart, input.meal_date),
738
+ slot: assertMealSlot(input.slot),
739
+ source: assertLocalMealSource(input.source),
740
+ servings: input.servings,
741
+ notes: assertBoundedText(input.notes, "notes", 1_000, { nullable: true }),
742
+ constraint_revision: assertRevision(input.constraint_revision, "constraint_revision"),
743
+ constraint_review_event_id: assertOpaqueId(input.constraint_review_event_id, MEAL_EVENT_ID_PATTERN, "constraint_review_event_id"),
744
+ compatibility: input.compatibility,
745
+ compatibility_caveat: assertBoundedText(input.compatibility_caveat, "compatibility_caveat", 1_000),
746
+ };
747
+ }
748
+ if (input.operation === "record_meal_plan_event") {
749
+ assertExactKeys(input, new Set([
750
+ "operation",
751
+ "expected_revision",
752
+ "idempotency_key",
753
+ "actor_label",
754
+ "week_start",
755
+ "event",
756
+ ]), "record_meal_plan_event request");
757
+ if (!isPlainObject(input.event) || input.event.kind !== "proposal_withdrawn") {
758
+ fail("VALIDATION_FAILED", "record_meal_plan_event supports only proposal_withdrawn");
759
+ }
760
+ assertExactKeys(input.event, new Set(["kind", "proposal_id", "reason"]), "record_meal_plan_event event");
761
+ return {
762
+ operation: "record_meal_plan_event",
763
+ expected_revision: assertRevision(input.expected_revision),
764
+ idempotency_key: assertIdempotencyKey(input.idempotency_key),
765
+ actor: assertLocalActor(input.actor_label),
766
+ week_start: assertMonday(input.week_start),
767
+ event: {
768
+ kind: "proposal_withdrawn",
769
+ proposal_id: assertOpaqueId(input.event.proposal_id, MEAL_PROPOSAL_ID_PATTERN, "proposal_id"),
770
+ reason: assertBoundedText(input.event.reason, "reason", 500, { nullable: true }),
771
+ },
772
+ };
773
+ }
197
774
  if (["finalize", "delete_collecting"].includes(input.operation)) {
198
775
  assertExactKeys(input, new Set(["operation", "expected_revision"]), `${input.operation} request`);
199
776
  return { operation: input.operation, expected_revision: assertRevision(input.expected_revision) };
@@ -250,7 +827,7 @@ async function readDocument(filePath) {
250
827
  }
251
828
  }
252
829
 
253
- async function ensurePrivateDirectory(root, directory) {
830
+ export async function ensurePrivateDirectory(root, directory) {
254
831
  const resolvedRoot = path.resolve(root);
255
832
  const relative = path.relative(resolvedRoot, directory);
256
833
  if (relative.startsWith("..") || path.isAbsolute(relative)) fail("VALIDATION_FAILED", "local household directory escapes the Codex home");
@@ -264,50 +841,205 @@ async function ensurePrivateDirectory(root, directory) {
264
841
  }
265
842
  } catch (error) {
266
843
  if (error?.code !== "ENOENT") throw error;
267
- await mkdir(current, { mode: 0o700 });
844
+ try {
845
+ await mkdir(current, { mode: 0o700 });
846
+ } catch (mkdirError) {
847
+ if (mkdirError?.code !== "EEXIST") throw mkdirError;
848
+ const concurrentStat = await lstat(current);
849
+ if (!concurrentStat.isDirectory() || concurrentStat.isSymbolicLink()) {
850
+ fail("UNSAFE_LOCAL_PATH", "local household path contains a non-directory or symbolic link");
851
+ }
852
+ }
268
853
  }
269
854
  await chmod(current, 0o700);
270
855
  }
271
856
  }
272
857
 
273
- async function acquireLock(directory, now) {
274
- const lockPath = path.join(directory, ".household.lock");
858
+ /**
859
+ * Acquires a private local-state lock.
860
+ *
861
+ * Purpose-specific fan-in operations may opt into a bounded wait so concurrent
862
+ * appends serialize. A short-lived guard serializes lease replacement so stale
863
+ * recovery cannot unlink a newly acquired lock through a pathname ABA race.
864
+ */
865
+ async function writeLockLease(lockPath, token, now) {
866
+ const handle = await open(lockPath, "wx", 0o600);
867
+ try {
868
+ await handle.writeFile(JSON.stringify({
869
+ token,
870
+ pid: process.pid,
871
+ created_at: now.toISOString(),
872
+ }));
873
+ await handle.sync();
874
+ } finally {
875
+ await handle.close();
876
+ }
877
+ }
878
+
879
+ export async function acquireLocalLock(
880
+ directory,
881
+ now,
882
+ { lockName = ".household.lock", waitForLiveWriter = false } = {},
883
+ ) {
884
+ if (!/^\.[a-z0-9-]+\.lock$/.test(lockName)) fail("VALIDATION_FAILED", "local lock name is invalid");
885
+ const lockPath = path.join(directory, lockName);
275
886
  const token = randomUUID();
276
- for (let attempt = 0; attempt < 2; attempt += 1) {
277
- try {
278
- const handle = await open(lockPath, "wx", 0o600);
279
- await handle.writeFile(JSON.stringify({ token, created_at: now.toISOString() }));
280
- await handle.sync();
281
- await handle.close();
282
- return { lockPath, token };
283
- } catch (error) {
284
- if (error?.code !== "EEXIST") throw error;
887
+ const waitDeadline = Date.now() + LOCK_WAIT_MS;
888
+ while (true) {
889
+ const acquired = await withLockGuard(lockPath, waitForLiveWriter, async () => {
890
+ try {
891
+ await writeLockLease(lockPath, token, now);
892
+ return true;
893
+ } catch (error) {
894
+ if (error?.code !== "EEXIST") throw error;
895
+ }
285
896
  let existing;
286
897
  try {
287
898
  existing = JSON.parse(await readFile(lockPath, "utf8"));
288
- } catch {
899
+ } catch (error) {
900
+ let lockStat;
901
+ try {
902
+ lockStat = await lstat(lockPath);
903
+ } catch (statError) {
904
+ if (statError?.code !== "ENOENT") throw statError;
905
+ await writeLockLease(lockPath, token, now);
906
+ return true;
907
+ }
908
+ if (!lockStat.isFile() || lockStat.isSymbolicLink()) {
909
+ fail("UNSAFE_LOCAL_PATH", "local lock is not a private regular file");
910
+ }
911
+ if (now.getTime() - lockStat.mtimeMs > LOCK_STALE_MS) {
912
+ await unlink(lockPath);
913
+ await writeLockLease(lockPath, token, now);
914
+ return true;
915
+ }
916
+ if (!(error instanceof SyntaxError)) throw error;
289
917
  fail("LOCAL_HOUSEHOLD_BUSY", "the local household is locked");
290
918
  }
291
919
  const createdAt = typeof existing?.created_at === "string" ? Date.parse(existing.created_at) : Number.NaN;
292
- if (!Number.isFinite(createdAt) || now.getTime() - createdAt <= LOCK_STALE_MS) {
920
+ if (!Number.isFinite(createdAt)) {
293
921
  fail("LOCAL_HOUSEHOLD_BUSY", "the local household is being updated elsewhere");
294
922
  }
295
- await unlink(lockPath);
296
- }
923
+ const ownerIsAlive = Number.isSafeInteger(existing.pid) && processIsAlive(existing.pid);
924
+ if (now.getTime() - createdAt > LOCK_STALE_MS && !ownerIsAlive) {
925
+ await unlink(lockPath);
926
+ await writeLockLease(lockPath, token, now);
927
+ return true;
928
+ }
929
+ return false;
930
+ });
931
+ if (acquired) return { lockPath, token };
932
+ if (!waitForLiveWriter) fail("LOCAL_HOUSEHOLD_BUSY", "the local household is being updated elsewhere");
933
+ if (Date.now() >= waitDeadline) fail("LOCAL_HOUSEHOLD_BUSY", "the local household is being updated elsewhere");
934
+ await new Promise((resolveWait) => setTimeout(resolveWait, LOCK_RETRY_MS));
297
935
  }
298
- fail("LOCAL_HOUSEHOLD_BUSY", "the local household could not be locked");
299
936
  }
300
937
 
301
- async function releaseLock(lock) {
938
+ function processIsAlive(pid) {
302
939
  try {
303
- const current = JSON.parse(await readFile(lock.lockPath, "utf8"));
304
- if (current?.token === lock.token) await unlink(lock.lockPath);
940
+ process.kill(pid, 0);
941
+ return true;
305
942
  } catch (error) {
306
- if (error?.code !== "ENOENT") throw error;
943
+ return error?.code === "EPERM";
307
944
  }
308
945
  }
309
946
 
310
- async function syncDirectory(directory) {
947
+ async function readGuardOwner(guardPath) {
948
+ const guardStat = await lstat(guardPath);
949
+ if (!guardStat.isDirectory() || guardStat.isSymbolicLink()) {
950
+ fail("UNSAFE_LOCAL_PATH", "local lock guard is not a private directory");
951
+ }
952
+ let handle;
953
+ try {
954
+ handle = await open(path.join(guardPath, "owner.json"), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
955
+ const owner = JSON.parse(await handle.readFile("utf8"));
956
+ if (typeof owner?.token !== "string"
957
+ || !/^[0-9a-f-]{36}$/.test(owner.token)
958
+ || !Number.isSafeInteger(owner.pid)
959
+ || typeof owner.created_at !== "string"
960
+ || !Number.isFinite(Date.parse(owner.created_at))) {
961
+ fail("LOCAL_HOUSEHOLD_BUSY", "the local lock guard is invalid");
962
+ }
963
+ return owner;
964
+ } catch (error) {
965
+ if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
966
+ return {
967
+ token: `orphan-${guardStat.ino}-${Math.trunc(guardStat.mtimeMs)}`,
968
+ pid: null,
969
+ created_at: guardStat.mtime.toISOString(),
970
+ };
971
+ } finally {
972
+ await handle?.close();
973
+ }
974
+ }
975
+
976
+ async function withLockGuard(lockPath, waitForLiveWriter, apply) {
977
+ const guardPath = `${lockPath}.guard`;
978
+ const waitDeadline = Date.now() + LOCK_WAIT_MS;
979
+ const token = randomUUID();
980
+ while (true) {
981
+ try {
982
+ await mkdir(guardPath, { mode: 0o700 });
983
+ const handle = await open(path.join(guardPath, "owner.json"), "wx", 0o600);
984
+ try {
985
+ await handle.writeFile(JSON.stringify({
986
+ token,
987
+ pid: process.pid,
988
+ created_at: new Date().toISOString(),
989
+ }));
990
+ await handle.sync();
991
+ } finally {
992
+ await handle.close();
993
+ }
994
+ break;
995
+ } catch (error) {
996
+ if (error?.code !== "EEXIST") throw error;
997
+ let owner;
998
+ try {
999
+ owner = await readGuardOwner(guardPath);
1000
+ } catch (readError) {
1001
+ if (readError?.code === "ENOENT") continue;
1002
+ throw readError;
1003
+ }
1004
+ const createdAt = Date.parse(owner.created_at);
1005
+ if (Date.now() - createdAt > LOCK_STALE_MS
1006
+ && (owner.pid === null || !processIsAlive(owner.pid))) {
1007
+ const retiredPath = `${guardPath}.retired-${owner.token}`;
1008
+ try {
1009
+ await rename(guardPath, retiredPath);
1010
+ continue;
1011
+ } catch (renameError) {
1012
+ if (!["EEXIST", "ENOTEMPTY", "ENOENT"].includes(renameError?.code)) throw renameError;
1013
+ }
1014
+ }
1015
+ if (!waitForLiveWriter) fail("LOCAL_HOUSEHOLD_BUSY", "the local household is being updated elsewhere");
1016
+ if (Date.now() >= waitDeadline) fail("LOCAL_HOUSEHOLD_BUSY", "the local household is being updated elsewhere");
1017
+ await new Promise((resolveWait) => setTimeout(resolveWait, LOCK_RETRY_MS));
1018
+ }
1019
+ }
1020
+ try {
1021
+ return await apply();
1022
+ } finally {
1023
+ const owner = await readGuardOwner(guardPath);
1024
+ if (owner.token === token) {
1025
+ await unlink(path.join(guardPath, "owner.json"));
1026
+ await rmdir(guardPath);
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ export async function releaseLocalLock(lock) {
1032
+ await withLockGuard(lock.lockPath, true, async () => {
1033
+ try {
1034
+ const current = JSON.parse(await readFile(lock.lockPath, "utf8"));
1035
+ if (current?.token === lock.token) await unlink(lock.lockPath);
1036
+ } catch (error) {
1037
+ if (error?.code !== "ENOENT") throw error;
1038
+ }
1039
+ });
1040
+ }
1041
+
1042
+ export async function syncDirectory(directory) {
311
1043
  const handle = await open(directory, constants.O_RDONLY);
312
1044
  try {
313
1045
  await handle.sync();
@@ -316,25 +1048,31 @@ async function syncDirectory(directory) {
316
1048
  }
317
1049
  }
318
1050
 
319
- async function writeDocument(root, document) {
320
- const filePath = localHouseholdPath(root);
321
- const directory = path.dirname(filePath);
1051
+ /**
1052
+ * Atomically replaces one bounded private file below the active local root.
1053
+ *
1054
+ * Callers retain authority over the fixed destination; this helper enforces
1055
+ * confinement, private modes, durability, and no-symlink directories.
1056
+ */
1057
+ export async function writePrivateFile(root, filePath, content, maximumBytes, label) {
1058
+ const resolvedRoot = path.resolve(root);
1059
+ const resolvedFile = path.resolve(filePath);
1060
+ const relative = path.relative(resolvedRoot, resolvedFile);
1061
+ if (relative.startsWith("..") || path.isAbsolute(relative)) fail("UNSAFE_LOCAL_PATH", `${label} path escapes the local root`);
1062
+ const directory = path.dirname(resolvedFile);
322
1063
  await ensurePrivateDirectory(root, directory);
323
- const serialized = `${JSON.stringify(document, null, 2)}\n`;
324
- if (Buffer.byteLength(serialized) > MAX_DOCUMENT_BYTES) {
325
- fail("LOCAL_HOUSEHOLD_TOO_LARGE", "local household exceeds 16 MiB");
326
- }
327
- const temporaryPath = path.join(directory, `.household.${process.pid}.${randomUUID()}.tmp`);
1064
+ if (typeof content !== "string" || Buffer.byteLength(content) > maximumBytes) fail("LOCAL_HOUSEHOLD_TOO_LARGE", `${label} exceeds its size limit`);
1065
+ const temporaryPath = path.join(directory, `.${path.basename(resolvedFile)}.${process.pid}.${randomUUID()}.tmp`);
328
1066
  const handle = await open(temporaryPath, "wx", 0o600);
329
1067
  try {
330
- await handle.writeFile(serialized);
1068
+ await handle.writeFile(content);
331
1069
  await handle.sync();
332
1070
  } finally {
333
1071
  await handle.close();
334
1072
  }
335
1073
  try {
336
- await rename(temporaryPath, filePath);
337
- await chmod(filePath, 0o600);
1074
+ await rename(temporaryPath, resolvedFile);
1075
+ await chmod(resolvedFile, 0o600);
338
1076
  await syncDirectory(directory);
339
1077
  } catch (error) {
340
1078
  try {
@@ -346,13 +1084,40 @@ async function writeDocument(root, document) {
346
1084
  }
347
1085
  }
348
1086
 
1087
+ async function writeDocument(root, document) {
1088
+ const serialized = `${JSON.stringify(document, null, 2)}\n`;
1089
+ await writePrivateFile(root, localHouseholdPath(root), serialized, MAX_DOCUMENT_BYTES, "local household");
1090
+ }
1091
+
349
1092
  function publicDocument(document) {
1093
+ const items = Array.isArray(document.journal.items) ? document.journal.items : [];
1094
+ const recipeContentRevisions = items.flatMap((item) =>
1095
+ isPlainObject(item) && item.kind === "recipe" && typeof item.id === "string" && ITEM_ID_PATTERN.test(item.id)
1096
+ ? [{ item_id: item.id, item_revision: localRecipeContentDigest(item) }]
1097
+ : []);
1098
+ const planning = document.journal.meal_planning;
1099
+ const withdrawals = new Set(planning?.events.flatMap((event) =>
1100
+ event.kind === "proposal_withdrawn" ? [event.proposal_id] : []) ?? []);
1101
+ const mealProposalStatuses = planning?.proposals.map((proposal) => {
1102
+ const currentItemRevision = proposal.source.kind === "journal_recipe"
1103
+ ? recipeContentRevisions.find(({ item_id: itemId }) => itemId === proposal.source.item_id)?.item_revision ?? null
1104
+ : null;
1105
+ const needsRecheck = proposal.constraint_revision !== planning.profile.revision
1106
+ || (proposal.source.kind === "journal_recipe" && currentItemRevision !== proposal.source.item_revision);
1107
+ return {
1108
+ proposal_id: proposal.id,
1109
+ active: !withdrawals.has(proposal.id),
1110
+ effective_compatibility: needsRecheck ? "needs_recheck" : proposal.compatibility,
1111
+ };
1112
+ }) ?? [];
350
1113
  return {
351
1114
  local_household_id: document.local_household_id,
352
1115
  promotion_idempotency_key: document.promotion_idempotency_key,
353
1116
  state: document.state,
354
1117
  revision: document.revision,
355
1118
  journal: document.journal,
1119
+ recipe_content_revisions: recipeContentRevisions,
1120
+ meal_proposal_statuses: mealProposalStatuses,
356
1121
  cloud_backup: document.cloud_backup,
357
1122
  cloud_backup_current: document.cloud_backup?.local_revision === document.revision,
358
1123
  };
@@ -362,7 +1127,7 @@ async function mutate(root, expectedRevision, now, apply) {
362
1127
  const filePath = localHouseholdPath(root);
363
1128
  const directory = path.dirname(filePath);
364
1129
  await ensurePrivateDirectory(root, directory);
365
- const lock = await acquireLock(directory, now);
1130
+ const lock = await acquireLocalLock(directory, now);
366
1131
  try {
367
1132
  const current = await readDocument(filePath);
368
1133
  if (current === null) fail("LOCAL_HOUSEHOLD_MISSING", "no local household exists");
@@ -373,15 +1138,95 @@ async function mutate(root, expectedRevision, now, apply) {
373
1138
  if (updated !== current) await writeDocument(root, updated);
374
1139
  return updated;
375
1140
  } finally {
376
- await releaseLock(lock);
1141
+ await releaseLocalLock(lock);
377
1142
  }
378
1143
  }
379
1144
 
380
- export async function initializeLocalHousehold(root, now = new Date()) {
1145
+ function requestFingerprint(value) {
1146
+ return createHash("sha256").update(stableJson(value)).digest("hex");
1147
+ }
1148
+
1149
+ function stableLocalEntityId(prefix, kind, idempotencyKey) {
1150
+ return `${prefix}_${createHash("sha256").update(`${kind}:${idempotencyKey}`).digest("hex").slice(0, 32)}`;
1151
+ }
1152
+
1153
+ function mealPlanningState(document) {
1154
+ const state = document.journal.meal_planning;
1155
+ if (state === undefined) fail("MEAL_PLANNING_PROFILE_REQUIRED", "record allergies and food sensitivities before planning meals");
1156
+ return state;
1157
+ }
1158
+
1159
+ function replayedMealEntity(state, receipt) {
1160
+ const entities = receipt.kind === "meal_proposal" ? state.proposals : state.events;
1161
+ const entity = entities.find(({ id }) => id === receipt.entity_id);
1162
+ if (entity === undefined) fail("CORRUPT_LOCAL_HOUSEHOLD", "meal-planning idempotency receipt has no matching entity");
1163
+ return entity;
1164
+ }
1165
+
1166
+ /**
1167
+ * Serializes an append-only meal mutation against current nested state.
1168
+ *
1169
+ * The outer revision is an observation, not a last-writer-wins precondition:
1170
+ * after locking, current profile/review references are revalidated so two
1171
+ * independent proposals from the same starting revision can both commit.
1172
+ */
1173
+ async function appendMealPlanningMutation(root, request, now, descriptor, apply) {
381
1174
  const filePath = localHouseholdPath(root);
382
1175
  const directory = path.dirname(filePath);
383
1176
  await ensurePrivateDirectory(root, directory);
384
- const lock = await acquireLock(directory, now);
1177
+ const lock = await acquireLocalLock(directory, now, { waitForLiveWriter: true });
1178
+ try {
1179
+ const current = await readDocument(filePath);
1180
+ if (current === null) fail("LOCAL_HOUSEHOLD_MISSING", "no local household exists");
1181
+ const state = mealPlanningState(current);
1182
+ const existingReceipt = state.idempotency.find(({ key }) => key === request.idempotency_key);
1183
+ if (existingReceipt !== undefined) {
1184
+ if (existingReceipt.kind !== descriptor.receiptKind || existingReceipt.fingerprint !== descriptor.fingerprint) {
1185
+ fail("IDEMPOTENCY_CONFLICT", "idempotency_key was already used for different meal-planning input");
1186
+ }
1187
+ return {
1188
+ status: "replayed",
1189
+ document: current,
1190
+ entity: replayedMealEntity(state, existingReceipt),
1191
+ };
1192
+ }
1193
+ if (current.revision < request.expected_revision) {
1194
+ fail("LOCAL_HOUSEHOLD_CONFLICT", `local household revision is ${current.revision}, before ${request.expected_revision}`);
1195
+ }
1196
+ if (state.idempotency.length >= MAX_MEAL_IDEMPOTENCY_RECEIPTS) {
1197
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many idempotency receipts");
1198
+ }
1199
+ const entity = apply(state, current.journal);
1200
+ const nextState = {
1201
+ ...state,
1202
+ proposals: descriptor.receiptKind === "meal_proposal" ? [...state.proposals, entity] : state.proposals,
1203
+ events: descriptor.receiptKind === "meal_proposal" ? state.events : [...state.events, entity],
1204
+ idempotency: [...state.idempotency, {
1205
+ key: request.idempotency_key,
1206
+ kind: descriptor.receiptKind,
1207
+ fingerprint: descriptor.fingerprint,
1208
+ entity_id: entity.id,
1209
+ }],
1210
+ };
1211
+ const updated = {
1212
+ ...current,
1213
+ revision: current.revision + 1,
1214
+ updated_at: now.toISOString(),
1215
+ journal: { ...current.journal, meal_planning: nextState },
1216
+ };
1217
+ assertJournal(updated.journal);
1218
+ await writeDocument(root, updated);
1219
+ return { status: descriptor.createdStatus, document: updated, entity };
1220
+ } finally {
1221
+ await releaseLocalLock(lock);
1222
+ }
1223
+ }
1224
+
1225
+ export async function initializeLocalHousehold(root, now = new Date(), householdName = undefined) {
1226
+ const filePath = localHouseholdPath(root);
1227
+ const directory = path.dirname(filePath);
1228
+ await ensurePrivateDirectory(root, directory);
1229
+ const lock = await acquireLocalLock(directory, now);
385
1230
  try {
386
1231
  const existing = await readDocument(filePath);
387
1232
  if (existing !== null) return { status: "existing", ...publicDocument(existing) };
@@ -393,13 +1238,13 @@ export async function initializeLocalHousehold(root, now = new Date()) {
393
1238
  revision: 1,
394
1239
  created_at: now.toISOString(),
395
1240
  updated_at: now.toISOString(),
396
- journal: {},
1241
+ journal: householdName === undefined ? {} : { household: { display_name: householdName } },
397
1242
  cloud_backup: null,
398
1243
  };
399
1244
  await writeDocument(root, document);
400
1245
  return { status: "initialized", ...publicDocument(document) };
401
1246
  } finally {
402
- await releaseLock(lock);
1247
+ await releaseLocalLock(lock);
403
1248
  }
404
1249
  }
405
1250
 
@@ -410,13 +1255,242 @@ export async function loadLocalHousehold(root) {
410
1255
 
411
1256
  export async function saveLocalHousehold(root, input, now = new Date()) {
412
1257
  const request = parseRequest({ ...input, operation: "save" });
1258
+ const document = await mutate(root, request.expected_revision, now, (current) => {
1259
+ const currentHousehold = current.journal.household;
1260
+ const requestedHousehold = request.journal.household;
1261
+ if (requestedHousehold !== undefined
1262
+ && stableJson(requestedHousehold) !== stableJson(currentHousehold)) {
1263
+ fail("VALIDATION_FAILED", "use rename_household to change the local household name");
1264
+ }
1265
+ const currentMealPlanning = current.journal.meal_planning;
1266
+ const requestedMealPlanning = request.journal.meal_planning;
1267
+ if (currentMealPlanning === undefined && requestedMealPlanning !== undefined) {
1268
+ fail("VALIDATION_FAILED", "use the purpose-specific meal-planning operations");
1269
+ }
1270
+ if (currentMealPlanning !== undefined
1271
+ && requestedMealPlanning !== undefined
1272
+ && stableJson(currentMealPlanning) !== stableJson(requestedMealPlanning)) {
1273
+ fail("VALIDATION_FAILED", "use the purpose-specific meal-planning operations");
1274
+ }
1275
+ const journalWithMealPlanning = currentMealPlanning === undefined
1276
+ ? request.journal
1277
+ : { ...request.journal, meal_planning: currentMealPlanning };
1278
+ const journal = currentHousehold === undefined
1279
+ ? journalWithMealPlanning
1280
+ : { ...journalWithMealPlanning, household: currentHousehold };
1281
+ return {
1282
+ ...current,
1283
+ revision: current.revision + 1,
1284
+ updated_at: now.toISOString(),
1285
+ journal,
1286
+ };
1287
+ });
1288
+ return { status: "saved", ...publicDocument(document) };
1289
+ }
1290
+
1291
+ export async function renameLocalHousehold(root, input, now = new Date()) {
1292
+ const request = parseRequest({ ...input, operation: "rename_household" });
413
1293
  const document = await mutate(root, request.expected_revision, now, (current) => ({
414
1294
  ...current,
415
1295
  revision: current.revision + 1,
416
1296
  updated_at: now.toISOString(),
417
- journal: request.journal,
1297
+ journal: {
1298
+ ...current.journal,
1299
+ household: { display_name: request.household_name },
1300
+ },
418
1301
  }));
419
- return { status: "saved", ...publicDocument(document) };
1302
+ return { status: "renamed", ...publicDocument(document) };
1303
+ }
1304
+
1305
+ export async function saveLocalMealPlanningProfile(root, input, now = new Date()) {
1306
+ const request = parseRequest({ ...input, operation: "save_meal_planning_profile" });
1307
+ const fingerprint = requestFingerprint({
1308
+ operation: request.operation,
1309
+ actor: request.actor,
1310
+ constraints: request.constraints,
1311
+ });
1312
+ const filePath = localHouseholdPath(root);
1313
+ const directory = path.dirname(filePath);
1314
+ await ensurePrivateDirectory(root, directory);
1315
+ const lock = await acquireLocalLock(directory, now, { waitForLiveWriter: true });
1316
+ try {
1317
+ const current = await readDocument(filePath);
1318
+ if (current === null) fail("LOCAL_HOUSEHOLD_MISSING", "no local household exists");
1319
+ const previous = current.journal.meal_planning;
1320
+ const existingReceipt = previous?.idempotency.find(({ key }) => key === request.idempotency_key);
1321
+ if (existingReceipt !== undefined) {
1322
+ if (existingReceipt.kind !== "meal_planning_profile" || existingReceipt.fingerprint !== fingerprint) {
1323
+ fail("IDEMPOTENCY_CONFLICT", "idempotency_key was already used for different meal-planning input");
1324
+ }
1325
+ return { status: "replayed", ...publicDocument(current) };
1326
+ }
1327
+ if (current.revision !== request.expected_revision) {
1328
+ fail("LOCAL_HOUSEHOLD_CONFLICT", `local household revision is ${current.revision}, not ${request.expected_revision}`);
1329
+ }
1330
+ if ((previous?.idempotency.length ?? 0) >= MAX_MEAL_IDEMPOTENCY_RECEIPTS) {
1331
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many idempotency receipts");
1332
+ }
1333
+ const profile = {
1334
+ schema_version: 1,
1335
+ revision: (previous?.profile.revision ?? 0) + 1,
1336
+ constraints: request.constraints,
1337
+ updated_at: now.toISOString(),
1338
+ updated_by: request.actor,
1339
+ };
1340
+ const receipt = {
1341
+ key: request.idempotency_key,
1342
+ kind: "meal_planning_profile",
1343
+ fingerprint,
1344
+ entity_id: stableLocalEntityId("mlr", request.operation, request.idempotency_key),
1345
+ };
1346
+ const mealPlanning = previous === undefined
1347
+ ? { schema_version: 1, profile, proposals: [], events: [], idempotency: [receipt] }
1348
+ : { ...previous, profile, idempotency: [...previous.idempotency, receipt] };
1349
+ const journal = { ...current.journal, meal_planning: mealPlanning };
1350
+ assertJournal(journal);
1351
+ const document = {
1352
+ ...current,
1353
+ revision: current.revision + 1,
1354
+ updated_at: now.toISOString(),
1355
+ journal,
1356
+ };
1357
+ await writeDocument(root, document);
1358
+ return { status: "meal_planning_profile_saved", ...publicDocument(document) };
1359
+ } finally {
1360
+ await releaseLocalLock(lock);
1361
+ }
1362
+ }
1363
+
1364
+ export async function reviewLocalMealConstraints(root, input, now = new Date()) {
1365
+ const request = parseRequest({ ...input, operation: "review_meal_constraints" });
1366
+ const fingerprint = requestFingerprint({
1367
+ operation: request.operation,
1368
+ actor: request.actor,
1369
+ week_start: request.week_start,
1370
+ constraint_revision: request.constraint_revision,
1371
+ });
1372
+ const result = await appendMealPlanningMutation(root, request, now, {
1373
+ receiptKind: "constraints_reviewed",
1374
+ fingerprint,
1375
+ createdStatus: "constraints_reviewed",
1376
+ }, (state) => {
1377
+ if (state.profile.constraints.status === "unresolved" || state.profile.revision !== request.constraint_revision) {
1378
+ fail("MEAL_PLANNING_PROFILE_CONFLICT", "constraint_revision is not the current confirmed meal-planning profile");
1379
+ }
1380
+ const reviewEvents = state.events.filter(({ kind }) => kind === "constraints_reviewed");
1381
+ if (reviewEvents.length >= MAX_MEAL_REVIEW_EVENTS
1382
+ || reviewEvents.filter(({ week_start: weekStart }) => weekStart === request.week_start).length >= MAX_MEAL_REVIEW_EVENTS_PER_WEEK) {
1383
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many constraint reviews");
1384
+ }
1385
+ return {
1386
+ id: stableLocalEntityId("mle", request.operation, request.idempotency_key),
1387
+ kind: "constraints_reviewed",
1388
+ week_start: request.week_start,
1389
+ actor: request.actor,
1390
+ constraint_revision: request.constraint_revision,
1391
+ occurred_at: now.toISOString(),
1392
+ schema_version: 1,
1393
+ };
1394
+ });
1395
+ return { status: result.status, ...publicDocument(result.document), event: result.entity };
1396
+ }
1397
+
1398
+ export async function appendLocalMealProposal(root, input, now = new Date()) {
1399
+ const request = parseRequest({ ...input, operation: "append_meal_proposal" });
1400
+ const fingerprint = requestFingerprint({
1401
+ operation: request.operation,
1402
+ actor: request.actor,
1403
+ week_start: request.week_start,
1404
+ meal_date: request.meal_date,
1405
+ slot: request.slot,
1406
+ source: request.source,
1407
+ servings: request.servings,
1408
+ notes: request.notes,
1409
+ constraint_revision: request.constraint_revision,
1410
+ constraint_review_event_id: request.constraint_review_event_id,
1411
+ compatibility: request.compatibility,
1412
+ compatibility_caveat: request.compatibility_caveat,
1413
+ });
1414
+ const result = await appendMealPlanningMutation(root, request, now, {
1415
+ receiptKind: "meal_proposal",
1416
+ fingerprint,
1417
+ createdStatus: "meal_proposal_appended",
1418
+ }, (state, journal) => {
1419
+ if (state.profile.constraints.status === "unresolved" || state.profile.revision !== request.constraint_revision) {
1420
+ fail("MEAL_PLANNING_PROFILE_CONFLICT", "constraint_revision is not the current confirmed meal-planning profile");
1421
+ }
1422
+ const review = state.events.find(({ id }) => id === request.constraint_review_event_id);
1423
+ if (review?.kind !== "constraints_reviewed"
1424
+ || review.week_start !== request.week_start
1425
+ || review.constraint_revision !== request.constraint_revision) {
1426
+ fail("MEAL_CONSTRAINT_REVIEW_REQUIRED", "the current constraints must be reviewed for this week before adding a proposal");
1427
+ }
1428
+ validateLocalMealProposalSource(request.source, journal);
1429
+ if (state.proposals.filter(({ week_start: weekStart }) => weekStart === request.week_start).length >= MAX_MEAL_PROPOSALS_PER_WEEK) {
1430
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposals for one week");
1431
+ }
1432
+ const slotKey = mealSlotKey(request.slot);
1433
+ if (state.proposals.filter((proposal) =>
1434
+ proposal.week_start === request.week_start
1435
+ && proposal.meal_date === request.meal_date
1436
+ && mealSlotKey(proposal.slot) === slotKey).length >= MAX_MEAL_PROPOSALS_PER_SLOT) {
1437
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposals for one meal slot");
1438
+ }
1439
+ return {
1440
+ id: stableLocalEntityId("mlp", request.operation, request.idempotency_key),
1441
+ week_start: request.week_start,
1442
+ meal_date: request.meal_date,
1443
+ slot: request.slot,
1444
+ proposed_by: request.actor,
1445
+ source: request.source,
1446
+ servings: request.servings,
1447
+ notes: request.notes,
1448
+ constraint_revision: request.constraint_revision,
1449
+ constraint_review_event_id: request.constraint_review_event_id,
1450
+ compatibility: request.compatibility,
1451
+ compatibility_caveat: request.compatibility_caveat,
1452
+ created_at: now.toISOString(),
1453
+ schema_version: 1,
1454
+ };
1455
+ });
1456
+ return { status: result.status, ...publicDocument(result.document), proposal: result.entity };
1457
+ }
1458
+
1459
+ export async function recordLocalMealPlanEvent(root, input, now = new Date()) {
1460
+ const request = parseRequest({ ...input, operation: "record_meal_plan_event" });
1461
+ const fingerprint = requestFingerprint({
1462
+ operation: request.operation,
1463
+ actor: request.actor,
1464
+ week_start: request.week_start,
1465
+ event: request.event,
1466
+ });
1467
+ const result = await appendMealPlanningMutation(root, request, now, {
1468
+ receiptKind: "meal_plan_event",
1469
+ fingerprint,
1470
+ createdStatus: "meal_plan_event_recorded",
1471
+ }, (state) => {
1472
+ const proposal = state.proposals.find(({ id }) => id === request.event.proposal_id);
1473
+ if (proposal === undefined || proposal.week_start !== request.week_start) fail("MEAL_PROPOSAL_NOT_FOUND", "proposal was not found in the requested week");
1474
+ if (state.events.some(({ kind, proposal_id: proposalId }) => kind === "proposal_withdrawn" && proposalId === proposal.id)) {
1475
+ fail("MEAL_PROPOSAL_WITHDRAWN", "proposal is already withdrawn");
1476
+ }
1477
+ const withdrawalEvents = state.events.filter(({ kind }) => kind === "proposal_withdrawn");
1478
+ if (withdrawalEvents.length >= MAX_MEAL_WITHDRAWAL_EVENTS
1479
+ || withdrawalEvents.filter(({ week_start: weekStart }) => weekStart === request.week_start).length >= MAX_MEAL_WITHDRAWAL_EVENTS_PER_WEEK) {
1480
+ fail("LOCAL_HOUSEHOLD_TOO_LARGE", "meal planning contains too many proposal withdrawals");
1481
+ }
1482
+ return {
1483
+ id: stableLocalEntityId("mle", request.operation, request.idempotency_key),
1484
+ kind: "proposal_withdrawn",
1485
+ week_start: request.week_start,
1486
+ actor: request.actor,
1487
+ proposal_id: request.event.proposal_id,
1488
+ reason: request.event.reason,
1489
+ occurred_at: now.toISOString(),
1490
+ schema_version: 1,
1491
+ };
1492
+ });
1493
+ return { status: result.status, ...publicDocument(result.document), event: result.entity };
420
1494
  }
421
1495
 
422
1496
  export async function finalizeLocalHousehold(root, input, now = new Date()) {
@@ -458,7 +1532,7 @@ export async function deleteCollectingLocalHousehold(root, input, now = new Date
458
1532
  const filePath = localHouseholdPath(root);
459
1533
  const directory = path.dirname(filePath);
460
1534
  await ensurePrivateDirectory(root, directory);
461
- const lock = await acquireLock(directory, now);
1535
+ const lock = await acquireLocalLock(directory, now);
462
1536
  let result;
463
1537
  try {
464
1538
  const current = await readDocument(filePath);
@@ -474,7 +1548,7 @@ export async function deleteCollectingLocalHousehold(root, input, now = new Date
474
1548
  result = { status: "deleted" };
475
1549
  }
476
1550
  } finally {
477
- await releaseLock(lock);
1551
+ await releaseLocalLock(lock);
478
1552
  }
479
1553
  try {
480
1554
  await rmdir(directory);
@@ -501,9 +1575,14 @@ async function readRequest() {
501
1575
 
502
1576
  export async function runRequest(root, input, now = new Date()) {
503
1577
  const request = parseRequest(input);
504
- if (request.operation === "initialize") return await initializeLocalHousehold(root, now);
1578
+ if (request.operation === "initialize") return await initializeLocalHousehold(root, now, request.household_name);
505
1579
  if (request.operation === "load") return await loadLocalHousehold(root);
506
1580
  if (request.operation === "save") return await saveLocalHousehold(root, request, now);
1581
+ if (request.operation === "rename_household") return await renameLocalHousehold(root, request, now);
1582
+ if (request.operation === "save_meal_planning_profile") return await saveLocalMealPlanningProfile(root, input, now);
1583
+ if (request.operation === "review_meal_constraints") return await reviewLocalMealConstraints(root, input, now);
1584
+ if (request.operation === "append_meal_proposal") return await appendLocalMealProposal(root, input, now);
1585
+ if (request.operation === "record_meal_plan_event") return await recordLocalMealPlanEvent(root, input, now);
507
1586
  if (request.operation === "finalize") return await finalizeLocalHousehold(root, request, now);
508
1587
  if (request.operation === "record_cloud_backup") return await recordCloudBackup(root, request, now);
509
1588
  return await deleteCollectingLocalHousehold(root, request, now);