Uh oh!
There was an error while loading. Please reload this page.
fix(settings): verify the repoint before reaping a rotated secret (#8262) - #8680
Conversation
) `reapRotatedSecret` deleted the handle `upsertRow` reported as `previousEnc` and inferred that the repoint had taken effect from `previousEnc !== nextEnc`. That inference fails on a `SettingsEngine` adapter that drops `context`: the readonly `value_enc` is stripped from the non-system UPDATE, the row keeps naming the old handle, and the reaper destroyed the ciphertext STILL IN FORCE — leaving a dangling `value_enc` that reads as empty, unrecoverably. Re-read the row and delete only once storage confirms it no longer names the handle. Refusals leave an orphan (recoverable) and are logged loudly, so a non-forwarding adapter now announces itself instead of silently losing values. The verification read sits behind every cheap guard and inside the reaper's existing "never fail the write" guarantee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…s type (#8262) `rowIdentity`'s `bypass` is typed `{ bypassTenantAudit?: true }` rather than `Record<string, unknown>`, so the reaper's re-read spreads into a `SettingsEngine.find` options object without an `as any`. The query-options-erasure ratchet caught the erasure as a new site — correctly: the verification read's whole value is that it goes through the declared contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…per-verify-not-infer
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8262
The defect, reproduced before it was fixed
SettingsService.reapRotatedSecretdeleted the handleupsertRowreported aspreviousEnc, and inferred that the repoint it was cleaning up after had takeneffect from
previousEnc !== nextEnc. That inference holds for the shippedadapter, which forwards
context: { isSystem: true }. It does not hold for anadapter that drops
context— the readerSettingsEngine's own doc commentcontemplates, and a documented extension point rather than a mistake nobody
makes.
With
contextdropped,sys_setting.value_encisreadonly: true, so theUPDATE has it stripped and the row keeps naming the OLD handle. The reaper then
deleted the ciphertext still in force:
materialiseRowdereferenced adangling handle, got nothing, and the setting silently read as empty —
unrecoverably, since the audit trail records digests rather than handles or
ciphertext.
The card's table, reproduced on today's
mainagainst a realObjectQLoverthe real
SysSetting/SysSecretschemas, before any source change:The tests were written first and run against unmodified source, so their
red-to-green transition is the anti-vacuity evidence rather than a post-hoc
ablation.
The fix
The reaper re-reads the row after the write and deletes
previousEnconly oncestorage confirms the row no longer names it.
The criterion is
current !== previousEnc, not the narrowercurrent === nextEncthe card suggested. Both refuse the case that matters (therow still naming
previousEnc), but under a concurrent rotation the row mayalready have moved on to a third handle — where
previousEncis genuinelyunreferenced and the narrower test would leak exactly the orphan the reaping
exists to prevent. The card labelled its direction a suggestion, not a ruling;
this keeps its substance (verify, do not infer) with the wider-of-the-two safe
tests.
Three properties held deliberately:
allowed to fail the write") now covers the read as well as the delete. Every
failure branch — read throws, row unreadable, row still naming the handle —
logs and returns.
one an orphan sweep can clean up. There is no recoverable direction on the
other side.
It sits behind all four existing cheap guards, so a rotation that cannot reap
anything issues no extra I/O.
The failure also stops being silent: a non-forwarding adapter now produces a
server-side error naming the row, the refusal and the cause. That is the only
signal available — the HTTP response still says the write landed, because the
strip happens a layer below and nothing at this layer can make such an adapter
correct. What this change buys is that the failure is survivable and visible.
The stale doc sentence, which rides the fix
SettingsEngine.update's warning still described only the pre-#8063consequence ("the rotated-away credential stays in force"), so an adapter author
reading it would not learn the failure had become data loss. It now states the
real consequence, records the #8063-to-#8262 window explicitly (an adapter
written against that window's docs is exposed to the destructive form), and
keeps the MUST-forward requirement unweakened.
Scope
⛔ No #8259 rider: the orphan-sweep decision is orthogonal to this defect
(sweeping old orphans vs. the reaper deleting a live value) and nothing here
touches it. #8103 is likewise untouched — it remains open by design.
packages/services/service-datasource(sibling card #8154) is not touched.premise_still_valid: true— the reaper, its call site and the stale sentencewere all where the card said, allowing for today's line drift.
Verification
Union re-run after the final commit, at
e901161ba:pnpm --filter @objectstack/service-settings test— 23 files, 456 testspassed (includes the 5 new reapRotatedSecret deletes the IN-FORCE ciphertext when a SettingsEngine adapter drops
context, leaving a dangling value_enc and an unreadable setting #8262 cases and the pre-existing [security] Rotating an encrypted setting silently does nothing — the new ciphertext is written butsys_setting.value_encstill points at the old handle, so a leaked credential stays live after a 200 #8030/fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) #8063reaping cases, which are the guard that this did not simply stop reaping).
pnpm --filter @objectstack/service-settings typecheck— clean;tsc --listFilesconfirms the edited test file is inside the program, so thenew test code is really type-checked.
check:test-source-alias,check:type-source-resolution,check:engine-double-contract,check:query-options-erasure,check:changeset-gate-self-tests,check:objectui-changeset,check:type-check-coverage,check:nul-bytes,plus live
check-empty-changeset,check-adr-0087-registration,check-changeset-no-major.check:query-options-erasurewas genuinely red at first — the verification readhad picked up an
as any, growing that file's erasure count 2 to 3. Fixed atthe source by narrowing
rowIdentity'sbypasstype, so the new read goesthrough the declared
SettingsEngine.findoptions type. The count is back tobaseline.
The two engine doubles this PR adds are pinned to the real dispatch contract via
assertEngineUpdateDispatch, matching the existing double in the same package.Generated by Claude Code