feat(objectql): the two transaction-seam refusals publish their error code as a constant - #16326
Conversation
…as a constant `transaction-errors.ts` opens by telling the reader that the errors in it identify themselves by a `code` field rather than by `instanceof`, "for the reason `DriverConnectError` already records: the check has to survive crossing a package boundary, where two copies of this module can exist" -- and offered nothing to import. Following that published instruction meant re-spelling the wire string in the consumer's own package, which acquires a `check:error-code-provenance` stamp site there and is then free to drift from what this engine throws with no compile error to say so. `TRANSACTION_UNSUPPORTED_CODE` and `CROSS_DATASOURCE_TRANSACTION_WRITE_CODE` each hold text byte-identical to the literal they replace: the quoted wire spelling occurs exactly once per code in this file on both sides of the change, so it moved rather than multiplied. Both classes were already exported from the barrel and stay exported; the constants join them there. Neither class is published from the lean `./core` entry, so the batteries-only placement of these two constants adds nothing to #16260's population: class and constant are reachable from exactly the same entry point. Placement differs deliberately from the sibling batch on this card. Each constant and its docblock sit ABOVE the class's own docblock rather than between that docblock and the class. Measured with `tsc --declaration`: two consecutive JSDoc blocks are both emitted against the declaration that follows, so interposing the constant moves the class's documentation onto the CONSTANT in the published `.d.ts` and leaves the class with none. The grouped shape already landed in `registry.ts` avoids the same thing. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com>
…d the barrel Row 1 of this batch, graded and pinned on its own. Five cases: the literal wire string (the byte-identity fence -- deliberately NOT a constant compare, because a pin that reads the constant cannot catch the constant being wrong); the code a real refusal carries, asserted with `name` and the datasource it reports rather than a bare `toThrow()`; barrel reachability by identity; barrel-class and barrel-constant agreement; and a cross-realm copy where `instanceof` is false while the `code` compare holds -- the control without which the file would pass just as happily against the `instanceof` recommendation this module tells readers not to use. This refusal carries no `status` field, so ADR-0112's `code` + `status` minimum reduces here to `code` plus the fields that discriminate it; inventing a `status` would be new published surface and is not what this card converts. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com>
…ts class and the barrel Row 2 of this batch, graded and pinned on its own, in the shape row 1 spells out. Case 2 drives all three write operations: the message embeds the operation by design while the code deliberately does not, so the three messages differ and the three codes are equal -- which is what stops a future per-operation message split taking the code with it. It also pins the four fields a caller reads to apply the remedy the message prescribes (split the unit per datasource), since recognising the refusal is the step that recovery begins with. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com>
Both rows are additive widening of a published surface and nothing is removed, so each is graded `minor` on its own line rather than as one lump. The entry records what a consumer gains, why `code` and not `instanceof`, and that the wire strings are byte-identical to the literals they replace. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. 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 284e9b148375e3902ac25d9c8a922e0fe6c462fa && git checkout 284e9b148375e3902ac25d9c8a922e0fe6c462fa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3e270d4e296368f6600d71fcec9902f3a14c1698 c4e60773b6c4f1751355b56346772f89e96979ea && git checkout -B drift-repro 3e270d4e296368f6600d71fcec9902f3a14c1698 && git merge --no-ff c4e60773b6c4f1751355b56346772f89e96979ea
node scripts/docs-audit/affected-docs.mjs --json 3e270d4e296368f6600d71fcec9902f3a14c1698 |
Contract review (clause ②) — PASS, bound to head
|
Part of #16159
Batch ii of that card's sweep, per the batching ruling recorded on it: the last two mechanical rows of its table, in one file.
TransactionUnsupportedErrorandCrossDatasourceTransactionWriteErroreach publish their ADR-0112codeas an importable constant, matching the shape set by #16283 and #16259.Authored by the
domain:engineexecution seat in sessionsession_01ARYe3yQTQCUFm5qPYNgKaJ, branchclaude/issue-16159-transaction-error-code-constants.⛔ Deliberately NOT in this PR, and untouched:
packages/objectql/src/validation/record-validator.ts(1 row — held untilVALIDATION_FAILEDvsVALIDATION_ERRORis ruled; folding an unruled naming question into a mechanical sweep is what the card's slicing exists to prevent) andcontent/docs/**(#16281 tracks the doc sweep — see the docs census below, which came back non-zero and still owes no edit).check:single-claim-pathsand found itsSINGLE_CLAIM_PATHSlist holds exactly one entry,.objectui-sha; the barrel is not a gate-declared single-writer path, so no gate serialises the two. The only coupling is textual, and this PR's barrel edits sit 6+ lines away from #16308's nearest hunk with no reflow of its lines.Why these two rows
packages/objectql/src/transaction-errors.tsopens by telling its reader that the errors in it "identify themselves by acodefield rather than byinstanceof, for the reasonDriverConnectErroralready records: the check has to survive crossing a package boundary, where two copies of this module can exist" — and offered nothing to import. The barrel repeats the instruction twice more, once per class: "codeis the boundary-crossing identity" and "codecrosses package boundaries". The only way to FOLLOW that published instruction was to re-spell the wire string in the consumer's own package, which acquires acheck:error-code-provenancestamp site there and is then free to drift from what the engine throws with no compile error to say so.⭐ Row 2 is the strongest case on the card so far for the affordance being used rather than merely available:
CrossDatasourceTransactionWriteError's own message prescribes a remedy — "split the work into per-datasource units and have the caller reconcile them explicitly" — which is code a caller writes around this refusal, and therefore code that has to recognise it first.packages/objectqlandpackages/spec, exactly two files in the repo name either code, and neither is a consumer recognising the refusal:packages/runtime/src/dispatcher-error-vocabulary.ts:267UNREGISTERED_CODE_SITES' docblockpackages/runtime/src/error-envelope.conformance.test.ts:330and:332packages/runtimealready declares@objectstack/objectql: workspace:*, so importing was always available and was deliberately declined⇒ The argument for these two rows is the published instruction and the realm split, not a shipped consumer cost. That is a weaker case than batch i's and it is stated as such.
Clause ② — graded per row, from a measurement
Batching changed how many PRs the sweep costs, ⛔ not how each row is graded. Instrument: build at head, swap both changed sources back to their base, rebuild, and compare exported NAME SETS across every declaration file
files[]publishes (⛔ not bytes alone:tsuprenumbers internal aliases and the shared chunk's filename is content-hashed).dist/index.d.tsdist/index.d.mtsdist/core.d.ts/.d.mtsutil-zFBRz_yg.d.ts/.d.mtsUnion over all six published declaration files: 386 ⇒ 388 names, ADDED = the two constants, REMOVED = empty.
transaction-errors.ts—ERR_TRANSACTION_UNSUPPORTEDTRANSACTION_UNSUPPORTED_CODEminortransaction-errors.ts—ERR_CROSS_DATASOURCE_TRANSACTION_WRITECROSS_DATASOURCE_TRANSACTION_WRITE_CODEminorEach row is separable in that measurement rather than lumped: each constant appears by name in the ADDED set of both realms, and REMOVED is empty everywhere.
⭐ Four of the six published declaration files are byte-identical across the ablation, which is the stronger statement a name set cannot make: it forecloses JSDoc and member-signature movement on
coreand on the shared chunk too. Both constants are declared directly inindex.d.ts, not through the shared chunk — the opposite of batch i, where two of three reached the barrel only via the chunk.Ablation hygiene, since these numbers depend on it: implementation committed first; each whole-line anchor proved unique in both directions before anything ran (
HEAD new=1 old=0 · BASE new=0 old=1, per row); mutation proven on disk (each path'sgit hash-objectequal to the BASE blob, plus the anchored counts flipped tonew=0 old=1); each leg rebuilt with the package's owntsupinvoked directly, no turbo cache on the path, and the rebuild proven bydist/index.d.tsmtime moving1788702203060 ⇒ 1788702393524 ⇒ 1788702414656; restore proven byte-exact (git hash-objectequal to the HEAD blob for both paths,git diff HEADempty,git status --porcelainempty ⇒ zero blast radius); and the restored build's six declaration files are byte-identical hashes to the head snapshot, so the base leg was a measurement and not a blind spot.The two inverted gates, measured rather than predicted
Both codes are registered in
ERROR_CODE_LEDGERunder@objectstack/objectql(packages/spec/src/api/error-code-ledger.zod.ts), like #16283's and #16308's rows and unlike #16259's. The prediction that follows was checked on both sides of the change, not assumed:check:error-code-provenanceis NOT neutral.311 ⇒ 313registered-code stamp sites,295 ⇒ 297listed, waivers unchanged at 16,scanned 2176 filesboth sides, exit 0 both sides. Exactly +1 per row: each newconstdefis a stamp site this gate sees, and it passes because the code is listed under this package's own owner key.check:dispatcher-error-vocabularydoes not move. The two runs' output is byte-identical (diff of the full logs, banner excluded: 0 lines) — 66 unregistered code-stamping sites all classified, 2372 non-test source files, 303 registered codes, exit 0 both. That table records UNREGISTERED sites, so a registered code is invisible to it by construction, and no row inpackages/runtime/src/dispatcher-error-vocabulary.tschanges. No cross-package edit is owed for either row.Shape, and one deliberate deviation from batch i
Follows #16283:
export const X_CODE = '…' as const;beside its class, the class field as a barereadonly code = X_CODE;(⛔ noas conston the FIELD —check:dispatcher-error-vocabulary'sclassconstpattern requires the constant name to be followed by;,,or a newline, so the suffix would take the site out of it), re-exported fromindex.tsbeside the class. Each string is byte-identical to the literal it replaces: the quoted wire spelling occurs exactly once per code in the file on both sides — it moved, it did not multiply or mutate.⭐ The deviation: each constant and its docblock sit ABOVE the class's own docblock, not between that docblock and the class. Measured on the real emitter, on this branch, with a firing counterfactual: with the constant interposed (PR #16308's placement),
tsupemitsdeclare class TransactionUnsupportedErrorwith zero lines between it and the constant — the class's own documentation is emitted onto the CONSTANT and the class ships undocumented. With the constant above, both keep their own docblock in the publisheddist/index.d.ts. A standalonetsc --declarationprobe gives the same answer and explains it: two consecutive JSDoc blocks both attach to the declaration that follows them. The grouped shape #16259 landed inregistry.tsalready avoids this. The counterfactual leg was restored byte-exact and the tree rebuilt to adistbyte-identical to the head snapshot.origin/main, so it is reported to the dispatching seat and not filed as a card.⭐ Neither class in this file is published from the lean
./coreentry (control:core.tsnamesDriverConnectError, and names noTransactionsymbol at all), so the batteries-only placement of these constants introduces no asymmetry here and adds nothing to #16260's population — class and constant are reachable from exactly the same entry point. That is a different reading from batch i, where two of the three classes ARE on./core.Verification
transaction-unsupported-code-constant.test.ts,cross-datasource-transaction-write-code-constant.test.ts), five cases each: the literal wire string (the byte-identity fence, deliberately NOT a constant compare — a pin that reads the constant cannot catch the constant being wrong), the code a real refusal carries asserted withnameand the discriminating payload (⛔ never a baretoThrow()), barrel reachability by identity, barrel-class/barrel-constant agreement, and a cross-realm copy whereinstanceofisfalsewhile thecodecompare holds. Row 2's case 2 drives all three write operations, pinning that the three messages differ while the three codes are equal.statusfield, so ADR-0112'scode+statusminimum reduces tocodeplus the discriminating fields; ⛔ inventing astatuswould be new published surface and is not what this card converts.pnpm --filter @objectstack/objectql exec vitest runover those two plus the four existing suites that exercise these refusals — 6 files / 65 tests passed, exit 0.pnpm --filter @objectstack/objectql typecheck— exit 0, includingcheck:test-typecheck: the test-layer debt ledger holds at 44 files / 242 errors / 69 pinned signatures, unchanged. Both new test files are proven inside that program bytsc --noEmit --listFiles -p tsconfig.test.json(1 hit each; firing control 1, negative control 0) and contribute 0 of the 242 — the 242 error lines in the same log are the firing control for that grep.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(5 paths vs merge base1ca95df16), 61 runnable commands, 57 exit 0, each captured redirect-then-read and ⛔ never through a pipe. The four that did not are NOT MEASURED rather than red, and none is about this diff:check:dual-build-cjs-loads,check:published-readme-exportsandcheck:type-check-debtreturned exit 3 PREREQUISITE NOT MET (they read built output), andcheck:react-declaration-parityprinted "MANIFEST is not set — this gate did NOT run" (it needs an objectui build and a browser).check:dual-build-cjs-loadsexit 0 (103 published require entry points across 66 packages, 619 emitted CommonJS files parse);check:published-readme-exportsexit 0 (324 documented symbols);check:type-check-debtexit 0 (12 ledger entries re-measured in 162s, 140 raw errors, none above its recorded number).check:dts-closure's first green was about a population that mostly excluded this tree — exit 0 while sweeping 15 built packages, because only objectql's dependency closure had been built. Re-run after the workspace build: 71 built packages, 163/163 declared declaration files present, exit 0. Quoting the first reading as evidence would have been the vacuous-green shape.pnpm lint(eslint . --no-inline-config) run IN FULL, exit 0 — ⛔ no narrowing claimed. Non-vacuity control: re-running eslint over exactly the four changed/added TypeScript files with--format jsonreports 4 files linted, 0 errors, 0 warnings, and the changeset.mdis in eslint's population too (1 file linted), so the repo-wide green demonstrably covers all five changed files.TransactionUnsupportedError,CrossDatasourceTransactionWriteErrorand both new constant names appear in 0 files undercontent/docs, but both wire codes appear in 2 files each:references/api/contract.mdxandreferences/api/error-code-ledger.mdx, which are AUTO-GENERATED enumerations of the ledger vocabulary. No edit is owed — this diff registers no new code and touches no file underpackages/spec, so the enumerated vocabulary is unchanged. Firing controls on the same corpus:@objectstack/objectql12 files,ERR_READONLY_FIELD_REJECTED3,transaction42; negative control 0. ⛔content/docs/releases/**is untouched.Every reading above was taken at this branch's head.
Generated by Claude Code