Skip to content

fix(service-settings): redact encrypted setting values at the REST read boundary (#7522) - #7554

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-7522-settings-redact-encrypted-read
Aug 11, 2026
Merged

huangyiirene merged 1 commit into
mainfrom
claude/issue-7522-settings-redact-encrypted-read

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #7522

What was exposed

GET /api/settings/:namespace returned the plaintext of every encrypted setting in the namespace:

  • in values.[key].value, and
  • repeated once more inside every cascadeChain entry (global / tenant / user / env).

Both specifier flavours were affected — type: 'password' and an explicit encrypted: true.

Storage was never the problem, and the issue's root-cause reading held up against the tree I checked out: sys_setting.value is null, value_enc holds a sec_ handle, sys_secret holds the aes-256-gcm ciphertext. The leak was entirely on the way out. materialiseRow() (settings-service.ts:1870) dereferences the handle and decrypts; get() copies that plaintext into each chain entry (:1068 / :1082 / :1094); getNamespace() (:1118) returns it; and the GET handler in settings-routes.ts handed the payload straight to sendOk — no redaction anywhere on the path.

The endpoint requires setup.access, so this is defense-in-depth, not privilege escalation. It is still a real exposure: any operator or integration holding setup access — and any log, proxy, browser cache or HAR capture on that response path — received the cleartext of every secret in the namespace, which is exactly what the value_enc + sys_secret split exists to prevent.

What is now redacted

At the REST boundary only. The mask shape is not invented here — it reuses the convention ADR-0100 pins for encrypted fields on the generic CRUD path (SECRET_MASK in @objectstack/objectql, the read-mask + no-op-echoed-mask pair the records-forms.encrypted-field-behavior checklist item exercises):

  • Read. A set secret is served as SETTINGS_SECRET_MASK — ••••••••, byte-identical to objectql's mask. An unset one stays null, so the redaction is presence-preserving and the console still renders "configured vs not configured". cascadeChain is masked entry by entry rather than dropped, so "global sets this, your user overrides it" still renders — only the values go.
  • Write. A submitted value equal to the mask means "unchanged", so the key is dropped from the patch instead of being persisted as the mask's literal text. Scoped to secret keys: a plain setting whose value genuinely is eight bullets still writes verbatim.
  • PUT's own response is redacted the same way. It carries resolved values too — including cascade entries the caller never submitted (an upper-scope secret it may not have set), so it is the same boundary.

source, locked, lockedReason and the 409 SETTINGS_LOCKED env-lock behaviour are untouched, and pinned by tests.

What deliberately still returns plaintext

SettingsService keeps decrypting. materialiseRow(), get(), getNamespace(), snapshotOf() and createClient() all still hand back real plaintext, because the mail / sms / storage / auth plugins read their credentials through exactly that path. A test pins that round-trip so this cannot later be "fixed" one layer down — which would break every in-process consumer.

The boundary, stated plainly: decryption is the service's job; not-emitting-it is the route's.

Where the encrypted-key set comes from

SettingsService.secretKeysOf(namespace) publishes the registry's own encryptedKeys set — literally the set setMany consults to decide what gets encrypted. The route reads that rather than re-deriving spec.encrypted || spec.type === 'password', because re-deriving the predicate at the boundary is how the two sides drift into "encrypted on write, cleartext on read". It throws UnknownNamespaceError for an unregistered namespace rather than answering an empty set: "I don't know this namespace" must never read as "nothing here is secret".

Verification

pnpm --filter @objectstack/service-settings test — 395 passed / 18 files, 10 of them new, all at the route level:

✓ GET /:ns leaks no ciphertext-backed cleartext — both flavours, value AND cascadeChain
✓ redaction is presence-preserving: an UNSET secret stays null, not a mask
✓ `source` and `locked` survive the redaction unchanged
✓ an env-locked secret is masked while `source: env` / `locked` / 409 SETTINGS_LOCKED are unchanged
✓ PUTting the echoed mask back is a NO-OP — the stored secret is not overwritten
✓ the echoed mask inside the read-shape {values:{k:{value}}} envelope is a no-op too
✓ a REAL new secret still writes, and the write RESPONSE is redacted too
✓ a non-encrypted key whose value genuinely IS the mask is written verbatim
✓ in-process consumers still receive REAL plaintext (createClient / snapshotOf)
✓ secretKeysOf reports both flavours and refuses an unknown namespace

The leak case is asserted whole-body (JSON.stringify(response) contains none of the three plaintexts, one of them seeded at global scope so cascadeChain carries a second ciphertext-backed entry) and per-surface, so a regression names which surface broke rather than just "a string appeared". The secrets are real ciphertext — LocalCryptoProvider + a sys_secret store, sec_ handles, the production code path.

Reverse verification — direction predicted before running. Took the route fix out with git checkout origin/main -- settings-routes.ts (never git stash) and re-ran: 6 of the 10 went red, and the 4 that stayed green are exactly those whose subject the route revert does not touch — the source/locked passthrough, the non-secret verbatim write, the in-process plaintext guard, and secretKeysOf (which lives in the service). Restored and re-ran green.

Type surface:

  • tsc --noEmit -p packages/services/service-settings → 13 errors, the ledgered count in scripts/check-type-check-coverage.mjs, i.e. this change adds none. (One was introduced en route — InMemoryCryptoProvider is a value-only alias and cannot be spelled as a type — and fixed by using LocalCryptoProvider in the test.)
  • Downstream consumer sweep, prefix direction (--filter '...@objectstack/service-settings' = the 9 packages that consume it, not its upstream deps): turbo run typecheck → 72 tasks successful, 72 total. No reverse type-verification is reported because the change is purely additive to the public type surface (a new module, a new method) — nothing was narrowed, so there is no key a rebuilt .d.ts should now reject.
  • node scripts/check-nul-bytes.mjs → OK.

No objectui change is required: the mask is presence-preserving and the echoed-mask write is a no-op, so today's console keeps working unmodified.


Generated by Claude Code

…ad boundary (#7522)

GET /api/settings/:namespace returned the plaintext of every encrypted setting
in the namespace — in values.<key>.value and repeated once more inside each
cascadeChain entry — for both specifier flavours (type: 'password' and an
explicit encrypted: true). Storage was always correct: sys_setting.value is
null, value_enc holds a sec_ handle, and sys_secret holds aes-256-gcm
ciphertext. The leak was entirely on the way out, where there was no redaction
step at all.

The endpoint requires setup.access, so this is defense-in-depth rather than
privilege escalation — but every operator, integration, proxy, browser cache
and HAR capture on that response path received the cleartext of every secret in
the namespace, defeating the point of the value_enc + sys_secret split.

The fix lives at the REST boundary and nowhere else, reusing the mask
convention ADR-0100 pins for encrypted FIELDS on the generic CRUD path rather
than inventing a sentinel:

  - read  — a set secret is served as SETTINGS_SECRET_MASK (the same eight
            bullets as objectql's SECRET_MASK); an unset one stays null, so the
            response is presence-preserving. cascadeChain is masked entry by
            entry. source, locked, lockedReason and the 409 SETTINGS_LOCKED
            env-lock behaviour are untouched.
  - write — a submitted value equal to the mask means "unchanged" and the key is
            dropped from the patch, so a form echoing what it read cannot
            overwrite the stored secret with the mask's literal text. Scoped to
            secret keys, so a plain setting whose value genuinely is eight
            bullets still writes verbatim. PUT's own response is redacted the
            same way — it carries resolved values too, including cascade entries
            the caller never submitted.

SettingsService still decrypts. materialiseRow(), get(), getNamespace(),
snapshotOf() and createClient() keep handing real plaintext to in-process
consumers, because the mail/sms/storage/auth plugins read their credentials
through exactly that path; a test pins that round-trip so this cannot be
"fixed" one layer down.

New public API: SETTINGS_SECRET_MASK, redactSecretValues, dropEchoedSecretMasks
and SettingsService.secretKeysOf(namespace) — the last one so the boundary reads
the SAME encrypted-key set setMany consults, which is what stops the two sides
drifting into "encrypted on write, cleartext on read".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMcwKmYDRFjtfT8jCRRXwB
@vercel

vercel Bot commented Aug 11, 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 11, 2026 5:21am

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
@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.

@huangyiirene
huangyiirene marked this pull request as ready for review August 11, 2026 05:32
@huangyiirene
huangyiirene added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 23bc6e1 Aug 11, 2026
26 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7522-settings-redact-encrypted-read branch August 11, 2026 05:48
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…easurement in platform-core (objectstack-ai#7573, objectstack-ai#7569) (objectstack-ai#7636)

* docs(qa): state the positive secret contract and de-flake the chart measurement (objectstack-ai#7573, objectstack-ai#7569)

Two checklist-item corrections in `platform-core.json`, one file, nothing else.

`settings-hub-roundtrip` (objectstack-ai#7573): the secret clause only stated a NEGATIVE —
"GET /api/settings never returns the plaintext". Masking, omitting the value
and dropping the key all satisfy that, and two of the three are regressions
the console renders "configured vs not configured" from. The clauses now
mirror the ten route-level pins PR objectstack-ai#7554 added in `settings-routes.test.ts`:
set → the mask, unset → null (presence-preserving), masking across
`values.<key>.value` AND every `cascadeChain` entry, `source`/`locked`/
`lockedReason` unchanged, both specifier flavours — plus the distinct
echoed-mask-PUT-is-a-no-op round-trip clause and the guard that the
in-process read path is deliberately NOT masked.

`nav-surfaces-render` (objectstack-ai#7569): a null chart `boundingBox` read once at a
fixed instant measures the recharts ResponsiveContainer layout window, not
the product (null only at t=1.5s, non-null on all 19 later samples, 5 real
charts on each of 3 fresh loads). The clause now requires a bounded
poll-until-non-null and the steps tell the runner to re-measure before
recording a red. The harness half — replacing `waitForTimeout(1500)` +
a single `boundingBox()` read in the pinned spec — is recorded in
`knownGaps`; it lives outside this PR's file surface, so objectstack-ai#7569 stays open.

* test(showcase-smoke): poll the chart box against a bounded deadline instead of a fixed 1500 ms wait (objectstack-ai#7569)

The harness half of objectstack-ai#7569, on the PM ruling that extended this claim's file
surface to the spec file after the premise check found the fixed wait was
never under `docs/qa/platform-checklist/`.

`page.waitForTimeout(1500)` followed by a single `boundingBox()` read
measured the recharts `ResponsiveContainer` initial-layout window, not the
product: across 3 fresh loads each drawing 5 real charts, polling every
400 ms returned null ONLY at t = 1.5 s — exactly where the suite measured —
and non-null on all 19 later samples; the same surfaces in isolation were
4/4 green. Both go, per the objectstack-ai#7472 / objectstack-ai#7409 shape: loop the predicate against
a deadline derived from the case's own budget, never sleep-then-read-once.
A bigger fixed number is not the fix.

Three properties kept deliberately:

- the last non-null box is retained, so a chart that IS laid out but
  collapsed still fails on its width (objectstack-ai#2616 D) instead of being reported as
  "no chart SVG" — those are different defects and now read differently;
- `pollUntil` returns its last sample rather than throwing, so a timeout
  fails as the assertion it belongs to, naming the surface;
- the `pageErrors` / placeholder / main-content assertions moved after the
  waits, so the error-collection window is as long as the render actually
  took rather than a fixed slice of it.

`nav-surfaces-render` is reconciled in the same commit: its knownGaps entry
recording this harness gap is removed now that the gap is closed, and its
revision-3 history entry covers both halves.

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ommits that decided them (objectstack-ai#20836)

Part of objectstack-ai#20596
Clause-②: no

## What changed

This is the fifteenth stage of the `domain:services` lane of the
dead-citation sweep. It covers
`packages/services/service-settings/src/**` and nothing else. By the
seat's claim (`5908460751`), it is the largest package left in the lane.
Later stages cover the other packages, so this PR says `Part of` and the
card stays open.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), by the method of stages 1 to 14 (the latest is PR
objectstack-ai#20816, landed as `73155fedc`). That is **11 sites on 11 lines in 9
files, covering 4 numbers**:

- 4 census sites (every census site this package has at the base);
- 7 sites in test comments, which the census defers. One of their
numbers, `objectstack-ai#11318`, stands only in test files here; it was read on its
own and answers 404.

Each rewritten line now cites the commit in this repository that decided
what the line describes, and says in its own words what was decided: **4
distinct commit shas**. No ADR records any of the four decisions (see
the per-number table), so ruling C's commit rung applies. No number was
dropped.

Only comments changed. Every touched source file keeps its line count
(11 lines out, 11 in, over 9 files), so no line citation into these
files moves. All 11 changed lines carried a dead citation. No code token
moves (see the guard below).

**No citation number is added.** The only tracker number on an added
line is the live `objectstack-ai#10251`, once, in
`settings-prebind-read-warning.test.ts:17`. It already stood on that
line, and it now sits beside the sha as the convenience link ruling C
allows: 「(commit 1ec36b7, PR objectstack-ai#10251)」. `1ec36b730` is that pull
request's squash commit.

2 dead sites are left on purpose: a test title and a test assertion
message (see the list below).

One more file: a `patch` changeset for `@objectstack/service-settings`,
because the rewritten prose ships (see Changeset below).

## Census: `service-settings`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count below is its `allocated-but-absent` findings under
`packages/services/service-settings/`. Each run counts as a reading only
because its board frontier equals the newest issue or pull-request
number, read by a separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
service-settings sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `73155fedc`, run 2026-09-30T09:39:38Z to 09:43:17Z |
enumerated, 187 pages, frontier objectstack-ai#20830 (newest objectstack-ai#20830 before and after)
| 752 | **4** | 4 | 4 | 3 |
| after | head `ac05607d6`, run 10:02:17Z to 10:06:00Z | enumerated, 187
pages, frontier objectstack-ai#20834 (newest objectstack-ai#20834 before and after) | 748 | **0** |
0 | 0 | 0 |

The whole-repo drop is 4, exactly this diff's census sites. The
`resolves` tally is 33,134 in both runs, and `resolves-as-pull-request`
(1,985) and `cross-repo-unjudged` (1,018) did not move either. Neither
run was truncated or discarded: both enumerations read 187 pages at the
newest frontier. The seat's census counted 4 here at `6bff748b`, and the
base agrees: `6bff748b` is an ancestor of the base, and no commit
between them touches this package's `src`.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `namesThisRepository`
over every `.ts` file under `service-settings/src` (64 files). It takes
its verdicts from the before census's own board reading rather than from
a second enumeration: a number is dead when that census reported it
`allocated-but-absent`, and alive when the gate's own census-scope
extraction judged it and the census did not report it. 10 numbers are
covered by neither, because they stand only in test files, or as the
second number of an `#A/#B` pair. Each was read on its own through the
read-only tools. 1 answers 404 (`objectstack-ai#11318`, on the issue and the
pull-request endpoint alike); 6 answer as issues; 3 answer as pull
requests (`objectstack-ai#7554`, `objectstack-ai#10251`, `objectstack-ai#5133`). The probe's control: the known
issue `objectstack-ai#11352` answers 404 on the pull-request endpoint.

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `73155fedc` | 534 | **13** | 4 | 7 | 0 | 2 |
| after, `ac05607d6` | 523 | **2** | 0 | 0 | 0 | 2 |

Its src-comment column equals the census's 4, which is the control on
the second instrument. The 519 live citations and 2 cross-repo citations
are the same in both readings, and the drop of 11 citations is exactly
the rewritten sites. A third, raw reading (every `#` followed by 2 to 6
digits, whatever surrounds it) finds 547 occurrences before and 536
after, the same drop of 11. The 13 tokens beyond the gate's grammar are
the same before and after, and none is dead (see Acceptance notes).

## Per-number table

Sites and files count every dead occurrence in scope at the base
(comments and strings, tests included). `rewritten / left` counts the
sites rewritten and the sites left. Each anchor was read in its message
and diff, not only its subject.

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#13279` | 4/4 | 4/0 | `6a180e42d` (PR objectstack-ai#13475): `resolveAuthzContext`
raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) when a
permission-store read throws, instead of answering an outage as a caller
with zero capabilities, and each production transport's fail-closed
`catch` re-raises that brand. The settings plugin's
`verifiedContextFromRequest` is one of them. Its message names `objectstack-ai#13279`
4 times and its diff 54 times; `git blame` puts
`settings-service-plugin.ts:307` in it, and the other three lines were
written by `ac9376a74` (PR objectstack-ai#16580), a descendant, which describes that
re-raise. Stage 6's anchor, reused by the storage, datasource and
analytics stages |
| `objectstack-ai#10159` | 3/2 | 3/0 | `1ec36b730` (PR objectstack-ai#10251): a settings write
issued before the engine is bound is refused with
`SETTINGS_ENGINE_NOT_BOUND` (503). Its message states that every read in
any state is unchanged, which is the "left reads open" all three lines
describe. The message does not name `objectstack-ai#10159`, but its own diff does,
once, in its changeset ("refused loudly instead of resolving
successfully while nothing reaches `sys_setting` (objectstack-ai#10159)"), and
`settings-prebind-read-warning.test.ts:17` already paired the two
numbers. `git blame` puts the three lines in `a24b7fa4d` (PR objectstack-ai#11044),
the later read-half fix, a descendant. New to the sweep |
| `objectstack-ai#17062` | 3/2 | 2/1 | `50b6f17d4` (PR objectstack-ai#17071): adds the package-local
route-ledger conformance guard beside the dogfood live-mount parity
gate, and updates the ledger header that had said such a guard was
deliberately omitted. Its message does not name `objectstack-ai#17062`; its diff does,
on 3 added lines, which are the three sites here (`git blame` puts all
three in it). New to the sweep |
| `objectstack-ai#11318` | 3/1 | 2/1 | `99ccbb9c8` (PR objectstack-ai#11467): the Settings, AI "Test
connection" fallback keeps its mount instruction on all three
real-provider branches and gains the cloud-only boundary read from
`PLATFORM_CAPABILITY_PROVIDERS.ai`. Its own changeset states that "the
embedder hint at the fourth site is deliberately left alone ... and
pinned by a contrast test", which is the fence `:339` describes. Its
message's trailer names `objectstack-ai#11318` as the issue it answers, its diff names
the number 3 times, and `git blame` puts all three lines in it. New to
the sweep |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1 for each of the 4), and all 4 are ancestors of
the base (`merge-base --is-ancestor`, exit 0 for each; reverse leg, base
against each anchor, exit 1 for each; control legs exit 0: stage 1's
landing `422db788a`, and the repository's root commit, which lies deeper
than every anchor; the history is complete, `--is-shallow-repository`
false, 15,193 commits). Each of the 4 numbers answers 404 on the issues
endpoint, which serves pull requests too, read one by one.

No ADR, `scripts/adr-anchors/` file or other `docs/` page records the
decision of any of the 4: `docs/adr` names none of the numbers, and none
of their mechanisms (`AuthzStoreUnavailableError`,
`SETTINGS_ENGINE_NOT_BOUND`, `engineBindPending`, the settings route
ledger, the AI hint's edition boundary).

## Wordings to check

- **Tag swaps in place.** 「[objectstack-ai#13279]」 became 「[commit 6a180e4]」
(`settings-service-plugin.ts:307`); 「(objectstack-ai#13279)」 became 「(commit
6a180e4)」 on 2 lines; 「objectstack-ai#13279's permission-store re-raise」 became
「commit 6a180e4's permission-store re-raise」. These are the forms the
storage and datasource stages used for the same sha.
- **`objectstack-ai#10159`.** 「is why objectstack-ai#10159's fix deliberately left reads open」
became 「is why commit 1ec36b7's write refusal deliberately left reads
open」; 「(objectstack-ai#10159's fix left reads open on purpose)」 became 「(commit
1ec36b7 left reads open on purpose)」; 「(objectstack-ai#10159 / PR objectstack-ai#10251)」 became
「(commit 1ec36b7, PR objectstack-ai#10251)」, with the pull-request number kept as
the convenience link beside its own squash commit.
- **`objectstack-ai#17062`.** 「Two layers, since objectstack-ai#17062.」 became 「Two layers, since
commit 50b6f17.」 The docblock goes on to describe the conformance test
that commit added as the second layer.
- **Two headers keep the antecedent of the prose below them**, the form
stage 14 used:
- `settings-route-ledger.conformance.test.ts:4`: 「Settings route-ledger
conformance (objectstack-ai#17062)」 became 「Settings route-ledger conformance (the
issue behind commit 50b6f17)」, because `:25` of the same docblock says
「(per the issue)」.
- `manifests/ai.manifest.test.ts:277`: 「objectstack-ai#11318 —」 became 「The card
behind commit 99ccbb9:」, because `:288` 「the very claim this card is
about」 and `:329` 「The un-followable form this card retired」 speak of
that card.
- **`ai.manifest.test.ts:339`.** 「deliberately not edited — objectstack-ai#11318
fences this site out by name」 became 「... — commit 99ccbb9 fences this
site out by name」. The commit's own changeset names that site (quoted in
the table).

## The 2 sites left

- **Test strings, 2 sites on 2 lines**, left as stages 1 to 14 left
theirs:
  - `manifests/ai.manifest.test.ts:292`, a `describe` title (`objectstack-ai#11318`);
- `settings-route-ledger.conformance.test.ts:88`, the assertion message
a failing run prints (`objectstack-ai#17062`). It is a string, not a comment, and form
C does not touch strings.
- No operator log string, runtime refusal, quoted maintainer ruling or
generated file in this package carries a dead number.
- **Outside `src`, listed and left, not edited in this stage:**
  - the shipping `README.md` names only the live `objectstack-ai#8026`;
- `vitest.config.ts` names only live numbers (`objectstack-ai#8020`, `objectstack-ai#8030`, `objectstack-ai#8063`,
`objectstack-ai#8104`, `objectstack-ai#10374`);
  - `tsconfig.json` and `package.json` name none;
- the release-owned `CHANGELOG.md` names `objectstack-ai#13279` and `objectstack-ai#10159` on 2
lines, the entries of `6a180e4` and `1ec36b7`, which are this PR's
anchors.

## Mechanical guard: no code token moves

The guard compares, base `73155fedc` against head, over all 9 touched
`.ts` files:

- **Reading 1**, the TypeScript parser's leaf nodes (a `forEachChild`
walk, so comments are trivia and JSDoc nodes are never visited). String
and template literals are therefore read in full.
- **Reading 2**, the full token stream in parser context (a
`getChildren` walk, so punctuation and keywords are included; JSDoc
nodes skipped).

Results:

- Real run at the final head `ac05607d6`: 9,999 base leaf tokens, **0
files with a token change** on either reading (exit 0). The first commit
`ff7ef46f4` gave the same, and no `.ts` path changed after it.
- Comment control in `settings-service.ts` (「deliberately left reads
open.」 to 「deliberately kept reads open.」): 0 files changed, as expected
(exit 0).
- Positive control, a code token renamed in `settings-service-plugin.ts`
(`isAuthzStoreUnavailableError(err)` to
`isAuthzStoreUnavailableErrorX(err)`): DIFFER on the identifier (exit
1).
- Positive control, one digit changed inside the kept test title
`ai.manifest.test.ts:292` (`objectstack-ai#11318` to `objectstack-ai#11319`): DIFFER on the string
literal (exit 1).

Every mutation went through `scripts/ablation-replace.mjs` (wrap mode)
under a shell trap that restores by absolute path, and each landed
(anchor 1 to 0, blob changed). Each restore was proven byte-identical to
the HEAD blob (`1248149a428d`, `a2fac9ad1f0b`, `79c2b40a48a6`), with
`git diff HEAD` empty and a clean tree afterwards.

## Changeset

This change ships bytes, so a `patch` changeset for
`@objectstack/service-settings`
(`.changeset/20596-service-settings-provenance-anchors.md`) is included.
Its body is stages 12 and 13's commit-anchor text, word for word, with
the package name changed.

Measured on the built package (A3), after a full workspace build in
which this package was a cache miss (71 of 71 tasks, 0 cached, at
`ff7ef46f4`, which holds every source-line change): `files[]` is `dist`,
`README.md` and `CHANGELOG.md`, and the package is not private.

- The rewritten `settings-service.ts:685` docblock, on the pre-bind read
reporter, is in all four entries: `dist/index.js`, `dist/index.cjs`,
`dist/index.d.ts` and `dist/index.d.cts` (once each).
- The other three rewrites (`settings-route-ledger.ts:17`,
`settings-routes.ts:72`, `settings-service-plugin.ts:307`) are stripped
by the bundle, and the other seven sit in test files.
- Positive controls, the unchanged line beside each rewrite, land
exactly where their neighbours do: the line before
`settings-service.ts:685` once in each of the four entries, and the
neighbours of the three stripped rewrites 0 everywhere.
- A never-written negative phrase appears nowhere in `dist`, and none of
the four old numbers is left there.

The later commit adds only the changeset.

## Gates (final head `ac05607d6`)

- **Citation judging, as CI runs it:** `pnpm check:issue-citations`
exits 0 (self-test, 114 cases, 8 batteries). `node
scripts/check-issue-citations.mjs` exits 0: 「no issue citations added
against 73155fe (4 file(s) read)」.
- **Doc authoring:** `pnpm check:doc-authoring` exits 0 (the
sibling-package prose-id baseline holds, no growth).
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `ac05607d6` derived 63 commands,
the same 63 as at dispatch.
- Each ran with its exit code captured before any pipe, and all 63 exit
0; none exited 3.
- `--ran`, fed each command with its exit code, reports 63 run, 0 NOT
MEASURED (a derived zero), 0 unrun, and exits 0.
- The full `turbo run build` above ran first under the shared verify
lock, so no gate hit an unbuilt workspace.
- **Roster families the derivation lists outside its commands** (their
rosters sit in directories this diff touches): `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit
0.
- **Tests and typecheck, under the verify lock, at `ac05607d6`:**
- `pnpm --filter @objectstack/service-settings test`: 33 files pass and
584 tests pass, which is every tracked test file under `src/`, the 5
touched ones included.
- `pnpm --filter @objectstack/service-settings typecheck` (`tsc
--noEmit`) exits 0, and `tsc --listFiles` puts all 9 touched files in
the program.
- **Lint, as a proven narrowing:** eslint with inline config disabled,
over the 9 touched `.ts` files, gives 9 files, 0 errors and 0 warnings
(its `--format json` output). All 9 are in eslint's own population (none
reported ignored; `dist/index.js`, the control, reads ignored).
`eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`, as its own lines 327-328 state), so a comment
edit here cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the 10 changed files for control bytes finds none.

## Acceptance notes

- **The gate-invisible spellings, grepped as the claim asked** (objectstack-ai#20636,
including the `clause #N` position). At the base, `#N-word` is on 0
lines. `#A/#B` is on 11 lines (12 second numbers), and every second
number is live: `objectstack-ai#6580`, `objectstack-ai#5094`, `objectstack-ai#11230`, `objectstack-ai#5480`, `objectstack-ai#5932`, `objectstack-ai#6199`
and `objectstack-ai#5204` by the census's own judgement, and `objectstack-ai#5133` read on its own
as a pull request. `option #N`, `clause #N` and URL-spelled links are on
0 lines. So the claim's 0 / 11 / 0 / 0 hold, and nothing dead hides
behind them. The one other raw token beyond the grammar is the colour
literal `'#6366f1'` in `manifests/branding.manifest.ts:32`.
- **「The card」 phrases.** 38 lines in 18 files under this package's
`src` speak of 「the card」 or 「this card」. They carry no number, and
neither instrument sees them. The ones whose antecedent this diff would
have removed are handled above; the rest are unchanged, as in stages 8
to 14.
- **The census instrument did not truncate in this stage.** Both
enumerations read 187 pages at the newest frontier.
- **Anchors the next stages can reuse**, each checked here: `objectstack-ai#10159` →
`1ec36b730`; `objectstack-ai#17062` → `50b6f17d4`; `objectstack-ai#11318` → `99ccbb9c8`; and the
reused `objectstack-ai#13279` → `6a180e42d`.
- **Base.** The branch is on `main` at `73155fedc`. `main` has since
moved two commits (`4b45afaed`, `15b586dcf`). Neither touches
`packages/services/service-settings`,
`scripts/check-issue-citations.mjs`, `.changeset/config.json` or a path
in this diff. `15b586dcf` moves `packages/spec/liveness/**`, a gate
input this comment-only diff cannot interact with. No merge was taken;
the merge queue rebuilds on the merged generation.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] GET /api/settings/:namespace returns encrypted setting values as plaintext — no redaction at the REST read boundary

2 participants