feat(service-settings): report-only sys_secret orphan classifier, and the reachability fact it falsifies (#8103) - #8261
Conversation
Lands the non-destructive half of #8103: an operator-facing answer to "which sys_secret rows are orphaned, and why", with no deletion and no migration. The sweep's vehicle stays an open maintainer decision. Re-measuring #8063's three reachability facts falsified the middle one: sys_setting.value_enc is NOT the only column holding a sys_secret handle. The store has three producers (SettingsService, the engine's secret-field channel storing `secret:<id>` on arbitrary business rows, and the datasource credential binder storing `sys_secret:<id>` at external.credentialsRef), and the engine's holders are not statically enumerable. So the classifier reports a row it cannot attribute as `unattributable` rather than `orphaned` — the guard that stands between a future sweep and a live datasource credential. Also pins the two directional guards the card names: a rotateKey() re-wrap keeps the handle stable and reads as in-force (rotation metadata never decides a verdict), and a legacy inline value_enc contributes no handle while flagging its (namespace, key) siblings. Tests drive a real ObjectQL over the real SysSetting/SysSecret schemas: the orphan-generating mechanism is reproduced rather than cited, and the fixture carries all four classes so the pins are not vacuous. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
…ot a raw byte check:nul-bytes caught a raw 0x00 in the composite-key separator -- the exact accident the gate documents (an editing tool materialising an escape while the author writes about it). The backslash-u-0000 spelling is byte-identical at run time. Also documents why NUL rather than a printable separator: no namespace or specifier key can contain one, so no two distinct pairs alias into one composite -- and an aliased pair here would silently change a row's verdict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
|
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:
|
…ugh assertEngineUpdateDispatch check:engine-double-contract (convention-scoped, so dispatch-gates.mjs cannot derive it) flagged the #8103 harness's SettingsEngine double as declaring an update() that does not route through the shared dispatch predicate. Took the pin rather than a baseline entry: the predicate reads only where / multi / data.id and never looks at context, so it is orthogonal to the defect this double reproduces (dropping context so the engine strips the read-only value_enc). The double stays loose in exactly one dimension and is now conformant in the one that drifts silently -- by-id vs multi. multi:true is passed unconditionally instead of re-deciding the branch, because that IS the settings adapter's contract: a scalar where.id outranks multi in the shared predicate, so one call reproduces both of wrapEngineAsSettingsEngine's branches without mirroring the guard. Imported from @objectstack/objectql (already a devDependency, already aliased to source by vitest.config.ts) rather than @objectstack/metadata-core: the cycle rule prefers metadata-core only when objectql depends on the package being pinned, and objectql does not depend on service-settings. No verdict logic changed; settings-service.ts and crypto-adapter.ts untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
Part of #8103 — the non-destructive half only. The deletion half stays open and is being decided on #8259, which this PR does not address.
⛔ No deletion ships here. No delete path, no boot-time migration, nothing behind a flag. The classifier is a pure function that cannot write and cannot decrypt.
Headline: re-measuring falsified one of the three reachability facts
The card asked for #8063's three reachability facts to be re-measured rather than cited. Measured on this checkout:
encrypt()callLocalCryptoProvider.encryptmintssec_+ 16 random bytes per call;rotateKeykeepsidstablesys_setting.value_encis the only column holding a handleold_hash/new_hashare content digests; no handle appears anywhere in an audit entrysys_secrethas three producers, not one — stated by its own schema (platform-objects/src/system/sys-secret.object.ts, "three privileged producer paths"):SettingsService→ handle insys_setting.value_encsecret-field channel (encryptSecretFields) →secret: idstored on an arbitrary business row, including tenant-authored objectssys_secret: idatexternal.credentialsRefTwo of the three are invisible from this package, and (2) is not statically enumerable — it is every
secret-typed field on every registered object. A sweep that treated "unreferenced bysys_setting" as "unreferenced" would delete live datasource credentials and live business-row secrets.That is why the verdict vocabulary has a third value: a row that cannot be attributed to the settings producer is reported
unattributable, neverorphaned. That guard is the safety property of this PR and is pinned by test.What landed
classifySysSecretRows()— pure, read-only, over caller-supplied snapshots:in_force— a livesys_setting.value_encnames this handleorphaned— unreferenced and attributable to a declared encrypted specifierunattributable— unreferenced and not provably settings' — ⛔ not an orphanSecretRowSnapshotdeliberately has nociphertextmember, so carrying cipher material into the report is a type error rather than a review question (maintainer ruling of 2026-08-12: report mode names counts and row ids without decrypting).Both directional guards the card names are pinned:
rotateKey()is not a retirement — it re-wraps in place and keeps the handle, so a re-wrapped row is the row in force. Verdicts are decided by reference only;version/rotated_atare reported and never consulted. Stripping that metadata changes no verdict, which the test asserts.sec_guard — an inlinevalue_enccontributes no handle to the referenced set, and anysys_secretrow sharing its(namespace, key)is flaggedlegacyInlineSibling.The report also carries explicit
caveatsnaming its own blind spots, so counts are never read bare.Mechanism reproduced, not cited
The filer's 7 → 8 → 9 anecdote is reproduced from a clean table against a real
ObjectQLover the realSysSetting/SysSecretschemas, driving the realSettingsService— writes through an adapter that dropscontext, which is the whole pre-fix defect (value_encisreadonly: trueand the engine strips it from a non-system UPDATE). Three writes give exactly 1 → 2 → 3, withvalue_encpinned at the first handle.A faithful pre-fix instance needs the store's
deleteabsent too, since the context fix and the reaper landed together in #8063. Wiring a live reaper to a context-dropping adapter instead yields 1 → 1 → 2 and a danglingvalue_enc— filed separately as a finding (#8262), not touched here.That double is loose in exactly one dimension (
context) and stays conformant in the rest: itsupdate()routes throughassertEngineUpdateDispatchpercheck:engine-double-contract. The predicate reads onlywhere/multi/data.idand never looks atcontext, so the pin is orthogonal to the defect being reproduced.The card's security framing is inverted for this population
Measured (and asserted in the test): after three pre-fix writes the value still in force is the oldest one — the credential the admin believed replaced — while the orphans hold the values the admin intended to set and which never took effect.
So deleting the orphans does not retire the exposed credential; the exposed one is referenced and no sweep would touch it. The orphans are still real exposure — if the admin also rotated at the provider, the newest orphan may be a currently-valid credential.
Tests
All four classes (orphaned, in-force, legacy-inline, re-wrapped) are present in one fixture, so the pins are not vacuous. 14 new cases; the package's 427 tests pass; typecheck clean.
Reverse verification, direction predicted before running, all RED as predicted:
unattributable→orphaned) → 3 failures including the safety pinmultitofalse→ the real engine's own "Update requires an ID or options.multi=true", proving the dispatch pin sits on the harness's live write path rather than being dead codeScope
Read/report path only.
settings-service.tsandcrypto-adapter.tsare untouched, so there is no overlap with #8026's write/persist path this round.Generated by Claude Code