feat(objectql): publish ReadonlyFieldRejectedError's code as an importable constant - #16283
Conversation
…table constant `content/docs/kernel/contracts/data-engine.mdx` already tells readers, of this exact refusal, to "Catch it by `code`, not `instanceof`, and read `drops` for the per-reason breakdown" — and the code was an inline string literal with nothing to import. The published guidance and the published surface disagreed, in the documentation's own words; that is why this row of #16159's table was converted ahead of the latent ones. `READONLY_FIELD_REJECTED_CODE` is a new export from `@objectstack/objectql`, re-exported from `index.ts` and deliberately not from the lean `core.ts` entry, matching the `*_CODE` constants already in this package. Dropping the `ERR_` prefix from the constant's NAME follows the two `ERR_`-prefixed precedents here (`HOOK_TARGET_REBIND_ERROR_CODE`, `SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`). The string is byte-identical to the literal it replaces: the quoted spelling occurs exactly once in the file on both sides of the change — it moved, it did not multiply or mutate. Two gate readings, measured rather than assumed, and the second contradicts what was expected of it: - `check:error-code-provenance` is NOT neutral here. Unlike the codes converted on #16259, `ERR_READONLY_FIELD_REJECTED` IS in `ERROR_CODE_LEDGER` (the gate skips unregistered codes), so the new constant is a `constdef` stamp site the gate sees: 310 -> 311 sites, 294 -> 295 listed, waivers unchanged at 16, exit 0 both sides. It passes because the code is listed under this package's own owner key. - `check:dispatcher-error-vocabulary` does NOT move, and no row in `packages/runtime/src/dispatcher-error-vocabulary.ts` changes. That table records UNREGISTERED code sites (`return !registered.has(value)`), and this code is registered, so the site is invisible to it by construction — there is no row for it to move. 66/66 classified either side, exit 0. No cross-package edit is owed for this row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 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 374de57600a498081185133f335eb5269bb0b7dc && git checkout 374de57600a498081185133f335eb5269bb0b7dc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f0d5b64aedbe056ffa222d4992c301a44dcc0640 f874c9817813051f6b43feb9cbcc56e808bfe921 && git checkout -B drift-repro f0d5b64aedbe056ffa222d4992c301a44dcc0640 && git merge --no-ff f874c9817813051f6b43feb9cbcc56e808bfe921
node scripts/docs-audit/affected-docs.mjs --json f0d5b64aedbe056ffa222d4992c301a44dcc0640
|
Contract review (clause ②) — PASS on content · landable on green CI — PR #16283 at head
|
Part of #16159
ERR_READONLY_FIELD_REJECTEDinpackages/objectql/src/readonly-strict-errors.ts, slice 2 of the sweep.Part of, not a closing keyword: six rows remain in three files after this one (driver-connect-errors.ts2,transaction-errors.ts2,summary-errors.ts1, plus the contestedrecord-validator.ts1), so #16159 stays open. Each row is its own published-surface widening and therefore its own Clause-② decision and its ownminorchangeset, exactly as the card argued and as slice 1 (#16259) landed it.Why this row went first
content/docs/kernel/contracts/data-engine.mdx:361tells customers, of this exact refusal:…and the code was an inline string literal with nothing to import. The published guidance and the published surface disagreed, in the documentation's own words. Every other row on the card is latent; this one is shipped. Following that instruction meant re-spelling
'ERR_READONLY_FIELD_REJECTED'in the consumer's own package — which acquires acheck:error-code-provenancestamp site there and can then drift from what the engine throws with no compile error to say so.READONLY_FIELD_REJECTED_CODEis the one new export, on the shape #16259 set:export const X = '…' as const, the class field reading from it, re-exported fromindex.tsand deliberately not from the leancore.ts. Dropping theERR_prefix from the CONSTANT's name follows this package's twoERR_-prefixed precedents (HOOK_TARGET_REBIND_ERROR_CODE='ERR_HOOK_TARGET_REBIND',SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE='ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED').The wire string does not change — proven twice
In source, the QUOTED spelling occurs exactly once on both sides: it moved, it did not multiply or mutate (base 1 → head 1; the class-field line goes 1 → 0 and the
constdefline 0 → 1). In the built declaration, the class's member is byte-identical either side:That one matters: the class field lost its
as constsuffix (required —check:dispatcher-error-vocabulary'sclassconstpattern refuses anas consttail), and the literal type survived anyway. A name-set comparison cannot see a member, so this was measured directly.Two gate readings, and the second contradicts what was expected of it
check:error-code-provenanceis NOT neutral here, unlike on feat(objectql): publish the registry's three conflict error codes as importable constants #16259. It skips codes absent fromERROR_CODE_LEDGER;ERR_READONLY_FIELD_REJECTEDis registered (packages/spec/src/api/error-code-ledger.zod.ts:543, under@objectstack/objectql), so the new constant is aconstdefstamp site the gate sees. Base → head: 310 → 311 sites, 294 → 295 listed, waivers unchanged at 16, exit 0 both sides. It passes because the code is listed under this package's own owner key.check:dispatcher-error-vocabularydoes not move, and no cross-package edit is owed. The dispatch expected a row inpackages/runtime/src/dispatcher-error-vocabulary.tsto goclassfield→classconst, as three did on feat(objectql): publish the registry's three conflict error codes as importable constants #16259. There is no such row: that table records unregistered code sites (return !registered.has(value),scripts/check-dispatcher-error-vocabulary.mjs:2566), and this code is registered, so the site is invisible to it by construction. Measured:grep -c ERR_READONLY_FIELD_REJECTEDon that file = 0, against a firing control ofNAMESPACE_CONFLICT= 3 (feat(objectql): publish the registry's three conflict error codes as importable constants #16259's rows) on the same command and file. The gate reads 66/66 classified, exit 0 on both sides.packages/runtimeis untouched by this PR.Published surface — Clause-②
yes, measuredBuilt at head, both edited sources swapped back to
origin/main07a43dc74, rebuilt (proven re-run:dist/index.d.tsmtime moved), and every declaration filefiles: ["dist"]publishes compared — all six, including the content-hashed shared chunk, not the root barrel alone. Compared by exported NAME SET as well as bytes:dist/index.d.tsREADONLY_FIELD_REJECTED_CODEdist/index.d.mtsREADONLY_FIELD_REJECTED_CODEdist/core.d.ts/.d.mtsdist/util-zFBRz_yg.d.ts/.d.mtsThe TypeScript checker's own resolved module exports agree:
index.d.ts251 → 252 (added exactly that one name),core.d.ts79 → 79. Two independent head builds produced identical counts and identical byte sizes, so the base/head delta is a real difference and not build nondeterminism.index.d.ts's whole byte delta is classified: 2 removed lines (the diff header and the old export list) and 46 added (the header, 43 JSDoc lines carried through from source, onedeclare const, and the new export list) — the export list differs by exactly one inserted name. ⇒ additive widening, nothing removed —needs:contract-reviewhung on both carriers,minorchangeset.Tests
New pin
packages/objectql/src/readonly-field-rejected-code-constant.test.ts, 6 cases, proven insidetsconfig.test.json's program by--listFiles(1 hit; firing control 1, negative control 0) and contributing 0 of the 242 ledgered debt errors. Two ablations, committed first, each proving the mutation on disk before measuring and the restore byte-exact after:@objectstack/objectqlfull suite 278 files / 4771 tests pass;typecheckgreen including the test layer. All 57 derived gate families fromscripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackexit 0, each captured redirect-then-read, plus the roster gates whose silence is not clearance.pnpm lint(repo-wideeslint . --no-inline-config) run in full, exit 0 — no narrowing claimed. Honest non-pass:check:type-check-debtfirst returned exit 3 (FATAL ERROR: … heap out of memory— nothing measured, not a finding); re-run at a larger heap it reads 12 entries / 140 raw errors, none above its recorded number. All readings are from commitf874c9817.What this does not do
packages/objectql/src/validation/record-validator.ts(VALIDATION_FAILED) is untouched. Its convergence question withEMPTY_CREDENTIAL_REFUSAL_CODE's'VALIDATION_ERROR'is unruled, and this PR does not answer it.data-engine.mdx's sentence becomes followable, not false, and 0 pages incontent/docsname any*_CODEconstant of this package (firing control@objectstack/objectql= 12 files), so no page goes stale. That the page could now point at the constant is filed as finding: the docs teachcatch it by codefor ReadonlyFieldRejectedError but still do not name the constant #16159 now publishes #16281, not ridden here.ReadonlyFieldRejectedErrorwas already exported from the barrel and stays exported. feat(objectql): publish the registry's three conflict error codes as importable constants #16259 could leave its classes unpublished; that option does not exist here, and un-exporting would be a breaking change well outside this row.🤖 Generated with Claude Code
https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Generated by Claude Code