Repository navigation
fix(service-settings)!: the user rung answers only its owner; a user-scoped write must name its user - #22246
Conversation
…scoped write must name its user The read half: resolveKeyFromRows takes the caller's user id and answers the user rung only for a row whose user_id equals it. A resolve whose context names no user has no user rung and falls through to the tenant rung, then global, then the default. get and getMany thread the id; getNamespace, createClient, runAction and resetNamespace reach the cascade through them. The write half: setMany refuses a user-scoped key when the context names no user, with a SettingsValidationError (invalid_value, constraint scope user) collected in the pre-flight, before any write. Pins on both halves over the engine-bound and in-memory stores, with per-user and tenant/global controls. Minor changeset, declared narrowing. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
An ownerless user row (the shape a write with no user stored before the refusal) makes the presence check load-bearing; a store that answers more rows than asked for makes the owner comparison load-bearing. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0cd90f47a07640fdffb5206a86dc4f23a1f9b1e1 && git checkout 0cd90f47a07640fdffb5206a86dc4f23a1f9b1e1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f4bed58341c16062b63bd95864aea53839293745 fd245469533a91cb8d89c77746eee88e6244eb74 && git checkout -B drift-repro f4bed58341c16062b63bd95864aea53839293745 && git merge --no-ff fd245469533a91cb8d89c77746eee88e6244eb74
node scripts/docs-audit/affected-docs.mjs --json f4bed58341c16062b63bd95864aea53839293745
|
Fixes #22168
Clause-②: no (narrowing)
SettingsServicenow treats the user rung as belonging to one user, on both its read side and its write side. This is the defensive fix the seat review on the card ruled, in two halves. One file changes (packages/services/service-settings/src/settings-service.ts), with a pin file and aminorchangeset that declares the narrowing.What changes
Read half:
resolveKeyFromRows. The cascade walk now takes the caller's user id. The user rung answers only a row whoseuser_idequals that id. A resolve whose context names no user (nouserId, or an empty one) has no user rung: it answers from the tenant rung, then global, then the manifest default, exactly as if no user row existed.getandgetManypass the id through, with one helper (callerUserIdOf) as the single definition of "present".getNamespace,createClient,runAction,resetNamespaceandsetMany's own re-resolve all reach the cascade throughgetorgetMany, so every read path gets the same answer. The engine-bound store and the no-engine in-memory fallback share the pick, so both are covered.Write half:
setMany, ruled A. A key declaredscope: 'user'in a batch whose context names no user is refused withSettingsValidationError(SETTINGS_VALIDATION). Each such key gets onefieldsentry:fieldnames the key,code: 'invalid_value'andconstraint: { scope: 'user' }. The check runs in the pre-flight, before validation, before any row load for that key and before any write, and it refuses the whole batch. Anullreset of such a key is refused too: the only row it could touch has no owner, and no reader returns that row any more.Unchanged:
Why
SETTINGS_VALIDATIONand not a new codeI checked the service's existing error vocabulary first, as the dispatch asked.
SettingsValidationErroralready refuses a whole batch before any write, with oneFieldErrorper key. It also already carries a refusal that is not about the submitted value: the manifest-sidevisiblepredicate, underinvalid_value, ADR-0114's slot for "rejected for a reason no other member names".SETTINGS_FORBIDDENis defined as the HTTP-boundary capability refusal, and its message names a capability.SETTINGS_LOCKEDdrives the lock affordances. A new code would also need a ledger row inpackages/spec, which is outside this card's fence.Reach (the round-1 census, re-read on this base)
The shipped settings manifests declare no
scope: 'user'key at manifest or specifier level. That was true at the claim's base and still is at0e9371f0c6. So no in-repo caller meets either half today. The in-process writers are the HTTP routes and the service's ownsetandresetNamespace, and the HTTP door always carries a user. The change is latent and defensive. It takes effect the first time a manifest declares a user-scoped key.Tests
Measured at
fd24546953. Every run went throughscripts/pm/os-verify-lock.sh.settings-user-rung-owner.pin.test.ts, 65 cases, run on both stores:get,getMany,getNamespace,createClient,runAction), for an empty context, a tenant-only context and an empty-string user id;codeplusfields), stores nothing and emits no change event, and a mixed batch is refused whole;pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2gave 39 files and 702 tests passed.pnpm --filter @objectstack/service-settings typecheckgave exit 0, and--listFilesshows the pin file is part of that program.scripts/ablation-replace.mjs(anchor hit once, blob changed, restore proven equal to HEAD,git diff HEADempty), with a bashtraprestore as well. Every leg turned red, and no control failed:Acceptance notes
loadScopedRowsand the in-memory filter). With the pick narrowed, those rows are inert for the cascade. Narrowing the load itself is defence in depth and is outside this card's fence. Carrier: none.nullwrite with no user id is refused. No in-repo writer could have produced one, since no shipped manifest declares a user-scoped key.resetNamespaceon a user-scoped key whose effective value comes from the tenant or global rung writesnullat the key's declared (user) rung, so the upper value stays in force even though it is counted as cleared. This was there before this change and is a reading, not measured. With no user id, that path now meets the write refusal instead of storing an ownerless row. Carrier: none.content/docs/protocol/kernel/config-resolution.mdxhas no row for this refusal. That file is outside this card's fence. Carrier: none.Generated by Claude Code