Skip to content

fix(objectql): the duplicate refusal reads in the writer's locale and names a declared composite unique index's fields - #33348

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/area-A2-33213
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/area-A2-33213

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

Fixes #33213

Clause-②: yes (widening)
Metadata-protocol: none

Maintainer quick read

  • What changes: the 409 UNIQUE_VIOLATION sentence is now composed by the engine in the writer's language from the platform operation-message catalog, and a composite unique index names its fields by label when that can be established.
  • Why: a downstream app writing a duplicate of a "Plant + Carbon Grade + Cost Week" unique index under Accept-Language: zh-CN got the English "A record with this value already exists" and could not tell which fields collided.
  • Risk and rollback: the wire shape is unchanged (code, 409 and the meaning of field stay), only the error text moves; the English single-field and no-field sentences are byte-identical. Rollback is a revert of this PR.
  • Read first: packages/objectql/src/duplicate-record-error.ts (declaredCompositeIndex and buildEndUserMessage), packages/types/src/unique-violation.ts (new uniqueViolationColumns; the two existing functions answer as before), packages/spec/src/system/operation-message.ts (three new catalog keys).
  • Two-minute check: pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/engine-duplicate-record-label-sentence.test.ts; expect all green, including the zh-CN composite case asserting 已存在「工厂」、「牌号」、「成本周次」均相同的记录.
  • Needed from you: nothing; the seat lands it.

What changes

  • The sentence is composed where the locale and the schema are. DuplicateRecordError (@objectstack/objectql) now renders its end-user sentence from the platform operation-message catalog (renderOperationMessage, the catalog DELETE_RESTRICTED already renders from) in the write's message context, so a deployment's errors.duplicate_record* translation overrides it. It is carried in-process as endUserMessage, and the DUPLICATE_RECORD arm of classifyDataError (@objectstack/types) ships it as the 409 body's error. That arm holds neither a locale nor a schema, which is why it composed fixed English before.
  • Three catalog keys, every platform locale (en, zh-CN, ja-JP, es-ES): duplicate_record (names nothing), duplicate_record_field ({{field}}, one label), duplicate_record_fields ({{fields}}, a list spelled by the locale's existing field_label_quoted / field_label_list_separator rules). The English duplicate_record and duplicate_record_field are byte-identical to the fixed text the HTTP doors answered, so an English deployment reads no change for one field or none.
  • A composite unique index is named only on a declared match. uniqueViolationColumns(error) (new, @objectstack/types) reads the composite key's columns from the SQLite and Postgres texts the existing readers already parse, organization part set aside as uniqueViolationField sets it aside. The engine names them only when that set equals the fields of a unique index ('global' or 'organization') the object declares, and the user's sentence names them only when every one has a label (translation, else declared label). Anything short of that names nothing: driver text alone cannot tell a tenancy.tenantField column from an authored one, and a wrong field name is worse than none. uniqueViolationColumn / uniqueViolationField answer exactly as before (the shared reader was generalised over a reduction, not changed).
  • The wire shape does not move. code stays UNIQUE_VIOLATION, the status 409, field keeps its single-column contract (absent for a composite), and no key is added. The composite's labels also enter the engine's message (relayed as developerMessage) and its API names enter the in-process developerMessage.
  • Why not userMessage: that name on a thrown error is the ApiError contract's producer-marked channel, which withDeclaredUserMessage promotes to its own wire key. Whether this refusal joins that channel is a contract decision this fix does not make, so the property is endUserMessage and the wire is unchanged (pinned: not.toHaveProperty('userMessage')).

Tests

Pins added (zh-CN and en, single-field and multi-field, plus the fallbacks):

  • packages/objectql/src/engine-duplicate-record-label-sentence.test.ts — new top-level describe in the existing harness, through the real write doors (insert, update by id) and the real mapDataError: a declared unique: 'organization' index over plant + carbon_grade + cost_week named in en (A record with the same “Plant”, “Carbon Grade”, “Cost Week” already exists) and zh-CN (已存在「工厂」、「牌号」、「成本周次」均相同的记录), the Postgres DETAIL shape, an uncatalogued locale (de-DE) falling back to the en sentence, a deployment errors.duplicate_record_fields override; five controls that must name nothing (no matching declared index, a non-unique match, no indexes, one unlabelled field, a MySQL index-only refusal), each asserting the unnamed sentence in zh-CN and en; a single unlabelled field in zh-CN. The two existing zh-CN single-field pins now expect 已存在相同客户名称的记录 (they pinned the English sentence around a Chinese label — the reported defect).
  • packages/rest/src/rest-duplicate-record-arm.test.ts — a real ObjectQL on real better-sqlite3 with a declared 'global' composite index: the exact 409 body in en and zh-CN, no field, no value.
  • packages/spec/src/system/operation-message.test.ts — the three keys in every locale with exactly their own placeholder, en byte-identity with the previous wire text, zh-CN / base-language / unknown-locale rendering, the errors. override.
  • packages/types/src/unique-violation.test.ts — uniqueViolationColumns on SQLite and Postgres composites, no overlap with uniqueViolationField, and undefined for index names, expression parts and a NOT NULL refusal.
Runs (heads 330e619 and af12b05)
  • At 330e619: pnpm --filter @objectstack/types test — Test Files 39 passed (39), Tests 907 passed (907).
  • pnpm --filter @objectstack/spec test — Test Files 839 passed, 1 skipped (840); Tests 26834 passed, 1 skipped, 1 todo.
  • pnpm --filter @objectstack/objectql test — 594 passed, 1 failed: federated-injected-column-readers.test.ts flagged an organization_id literal the first cut used in the matcher. Fixed in af12b05 by comparing the declared index as written (no literal); re-run at af12b05: that file plus the three duplicate-record files, Test Files 4 passed (4), Tests 119 passed (119). The full objectql suite was not re-run at af12b05 (lock contention); CI runs it.
  • pnpm --filter @objectstack/rest exec vitest run on rest-duplicate-record-arm, import-runner-unique-violation-row, import-row-report-field-20701, error-response-structured-arm-door-parity, error-response-generic-passthrough-object-parity at af12b05 — Test Files 5 passed (5), Tests 135 passed (135).
  • pnpm --filter @objectstack/types typecheck, @objectstack/objectql typecheck, @objectstack/spec typecheck — exit 0 at 330e619 (the af12b05 objectql source change passed its tsup DTS build in the rest closure build).
Ablation (one-shot, not kept)

node scripts/ablation-replace.mjs --file packages/objectql/src/duplicate-record-error.ts --anchor "this.endUserMessage = buildEndUserMessage(...);" --replacement "this.endUserMessage = '';" under the verify lock, running engine-duplicate-record-label-sentence.test.ts (the subject resolves from src, so no rebuild leg): mutation landed (anchor 1 -> 0, blob 55cc4074 -> be7c0e75), Tests 24 failed / 37 passed (61) — red, the expected direction; restored (blob == HEAD 55cc4074, git diff HEAD empty).

Acceptance notes

  • MySQL still names no field, single or composite. Its duplicate-entry text names an index (for key 'idx_…'), never columns; mapping a physical index name to a declared index is the wider schema-lookup contract uniqueViolationColumn's doc already defers. The sentence is still in the writer's locale there.
  • A legacy hand-written global index that lists organization_id itself (fields: ['organization_id', 'a', 'b']) matches nothing and gets the unnamed (localized) sentence: the declared side is compared as written. The spec already steers new code to unique: 'organization', which matches.
  • A bare unique: true index (refused at parse since protocol 18, converted on stored rows) is not treated as unique by the matcher. The existing real-driver pin in rest-duplicate-record-arm.test.ts §2 registers such an object straight into the registry and keeps its unnamed sentence for that reason; the new real-driver pin declares 'global'.
  • The raw driver unique arm further down classifyDataError (a unique error that did not pass through the engine envelope) keeps its fixed English sentence: that module holds no locale. Every engine write door wraps first, so the reported path is the envelope.
  • The envelope without endUserMessage fallback in the arm keeps the previous sentence for an envelope built by an older engine; every envelope this engine builds carries the sentence.
  • Locale ladder: like every renderOperationMessage refusal, this takes the one ExecutionContext.locale value, so a request locale the catalog lacks reads en, not the workspace default (the limitation ApiErrorSchema.userMessage "Language" already records for the operation refusals).

Verification

  • Local gate families: node scripts/pm/dispatch-gates.mjs --commands derived 87 families for this diff at af12b05; all 87 run, 80 exit 0. Seven exited 3 = PREREQUISITE NOT MET, read as NOT MEASURED (they read built output that predates the source or needs a deeper clone): check-plugin-teardown-shape --self-test (shallow clone), spec check:api-surface, check:dual-source-exports, check:entry-nameability, check:exported-any (spec dist predates a test-file edit), check:dual-build-cjs-loads, check:type-check-debt (closure not built). CI runs all of them on a clean build. --ran reconciliation: 87 derived, 87 run, 0 unrun.
  • No control bytes in any changed file (grep -naP over the diff: no match).
  • The branch sits on e540baf; origin/main has since moved 21 commits, none touching a file of this diff (git merge-tree clean).

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4cz2MxDkQYCC4U8indM

…nd name a declared composite unique index's fields

Claude-Session: https://claude.ai/code/session_016WV4cz2MxDkQYCC4U8indM
Co-authored-by: Claude <noreply@anthropic.com>
…on a real driver; changeset

Claude-Session: https://claude.ai/code/session_016WV4cz2MxDkQYCC4U8indM
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:system tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 14, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cz2MxDkQYCC4U8indM

Request: review · area A2 · head af12b05 · Fixes #33213 · touches packages/spec/src (contract-review tier)

@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/spec, @objectstack/types, touching 31 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via DuplicateRecordError (symbol, a top-level class))
  • content/docs/protocol/kernel/error-handling.mdx (via DuplicateRecordError (symbol, a top-level class), structuredCodeAnswer (symbol, a top-level function))
What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 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 — 176 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 6538ba48fc4590402cd6280cf83a8902f6de83d3 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6538ba48fc4590402cd6280cf83a8902f6de83d3

⚠️ 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 6538ba48fc4590402cd6280cf83a8902f6de83d3 → 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-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head af12b05 · 2026-11-14T17:16:40Z

review#3.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: af12b055c225e462e708648317284d819b2b1405
Local-runs: none
Scope: packages/spec/src/system/operation-message.ts, packages/types/src/unique-violation.ts, packages/types/src/data-error-classification.ts, packages/objectql/src/duplicate-record-error.ts + packages/rest/src/rest-duplicate-record-arm.test.ts, packages/objectql/src/engine-duplicate-record-label-sentence.test.ts, packages/spec/src/system/operation-message.test.ts, packages/types/src/unique-violation.test.ts

① Derived judgments

  • Spec (hit). BUILTIN_OPERATION_MESSAGES gains duplicate_record, duplicate_record_field and duplicate_record_fields in every platform locale (the every-locale-defines-every-key test). The English strings are byte-identical to the fixed text the classification answered before for a single field or none. These are additive catalog keys and a deployment can override them through errors.duplicate_record*. Nothing an author writes moves, so Metadata-protocol: none and @objectstack/spec: patch hold.
  • Types. uniqueViolationColumns(error) returns columns only where every part is a plain column and at least two remain after the organization part is set aside, so it never overlaps uniqueViolationField. The jsdoc's "driver text is evidence, not a verdict" is honoured by the one caller.
  • Engine. A composite key is named only when the reported columns match, as a set and with no duplicates, a unique: 'global' | 'organization' index the object declares, and every field has a real label (a translation or the declared label, never the API name). Otherwise it uses the nameless sentence. That keeps "a wrong field name is worse than none". endUserMessage is rendered through renderOperationMessage with the write context's locale/translate, the same ladder DELETE_RESTRICTED uses. Values never reach the sentence, which is pinned (not.toContain(VALUE)).
  • Wire. mapDataError relays endUserMessage as error when present, else the old English. code, status 409 and the field key (one determinable column only) are unchanged. On a real better-sqlite3 driver, a composite conflict answers the labelled sentence in en and zh-CN with no field key.
  • Clause-②: yes (widening) holds: new exports and catalog keys, and no input is refused. The error text now follows the writer's language. That changes the language of a human-facing field, not a contract member.
  • nit N1: in English the composite list reads the same “Tenant”, “Email” (shared field_label_list_separator). "“Tenant” and “Email”" would read better. That's a catalog wording choice for later.

② Semver level

patch on @objectstack/spec, @objectstack/types and @objectstack/objectql with Metadata-protocol: none (AGENTS.md: none takes patch). Correct.

③ Boundary flags

④ Design judgment

#33213 asks that a duplicate refusal read in the writer's language and say which combination collided. Composing at the producer (the engine, which holds the schema and the locale), and relaying in the classification (which holds neither), is the same split ruling C on #32440 set for refusal sentences. Matching driver columns against a declared index before naming anything turns driver text into a verdict only when the schema confirms it. Three keys chosen by how much can be named honestly is the smallest catalog that covers the cases. The design holds.

Implemented-by: claude/area-A2-33213
Reviewed-by: session_016WV4d1PGptBW3QRQNmy5e6

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cz2MxDkQYCC4U8indM

ACCEPT · area A2 · Fixes #33213 · head 0b701b1 · 2026-11-14T18:21Z

  • Reviewer: review#3 contract record (CONTRACT_REVIEW_TIER, spec catalog keys) PASS @af12b055 (comment 6881384349). 0b701b1 is a merge of origin/main only (no file touched by both sides); re-run on it: spec operation-message.test.ts 43/43, types 40 files / 918 tests, objectql duplicate-record + federated-injected-column files 4 / 119 — all green.
  • Declaration: Clause-②: yes (widening), Metadata-protocol: none, patch.
  • Governed: check-governed-merges --pr 33348 → NOT governed; +623 / -40.
  • Checks @0b701b15: 67 total = 56 success + 11 expected skips (check-expected-skips OK), 0 failed, 0 pending.
  • K = 10 (< 15).
  • Follow-up nit (review N1): English composite list reads the same “Tenant”, “Email”; catalog wording choice for later.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 18:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit 09da8c7 Nov 14, 2026
68 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/area-A2-33213 branch November 14, 2026 19:49
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:system size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant