Skip to content

fix(core): mask credential-class fields on every write response - #21816

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21787-write-path-credential
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21787-write-path-credential

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21787

Clause-②: no

What changed

Credential-class field values are now masked on every write response, as on reads.

  • The engine masks credential-class fields (secret, and password outside the exempt managedBy bucket, ADR-0100) on its read path only. Its write results keep the stored row whole by design, so privileged server-side writers can read back what they wrote. The rule for what an external caller receives therefore sits at the write mouths, the same boundary that already omits internal: true fields.
  • omitInternalFieldsFromWriteResponse (@objectstack/core) is the one helper every generic write mouth already calls: protocol *Data faces, the REST cross-object batch, and the MCP stdio bridge. It now applies the credential mask first (maskCredentialFieldsInWriteResponse, new export) and then omits internal fields, in the same order the engine's read path uses. The mask reads the same isMaskedOnReadFieldType declaration in @objectstack/spec/data that the engine's read mask reads, so the two cannot drift. It never adds a key, so a field already removed by field-level security stays absent.
  • callData's fallback create and update arms (@objectstack/runtime, used when no protocol service is registered) answered without that helper. They now call it too.
  • Unchanged: engine-level write results, and the echoed-mask write guard. A client that saves a masked value back leaves the stored credential as it was. The dogfood test pins this.

Tests

All runs below are from head 91d3806ab5 or from a commit whose tree matches it for the files each run covers.

  • New packages/core/src/utils/internal-write-response.test.ts: the collector, the mask, the order (mask, then omit), the better-auth exemption, and a field removed upstream staying absent. 5/5 pass.
  • The three existing write-mouth tripwires now also check credential-class stored values: a password plaintext and a secret: handle ref on every stored row, in the protocol, REST and MCP suites. Each tripwire enumerates its whole surface, so a new write mouth must register there. Protocol 18/18, REST 14/14, MCP 14/14 (MCP adds a test that the update echo does not return the caller's own credential in clear).
  • New packages/runtime/src/action-execution-calldata-write-response.test.ts: the fallback create and update arms. 3/3 pass.
  • New packages/qa/dogfood/test/write-response-credential-mask.dogfood.test.ts: a real boot with a synthetic object holding one password and one secret field. Before anything else, the test proves the plaintext and the handle ref are really stored. It then checks single create, single update, createMany, updateMany, the per-object batch, the cross-object batch, and the masked-echo round trip. 8/8 pass.
  • Full package suites: core 2225/2225, metadata-protocol 5249 passed and 19 skipped, rest 5074 passed and 327 skipped, mcp 390/390. In runtime, the action-execution*, http-dispatcher.mcp* and domains/mcp files pass (157/157). Neighbouring credential and internal dogfood suites pass (97/97 across 8 files).
  • Typecheck is clean for core, runtime, metadata-protocol, rest, mcp and dogfood. A --listFiles count confirms the new and edited test files are inside the compiled programs.

Ablation, run through scripts/ablation-replace.mjs with a dist preflight. The single call that applies the credential mask inside the shared helper was replaced. @objectstack/core was rebuilt, and the preflight showed the marker present in dist/.

Suite Result with the mask call removed
core 2 red
protocol tripwire 8 red (every write face plus the negative control)
REST tripwire 8 red, including both batch routes
MCP tripwire 3 red
runtime fallback 2 red
dogfood 7 red: every write door, while the arming test stayed green

The restore was proven: blob equal to HEAD, git diff HEAD empty, and the core rebuild showed the marker absent from dist/ with a clean tree. A first ablation attempt used a preflight marker that the pristine build also emits, so its preflight reading was void. That run is not counted; the run above uses a unique marker.

Gates: node scripts/pm/dispatch-gates.mjs --ran reconciles 78 derived families: 77 run with exit 0, 1 NOT MEASURED. check:dual-build-cjs-loads exited 3 with PREREQUISITE NOT MET because unrelated packages had no dist/. check:engine-double-contract asked for the new pinned double to be recorded, and scripts/engine-double-contract.pinned.json is updated.

Declared narrowing, left to CI: the full runtime suite; the workspace type-check lanes; main was not merged back in before opening.

Acceptance notes

  • Placement: the fix is in the shared write-response helper, not the engine's maskSecretFields. That is the boundary the existing ruling set for the sibling internal guarantee: engine write results stay whole for privileged callers, and every external write mouth applies the response rules. Masking inside the engine would also mask results that server-side writers read back.
  • Naming: omitInternalFieldsFromWriteResponse now also masks credentials, so its name describes less than it does. A rename touches every write mouth and every tripwire, so it is left out of this PR. Carrier: none.
  • ADR-0100: the ADR describes the mask on the read path only. Whether it should gain a line recording the write-response half is a maintainer call, because docs/adr/** is governed. This PR does not touch it.
  • Not examined: outbound record surfaces that are not write responses, such as event payloads, were not checked against this rule.

Generated by Claude Code

claude added 6 commits October 5, 2026 03:04
The shared write-response helper every generic write mouth already routes
through now applies the engine's read-path credential mask (secret fields,
and password fields outside the exempt managedBy bucket) before omitting
internal fields, so a write answers what a read of the same row would.
The three write-mouth tripwires (protocol, REST, MCP) now also scan for
stored credential values.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
… responses

test(dogfood): pin write-response credential masking across every write door

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/rest, @objectstack/runtime, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/declarative-endpoints.mdx (via callData (symbol, a top-level function))
  • content/docs/data-modeling/fields.mdx (via managedBy (symbol, a field of interface SchemaWithFields))
  • content/docs/data-modeling/objects.mdx (via managedBy (symbol, a field of interface SchemaWithFields))
  • content/docs/permissions/authentication.mdx (via managedBy (symbol, a field of interface SchemaWithFields))
  • content/docs/permissions/authorization.mdx (via callData (symbol, a top-level function), managedBy (symbol, a field of interface SchemaWithFields))
  • content/docs/permissions/permission-sets.mdx (via managedBy (symbol, a field of interface SchemaWithFields))
  • content/docs/permissions/system-context.mdx (via callData (symbol, a top-level function), handleActionsRequest (symbol, a top-level function), managedBy (symbol, a field of interface SchemaWithFields))
  • content/docs/protocol/kernel/http-protocol.mdx (via callData (symbol, a top-level function))

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

  • content/docs/releases/v12.mdx (via managedBy (symbol, a field of interface SchemaWithFields))
  • content/docs/releases/v16.mdx (via managedBy (symbol, a field of interface SchemaWithFields))
  • content/docs/releases/v17/17-0.mdx (via managedBy (symbol, a field of interface SchemaWithFields))
  • content/docs/releases/v17/17-6.mdx (via managedBy (symbol, a field of interface SchemaWithFields))
  • content/docs/releases/v17/index.mdx (via managedBy (symbol, a field of interface SchemaWithFields))

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/rest/src/rest-server.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 49 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 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c226288acbf94c5a80b51ce7845eca8aec9a2dba — the merge of head 5ec8fa7db16ea79de8ecc7c1a056f15f2afb12d5 into base 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a, 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 c226288acbf94c5a80b51ce7845eca8aec9a2dba && git checkout c226288acbf94c5a80b51ce7845eca8aec9a2dba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a 5ec8fa7db16ea79de8ecc7c1a056f15f2afb12d5 && git checkout -B drift-repro 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a && git merge --no-ff 5ec8fa7db16ea79de8ecc7c1a056f15f2afb12d5

node scripts/docs-audit/affected-docs.mjs --json 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a

⚠️ 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 18c7dfd2e68cd2630420080b49a5f6a62fe60a6a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…rite responses

- callData fallback write arms echo only the receipt when no schema resolves.
- The cross-object batch update arm calls the core write-response helper
  directly instead of an optional protocol method, failing closed with no
  schema.
- The declarative update action result masks its echoed patch and undo
  redo data through the same helper; no schema means no echoed patch.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
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

2 participants