Found while measuring #8103 (report-only orphan classifier, PR #8261). Filed unassigned for triage; not touched in that PR, since the reaping landed in #8063 and re-litigating it was explicitly out of scope there.
What was measured
SettingsService.reapRotatedSecret deletes the handle that upsertRow reports as previousEnc. It never confirms the repoint it is cleaning up after actually took effect — it infers that from previousEnc !== nextEnc.
That inference holds for the shipped adapter, which forwards context: { isSystem: true }. It does not hold for an adapter that drops context — and SettingsEngine's own doc comment contemplates exactly that reader ("⛔ An adapter over IDataEngine MUST forward this. Dropping it restores the defect silently").
Driving a real ObjectQL over the real SysSetting / SysSecret schemas with such an adapter, three successive writes of an encrypted specifier give:
| writes |
sys_secret rows |
sys_setting.value_enc |
| pre-#8063 (no reaper, context dropped) |
1 → 2 → 3 |
pinned at the first handle |
| today's reaper + context dropped |
1 → 1 → 2 |
pinned at the first handle — which no longer exists |
Sequence on the second write: the new ciphertext H2 is inserted; the UPDATE has value_enc stripped (it is readonly: true and the caller is not system), so the row still names H1; upsertRow returns previousEnc = H1; the reaper then deletes H1 — the row still in force.
Why this is worse than the defect it descends from
materialiseRow dereferences the handle, gets nothing back, logs and returns null. So the setting silently reads as empty and the stored credential is gone — not recoverable, since the audit trail records digests rather than handles or ciphertext.
Before #8063 the same adapter bug was bad but non-destructive: the rotated-away credential merely stayed in force. After it, the same bug destroys the value. The warning in settings-service.types.ts still describes only the old consequence ("the rotated-away credential stays in force"), so an adapter author reading it will not learn that the failure is now data loss.
Suggested direction (not a design ruling)
Make the reaper verify rather than infer — re-read the row and delete previousEnc only when value_enc really is nextEnc. That turns a non-forwarding adapter back into a recoverable failure and costs one read on a path that already awaits I/O. The doc comment's consequence sentence wants updating either way.
Repro
packages/services/service-settings/src/sys-secret-orphan-report.test.ts on PR #8261 carries the harness: boot({ forwardContext: false, withDelete: false }) is the faithful pre-fix instance; flipping withDelete back on reproduces the 1 → 1 → 2 table above.
Found while measuring #8103 (report-only orphan classifier, PR #8261). Filed unassigned for triage; not touched in that PR, since the reaping landed in #8063 and re-litigating it was explicitly out of scope there.
What was measured
SettingsService.reapRotatedSecretdeletes the handle thatupsertRowreports aspreviousEnc. It never confirms the repoint it is cleaning up after actually took effect — it infers that frompreviousEnc !== nextEnc.That inference holds for the shipped adapter, which forwards
context: { isSystem: true }. It does not hold for an adapter that dropscontext— andSettingsEngine's own doc comment contemplates exactly that reader ("⛔ An adapter overIDataEngineMUST forward this. Dropping it restores the defect silently").Driving a real
ObjectQLover the realSysSetting/SysSecretschemas with such an adapter, three successive writes of an encrypted specifier give:sys_secretrowssys_setting.value_encSequence on the second write: the new ciphertext
H2is inserted; the UPDATE hasvalue_encstripped (it isreadonly: trueand the caller is not system), so the row still namesH1;upsertRowreturnspreviousEnc = H1; the reaper then deletesH1— the row still in force.Why this is worse than the defect it descends from
materialiseRowdereferences the handle, gets nothing back, logs and returnsnull. So the setting silently reads as empty and the stored credential is gone — not recoverable, since the audit trail records digests rather than handles or ciphertext.Before #8063 the same adapter bug was bad but non-destructive: the rotated-away credential merely stayed in force. After it, the same bug destroys the value. The warning in
settings-service.types.tsstill describes only the old consequence ("the rotated-away credential stays in force"), so an adapter author reading it will not learn that the failure is now data loss.Suggested direction (not a design ruling)
Make the reaper verify rather than infer — re-read the row and delete
previousEnconly whenvalue_encreally isnextEnc. That turns a non-forwarding adapter back into a recoverable failure and costs one read on a path that already awaits I/O. The doc comment's consequence sentence wants updating either way.Repro
packages/services/service-settings/src/sys-secret-orphan-report.test.tson PR #8261 carries the harness:boot({ forwardContext: false, withDelete: false })is the faithful pre-fix instance; flippingwithDeleteback on reproduces the 1 → 1 → 2 table above.