Repository navigation
feat(spec,objectql): declare the masked-on-read field types in spec; collectMaskedReadFields derives (#20141) - #20235
Conversation
…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>
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>
📓 Docs Drift CheckThis PR changes 2 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: 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 levelClause-②: 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 flagsBlocking: none
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: 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-field-types-declared
…origin/main c02fa12 Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…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>
Contract reviewServed-tier: 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: (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 ( (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 (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 (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 levelUnchanged 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 flagsBlocking: none
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: VERDICT: PASS |
Fixes #20141
Clause-②: yes
What
packages/spec/src/data/masked-field-types.tsdeclares, once, which field types are masked on read (ADR-0100). It is re-exported from@objectstack/spec/data, besidesecret-mask:MASKED_ON_READ_FIELD_TYPES: a deep-frozen per-type rule table.secrethasexemptManagedBy: [], so it is masked on every object.passwordhasexemptManagedBy: ['better-auth'].isMaskedOnReadFieldType(fieldType, managedBy): the one reading of that table.FieldTypeReadMask: the rule's type. ItsexemptManagedByis typed againstObjectSchema.managedBy's own enum.The
FieldTypeprose forpassword/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,collectMaskedReadFieldsand its siblingcollectMaskedPasswordFieldsnow askisMaskedOnReadFieldType. They no longer carry their owndef.type === 'secret'/'password'arms or their ownmanagedBy === 'better-auth'test. No behaviour change. The evidence is below.Why a per-type rule plus a predicate (the AI-misuse axis)
passwordon 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.managedBythe 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.FieldType, and exemption values againstObjectSchema.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.managedByis a required argument of the predicate. A call site cannot leave the exemption out by omission. It passesundefinedexplicitly for an object with no bucket.FieldSchema. That would be a new authorable key an author could set or unset per field, not a fact about the type.No new
managedByvalue 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.collectCredentialFieldsandcollectSecretFieldsanswer different questions and are untouched (see Acceptance notes). The first is the aggregate refusal:secretorpassword, unconditionally. The second is the encryption write path.Security floor: masked-on-read answer identical to
mainI ran one table through
main'scollectMaskedReadFields/collectMaskedPasswordFields(base4df101c38, file blob708318ed6) and through this branch's (blob86b21b30f). Each row records both collectors' answers.The rows cover every
FieldType(49 types) plus 10 off-enumtypeinputs. They include non-strings,['secret'],'constructor','toString','__proto__'and case variants. Each type is crossed with 11managedByinputs: key absent, the six declared buckets,undefined,null, the retired'system', and'not-a-bucket'. With one mixed object permanagedByand 5 degenerate schemas, that is 665 rows. sha256 of the JSON:4df101c388a418a2e1b6ed2a6159f5b1e6289d25703df5c0d8eacd3e151084a96bcdc5fd78a418a2e1b6ed2a6159f5b1e6289d25703df5c0d8eacd3e151084a96bcdc5fd7secretarm deleted0cdcbcad7818e65526687c0bd8e6dd09a60bb54a6b3dfbe040ce8652358cc216The 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 sameFieldTypexmanagedBypopulation:packages/spec/src/data/masked-field-types.test.tspins 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 pointedcollectMaskedReadFieldsback at a hard-coded copy that dropssecret:def.type === 'password'and managedBy is not better-auth. The mutation landed (anchor 1 to 0; blob86b21b30fto0533a577a).vitest run src/secret-fields.test.tsthen reported 6 failed | 34 passed:"secret" x (absent): expected false to be trueRestored with
git checkout HEAD: blob equals HEAD86b21b30f,git diff HEADis empty, status is clean. The subject is imported relatively (./secret-fields.js, i.e.src), so nodistsits on the resolution path.Reverse verification (the declaration's types)
Run with
tsc --noEmit -p packages/spec/tsconfig.jsonagainst the mutated file, then restored (blob38b1cadb6,git diff HEADempty):managedByvalue (['better-auth', 'identity']): tsc exit 2, TS2322 atmasked-field-types.ts(90,14)and(98,5).pin_code): tsc exit 2, TS2353 "Object literal may only specify known properties, and 'pin_code' does not exist" at(94,5).ablation-replacerefused it and restored. It was rerun with a disjoint anchor.Tests (at
3d14f9e75, after mergingmain805af4f29)All runs are
--project localwhere the package has projects:@objectstack/spec@objectstack/objectql@objectstack/platform-objects@objectstack/plugin-auditplugin-auditimportscollectMaskedReadFieldsfrom@objectstack/objectql/coreinaudit-writers.ts; this consumer was missing from the dispatch list.Typecheck:
pnpm --filter @objectstack/spec typecheckexit 0, andpnpm --filter @objectstack/objectql typecheckexit 0.--listFilesOnlyconfirms both new or edited test files are in the test tsc programs.Gates (at
3d14f9e75)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 87 commands, and--ranreconciled them: 85 run, all exit 0; 2 NOT MEASURED; 0 unrun.pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debtboth exited 3 (PREREQUISITE NOT MET). Each needs every workspace package'sdist, and only the spec to objectql closure is built here. CI builds the whole tree.packages/spec/api-surface/data.jsonandexport-origins/data.jsonwere regenerated, not hand-edited. They add 3 entries each; everything else is untouched.pnpm lint):eslint --no-inline-config --format jsonover 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.mjsenables no type-aware linting (noparserOptions.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/fieldsshould re-bind, on its own card once a spec release carrying this is installable, toisMaskedOnReadFieldTypefrom@objectstack/spec/data. It should pass the object'smanagedBy.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.tspins that).Acceptance notes
These are not filed.
packages/spec/src/data/object.zod.tswarnGenericPasswordFields(the author-time warning) keeps its ownmanagedBy === '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 fromisMaskedOnReadFieldTypeis a mechanical follow-up for whoever next edits that function.collectMaskedReadFieldsas "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,secretorpasswordunconditionally) 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