Skip to content

feat(spec,objectql): declare the masked-on-read field types in spec; collectMaskedReadFields derives (#20141) - #20235

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20141-masked-field-types-declared
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20141-masked-field-types-declared

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20141
Clause-②: yes

What

packages/spec/src/data/masked-field-types.ts declares, once, which field types are masked on read (ADR-0100). It is re-exported from @objectstack/spec/data, beside secret-mask:

  • MASKED_ON_READ_FIELD_TYPES: a deep-frozen per-type rule table. secret has exemptManagedBy: [], so it is masked on every object. password has exemptManagedBy: ['better-auth'].
  • isMaskedOnReadFieldType(fieldType, managedBy): the one reading of that table.
  • FieldTypeReadMask: the rule's type. Its exemptManagedBy is typed against ObjectSchema.managedBy's own enum.

The FieldType prose for password / secret (field.zod.ts, the enum comments only) now cites the declaration. It used to be the only statement of the fact.

In @objectstack/objectql, collectMaskedReadFields and its sibling collectMaskedPasswordFields now ask isMaskedOnReadFieldType. They no longer carry their own def.type === 'secret' / 'password' arms or their own managedBy === 'better-auth' test. No behaviour change. The evidence is below.

Why a per-type rule plus a predicate (the AI-misuse axis)

  • An exemption cannot be separated from its type. Suppose the shape were a bare type set with a separate exemption map. A reader could take the set and drop the map, which masks password on better-auth objects and breaks login. Or it could take the map without the set. Here the exemption is a field of the rule it qualifies.
  • Exemptions fail closed. The list names the buckets that are NOT masked. Any managedBy the table does not list exempts nothing: undefined, a future bucket, a typo in stored unvalidated metadata. So the field stays masked. An allow-list ("masked when managedBy is ...") would unmask on exactly those inputs.
  • Nothing can be invented. Keys are checked against FieldType, and exemption values against ObjectSchema.managedBy's enum, at compile time. The reverse verification below shows both refusals. The spec test holds the same two memberships at run time against the live schemas.
  • managedBy is a required argument of the predicate. A call site cannot leave the exemption out by omission. It passes undefined explicitly for an object with no bucket.
  • Rejected: a per-field flag on FieldSchema. That would be a new authorable key an author could set or unset per field, not a fact about the type.

No new managedBy value is introduced, and the predicate reads no key the spec does not declare.

The sibling collector

collectMaskedPasswordFields (the password scope of the empty-credential refusal) derives too. It keeps its 'password' arm, because that is its scope by definition, not a copy of the masked set. It asks the predicate for the exemption.

collectCredentialFields and collectSecretFields answer different questions and are untouched (see Acceptance notes). The first is the aggregate refusal: secret or password, unconditionally. The second is the encryption write path.

Security floor: masked-on-read answer identical to main

I ran one table through main's collectMaskedReadFields / collectMaskedPasswordFields (base 4df101c38, file blob 708318ed6) and through this branch's (blob 86b21b30f). Each row records both collectors' answers.

The rows cover every FieldType (49 types) plus 10 off-enum type inputs. They include non-strings, ['secret'], 'constructor', 'toString', '__proto__' and case variants. Each type is crossed with 11 managedBy inputs: key absent, the six declared buckets, undefined, null, the retired 'system', and 'not-a-bucket'. With one mixed object per managedBy and 5 degenerate schemas, that is 665 rows. sha256 of the JSON:

collector masked cells sha256
base 4df101c38 21 8a418a2e1b6ed2a6159f5b1e6289d25703df5c0d8eacd3e151084a96bcdc5fd7
this branch 21 8a418a2e1b6ed2a6159f5b1e6289d25703df5c0d8eacd3e151084a96bcdc5fd7
control: base with the secret arm deleted 10 0cdcbcad7818e65526687c0bd8e6dd09a60bb54a6b3dfbe040ce8652358cc216

The control row shows the harness tells the two apart.

Three in-repo pins were added to packages/objectql/src/secret-fields.test.ts, over the same FieldType x managedBy population:

  • The floor: a literal expected column (secret always, password unless better-auth, nothing else). It does not read the spec, so any edit to the declaration turns it red and has to be read.
  • The agreement: the runtime collectors and the spec predicate give the same answer, cell for cell.
  • Order: a mixed-object pin on order and empty definitions.

packages/spec/src/data/masked-field-types.test.ts pins the table's content, its vocabulary against the live enums, the deep freeze and the predicate over the full grid.

Ablation (committed first, restored and proven)

With node scripts/ablation-replace.mjs, I pointed collectMaskedReadFields back at a hard-coded copy that drops secret: def.type === 'password' and managedBy is not better-auth. The mutation landed (anchor 1 to 0; blob 86b21b30f to 0533a577a).

vitest run src/secret-fields.test.ts then reported 6 failed | 34 passed:

  • the agreement pin: "secret" x (absent): expected false to be true
  • the floor
  • the mixed-object pin
  • three existing secret masking tests

Restored with git checkout HEAD: blob equals HEAD 86b21b30f, git diff HEAD is empty, status is clean. The subject is imported relatively (./secret-fields.js, i.e. src), so no dist sits on the resolution path.

Reverse verification (the declaration's types)

Run with tsc --noEmit -p packages/spec/tsconfig.json against the mutated file, then restored (blob 38b1cadb6, git diff HEAD empty):

  • An invented managedBy value (['better-auth', 'identity']): tsc exit 2, TS2322 at masked-field-types.ts(90,14) and (98,5).
  • An invented key (pin_code): tsc exit 2, TS2353 "Object literal may only specify known properties, and 'pin_code' does not exist" at (94,5).
  • My first attempt at the key leg was a no-op. The replacement contained its own anchor, and ablation-replace refused it and restored. It was rerun with a disjoint anchor.

Tests (at 3d14f9e75, after merging main 805af4f29)

All runs are --project local where the package has projects:

package test files tests result
@objectstack/spec 545 16041 (+2 todo) passed
@objectstack/objectql 318 5766 passed
@objectstack/platform-objects 55 911 passed
@objectstack/plugin-audit 25 363 passed

plugin-audit imports collectMaskedReadFields from @objectstack/objectql/core in audit-writers.ts; this consumer was missing from the dispatch list.

Typecheck: pnpm --filter @objectstack/spec typecheck exit 0, and pnpm --filter @objectstack/objectql typecheck exit 0. --listFilesOnly confirms both new or edited test files are in the test tsc programs.

Gates (at 3d14f9e75)

  • Dispatch gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 87 commands, and --ran reconciled them: 85 run, all exit 0; 2 NOT MEASURED; 0 unrun.
  • NOT MEASURED. pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both exited 3 (PREREQUISITE NOT MET). Each needs every workspace package's dist, and only the spec to objectql closure is built here. CI builds the whole tree.
  • Regenerated artifacts. packages/spec/api-surface/data.json and export-origins/data.json were regenerated, not hand-edited. They add 3 entries each; everything else is untouched.
  • Narrowed lint (not the repo-wide pnpm lint): eslint --no-inline-config --format json over the 6 changed TS files gave 6 files, 0 errors and 0 warnings. eslint's own config reports each file as linted, not ignored. eslint.config.mjs enables no type-aware linting (no parserOptions.project), so this diff cannot move a verdict in an untouched file.

For objectui (no objectui change here)

objectui's MASKED_FIELD_TYPES / isMaskedFieldType() in @object-ui/fields should re-bind, on its own card once a spec release carrying this is installable, to isMaskedOnReadFieldType from @objectstack/spec/data. It should pass the object's managedBy.

If the renderer must enumerate the masked types, Object.keys(MASKED_ON_READ_FIELD_TYPES) is the set. It should still ask the predicate for the exemption rather than restate it.

Whether the renderer honours the better-auth exemption is that card's decision. No shipped better-auth object declares a password-typed field today (platform-objects.test.ts pins that).

Acceptance notes

These are not filed.

  • packages/spec/src/data/object.zod.ts warnGenericPasswordFields (the author-time warning) keeps its own managedBy === 'better-auth' / type === 'password' test. That is a third spelling of the password half of this fact, inside spec. It agrees today and is outside this card's file surface. Deriving it from isMaskedOnReadFieldType is a mechanical follow-up for whoever next edits that function.
  • ADR-0100 section C names collectMaskedReadFields as "the one place" the better-auth exemption lives. After this PR the one place is the spec declaration and the collector derives, which is the same single-ownership decision relocated. The ADR text is Tier H and outside this surface, so a status-line or pointer amendment is the maintainer's call.
  • collectCredentialFields (the aggregate refusal set, secret or password unconditionally) is a separate hand-written type list by design ("is this a credential type?"). If the masked set ever grows, the aggregate refusal will not follow on its own. That drift has not been exercised; no card.

Generated by Claude Code

…collectMaskedReadFields derives

packages/spec/src/data/masked-field-types.ts declares MASKED_ON_READ_FIELD_TYPES
(secret always; password exempt on managedBy 'better-auth'), a deep-frozen
per-type rule table, read through isMaskedOnReadFieldType(fieldType, managedBy).
objectql's collectMaskedReadFields and collectMaskedPasswordFields ask the
predicate instead of carrying their own type arms. Behaviour is unchanged:
a table test pins every FieldType x managedBy cell.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…-on-read declaration

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 10 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/data-modeling/field-types.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/data-modeling/fields.mdx (via managedBy (literal, a string literal in exemptManagedBy))
  • content/docs/data-modeling/objects.mdx (via managedBy (literal, a string literal in exemptManagedBy))
  • content/docs/data-modeling/validation-rules.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/getting-started/quick-reference.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/permissions/authentication.mdx (via managedBy (literal, a string literal in exemptManagedBy))
  • content/docs/permissions/authorization.mdx (via managedBy (literal, a string literal in exemptManagedBy))
  • content/docs/permissions/permission-sets.mdx (via managedBy (literal, a string literal in exemptManagedBy))
  • content/docs/permissions/system-context.mdx (via managedBy (literal, a string literal in exemptManagedBy))
  • content/docs/protocol/objectql/types.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/protocol/objectui/layout-dsl.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/protocol/objectui/widget-contract.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/ui/actions.mdx (via FieldType (symbol, a top-level const object))

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

  • content/docs/releases/v12.mdx (via managedBy (literal, a string literal in exemptManagedBy))
  • content/docs/releases/v16.mdx (via managedBy (literal, a string literal in exemptManagedBy))
  • content/docs/releases/v17/17-0.mdx (via managedBy (literal, a string literal in exemptManagedBy))
  • content/docs/releases/v17/17-1.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/releases/v17/index.mdx (via managedBy (literal, a string literal in exemptManagedBy))

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
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 137 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 ab820016b3e9691870e24a9bbb867336ee5e8f72 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 ab820016b3e9691870e24a9bbb867336ee5e8f72 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3d14f9e7529a54776e58cda879b22124870b3995

Read: card #20141 (body, triage 5831443600, claim 5855274180, dev report 5856093889), PR #20235 (object, body, 9-file list, 4 commits, full diff vs merge base 805af4f, 35 check-runs), head sources (masked-field-types.ts blob 38b1cadb6 + test, field.zod.ts hunk, data/index.ts, secret-fields.ts blob 86b21b30f + test, both generated JSONs, changeset), base secret-fields.ts blob 708318ed6 and its importers (objectql core.ts/engine.ts/index.ts, platform-objects, plugin-audit), ADR-0100, object.zod.ts managedBy + warnGenericPasswordFields, changeset gate headers, main rulesets. Ran: my own 2314-row FieldType x managedBy table through base and head from source (Node type-stripping, no build) plus a discriminating control; in a detached worktree at the head: narrowed tsc on the declaration clean and under three mutations, spec masked-field-types.test.ts, client test suite, objectql secret-fields.test.ts. NOT MEASURED: the red CI shard's own log (its host is policy-denied for this container, so the failing client test is unread); objectui's side; the full spec/objectql suites beyond the two test files; the derived gate families (CI's verdicts used).

① Derived judgments

(a) Security floor, byte-identical: PASS. My table: 78 type inputs (all 49 FieldType members + 29 off-enum: undefined, null, numbers, booleans, '', case and whitespace variants, arrays, objects, 'constructor', 'toString', 'proto', 'hasOwnProperty', 'valueOf', 'prototype', Symbol, bigint, NaN, function) x 29 managedBy inputs (key absent, the six declared buckets, undefined, null, retired 'system', 'not-a-bucket', '', case/whitespace/underscore variants, array, object, 0, 1, true, false, NaN, Symbol, String object, function, bigint) = 2262 single-field cells, plus 29 mixed objects per managedBy (text/secret/password beside null, undefined, typeless, string and number definitions, ackPlaintextMasking, array-typed and case-variant types) and 23 degenerate schemas (null, undefined, {}, fields null/undefined/{}/string/number/true/array/Map/frozen/prototype-inherited, no-type-only, better-auth mixed, managedBy as object/array, 'proto'/'constructor' field names, numeric keys, schema as string/number/array). Both collectors per row, base blob 708318ed6 vs head blob 86b21b30f (head's spec import rewired to the head declaration blob 38b1cadb6; nothing else changed): sha256 5918b958fa7d8c6f06c061207d4c34f3912a935b8756cb3996b9c61aa4561e81 on both sides, 0 rows moved, 94 masked rows each; every grid cell equals the literal column (secret always; password unless exactly 'better-auth'; nothing else) on both sides. Control (base with the secret arm deleted): sha 03ed7f889fd01cd21d404a39597b602a6c63bd3eefdfadab7a6581fd888847d0, 65 rows moved, so the harness discriminates. Order and empty-definition skipping preserved (mixed rows identical, e.g. ['b','c','j','k','n'] / better-auth ['b','j']).

(b) One declaration, truly derived: PASS. In the diff's files the only 'better-auth' literal outside prose is the table entry; collectMaskedReadFields has no def.type arm at all and calls isMaskedOnReadFieldType(def.type, managedBy); collectMaskedPasswordFields keeps def.type === 'password' as its scope and asks the same predicate for the exemption (its former early return on managedBy is gone). The predicate is the one reading (own-key check, then exemptManagedBy.includes). Fail closed, verified over all 29 managedBy inputs: 'secret' is true for every one; 'password' is false only for the exact string 'better-auth'; no non-credential or off-enum type is ever masked.

(c) Contract shape: PASS. Table type Readonly<Partial<Record<FieldType, FieldTypeReadMask>>> with a satisfies clause; exemptManagedBy is readonly NonNullable<ServiceObject['managedBy']>[], and ServiceObject is z.input of ObjectSchemaBase whose managedBy is a six-value z.enum, so the values are a literal union, not string. Narrowed tsc in the worktree: clean exit 0; invented key pin_code gives TS2353 (exit 2); invented bucket 'identity' gives TS2322 at (90,14) and (98,5) (exit 2); a widened string[] is also refused with TS2322 (exit 2); file restored to blob 38b1cadb6, tree clean. Deep-frozen at runtime: Object.isFrozen true on the table, each rule and each exemptManagedBy array (my probe and the spec test's push-throws pin). managedBy is required: signature (fieldType: unknown, managedBy: unknown), so a one-argument call is a TS2554; the unknown typing is right because callers hand it stored, unvalidated metadata and any junk reads as "not exempt", i.e. masked. Required is the right call for an AI author: the omission that hurts is forgetting the exemption on a better-auth object (login breaks), and a required parameter makes that omission a compile error rather than a silent default. Exports: exactly three; FieldTypeReadMask must be exported for the table's declared type to be nameable in the emitted d.ts, the table is the enumeration a renderer may need, the predicate is the reading. Nothing beyond that. Nit, non-blocking: import type { SECRET_MASK } serves only a JSDoc link (erased; lint green).

(d) Surface: PASS. api-surface/data.json gains exactly FieldTypeReadMask (interface), MASKED_ON_READ_FIELD_TYPES (const), isMaskedOnReadFieldType (function); export-origins/data.json gains the same three, each pointing at src/data/masked-field-types.ts; no other line moves in either file. @objectstack/spec/data is the right entry: secret-mask and ServiceObject already live there, objectql's secret-fields.ts already imported SECRET_MASK from it at runtime (so no new package-graph edge), the module has zero runtime imports, and the data entry carries browser export conditions for a renderer. isMaskedOnReadFieldType, the symbol the body names for objectui, is present in api-surface/data.json.

(e) Prose: PASS. One hunk, @@ -44,15 +44,17 @@, inside the FieldType enum; every added or removed line is a // comment; it cites MASKED_ON_READ_FIELD_TYPES in ./masked-field-types, which exists and is exported. Nit, non-blocking: three new comments (spec data/index.ts, the module header, secret-fields.ts) say objectui "derives" from the declaration in the present tense; per the ruling and the card, objectui still owns its interim copy until its own re-bind card lands.

② Semver level

Clause-②: yes on PR body line 2 and in the changeset body, with no widening/narrowing arm (correct: a new declared surface, nothing narrowed). Changeset: @objectstack/spec minor (three new public exports), @objectstack/objectql patch (no new export: index.ts and core.ts are untouched; behaviour identical per (a)). Against the gate headers: check-changeset-no-major's LEVEL axis requires a clause-② PR to grade at least one package whose published source it moves minor or above, satisfied by spec; its launch-window guard forbids major, none declared; check-adr-0087-registration demands a disposition only for a declared breaking change, and none is declared; check-empty-changeset: frontmatter non-empty, no foreign changeset touched. Check Changeset check-run at this head: success. Level: right.

③ Boundary flags

Blocking: none
(None of the dev's three notes blocks this PR: none is in the diff's files or the claim's surface, and none changes what leaves the engine in clear today.)
Non-blocking:

  • warnGenericPasswordFields (packages/spec/src/data/object.zod.ts:2587-2592) keeps managedBy === 'better-auth' plus type === 'password' by hand, one directory from the new declaration, inside the package that now owns the fact. "Noted, not filed" is the wrong disposition: this is code, not prose, and it is exactly the class the card exists to remove; the day exemptManagedBy changes, spec will warn on the wrong objects while masking the right ones. File a domain:spec follow-up (mechanical: derive from isMaskedOnReadFieldType('password', managedBy), keeping the ackPlaintextMasking clause).
  • ADR-0100 section C now reads stale: it names collectMaskedReadFields as the one place the better-auth exemption lives, and after this PR the one place is the spec declaration with the collector deriving. Tier H, the maintainer's hand, so not this PR's surface; but a "where the fact lives" pointer in the governing ADR should be filed as a Tier H amendment card, not left in a PR body.
  • collectCredentialFields (secret or password, unconditional), collectSecretFields (secret only) and EmptyCredentialWriteError.fieldType: 'secret' | 'password' enumerate the same type set by hand, but answer different questions (aggregate refusal, encryption write path, error vocabulary) and carry no exemption, so they are not copies of the masked-on-read fact. "Noted" is acceptable; they sit in the same file the declaration's table test forces an author into when the set grows.

Closing keywords: the body's only closing keyword is "Fixes #20141" (line 1); it closes exactly #20141. The PR is a draft, head repo is the base repo.

CI at this head: 35 check-runs converged: 30 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 2 failure: Test Core (5/6) (step "Run this shard's tests": packages/client pnpm run test exited 1; the log host is policy-denied here, so the failing test is unread) and the required Test Core roll-up ("1 of 6 declared shards published no positive attestation"). Every other required context is green (Build Core, Dogfood Regression Gate, Governed Surface Queue Guard, Lint & Repo Gates, Temporal Conformance, TypeScript Type Check), as are Check Changeset, Spec property liveness and all four Type Check legs. At the same head, with @objectstack/client's dependency closure built in a detached worktree, the client suite passed locally (50 files, 636 tests) and objectql's secret-fields.test.ts passed (40 tests); no client test declares a credential-typed field, so the diff reaches that suite only through the engine's generic read mask, whose answer is unchanged per (a). The shard's red is therefore not reproduced and not attributable to the diff on any evidence I can reach, but it is a red required check at this head.

Implemented-by: claude/issue-20141-masked-field-types-declared
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

VERDICT: FAIL

Sole cause: the required Test Core check is red at this head (shard 5, packages/client, unreproduced locally). Every derived judgment, the semver level and the boundary flags pass; a green re-run of that shard at this same sha needs only a fresh record, no code change.

…sked-on-read declaration

ObjectSchema.create()'s author-time password warning no longer hard-codes
managedBy 'better-auth'; it warns exactly where isMaskedOnReadFieldType
('password', managedBy) says the read mask applies. Behaviour unchanged
(same warn/no-warn per managedBy x field-shape row). A pin in object.test.ts
ties the warning to the declaration. Comments no longer say objectui derives
from the declaration today: it keeps its interim copy until its re-bind card.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5cb765bc7fe8f10af93065acb3963f8bba892c92

Delta over record 5856331212 at 3d14f9e. Read: records 5856331212 (PR), 5856361779 and 5856811412 (#20141); PR object and body, all 7 commits (3 since 3d14f9e), 35 check-runs at 5cb765b; blob ids of the 11 changed files at main c02fa12, 3d14f9e, merge f0a0378, regen 087ce04 and head; full diffs 087ce04..5cb765b and c02fa12..5cb765b; head sources (object.zod.ts import block and warnGenericPasswordFields, object.test.ts pin, masked-field-types.ts, data/index.ts, secret-fields.ts and its test, changeset, both generated JSONs); base warnGenericPasswordFields; ADR-0100 section C. Ran, from git-archive snapshots in my scratchpad with tsx over src (no build; zod 4.6.1 resolved read-only from an installed sibling checkout): a 348-row password-warning table at base and head plus a control; a 1739-row collector table over byte-identical base and head blobs plus a control; the declaration imported alone under plain Node type-stripping; the real object.test.ts and masked-field-types.test.ts under vitest at head and on three mutants. NOT MEASURED: the full spec/objectql suites and the derived gate families (CI's verdicts used); objectui's side; whether main's advance past c02fa12 (remote tip 0d3ec47, not local) touches this PR's files, beyond GitHub reporting mergeable_state clean.

① Derived judgments

(a) What changed since 3d14f9e: PASS. Three commits: f0a0378 (merge of main c02fa12), 087ce04 (regen), 5cb765b (patch round). Merge: git diff --name-only c02fa1276 f0a0378ff lists exactly the PR's 9 files, so every other file (the 87 brought in from main's 89-file delta 805af4f..c02fa12) carries main's blob; each of the 9 PR files carries the old head's blob unchanged (old == merge for all 9: secret-fields.ts 86b21b30f, masked-field-types.ts 38b1cadb6, its test 197142dde, field.zod.ts 1d564b908, data/index.ts 4465a0747, changeset cc1ab9801, secret-fields.test.ts 5b05314dc, JSONs b48a7dc4e / 455d16768; main did not touch field.zod.ts in that window, blob 47216c9bb at both ends). The only overlap with main's delta was the two generated JSONs, which the merge kept at the PR-side blobs, so they lacked main's own new entry. Regen 087ce04: +1 line in each JSON, exactly main's likePatternToRegExp entry (identical to git diff 805af4f29 c02fa1276 on those files). At head, git diff c02fa1276 5cb765bc7 on the two JSONs is exactly +3 lines each: FieldTypeReadMask (interface), MASKED_ON_READ_FIELD_TYPES (const), isMaskedOnReadFieldType (function), and the same three in export-origins pointing at src/data/masked-field-types.ts; nothing else moves; JSON blobs 696401f40 / f258cf3e5 are unchanged from 087ce04 to head. Patch 5cb765b (087ce04..5cb765b), 7 files: object.zod.ts 1696bdf48 to 76886c7af, CODE: one import line (import { isMaskedOnReadFieldType } from './masked-field-types';, top import block) and one line in warnGenericPasswordFields (if (managedBy === 'better-auth') return; to if (!isMaskedOnReadFieldType('password', managedBy)) return;); COMMENT: a 3-line note above the import and the docblock rewording; the rest of the function (fields walk, ackPlaintextMasking filter, name fallback, dedupe Set, console.warn text) is byte-unchanged from base. object.test.ts c4ee4371e to ece2371d3, CODE (test): one import and one describe with one it (the agreement pin) inside the existing password-warning describe. masked-field-types.ts 38b1cadb6 to 44fc16f29: COMMENT only (module header). data/index.ts 4465a0747 to 46cf2bf86: COMMENT only. secret-fields.ts 86b21b30f to 4a92749a5: COMMENT only (no non-comment line in the diff). secret-fields.test.ts 5b05314dc to 96e0429ce: COMMENT only (docblock). Changeset cc1ab9801 to e5efe4b67: PROSE, one paragraph added, frontmatter unchanged. Unchanged since 3d14f9e: masked-field-types.test.ts and field.zod.ts. Whole PR vs merge base: 11 files, +439 / -16.

(b) The warning derives, behaviour identical: PASS. My table: 29 managedBy inputs (key absent; the six declared buckets platform, config, system-data, engine-owned, append-only, better-auth; undefined; null; retired 'system'; 'not-a-bucket'; ''; 'Better-Auth', 'BETTER-AUTH', ' better-auth', 'better-auth ', 'better_auth', 'betterauth'; ['better-auth']; an object; 0; 1; true; false; NaN; a String object; 1n; a function; a Symbol) x 12 field shapes (password; password plus ackPlaintextMasking; secret; text; mixed of text, password, secret, acked password, ack false, password with label; fields absent; fields null; fields string; def null; def string; type 'Password'; two passwords) = 348 rows through ObjectSchema.create(), each recording warned, warn count, message text (name normalised), threw plus error head, and a repeat create() with the same name for dedupe. Base (object.zod.ts blob 1696bdf48, c02fa12's) and head (76886c7af): identical JSON, sha256 c7b7ae0525719332649fc275a8b5e79c7723dd021d841f9daaf2ec5c036e7155 on both sides, 348 rows, 84 warned, 0 re-warned on repeat, 0 rows moved. Shape on both sides: the exact string 'better-auth' warns on nothing; every other managedBy input warns on password, mixed (b, e, f) and two-passwords (p1, p2); the 22 non-bucket inputs warn and then throw at parse (the warning runs before ObjectSchemaBase.parse; 63 warn-then-throw rows, identical); secret, text, acked password and the degenerate shapes never warn; one message text; warnCount never above 1. Control (head with the line replaced by if (managedBy === 'config') return;): sha b52f23a89020ba1f8eb7e1f75d78dc75c7a3dae0b0c198dc04fdfb64a886ce2a, 6 rows moved (config loses 3, better-auth gains 3), so the harness discriminates. Exemption source: across the 11 diff files the only non-comment 'better-auth' tied to the read mask is the table entry masked-field-types.ts:101; object.zod.ts's remaining code-line hits are the managedBy enum (1705), its description string (1712), an unrelated error string (546) and the CRUD affordance matrix (3043), a different fact. warnGenericPasswordFields calls isMaskedOnReadFieldType('password', managedBy) and nothing else for the exemption; 'secret' is passed nowhere in it and its field filter is still def.type === 'password', so it is not widened. The pin is real, measured on the real test file under vitest (--project local, -t 'password-field author warning'): head 8 passed. Mutant B (warning === 'config', declaration unchanged; the dev's ablation): 2 failed, the agreement pin (managedBy config: expected false to be true) and the existing better-auth no-warn test. Mutant A (declaration exemption changed to ['config'], warning hard-coded back to === 'better-auth'): 1 failed, the agreement pin (managedBy config: expected true to be false). Mutant C (declaration changed to ['config'], warning derived): the agreement pin stays green by design because the warning followed, and the change is caught by the existing does NOT warn for a password field on a better-auth object (1 failed) and by masked-field-types.test.ts (3 failed: content, freeze-with-content, full grid; 8 passed at head). So a declaration change the warning does not follow turns the new pin red, and a declaration change it does follow cannot pass the suite either. My tsx replica of the pin's assertion agrees with vitest on all four snapshots.

(c) The import: PASS. Forced: the ordered derivation needs the predicate in scope inside object.zod.ts; a same-directory named import is the only sane spelling (the data barrel re-exports object.zod.ts itself; a dynamic import cannot serve a synchronous warning); it adds lines only, and the dev declared the deviation from the literal surface in the report. No cycle: masked-field-types.ts has exactly three imports and all are import type (field.zod, object.zod, secret-mask), so zero runtime imports; proven by copying the file alone into an otherwise empty directory and importing it under plain Node type-stripping with no loader: it loads, exports exactly MASKED_ON_READ_FIELD_TYPES and isMaskedOnReadFieldType, answers password/better-auth false, password/undefined true, secret/better-auth true, text/undefined false, and is deep-frozen. The new edge object.zod.ts to masked-field-types.ts therefore ends at a leaf; the type-only reference back to object.zod.ts is erased. No new package-graph edge (same package; the module already sits in the data barrel), and the public surface did not move with it (api-surface JSON blob unchanged from 087ce04 to head).

(d) Read-mask collectors unchanged: PASS. secret-fields.ts moved no non-comment line between 3d14f9e and head. Re-verified against the base by behaviour, running the byte-identical git blobs 708318ed6 (base) and 4a92749a5 (head) as-is via tsx, with @objectstack/spec/data resolved through a scratch stub re-exporting the head snapshot's secret-mask.ts and masked-field-types.ts: 1739 rows (49 FieldType members plus 19 off-enum type inputs, including 'constructor', 'toString', 'proto', case variants, arrays, objects, NaN, bigint, function, x 25 managedBy inputs; plus 25 mixed objects and 14 degenerate schemas), both collectors per row: sha256 1ec6740c81af17f9df8245e893dcfce2e555ddb734886a16442c89833a1db534 on both, 78 masked rows, 0 rows moved; every grid cell equals the literal column (secret always; password unless exactly 'better-auth'; nothing else). Mixed rows: absent ['b','c','i','j','k','n'] / password half ['c','i','k']; better-auth ['b','j','n'] / []. Control (base with the secret arm deleted): sha 3d0646315d40dddaca45deed2a1a7e53ed6df71d2933edab0c99d595ae5c7ad2, 50 masked rows, 238 diff lines.

(e) Wording: PASS. The four present-tense sentences are gone: data/index.ts, the masked-field-types.ts header, the secret-fields.ts docblock and the secret-fields.test.ts docblock now say objectui keeps its interim MASKED_FIELD_TYPES until its own re-bind card lands. The remaining objectui or renderer mentions in the diff's files are past tense ("two consumers each carried"), conditional ("once the renderer derives"), or advisory ("should derive" in the changeset); the PR body's objectui section says "should re-bind, on its own card" and "no objectui change here".

② Semver level

Unchanged and right: @objectstack/spec minor (three new exports, unchanged since 3d14f9e; the api-surface delta vs main is exactly those three and the new import adds no export), @objectstack/objectql patch (this round's objectql change is comment-only; index.ts and core.ts untouched; collector behaviour identical per (d)). Clause-②: yes on PR body line 2 and in the changeset. The added changeset paragraph is accurate on both claims: the warning reads its exemption from isMaskedOnReadFieldType instead of hard-coding 'better-auth' (one line, verified), and which objects and fields warn is unchanged (348-row table identical). Check Changeset check-run: success at this head. The PR body itself is stale prose, not code: its Tests and Gates sections still cite 3d14f9e and its Acceptance notes still describe warnGenericPasswordFields as keeping its own test; the dev's report carries the replacement text for the seat to write.

③ Boundary flags

Blocking: none
Non-blocking:

  • The earlier record's first item (warnGenericPasswordFields as a hand-written copy of the fact) is resolved by this round; no follow-up card needed.
  • ADR-0100 section C (docs/adr/0100-credential-field-channels.md:109-113) still names collectMaskedReadFields as where the better-auth exemption "lives in exactly one place"; docs/ is untouched at this head. Tier H, the maintainer's pointer amendment, not this PR's surface. Disposition unchanged.
  • collectCredentialFields, collectSecretFields and EmptyCredentialWriteError.fieldType are untouched by the diff (the secret-fields.ts change touches only the two masked collectors and comments); separate questions by design, no exemption carried. Disposition unchanged.
  • PR body stale (Tests, Gates and Acceptance notes cite 3d14f9e and the pre-round warning); a seat write, no code change.
  • main has advanced past the merged c02fa12 (remote tip 0d3ec47); whether it touches this PR's files is NOT MEASURED here (not local, not fetched). GitHub reports mergeable true, mergeable_state clean, so no textual conflict.
  • Nit carried from the earlier record: import type { SECRET_MASK } in masked-field-types.ts serves a JSDoc link only; untouched, lint green per CI.

Closing keywords: the body's only closing keyword is "Fixes #20141" (line 1) and #20141 is the only issue reference in the body; it closes exactly #20141. Draft PR; head repo is the base repo.

CI at this head: 35 check-runs at 5cb765b, all completed: 32 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure. Test Core (5/6): success (completed 2026-09-27T14:12:43Z), and the Test Core roll-up: success; the other shards (1/6) through (6/6) success. Every required context success: Build Core, Dogfood Regression Gate (and its three shards), Governed Surface Queue Guard, Lint & Repo Gates, Temporal Conformance (live PG + MySQL), TypeScript Type Check (and the four Type Check legs: consumer gates, debt ledger, source gates, workspace), Test Core. Also success: Auto Label, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Verify CLI, Flag docs affected by code changes, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, The card this PR closes must claim this branch, filter. mergeable: true; mergeable_state: clean (null / unknown on the first three polls, computed by the fourth).

Implemented-by: claude/issue-20141-masked-field-types-declared
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 15:01
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 08c8484 Sep 27, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20141-masked-field-types-declared branch September 27, 2026 15:22
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 protocol:data size/m tests tooling

Projects

None yet

2 participants