Skip to content

feat(service-settings): the namespace read lists what is stored beneath an env pin - #30835

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-56aarf
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-56aarf

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

domain:server#1 · os-elon-musk · session_016WV4d13xV55y9KgsteqXK9

Fixes #30823

Claim: 6677761672 (README surface amendment 6677849918)

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:设置命名空间读取(getNamespace,即 GET /api/settings/:namespace)对被 OS_* 环境变量锁定的字段,在 cascadeChain 的 env 条目下面列出锁定之下已保存的值(各层已存行 + 默认值);值、来源、锁定状态都不变,env 仍是唯一生效项。
  • 为什么改:管理员用环境变量临时锁定某项(如允许 IP 列表)自救后,必须先清掉下面那份已保存的旧值再去掉变量,否则旧值回来;Setup 页(Settings: an env-pinned field offers no way to clear the value saved beneath the pin #30665)要知道"下面有没有已保存的值"才能显示"清除已保存的值",此前读取接口不给这个信息。
  • 风险与回滚:只加信息、不改已有字段;秘密类字段下面只给掩码或 null,且根本不解密;get()/getMany() 这两个每个请求都会走的轻量读取保持不读库,不增加开销。单 PR 可整体回滚。
  • 先看:packages/services/service-settings/src/settings-service.ts 的 resolveMany 与 withStoredBeneath / storedSecretPresence(填充与秘密处理);settings-service.test.ts 末尾新 describe;settings-routes.test.ts 末尾 REST 用例。
  • 两分钟验证:pnpm --filter @objectstack/service-settings exec vitest run src/settings-service.test.ts src/settings-routes.test.ts src/settings-getmany.test.ts,预期全部通过;其中"锁定之下"相关用例 10 个。
  • 需要你做的:无,席位落地。

What changes

  • The namespace read lists what is stored beneath a pin. SettingsService.getNamespace(), the read GET /api/settings/:namespace already passes through (redacted, settings-routes.ts untouched), answers a key whose OS_* override is in force with the same value, source: 'env', locked: true and lockedReason as before. Its cascadeChain now lists, below the env entry, the entries the key's read carries with no override in force: the stored rows the caller's cascade draws on (global, tenant, user, each with its own locked / lockedReason), then default. Their effective flag is removed; the env entry stays first and the only effective one, so the schema's "the first entry where value is non-null is also the effective source" stays true.
  • The scope a clear nulls is readable. A clear (null) through a pin writes the key's declared scope, for the caller's user and organization reach. The listed rows are picked by the same preferredRow / reach / user-owner rules, so the entry at the specifier's scope is the value that clear removes (non-null before, null after). An upper row's lock rides along, so a clear that would be refused locked-by-SCOPE is visible too.
  • Secrets. A secret key's entries beneath a pin carry SETTINGS_SECRET_MASK when a value is stored and null when none is, in process too: storedSecretPresence reads the row, the secret is never decrypted. No in-process consumer reads those entries (consumers read value, still the env value in plaintext as before), and a key pinned because its stored secret no longer decrypts still reads. The REST redaction maps them as before.
  • Cost. getMany now delegates to a private resolveMany(…, beneathPins); getNamespace passes true. A pinned key joins the namespace read's existing grouped row load, so the read costs one more load only when every key of a group is pinned (pinned in settings-getmany.test.ts: same find count with and without the pin).
  • Docs. content/docs/protocol/kernel/config-resolution.mdx (The Environment Layer) and the package README's "Env override" section say what the namespace read lists beneath a pin, and that get() / getMany() answer from the env alone.

Accepted deviation from the order (seat, thread note)

The order's item 5 named SettingsService.get beside the inspection path. get() and getMany() keep answering a pin from the env alone, without touching the store: they are per-request reads (resolve-authz-context reads getMany('localization', …) on every request context, plugin-auth reads get() on request paths), and filling them would add two store reads per request wherever a namespace is fully pinned. settings-prebind-read-warning.test.ts case 5 pins get()'s in-window env answer silent for the same reason. ResolvedSettingValueSchema.cascadeChain's docblock already scopes the full chain to "the inspection path used by the settings UI". The card's "the settings REST answer carries it" is met by the namespace read.

The reader #30665 plans

The console's Settings page reads values only through getSettingsNamespace (ui/apps/console/src/pages/settings/api.ts, GET /api/settings/:namespace); the settings REST family has no single-key read. So #30665 reads the namespace endpoint this PR fills. SettingsField.tsx's sourceBadgeOf returns "inherited from env" before it looks below the effective entry, so today's badges do not change.

Tests

  • settings-service.test.ts, new top-level describe (real SettingsService, in-memory store): a pinned key over a stored row lists it beneath the env entry and the answer is otherwise the pin's (whole object compared); after the clear through the pin (PR fix(service-settings): a clear goes through an env pin, so the allowed-IP lockout recovery survives removing the variable #30448's door) the stored entry reads null, and a pin with no row lists none; a global key lists its global row and an upper row's lock rides along; a user key lists the caller's own row only; a secret shows the mask, never its value, and decrypt is never called, and after the clear it reads null; an override coercing to null keeps its single entry; get() and getMany() still answer from the env alone.
  • settings-routes.test.ts, new top-level describe (REST, LocalCryptoProvider + sys_secret store): GET /:ns lists a pinned key's stored row and a pinned secret's mask, the wire carries neither plaintext, decrypt is never called; PUT { key: null } through the pin, then GET lists the entry null. The existing env-locked secret pin now expects the default entry beneath the env one (this change makes its one-entry chain false).
  • settings-getmany.test.ts, new top-level describe: over the engine double, a pin over a global row lists that row at the same find count as the read without the pin.
Ablations (scripts/ablation-replace.mjs; each anchor 1 → 0, blob changed, restored blob == HEAD, `git diff HEAD` empty; the suites import src, no build between)
  • A. getNamespace asks resolveMany(…, false) (the fill off): 9 failed / 198 passed across the three files — every new beneath-pin case, the REST cases and the read-count case, plus the updated env-locked secret pin. The null-coercion case and the get() / getMany() case stay green, as they pin the unfilled shape.
  • B. a pinned secret materialised through materialiseRow (decrypting): 2 failed / 205 passed — the service and REST secret cases.
  • C. the null-value guard removed: 1 failed / 160 passed — the null-coercion case.

Acceptance notes

  • Named gap: an override whose value coerces to null (OS_X=null on a key whose default is an array or object, which coerceEnvValue JSON-parses) keeps its single env entry. Any non-null entry listed beneath it, the default included, would be the chain's first non-null entry and contradict the effective-source rule while the env value is in force. Saying "stored beneath" there needs a schema field (a packages/spec change, maintainer consent); not done here.
  • The PUT answer is get()'s, so a clear's response carries the env-only chain; the next namespace read shows the cleared entry.
  • resetNamespace is unchanged: it still reads resolveStored for a pinned key, which covers the null-coercion case the namespace read leaves out.
  • Spec prose, not changed here (packages/spec is out of surface): cascadeChain's docblock says "locked: true on an upper entry means writes to lower entries are rejected", which since the ADR-0007 D6 amendment holds for every write but a clear under an env entry.

Verification

Local runs on the final head are quoted with that head in the report comment on #30823: @objectstack/service-settings test and typecheck; every family node scripts/pm/dispatch-gates.mjs --commands derives from this diff, reconciled with --ran; pnpm lint. CI's required checks are the authority for everything else.


Generated by Claude Code

…th an env pin

getNamespace (the read GET /api/settings/:namespace serves) now lists,
below the env entry of a pinned key's cascadeChain, the entries the key's
read carries with no override in force: the stored rows the caller's
cascade draws on, then the default. The env entry stays first and the only
effective one; value, source, locked and lockedReason are unchanged. A
secret's entries beneath the pin carry the read mask or null and are never
decrypted. get() and getMany() keep answering a pin from the env alone.

Claude-Session: https://claude.ai/code/session_016WV4d13xV55y9KgsteqXK9
Co-authored-by: Claude <noreply@anthropic.com>
…the namespace read

Service pins: a pinned key over a stored row lists it beneath the env entry;
after the clear through the pin it reads null; a pin with no row lists none;
a global row and an upper row's lock ride along; a user key lists the
caller's own row only; a secret shows the mask, never its value, and is not
decrypted; an env value that coerces to null keeps its single entry; get()
and getMany() still answer from the env alone. REST pins on GET and on the
PUT clear, a read-count pin on the grouped load, and the env-locked secret
pin now lists the default entry beneath the env one.

Claude-Session: https://claude.ai/code/session_016WV4d13xV55y9KgsteqXK9
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-settings, touching 8 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-settings/README.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via SettingsService (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx (via /api/settings/:namespace (route, a path literal in a comment in SettingsService))
  • content/docs/permissions/authentication.mdx (via /api/settings/:namespace (route, a path literal in a comment in SettingsService))
  • content/docs/protocol/kernel/config-resolution.mdx (via SettingsService (symbol, a top-level class), getMany (symbol, a method of class SettingsService), getNamespace (symbol, a method of class SettingsService), /api/settings/:namespace (route, a path literal in a comment in SettingsService))
  • content/docs/protocol/kernel/index.mdx (via SettingsService (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-7.mdx (via /api/settings/:namespace (route, a path literal in a comment in SettingsService))

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.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-settings/README.md) — pages documenting those are invisible to this run
  • the SDK route bridge reached 63 of 222 client-bound route-ledger rows — the other 159 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 159: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b6182249c3278a60d07727b59afc291054521b4b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 383edca2f60f01ab08aa2643f4432d53766caa19 — the merge of head de2998b32bdf4b5bca9375072018940ea2ce98c2 into base b6182249c3278a60d07727b59afc291054521b4b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 383edca2f60f01ab08aa2643f4432d53766caa19 && git checkout 383edca2f60f01ab08aa2643f4432d53766caa19
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b6182249c3278a60d07727b59afc291054521b4b de2998b32bdf4b5bca9375072018940ea2ce98c2 && git checkout -B drift-repro b6182249c3278a60d07727b59afc291054521b4b && git merge --no-ff de2998b32bdf4b5bca9375072018940ea2ce98c2

node scripts/docs-audit/affected-docs.mjs --json b6182249c3278a60d07727b59afc291054521b4b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b6182249c3278a60d07727b59afc291054521b4b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

契约复审 · CONTRACT_REVIEW_TIER · PASS · 2026-11-06T06:39Z · #30823

Head-sha: de2998b

Must-fix: none.

Should-fix (non-blocking):

  1. The pre-bind window. A namespace whose every key is pinned now reaches loadRowSets, and with it reportPreBindRead (settings-service.ts :2131, :3588). Before this PR it returned before any store read. settings-prebind-read-warning.test.ts :404 pins silence for get() only, so getNamespace of a fully pinned namespace read in the window now logs one warning per namespace. That is arguably correct, because the entries beneath are answered from the fallback, but the choice is undocumented. Name it in the docblock at :2104 or in a test.
  2. The same fully pinned case with a tenant or user key now calls organizationReachOf (:2128–2130), which throws on a failed read (:2286). So a namespace read that used to answer from env alone can now fail. This is the same thing any unpinned key already does, and it is rare.
  3. The locked docblock prose in packages/spec/src/system/settings-manifest.zod.ts (:805–809, :825–827) still drifts. That is outside this card's surface, so it is recorded here only.

Confirmed (read in the code):

  • Chain invariants. The env entry is first and the only one with effective: true (:2094–2103). The fill beneath it runs only when the env value is non-null (:2104–2107), so the first non-null entry stays env. withStoredBeneath strips effective from every entry beneath (:4185–4191). Top-level value, source, locked and lockedReason are unchanged. resolveKeyFromRows never emits env and orders global, tenant, user, default (:2194–2235), so no scope is duplicated or misordered. A lock on a global row beneath stays on its own entry and does not take over the top-level lock (test :3052–3072).
  • Secrets, on every path.
    • In process, a pinned secret's rows go through storedSecretPresence (:2140–2146, :4197–4200) and come out as the mask or null, never decrypted (the test asserts that decrypt is not called, :3089–3112).
    • REST GET passes the whole payload through redactSecretValues (settings-routes.ts :309), whose maskResolved masks every non-null chain entry (settings-secret-redaction.ts :88–97). The route test checks that the wire carries neither the stored plaintext nor the env plaintext (:971–974).
    • snapshotOf, servedManifest and resetNamespaceRows do not read the chain. The PUT answer is get()'s, which is env-only.
  • Tenancy. A pinned key joins the same pending groups, so its rows load with the caller's user and organization reach (:2123–2133, :3612–3630). The user rung compares user_id to the caller (:2227). A test shows only user A's row, and that a caller naming nobody gets none (:3074–3087).
  • Cost. Pins add no extra read: one grouped load either way (settings-getmany.test.ts :246–264 pins equal find counts).
  • Tests. All three of the card's cases (:3024–3057), secret masking in process, the REST level (settings-routes.test.ts :927–998, including PUT null then GET), the null-coercion gap (:3114–3124), and env-only get / getMany (:3126–3131). The ablation counts match the cases each ablation would trip.
  • Contract. Clause-②: yes (widening): the served namespace read carries more. Metadata-protocol: none: packages/spec is untouched. AGENTS.md (about :1026–1028) gives none a patch changeset, so the patch level is right.
  • Docs. config-resolution.mdx :265–276 and the README :37–41 match the code.

Consumers checked:

  • The console reads the chain only for the effective entry, and only when the key is not locked (ui/apps/console/src/pages/settings/SettingsField.tsx :142–167, :246). workspaceTimezonePromptLogic.ts :263–268 reads source only.
  • No in-process getNamespace caller outside service-settings reads cascadeChain: service-sms, service-storage, plugin-email, plugin-auth, and the organizations membership gate.
  • Every service-settings test that reads a pinned chain was updated: the one prior pin is at settings-routes.test.ts :617.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

席位核对 · ACCEPT · 2026-11-06T06:40Z · #30823

Head-sha: de2998b

  • Shape: 7 files, +388/−12: settings-service.ts (+100/−11), three test files, the README's "Env override" section, content/docs/protocol/kernel/config-resolution.mdx, and one patch changeset. Everything is inside claim 6677761672 and README amendment 6677849918. Not governed.
  • What it does:
    • getNamespace, the read behind GET /api/settings/:namespace, lists the stored entries beneath an env pin in cascadeChain. The env entry stays first and effective, and the answer's value, source and locked are unchanged.
    • A secret beneath a pin shows the mask or null and is never decrypted.
    • That is what Settings: an env-pinned field offers no way to clear the value saved beneath the pin #30665 needs to offer "clear the saved value". It reads the namespace endpoint.
  • Contract review: PASS at CONTRACT_REVIEW_TIER, record 6678895942 at this head, no must-fix. It confirmed the chain invariants of the schema docblock, secret masking on every path, the caller's tenancy on the rows beneath, and no consumer that misreads the new entries.
  • Accepted deviations:
    • get() and getMany() stay env-only. They are per-request hot paths, and the schema docblock already scopes the chain to the inspection path.
    • An env value that coerces to null keeps an env-only chain, which is named as a gap in the PR.
    • The changeset is patch: Metadata-protocol: none takes patch per AGENTS.md, and the review confirmed it.
  • Recorded, not blocking (review should-fixes 1 and 2): a fully pinned namespace now reads the store. So in the pre-bind window it logs one warning per namespace, and its organization lookup can throw as any unpinned key's can. Name the warning choice in a docblock or a test on the next touch of this file.
  • Gates: every check-run on this head has concluded: 50 distinct checks, the latest run of each 38 success, 12 skipped, 0 failure. Locally (dev report 6678794925): service-settings 70/70 files, 1216/1216 tests; typecheck 0; dispatch-gates 90 derived, 90 run, 0 NOT-MEASURED, all exit 0; pnpm lint 0. Ablations A, B and C each turn the expected tests red.
  • Contract: Clause-②: yes (widening), Metadata-protocol: none, both confirmed.
  • Landing: ready, then auto-merge, through the relay. Fixes #30823, which unblocks Settings: an env-pinned field offers no way to clear the value saved beneath the pin #30665.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 06:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
Merged via the queue into main with commit 87fc795 Nov 6, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-56aarf branch November 6, 2026 07:02
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/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Settings read: an env-pinned key does not say whether a value is stored beneath the pin, so Setup cannot offer to clear it

1 participant