/** * - the client's copy of the scribe draft retention policy. * * **Spencer, 2026-08-11: "go with the 7 day retention"**, then revised the same day to **30 days** once * the storage cost was measured (a 30-minute encounter is ~40 KB of text; 100 clinicians is ~1.6 GB, about * $0.40/month - cost was never the constraint). * * THIS VALUE IS DUPLICATED ACROSS REPOS ON PURPOSE, AND THAT MAKES IT DANGEROUS. * The authority is the server: `aws/sam/services/api/src/utils/scribeDraftRetention.ts` owns the TTL that * actually destroys the row. This copy exists only so the client can say "expires in N days" without a * round trip. If the two drift, the UI promises a window the server does not honour - a practitioner is * told they have a week and the draft is already gone. * * A cross-repo constant is only protected by a test that pins the exact LITERAL on BOTH sides. * `__tests__/draftRetentionPolicy.test.ts` pins this one. A pattern assertion, or a test that merely * compares this constant to itself, reads as protection while proving nothing - that exact vacuous shape * cost real time on the campaign opt-out footer, where changing the server constant left both repos' suites * green. When this number changes, it changes in two repos in one coordinated commit. */ export const SCRIBE_DRAFT_RETENTION_DAYS = 30; /** * The shape the nagger and the expiry warning both read. * * `acceptedAtMs` set means a human has adopted the note into the record; the draft is then deleted * outright rather than retained, because keeping the machine-generated original beside the accepted * content leaves a second, unreviewed copy of the same PHI with no clinical purpose. */ export interface ScribeDraftLike { /** When the draft was generated. */ createdAtMs: number; /** * The server's OWN expiry instant, when the caller has it. * * TWO SOURCES FOR ONE FACT, and this is where they are reconciled rather than left to diverge. * `GET /R4/scribe/drafts` returns `expiresAtMs`; the constant above is a client COPY of the policy * that produced it. **The server's value wins when present** - the copy exists only so a surface * that never asked can still say something, and a copy must never overrule the original. */ expiresAtMs?: number; /** Set once a human has accepted the note into the record. */ acceptedAtMs?: number | null; } const MS_PER_DAY = 24 * 60 * 60 * 1000; /** The instant this draft - and the audio behind it - cease to exist. */ export const draftExpiresAtMs = (draft: ScribeDraftLike): number => draft.expiresAtMs ?? draft.createdAtMs + SCRIBE_DRAFT_RETENTION_DAYS * MS_PER_DAY; /** * Days left before this draft is destroyed. ** ROUNDED DOWN, AND THAT IS A RULING, NOT A STYLE.** * * > *"Down, never up: 'expires in 4 days' when 4.9 remain is a day early and harmless; the same rounding * > up on 3.1 promises a day that does not exist, and at expiry the draft AND the audio it came from are * > both already gone. There is nothing to regenerate from."* * * WHY THIS IS ONE FUNCTION AND NOT THREE * Measured on mount `8fb5e793590fa0a39316`, same draft, same minute: the borrowed **Notes to finish** * panel printed **"Expires in 26 days"** and the **Draft note from the recording** panel inside the * encounter printed **"27 days left."** Ground truth from `GET /R4/scribe/drafts`: **26.15 days**. 26 is * right. There were THREE implementations - one `floor` and two `ceil` - and the rule above was written * next to the only one that obeyed it. * **The half that made it worth fixing rather than tolerating: the surface that rounded UP is the one * the clinician is standing in** while deciding *"do I finish this note now or tomorrow?"* - the exact * decision the number exists to inform, and the only one where a day too many costs the note. * So the fix was NOT swapping two `ceil`s for `floor`s: that leaves three implementations standing and * no way for the next reader to tell which is authoritative. **There is one, and it lives beside the * retention constant it is derived from.** */ export const daysUntilDraftExpiry = (draft: ScribeDraftLike, nowMs: number): number => Math.floor((draftExpiresAtMs(draft) - nowMs) / MS_PER_DAY);