Skip to content

fix(rest): an import row for a missing database column answers what the create door answers - #20941

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20701-row-driver-error-door
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20701-row-driver-error-door

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20701
Clause-②: no

Item 4 of the REST half of #20701 (the schema-drift row), as triage's answer 5917724406 scopes it. Item 1, the drop signal on the row report, is not in this PR: it waits on #20922, which waits on #20921. #20701 remains open.

What changed

toFailedResult (packages/rest/src/import-runner.ts) now classifies a write error through mapDataError, the mapper POST /api/v1/data/:object uses, for the same object. When the mapper's verdict is INVALID_FIELD, the row carries the door's code, field and sentence. Every other verdict leaves the row as it was.

So a declared field whose database column is missing (metadata and the physical schema have drifted) now fails the commit row, and the async job's rows, the way the create door fails it: INVALID_FIELD, field set, and the sentence "The database table of object 'X' has no column for field 'f'. If the object declares 'f', its database schema has drifted from the metadata: run 'os migrate' to reconcile." Before, the row carried the driver's code (SQLITE_ERROR), the driver's text and no field. The sync route and the async job worker both run through runImport, so one function covers both.

A field-level finding still wins over the door, as it does for code. An undeclared key reads exactly as before, because the door relays the engine's own INVALID_FIELD envelope in the engine's own words. packages/rest/src/error-response.ts is not edited: mapDataError is exported, side-effect free and callable as it stands.

Repro (SQLite :memory:, real ObjectQL + ObjectStackProtocolImplementation + RestServer routes)

Drift is made the way it happens: the object declares late after the DDL ran, with no re-sync.

surface before (base 67c1b11a20) after (132e93c05b)
POST /api/v1/data/:object with late 400 INVALID_FIELD, field: 'late', the drift sentence unchanged
import commit, insert failed, SQLITE_ERROR, "table proj_probe has no column named late", no field failed, INVALID_FIELD, field: 'late', the door's sentence
import commit, upsert onto an existing row failed, SQLITE_ERROR, "no such column: late", no field failed, INVALID_FIELD, field: 'late', the door's sentence
async job, GET .../import/jobs/:jobId/results failed, SQLITE_ERROR, the driver's text, no field failed, INVALID_FIELD, field: 'late', the door's sentence
import dry run ok, created ok, created (unchanged, see Acceptance notes)
unique conflict row (control) UNIQUE_VIOLATION, field: 'code', the engine's sentence byte-identical
NOT NULL row (control) SQLITE_CONSTRAINT_NOTNULL, "must is required.", no field byte-identical
writable row beside the drifted one (control) ok, created ok, created

Why only the INVALID_FIELD verdict

The claim's controls say a unique-conflict row and a NOT NULL row keep today's answers. The door answers both in other words, so routing the whole row through the mapper would move them:

  • Unique conflict. Row: UNIQUE_VIOLATION, field, and the engine's sentence ("Duplicate record refused on 'X': a unique constraint on 'code' already holds this value. No record was written."). Door: the same code and field, but the curated sentence "A record with this code already exists" (no period), with the engine's sentence moved to developerMessage.
  • NOT NULL. Row: the driver's code (SQLITE_CONSTRAINT_NOTNULL on SQLite), "must is required.", no field. Door: 400 VALIDATION_FAILED with a required finding for must, "must is required" and a drift hint.

These differences are named in the report on #20701. This PR does not change them.

Other dialects (read from source, NOT MEASURED)

The mapper's driver-string branch recognises SQLite has no column named c and no such column: c, MySQL Unknown column 'c' (the pattern is not anchored to in 'field list', so an update or where-clause spelling matches too), and Postgres through matchMissingColumnOfRelation, which matches only the write-path template column "c" of relation "t" does not exist. The Postgres read-path spelling column "c" does not exist is not matched, so an upsert match lookup on a drifted column in Postgres would keep the row's old answer. The mapper is not widened here.

Pins

packages/rest/src/import-row-schema-drift-20701.test.ts, 5 cases through the public doors: the insert commit row, the upsert (update path) commit row and the async job's results row each equal the create door's { code, field, error } and carry none of the driver's words; a writable row beside the drifted one is created (control); the unique-conflict and NOT NULL rows keep their answers (controls). The control pins say "unchanged by this PR", not "right": a card that converges them updates them on purpose.

Verification (head 132e93c05b)

Heavy steps ran under scripts/pm/os-verify-lock.sh.

  • Dependency closure turbo run build --filter='@objectstack/rest^...': 24 tasks successful.
  • Pins plus the neighbouring import suites (the new file, import-row-report-field-20701, import-runner-unique-violation-row, import-runner-error-sanitize, import-job-integration, import-integration, import-dryrun-parity, import-runner-bulk, and the throwaway probe): Test Files 9 passed (9), Tests 160 passed (160).
  • Package suite pnpm --filter @objectstack/rest test: Test Files 243 passed (243), Tests 4858 passed | 114 skipped (4972). The --maxWorkers=2 I passed after a bare -- was dropped by vitest, so this ran with the default worker count; it is still the whole local project. The package's one repo-project test reads none of the changed files.
  • Typecheck pnpm --filter @objectstack/rest typecheck: exit 0, check:test-typecheck: OK; tsc -p tsconfig.test.json --listFilesOnly lists the new pin file.
  • Ablation, with the change committed first, through scripts/ablation-replace.mjs WRAP mode. The subject is read from packages/rest/src by relative import, so no dist/ is involved. Anchor const door = mapDataError(err, objectName).body; replaced by an empty door, 1 to 0; blob e8012ff2402a to bf0b6d366a5c. Result over the new file and the fix(rest): an import row names its column from the error envelope, and the driver-string branch stops calling a declared field unknown #20905 pin file: 3 failed, 11 passed. The three are the insert, upsert and async-job drift cells, each back at SQLITE_ERROR with the driver's text; the controls and the fix(rest): an import row names its column from the error envelope, and the driver-string branch stops calling a declared field unknown #20905 pins stayed green, as predicted. Restored: blob == HEAD (e8012ff2402a), git diff HEAD empty.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 132e93c05b derives 61 commands. Each ran with its exit code recorded before any pipe, and --ran reads "61 derived famil(ies) accounted for — 60 run, 1 NOT-MEASURED". NOT MEASURED: check:dual-build-cjs-loads, PREREQUISITE NOT MET (exit 3), because it needs every package's dist/, which is the full build CI runs. check:dts-closure, check:sourcemap-no-sources-content and check:type-check-debt were re-run after building @objectstack/rest and the six packages the debt re-measure needs, and read 0.
  • Lint, narrowed with proof at 132e93c05b. (1) eslint's own config lints the two .ts files in this diff, and isPathIgnored is true for the changeset .md, so those two files are the diff's whole lint population. (2) eslint --no-inline-config --format json over them: files=2 errors=0 warnings=0. (3) calculateConfigForFile shows parserOptions.project and projectService null on both, so type-aware linting is off and this diff cannot change eslint's verdict on any file it does not touch.
  • Control-byte self-scan over the three changed files: clean.

Acceptance notes

  • The dry run still previews a drifted column as ok. engine.validate reads the metadata, never the table, so the preview promises a row the commit then fails. That is the card's own path ("an import preview promises only rows the commit will write"), and the report names it for the seat. The new pin file does not vouch for the dry-run reading.
  • A sandboxed producer that throws INVALID_FIELD with no field-level finding now gets the door's answer on its row too, which is the business sentence the sandbox carried rather than the QuickJS debug wrapper. Read from source (classifyDataError's sandbox unwrap), NOT MEASURED.
  • Rows whose door verdict is not INVALID_FIELD keep sanitizeRowError. That covers the two controls above, and any driver text sanitizeRowError passes through because it does not read as SQL. The door's answer for those was not measured here.

Generated by Claude Code

… door answers

toFailedResult classifies a write error through mapDataError, the mapper
POST /data/:object uses, and takes the door's INVALID_FIELD verdict (code,
field, sentence) when it gives one. A declared field whose column is missing
now fails the commit row and the async job's rows with INVALID_FIELD naming
the field, not the driver's SQLITE_ERROR and raw text. Unique-conflict and
NOT NULL rows keep their answers.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 10 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json bee75cebe63889f93fa1e6dc358cd8c5f7b9a928.

⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: /data/:object (route, 64 pages)
  • 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 — 15 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 bee75cebe63889f93fa1e6dc358cd8c5f7b9a928 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 bee75cebe63889f93fa1e6dc358cd8c5f7b9a928 → 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: 132e93c05bd736c80a166cff38955b910ec8cdf9
Local-runs: none

Inputs read: card #20701 body and all sixteen comments (governing: 5897601448, 5906762084, 5910777625, 5917724406, 5918402332, 5919618026; context: PR #20905 landed as 165c1d49e3 and its record 5916269679); PR #20941 body, file list (3 files: packages/rest/src/import-runner.ts +38/-4, packages/rest/src/import-row-schema-drift-20701.test.ts new, .changeset/20701-import-row-schema-drift-door-answer.md new) and the net diff 67c1b11a20...132e93c05b (260 insertions, 4 deletions, two commits); the check-runs on the head, polled to convergence. Source premises were read on the merge-base 67c1b11a20 by git show; nothing was built, run, checked out or re-run.

① Derived judgments

(a) toFailedResult asks the create door's own mapper, and adopts only its INVALID_FIELD verdict — right, and it is the same mapper, not a second rule.

  • Same mapper, same argument. The create door is the POST ${dataPath}/:object handler (rest-server.ts:8764-8765 on the merge-base) and its catch answers mapDataError(error, req.params?.object) (:8806); the row now calls mapDataError(err, objectName) with the object being imported. The import derives nothing about the field or the column itself: which arm fires, what field is, and the sentence are all the mapper's. The door.code === 'INVALID_FIELD' test is an adoption gate on the verdict, not a classification, so triage's ⛔ "no second rule inside the import" (5897601448) holds. mapDataError is withDeclaredUserMessage(error, classifyDataError(error, object)) (error-response.ts:680-682) and neither function logs or writes anything (no logger/console call in :1276-1990), so calling it per failed row is side-effect free; ./error-response.js was already imported for isEngineDuplicateRecordEnvelope, so no new import edge.
  • Partial adoption is right for this claim. The claim (5918402332) pins "a unique conflict and a not-null row keep today's answers (controls)", and the dev measured that full adoption moves both: the 409 arm (error-response.ts:1110) REPLACES the engine's sentence with the curated one and ships the engine's as developerMessage, and the NOT NULL branch (:1809-1832) answers VALIDATION_FAILED with a required finding and a hint where the row relays the driver's code. Taking every verdict would have breached the claim's controls; the consequence, two sentence sources inside one row report, is escalated under ③ items 2 and 3.
  • No error that answered a different row today becomes INVALID_FIELD wrongly. The mapper has exactly three ways to say INVALID_FIELD: the structured arm for an envelope with code === 'INVALID_FIELD' that is not sandbox-origin (:1261-1273), the driver-string missing-column branch (:1787-1807), and the sandbox unwrap relaying a declared code through thrownCodeFields (:495-500). Each, read against the merge-base:
    • An undeclared key: the engine's declared-field door throws code: 'INVALID_FIELD', field, fields as bare strings, message Unknown field 'k' on object 'X' (engine.ts:1860-1868). fields[0] is a string, so first is undefined and the door is asked; the structured arm relays String(error.message), the same code and the same field. Before, the row read e.code, e.field and sanitizeRowError(message), which is message.slice(0, 300) for this text (looksLikeSql matches only a statement-leading message, import-runner.ts:237-239). Byte-identical unless the key name is itself around 270 characters; then the row now carries the door's untruncated sentence, which is the door's answer. Pinned as a control on code and field by import-row-report-field-20701.test.ts (in the dev's ablation, those pins stayed green). Right; the dry run reaches the same arm through previewVerdict's throw and reads the same.
    • A validation finding: ValidationError.fields[] holds objects, first is defined, the door is never asked; unchanged. An empty fields[] asks the door, which answers VALIDATION_FAILED, not adopted, so the old rule runs. Right.
    • An error already carrying field: the engine's DuplicateRecordError answers the 409 UNIQUE_VIOLATION arm, not adopted, so the row keeps UNIQUE_VIOLATION, field and the engine's sentence (pinned control). A read door's INVALID_FIELD from the upsert match lookup (findExisting, same object, import-runner.ts:528-546) takes the structured arm and reads as before on code and field. The reference resolver swallows every findData throw (resolveRef's lookup catch, :502), so a REFERENCED object's drifted column never reaches toFailedResult, and the sentence's object name is always the imported object's. Right.
    • Non-SQLite driver text: MySQL Unknown column 'c' (unanchored) and Postgres column "c" of relation "t" does not exist (matchMissingColumnOfRelation, packages/types/src/relation-sub-object.ts:91) classify into the same missing-column arm with field from the capture; that is the same class and the same answer the door gives on those dialects. NOT NULL text in all three dialects goes to VALIDATION_FAILED, a missing relation to OBJECT_NOT_FOUND or the 500 fault, none adopted. Right; unmeasured on Postgres and MySQL, as the PR body says.
    • A sandboxed producer declaring INVALID_FIELD with no finding: SandboxError carries innerMessage, code, fields, status, userMessage and no field (quickjs-runner.ts:1591-1640). Before, the row relayed the QuickJS debug wrapper through sanitizeRowError(e.message); now the sandbox unwrap's body (error: innerMessage, code: 'INVALID_FIELD') is adopted, so the row carries the business sentence, still with no field. That is the door's answer and the direction the card wants; unmeasured by the dev, and not in the changeset. Residue, not a verdict mover.

(b) Both call sites pass the right object, and the dry run is untouched. objectName is the runner's own option (import-runner.ts:424). The flush call (:813) covers every bulk-insert outcome (insertManyData per-row outcomes, createManyData degradation, writeOne); the per-row catch (:929) covers the upsert's update path (updateData, :895), the non-bulk createData (:919), findExisting, coerceRow and previewVerdict. The sync route and the job worker both run runImport, so the async job's rows come through the same two sites, and the results route is pinned. On the dry run previewVerdict asks validateData, which reads metadata and never the table, so a drifted column raises no driver error there and its preview row (ok/created) is unchanged, as the PR states and the pin file's docblock refuses to vouch for; an undeclared key on the dry run reads as before per (a). Right.

(c) Row shape: existing keys only, and the sentence is safe. The adopted literal is row, ok, action, error, code plus a conditional field, every one an existing key of ImportRowResultSchema (packages/spec/src/api/export.zod.ts:322-331); the door's object, hint, userMessage and developerMessage are not copied, so no key is added. String(door.error) is safe: both INVALID_FIELD arms always set a string error. The sentence is fixed text plus a [a-z0-9_]+ capture and the object name, never the driver's raw text; the object name equals the table name (Prime Directive 6) and the old row already relayed table proj_probe has no column named late verbatim (measured by the dev; looksLikeSql does not match it), so nothing is exposed that the old row did not already expose. os migrate is a real command. Right.

(d) Changeset (@objectstack/rest patch): accurate, and it claims nothing undelivered. Every claim is pinned or verified above: the sync route and the async job's rows; INVALID_FIELD, field and the door's sentence; the old driver code and text; "the same mapper as the create door", adopted for INVALID_FIELD only; unique-conflict and NOT NULL rows unchanged; the dry run still previews the row as ok. Two omissions, neither a false claim: the sandbox-producer case in (a), and the undeclared-key sentence no longer being cut at 300 characters. The (#20701) in the title follows the existing changeset convention.

② Semver level

Clause-②: no with patch is right. code, field and error are existing optional keys of ImportRowResultSchema, filled with different values on one class of failed rows; no key is added, no row that failed now succeeds or the reverse, and INVALID_FIELD is a value the row already emits for an undeclared key, so no vocabulary widens. A driver-dialect code such as SQLITE_ERROR was never contract vocabulary (ADR-0112). AGENTS.md: a bug fix in a released package takes patch. The claim's own Clause-② reading (5918402332) says the same and the dev re-read it against the real diff.

③ Boundary flags

Dev deviations, each answered:

  1. vitest dropped --maxWorkers=2 after a bare --; the whole local project still ran. The documented trap, no reading lost; Test Core on the head is the gate. Accepted.
  2. check:dual-build-cjs-loads NOT MEASURED locally (needs every package's dist/). Lint & Repo Gates and Build Core on the head cover it. Accepted.
  3. The NOT NULL control pin asserts the driver's own code SQLITE_CONSTRAINT_NOTNULL as "unchanged by this PR", not as right, and the docblock says a converging card flips it. An honest characterisation pin of the control the claim named; it will be rewritten by whichever card takes ③ item 2. Accepted.
  4. Commit trailers are the model-free pair (Claude-Session plus Co-authored-by: Claude) on both commits 4f650c6ecd and 132e93c05b, as AGENTS.md requires over the harness reminder; no model identifier in the title, body, changeset or code. Accepted.

Dev out_of_scope_findings, each answered or escalated:

  1. Dry-run preview of a drifted column answers ok/created while the commit fails it (class a, measured on both bases) — ESCALATED to the dispatching seat, for triage. It is the card's title family (the Path line, "an import preview promises only rows the commit will write") but not inside the confirmed REST scope: 5906762084 fixed the REST half at three items, 5917724406 folded item 4 as the commit and async rows only, and the maintainer's 「20701 同意」 confirmed that scope. The fix cannot be REST's: the preview's verdict is engine.validate's, which reads metadata and never the physical column set, and the create door has no preview at all, so a REST-side table read would be exactly the second rule triage forbade (⛔ 5897601448, ⛔ 5906762084). The layer is the engine lane (ObjectQL.validate, or the write path's own pre-driver check) consulting the driver's known column set, the way engine: a caller-supplied formula value reaches the driver (SQL fails with its own text, memory stores it) — strip it, report it in droppedFields, and let engine.validate run the same write doors (engine half of #20701) #20805 was cut as a domain:engine child; whether the platform wants the preview to detect drift at all, or leaves drift to os migrate, is triage's or the maintainer's call. The dev's class and reach are right. Not a verdict mover.
  2. A NOT NULL row relays the driver's code where the door answers VALIDATION_FAILED with a required finding (class a, measured) — ESCALATED, as a candidate REST item. Inside the card's goal as triage framed item 4 ("the import row answers what the create door answers"), in the same function, and one adoption-gate widening away: the door's fields[0] is { field, code: 'required' }, which the row's finding-wins rule already renders. But triage's item-4 sentence defines the item by the drift case and the claim named NOT NULL a control, so it was outside this claim and the dev was right to leave it. It also puts a driver-dialect code (SQLITE_CONSTRAINT_NOTNULL; 23502 and ER_BAD_NULL_ERROR on the other dialects) on the wire code, an ADR-0112 vocabulary concern in its own right. Triage decides whether it becomes REST item 5 and the control pin flips with it. Not a verdict mover.
  3. Unique-conflict sentence, wording only. Same code and field at both doors; the row carries the engine's sentence, the door the curated one with the engine's as developerMessage. The unnamed case already deliberately shares the door's wording (UNNAMED_CONFLICT, import-runner.ts:254). "Noted, not filed" is right under Prime Directive 10 (no defect, no contract violation); it rides with item 2 if triage takes that. Not a verdict mover.
  4. Postgres read-path template unmatched (source reading, not measured). Confirmed on the merge-base: matchMissingColumnOfRelation is anchored to column "c" of relation "t" does not exist and its docblock says the read-path column "c" does not exist "does not match, and it does not need to" (relation-sub-object.ts:68-93). The reach is the upsert match lookup on a drifted matchFields column under Postgres, a read the door itself answers the same way, so it is the mapper's pre-existing gap, not this PR's. Without a repro it has no card evidence; the live-Postgres lane exists if the seat wants one. "Noted" is right. Not a verdict mover.

File list: three paths, none a governed surface (Governed Surface Queue Guard success); head repo is the base repo; the PR is a draft, per the dispatch; first line Part of #20701 with no closing keyword (Part-of PR must not also close its card success); Check Changeset success.

Check-runs on 132e93c05b (converged 2026-09-30T21:13:58Z; 34 runs, collapsed to latest-per-name): 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure, 0 in progress. The seven required contexts are all success: Lint & Repo Gates, TypeScript Type Check (rollup, with Type Check · consumer gates, debt ledger, source gates, workspace), Test Core (rollup and shards 1-6), Dogfood Regression Gate (rollup and 1-3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard.

Implemented-by: claude/issue-20701-row-driver-error-door
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 21:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit f80e2a6 Sep 30, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20701-row-driver-error-door branch September 30, 2026 21:38
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…answers what the create door answers (objectstack-ai#20956)

Part of objectstack-ai#20701
Clause-②: no

REST item 5 of objectstack-ai#20701, as triage's answer `5920419144` scopes it and the
claim `5920542737` holds it: the NOT NULL row and the unique-conflict
row's sentence. Item 1 (the drop signal) is not in this PR and remains
open on the card; it waits on objectstack-ai#20922.

## What changed

- **`packages/rest/src/import-runner.ts`.** `toFailedResult` widens item
4's adoption gate (PR objectstack-ai#20941, `f80e2a6dad`) on `mapDataError`'s verdict,
the mapper `POST /api/v1/data/:object` uses, from `INVALID_FIELD` to two
more verdicts. The gate lives in `adoptDoorVerdict`, with the verdict
set `ADOPTED_DOOR_VERDICTS`.
- **A driver's NOT NULL refusal.** The door answers `400
VALIDATION_FAILED` with `fields: [{ field, code: 'required' }]`. The row
now reads `code: 'required'`, `field` and the door's sentence. It used
to relay the dialect code (`SQLITE_CONSTRAINT_NOTNULL`) with no `field`.
No driver dialect's code reaches the wire `code` (ADR-0112).
- **A unique conflict.** `code: 'UNIQUE_VIOLATION'` and `field` are the
same as before. The sentence is now the door's ("A record with this code
already exists"), where it was the engine's (which the door ships as
`developerMessage`).
- The gate is on the door's VERDICT, not on the error. Which arm fired,
the `field` and the sentence are all the mapper's, and the import
derives none of them. A `VALIDATION_FAILED` that carries no finding is
not taken, and a finding on the thrown error still wins over the door,
as before.
- No key is added. The door's `hint`, `object` and `developerMessage`
are not keys of `ImportRowResultSchema`, and they stay off the row.
- **Pins, `packages/rest/src/import-row-schema-drift-20701.test.ts`.**
PR objectstack-ai#20941's two "unchanged by this card" control pins flip on purpose.
The pins cover the commit (insert and upsert) and the async job's
results route. Each asserts the row equals the door's answer, the row's
key set is exactly `row, ok, action, code, field, error`, and the `code`
is no dialect code. The item-4 drift cases and a writable row beside
each failed row stay as controls.
- **`packages/rest/src/error-response.ts`** is not edited. The mapper
could be adopted as it stands.
- **Changesets.** The new
`.changeset/20701-import-row-not-null-and-unique-door-answer.md` is
`@objectstack/rest` `patch`. See the next section for the second
changeset file.

## A pending release note corrected: please confirm

This PR also edits
`.changeset/20701-import-row-schema-drift-door-answer.md`, item 4's
changeset from PR objectstack-ai#20941. That changeset is still pending (it has not
been released). It said: "Rows for a unique conflict or a NOT NULL
failure are unchanged." This PR changes exactly those rows, and both
notes ship in the same release, so the sentence would be false in the
CHANGELOG. This PR removes that one sentence and changes nothing else in
that file.

This is the DELIBERATE CORRECTION class that
`scripts/check-empty-changeset.mjs` names. Its scan exits 1 here on
purpose, and `Check Changeset` stays red until a person confirms the
correction on this PR. `Check Changeset` is not a required context. ⛔
`skip-changeset` does not apply.

## Measured: before and after

Readings are from real ObjectQL, `ObjectStackProtocolImplementation` and
`better-sqlite3` `:memory:`. The object has `code` (`unique: true`) and
`must` (`storage: { notNull: true }`, not `required`). The probe was
throwaway and was never committed. "Before" is `75519e1c0a` and "after"
is `1eada1f134`.

| door or row | before | after |
|:--|:--|:--|
| `POST /data/:object` without `must` | `400 VALIDATION_FAILED`, `fields
[{ must, required }]`, "must is required", `hint` | unchanged |
| `PATCH /data/:object/:id` with `must: null` | the same | unchanged |
| import commit row, insert, without `must` | `code
SQLITE_CONSTRAINT_NOTNULL`, "must is required.", no `field` | `code
required`, `field must`, "must is required" |
| import commit row, upsert with no match, without `must` | the same as
insert | the same as insert |
| async job results row, without `must` | the same as insert | the same
as insert |
| `POST /data/:object`, repeated `code` | `409 UNIQUE_VIOLATION`, `field
code`, "A record with this code already exists", `developerMessage` =
the engine's sentence | unchanged |
| `PATCH /data/:object/:id`, repeated `code` | the same 409 | unchanged
|
| import commit row, insert or upsert-update, repeated `code` |
`UNIQUE_VIOLATION`, `field code`, the engine's sentence "Duplicate
record refused on 'proj_probe': …" | `UNIQUE_VIOLATION`, `field code`,
"A record with this code already exists" |
| async job results row, repeated `code` | the same as the commit | the
same as the commit |
| dry run, both rows | `ok`, `created` | unchanged (not pinned; see the
notes) |

**Why the row's `code` is `required`, not `VALIDATION_FAILED`.** The row
applies the rule that a finding wins over the top-level code (`objectstack-ai#4633`),
and it applies it to the door's finding as it does to the engine's. A
metadata-`required` field the engine refuses already reads `{ field,
code: 'required' }` on the row (`import-integration.test.ts`,
"required-field dry-run fidelity"), while the door answers
`VALIDATION_FAILED` with that same finding. So this is the row's
existing rendering of the door's verdict, not a new one.

## Verification (at `1eada1f134`)

- **Pins and the neighbouring import suites** (drift pins,
`import-row-report-field-20701`, `import-runner-unique-violation-row`,
`import-runner-error-sanitize`, `import-job-integration`,
`import-integration`, `import-dryrun-parity`, `import-runner-bulk`, and
the probe): `Test Files 9 passed (9)`, `Tests 163 passed (163)`.
- **`pnpm --filter @objectstack/rest exec vitest run --project local
--maxWorkers=2`:** `Test Files 244 passed (244)`, `Tests 4873 passed |
114 skipped (4987)`, VERDICT command-exit 0.
- **`pnpm --filter @objectstack/rest typecheck`:** exit 0,
"check:test-typecheck: OK". `tsc -p tsconfig.test.json --listFilesOnly`
lists the pin file (count 1).
- **Ablation.** The fix was committed first. Each mutation went through
`scripts/ablation-replace.mjs` in wrap mode, with the anchor at x1 → x0
and the blob changed. Each was restored to blob == HEAD with an empty
`git diff HEAD`.
- A. `UNIQUE_VIOLATION` removed from the verdict set: `Tests 3 failed |
20 passed (23)`. The three failures are the unique insert, the unique
upsert and the async job case.
- B. `VALIDATION_FAILED` removed: 3 failed. They are the NOT NULL
insert, the NOT NULL upsert and the async job case.
- C. The door's finding no longer wins over its top-level code: 3
failed, on the same NOT NULL cases.
- All three moved in the predicted direction. The drift pins and
`import-runner-unique-violation-row` stayed green in each run.
`import-runner.ts` is imported relatively from `packages/rest/src`, so
no build or dist preflight applies.
- **Lint, narrowed with proof:**
- (1) Population from eslint's own config: `calculateConfigForFile`
lints both `.ts` files, and `isPathIgnored` is true for both changesets.
- (2) `eslint --no-inline-config --format json`: files=2, errors=0,
warnings=0.
- (3) Invariance: `parserOptions.project` and `projectService` are null
on both files, and the active rules are single-file syntactic rules. So
type-aware linting is off, and this diff cannot move eslint's verdict on
any untouched file.
- **Control-byte self-scan** over the 4 changed files: clean.
- **`dispatch-gates --commands`** at `1eada1f134` (merge base
`75519e1c0`) derived 61 commands, and each was run with its exit code
recorded before any pipe. `--ran`: "61 derived famil(ies) accounted for
— 59 run, 2 NOT-MEASURED".
  - 58 exited 0.
- `check-empty-changeset.mjs --base origin/main` exited 1, by design
(see the section above).
- NOT MEASURED: `check:dual-build-cjs-loads` and
`check:type-check-debt`. Reason: PREREQUISITE NOT MET (exit 3). Both
need every package's `dist/`. The one full-build attempt was cut off by
a container restart, and it was not repeated on this shared box. `Build
Core` and `Lint & Repo Gates` cover them on this PR.
- `origin/main` moved to `013f97df93` after the base. None of its three
commits touches `packages/rest`, `objectql`, `types`,
`metadata-protocol`, `driver-sql` or the `20701` changesets.

## Acceptance notes

- **The dry run is unchanged.** Both rows preview as `ok` / `created`.
`engine.validate` reads metadata and does not judge `storage.notNull` or
uniqueness. Triage settled the drift case (`5920419144`, Ask 2), and
this PR adds no preview-side check.
- **The other dialects are read from source only, NOT MEASURED.** The
mapper's not-null branch matches SQLite `NOT NULL constraint failed:
t.c`, Postgres `null value in column "c"` (SQLSTATE `23502`) and MySQL
`Column 'c' cannot be null` (`ER_BAD_NULL_ERROR`). None of the three is
caught earlier by the missing-column limbs. The mapper is not widened.
- **A driver's unique refusal that reaches the row without the engine's
envelope** now reads `UNIQUE_VIOLATION` with the door's `field` and
sentence, where it relayed the dialect code. On the real engine this
path is unreachable: insert, update and the bulk path all envelope the
refusal (measured on the bulk path).
- **A sandboxed producer that declares `UNIQUE_VIOLATION` with no
finding** now carries its business sentence (`innerMessage`) instead of
the QuickJS debug wrapper. That is the door's answer, and it is the same
residue class item 4's record named for `INVALID_FIELD`.
- **A producer that throws `code: 'UNIQUE_VIOLATION'` with a declared
status and its own `field`** leaves through the door's passthrough,
which carries no `field`. The row now matches the door and carries no
`field` either. No producer of that shape reaches the import through the
engine: driver-memory's refusal is enveloped. Noted.
- **`isEngineDuplicateRecordEnvelope` in `toFailedResult`** is now
reached only when the thrown error carries a finding without a code. The
engine's envelope carries none, so for that envelope the door's arm
answers first. It is left in place and is unreachable in practice.
- **The door's NOT NULL `hint` names drift** ("the physical schema has
drifted from metadata. Run 'os migrate'"). For a field that declares
`storage.notNull` on purpose, that advice is wrong. The hint is the
door's text in `error-response.ts` and does not reach the row. Noted,
not changed.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_

Co-authored-by: Claude <noreply@anthropic.com>
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 size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants