Skip to content

feat(service-settings): report-only sys_secret orphan classifier, and the reachability fact it falsifies (#8103) - #8261

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-8103-sys-secret-orphan-report
Aug 13, 2026
Merged

feat(service-settings): report-only sys_secret orphan classifier, and the reachability fact it falsifies (#8103)#8261
os-zhuang merged 3 commits into
mainfrom
claude/issue-8103-sys-secret-orphan-report

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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:

Fact Reading
Handle ids minted per encrypt() call GREENLocalCryptoProvider.encrypt mints sec_ + 16 random bytes per call; rotateKey keeps id stable
sys_setting.value_enc is the only column holding a handle RED — FALSIFIED
Audit trail records digests, not handles GREENold_hash/new_hash are content digests; no handle appears anywhere in an audit entry

sys_secret has three producers, not one — stated by its own schema (platform-objects/src/system/sys-secret.object.ts, "three privileged producer paths"):

  1. SettingsService → handle in sys_setting.value_enc
  2. the engine's secret-field channel (encryptSecretFields) → secret: id stored on an arbitrary business row, including tenant-authored objects
  3. the datasource credential binder → sys_secret: id at external.credentialsRef

Two 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 by sys_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, never orphaned. 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 live sys_setting.value_enc names this handle
  • orphaned — unreferenced and attributable to a declared encrypted specifier
  • unattributable — unreferenced and not provably settings' — ⛔ not an orphan

SecretRowSnapshot deliberately has no ciphertext member, 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_at are reported and never consulted. Stripping that metadata changes no verdict, which the test asserts.
  • Legacy inline crypto, in the other direction from fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) #8063's sec_ guard — an inline value_enc contributes no handle to the referenced set, and any sys_secret row sharing its (namespace, key) is flagged legacyInlineSibling.

The report also carries explicit caveats naming 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 ObjectQL over the real SysSetting/SysSecret schemas, driving the real SettingsService — writes through an adapter that drops context, which is the whole pre-fix defect (value_enc is readonly: true and the engine strips it from a non-system UPDATE). Three writes give exactly 1 → 2 → 3, with value_enc pinned at the first handle.

A faithful pre-fix instance needs the store's delete absent 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 dangling value_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: its update() routes through assertEngineUpdateDispatch per check:engine-double-contract. The predicate reads only where / multi / data.id and never looks at context, 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:

  • collapsing the attribution guard (unattributableorphaned) → 3 failures including the safety pin
  • letting re-wrap metadata decide a verdict → 3 failures including the re-wrap guard
  • flipping the engine double's multi to false → 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 code

Scope

Read/report path only. settings-service.ts and crypto-adapter.ts are untouched, so there is no overlap with #8026's write/persist path this round.

Generated by Claude Code

claude added 2 commits August 13, 2026 01:11
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
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 13, 2026 1:51am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-settings.

5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services/service-settings)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services/service-settings)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services/service-settings)
  • content/docs/plugins/packages.mdx (via @objectstack/service-settings)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/service-settings)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/service-settings)
  • content/docs/releases/v9.mdx (via @objectstack/service-settings)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…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
@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 02:07
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 59ed2e9 Aug 13, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8103-sys-secret-orphan-report branch August 13, 2026 02:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants