PER-1

softDeleteSet canonical utility

Origin

Extracted into a shared utility during the persistence-consistency pass in April 2026 after discovering four slightly different inline soft-delete patterns across domain files. One variant forgot to set updatedAt, leaving audit queries filtering by updatedAt > X blind to the deletion timestamp. The utility moves the pattern to a single function import, so no domain can invent its own variant.

Rule Text

Every soft-delete mutation MUST use softDeleteSet() from packages/api/lib/soft-delete.ts. The function returns { deletedAt: getNow(), updatedAt: getNow() }. Both columns are set in a single call, and the time source is always the canonical getNow() helper.

// CORRECT
await db.update(properties)
  .set(softDeleteSet())
  .where(and(eq(properties.id, input.propertyId), notDeleted(properties.deletedAt)))
  .returning();

// FORBIDDEN: missing updatedAt
await db.update(properties)
  .set({ deletedAt: getNow() })
  .where(...);

// FORBIDDEN: non-canonical time source
await db.update(properties)
  .set({ deletedAt: new Date() })
  .where(...);

Hard deletes via db.delete(...) on any soft-deletable table are separately forbidden. Soft delete is the only permitted delete mode.

Testable Assertion

const result = softDeleteSet();
expect(result.deletedAt).toBeInstanceOf(Date);
expect(result.updatedAt).toBeInstanceOf(Date);
expect(result.deletedAt.getTime()).toBe(result.updatedAt.getTime());

Enforcement

  • Gate-time — An ast-grep rule (require-softDeleteSet) scans every .update(...).set(...) call on a soft-deletable table. If the set payload includes deletedAt without the softDeleteSet() helper, the gate rejects the commit. A companion rule (no-hard-delete) blocks db.delete(...) on any soft-deletable table.

Violation Closed

Silent drift between domains when each one re-implemented soft-delete inline. The original failure mode: one domain set deletedAt but not updatedAt, so audit-log queries filtering by updatedAt > X missed the deletion entirely and the entity appeared untouched in forensic review. The canonical utility closes that class of bug because no domain writes the pattern by hand anymore. Every call site imports the same function.