You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
revert(retention): undo #1212's default change -- it deletes PHI against an explicit opt-out
REFUTED BY SESSION B, VERIFIED BY ME, AND THEY ARE RIGHT. Restores all
seven touched files to origin/main content. Forward-only; no history
rewrite, so the two reverted commits stay readable as the record.
THE DEFECT, measured not argued:
gate guard: if not settings.retention.allow_unbounded_phi:
RetentionSettings(allow_unbounded_phi=True).messages_days -> 60
allow_unbounded_phi is the AUDITED, DELIBERATE keep-forever opt-out. When
it is true the auto-bound block is SKIPPED ENTIRELY, so the field keeps
its model default. At 0 that matched the operator's explicit choice. At
60 the RetentionRunner starts and PURGES PHI BODIES AFTER 60 DAYS on
exactly the instances whose operator consciously said keep them forever.
Silent PHI deletion against an explicit audited decision, landing ONLY on
the path where someone opted in on purpose. That is the inversion of the
item's intent, and it is worse than the problem #1212 set out to fix.
AND THE CHANGE WAS A NO-OP WHERE IT WAS AIMED. The auto-bound keys on
model_fields_set -- whether the operator WROTE the key, not its value --
so an operator who says nothing still gets 30 and always did. I had
already found and documented that half. What I MISSED is the half above:
I reasoned about the branch where the gate RUNS and never about the
branch where it is SKIPPED. Session B checked the skip.
A SECOND PREMISE OF MINE WAS ALSO STALE: PR #71 inverted the prod gate,
so a production PHI instance with an unset window no longer refuses to
start -- it starts, auto-bounded at 30. tests/test_cli.py:1619 is now
test_serve_auto_bounds_an_unset_body_window_in_prod asserting rc == 0.
So the 'more secure by default, less explicit by default' trade I raised
for the owner WAS ALREADY MADE and was never mine to weigh.
IF THE 60 IS STILL WANTED, the value to change is auto_bound_days in the
retention classification tuple -- what the gate actually applies -- NOT
the field default. Note that moving it 30 -> 60 LOOSENS the shipped PHI
bound rather than tightening it, which sharpens the owner question rather
than resolving it. Also: retention_classification.py carries nine windows
behind MIN_PHI_RETENTION_WINDOWS = 9 with a two-way drift test against
docs/PHI.md section 2, so any real window change must move the tuple AND
the doc together.
The knowledge is the deliverable here; the code was wrong. Ledger
amendment to follow so #1212 records the refutation rather than reading
as unbuilt.
Copy file name to clipboardExpand all lines: docs/CONFIGURATION.md
+6-21Lines changed: 6 additions & 21 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -699,35 +699,20 @@ Enforced by the engine's retention/purge task ([pipeline/retention.py](../messag
699
699
A purge **NULLs the PHI *body*** past its window while **keeping the message row** (counts,
700
700
disposition, and the audit trail stay intact — the Mirth Data-Pruner pattern); it never deletes a
701
701
`messages` row and never touches a body still in flight. The *row* survives; its PHI *columns* do not
702
-
— `messages.metadata` is nulled in the same statement as the body (ASVS 14.2.7).
703
-
704
-
**Two layers set the PHI-body windows, and the tighter one wins.** The **model default** is `60` days
705
-
(#1212) — it replaced keep-forever, so an instance that configures nothing is bounded rather than
706
-
unbounded, and the retention runner now **starts on a fully-defaulted engine** where it previously
707
-
stayed off. On top of that, `serve`'s **posture gate auto-bounds an *unset* window to `30` days on a
708
-
PHI instance** (owner ruling 2026-07-30), and **all three built-in environment names (`dev`,
709
-
`staging`, `prod`) derive PHI**. So in practice: **a PHI instance that sets nothing gets 30; a
710
-
non-PHI instance that sets nothing gets 60; an explicit value always wins over both.** The gate keys
711
-
on whether the field was *set*, not on its value, which is why the model default does not soften it.
712
-
713
-
The remaining `[retention]` fields still default to `0`/`""` = keep/off, and `0` continues to mean
714
-
keep-forever wherever it is set *explicitly* — only the default moved. Note the semantic flip that
715
-
follows on a non-PHI instance: a config that omits the window used to mean *keep forever* and now
716
-
means *purge at 60 days*.
717
-
718
-
The posture gate means retention is **not opt-in on a PHI instance**: under
719
-
`[security].enforcement = enforce` (the default) an unbounded
702
+
— `messages.metadata` is nulled in the same statement as the body (ASVS 14.2.7). The raw `[retention]` fields still default to
703
+
`0`/`""` = keep/off, **but `serve` applies a posture gate on top of them, so retention is *not*
704
+
opt-in on a PHI instance**: under `[security].enforcement = enforce` (the default) an unbounded
720
705
`[security].delete_message_bodies_after_days` or `[retention].dead_letter_days`**refuses to start
721
706
(exit 2)**; on a non-enforcing PHI instance each *unset* window is auto-bounded to **30 days**. All
722
707
three built-in environment names (`dev`, `staging`, `prod`) derive PHI. The audited opt-out is
723
708
`[security].allow_keeping_phi_indefinitely = true`. See [PHI.md §8](PHI.md#8-retention--purge).
724
709
| Key | Type | Default | Notes |
725
710
|---|---|---|---|
726
-
|`messages_days`||*(60)*|**→ moved to `[security].delete_message_bodies_after_days`** (ADR 0118) — set it there; no longer accepted in `[retention]`, and setting it here raises. The model default shown in parentheses is **60 days** (#1212), not keep-forever — but on a **PHI** instance `serve`'s posture gate auto-bounds an unset window to **30** first, and every built-in environment derives PHI, so 60 is what a **non-PHI** instance gets. The `[security]` desugar is presence-gated **per key**, so merely having a `[security]` block changes nothing here — only setting `delete_message_bodies_after_days` writes through. |
727
-
|`dead_letter_days`| int |`60`| past N days, null the bodies of **dead-lettered** outbound rows (their own window — a dead row stays replayable until purged). `0` = keep. Defaulted to 60 by #1212 alongside `messages_days`; kept a **separate** window because a site that wants longer to replay a dead row must be able to say so without also extending live-body retention.|
711
+
|`messages_days`|||**→ moved to `[security].delete_message_bodies_after_days`** (ADR 0118) — set it there; no longer accepted in `[retention]`. |
712
+
|`dead_letter_days`| int |`0`| past N days, null the bodies of **dead-lettered** outbound rows (their own window — a dead row stays replayable until purged). `0` = keep |
728
713
|`allow_unbounded_phi`|||**→ moved to `[security].allow_keeping_phi_indefinitely`** (ADR 0118) — set it there; no longer accepted in `[retention]`. |
729
714
|`state_max_age_days`| int |`0`| past N days, **delete** transform-state entries (ADR 0005) last written before the cutoff — keeps the in-memory state cache + table bounded. A simple global age purge (by `set_at`); per-namespace policy is a follow-up. `0` = keep |
730
-
|`connection_event_retention_hours`| int |`0`| past N **hours**, **delete**`connection_event` rows (the `[diagnostics]`#46 transport/lifecycle log — high-volume under a connect-per-message sender or a probe storm, so its own short window in **hours**, not days). `0` = inherit the `messages_days` body window (the ADR 0021 §7.5 default) — which since #1212 means these rows, and resolved operator-alert instances on the same window, are **bounded at 60 days on a defaulted config** rather than kept forever. |
715
+
|`connection_event_retention_hours`| int |`0`| past N **hours**, **delete**`connection_event` rows (the `[diagnostics]`#46 transport/lifecycle log — high-volume under a connect-per-message sender or a probe storm, so its own short window in **hours**, not days). `0` = inherit the `messages_days` body window (the ADR 0021 §7.5 default). |
731
716
|`app_log_days`| int |`0`| past N days, **delete** application **log files** (`.log`/`.txt`, one level) from the configured `[logging].log_dir` (#120). The supervisor (NSSM `AppRotateBytes`) rotates the daily logs by **size** but never by **age**, so the log dir grows unbounded; this bounds it (by file mtime, so the currently-written file is never eligible). `0` = keep. **No-op unless `[logging].log_dir` is set.** Metadata only — file content is never read. While `app_log_compress_days` is on, the same window also ages out the `*.log.gz`/`*.txt.gz` archives that setting produces — so compressing a log doesn't make it immortal; with compression off the eligible set is exactly what it was |
732
717
| `app_log_compress_days` | int | `0` | past N days, **gzip** application **log files** (`.log`/`.txt`, one level — the same selection as `app_log_days`, by mtime, so the currently-written file is never eligible) in `[logging].log_dir` to `<name>.gz` (#119). The log stays readable (`gzip -d`) at a fraction of the disk, so a long-running box keeps far more history for the same footprint. Each file is **free-space prechecked** (`shutil.disk_usage` must show room for the source **plus** its archive plus a `max(10%, 1 MiB)` margin — short, and the file is **skipped and logged**, never attempted) and each written archive is **integrity-validated** — staged to an **exclusively created, randomly named** temp file beside it (`tempfile.mkstemp`: `O_CREAT\|O_EXCL`, so it never truncates an existing file, never follows a symlink, and never collides with a sibling engine shard compressing the same directory), `fsync`ed, re-read **off disk**, decompressed and compared **byte-for-byte** against the original, renamed into place, and then **validated again at `<name>.gz` itself** — and it is that last check, on the bytes actually sitting where the log used to be, that authorizes removing the original. Any failure leaves the original **in place**, does not count it as compressed, and logs it; an existing `<name>.gz` is never clobbered. The archive inherits the source's mtime, so `app_log_days` still ages it out. Files over 64 MiB are skipped (the codec is in-memory), and so is a file whose archive would not be **smaller** than it (an empty or already-compressed log — compressing must never *cost* disk). Names/counts/sizes are logged, **never file content**. `0` = never compress. **No-op unless `[logging].log_dir` is set.** Set it **shorter** than `app_log_days` — a longer window compresses nothing, since the delete sweep runs first |
733
718
|`search_preset_days`| int |`0`| past N days, **delete** saved-search presets (ADR 0136) neither used nor edited since the cutoff. The stored `criteria` is the operator's own content/`field_value` needle — **PHI-shaped by construction**, encrypted at rest — so it needs a window like any other PHI tier (ASVS 14.2.7). The whole **row** is deleted, not blanked: a preset's entire payload *is* its criteria. **Keys on last-USED** (BACKLOG #306) — the cutoff is compared against the *later* of `updated_at` (written by a save) and `last_used_at` (written by a recall), so a preset you run daily but never re-save is **kept**. A preset last touched before the `last_used_at` column existed has it NULL and ages out on `updated_at` alone. `0` = keep forever (the default) |
0 commit comments