Skip to content

fix(rest): an import row names its column from the error envelope, and the driver-string branch stops calling a declared field unknown - #20905

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20701-import-drop-signal
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20701-import-drop-signal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20701
Clause-②: no

The REST half of the #20701 split, delivered in part. Two of its three items land here. The third, the drop signal on the import row report, cannot be read from the seam this package reads, so it goes back to the seat with the extraction it needs (see "Not in this PR"). #20701 remains open.

What changed

  • An import row names its column from the error envelope. toFailedResult (packages/rest/src/import-runner.ts) read field only off a ValidationError finding. The engine's declared-field door throws INVALID_FIELD / 400 with the column in the envelope's field member and the bare names in fields (strings, not findings). So a row that door refused named no column, on the dry run and on the commit alike, while POST /api/v1/data/:object named it. The row now takes the envelope's field when no finding names one. A finding still wins, as it does for code. DuplicateRecordError carries field too, so a unique-conflict row now names the column the 409 names.
  • The driver-string branch stops calling a declared field unknown. mapDataError's missing-column branch (packages/rest/src/error-response.ts) sees only the driver's text, not the object's field map. The engine refuses an undeclared write key itself before any driver runs, in its own words. So what reaches this branch is usually a field the object DECLARES whose column is missing (schema drift). The sentence now reads "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." code, status, field and object are unchanged. The engine's own "Unknown field" sentence for an undeclared key is untouched, and a pin holds that control.
  • Pins: packages/rest/src/import-row-report-field-20701.test.ts, 9 cases on real ObjectQL + ObjectStackProtocolImplementation + SQLite :memory:, through the public doors.
  • Changeset: @objectstack/rest patch.

Clause-②: no, re-read against this diff. The claim's yes (widening) was written for the drop-signal key, which is not in this diff. field is an existing optional key of ImportRowResultSchema, now filled in more cases. The sentence change moves no key and no accept set. No error code is minted.

Readings

Base ca5408c629 (before) and head 901010d4d8 (after). Real ObjectQL + protocol + SQLite :memory:, non-system caller. The "before" column is from a throwaway probe (never committed); the unique-conflict "before" is ablation leg A below, which restores the old read.

column import dry run import commit POST /api/v1/data/:object
undeclared nope failed INVALID_FIELD, no field → field: 'nope' the same 400 INVALID_FIELD, field: 'nope' (unchanged)
unique value already held not measured (validate does not touch the driver) failed UNIQUE_VIOLATION, no field → field: 'code' 409 UNIQUE_VIOLATION, field: 'code' (unchanged)
declared late, table has no column not measured (validate does not touch the driver) failed SQLITE_ERROR with the driver's text; not re-measured after, and the driver's error carries no field for this PR to read (see Acceptance notes) 400 INVALID_FIELD "Unknown field 'late' on object 'proj'" → "The database table of object 'proj' has no column for field 'late'. ..."
formula doubled ok, created, no drop signal (unchanged) ok, created, no drop signal (unchanged) 201 + droppedFields reason computed
readonly ro ok, created, no drop signal (unchanged) ok, created, no drop signal (unchanged) 201 + droppedFields reason readonly
writable title ok, created ok, created 201

Not in this PR: the drop signal, and why

The card's first item reads: "The import row report carries the drop signal, in the dry run and in the commit alike, read from the engine's droppedFields". This package cannot read it on the dry run, and on the commit's bulk path it cannot place it on a row.

  1. The dry run has no source for it. The dry run asks p.validateData, the ImportProtocolLike seam typed by ValidateDataRequest / ValidateDataResponse from @objectstack/spec/api. ObjectQL.validate reports drops through its onFieldsDropped option. But ObjectStackProtocolImplementation.validateData (packages/metadata-protocol/src/protocol.ts) forwards only mode and context, and ValidateDataResponseSchema has no droppedFields member. Measured at base for a formula key and a readonly key: the response is { object, mode, valid, results, posture }, with nothing about the drop.
  2. The commit's bulk path names no row. Import creates go through insertManyData, whose droppedFields is the batch-level union by design. Its docblock and ObjectQL.insertMany's both say why: the union cannot be resolved back to rows, and a reconstruction at a call site names rows that are not at fault. The per-row writes (createData, updateData) report per row.

So both shapes need work outside packages/rest:

  • A per-row signal needs a per-row channel from the engine's insertMany, plus the protocol and spec halves of the dry run.
  • An import-wide signal needs the dry-run channel, plus a place on sys_import_job for the async path.

Either shape also owes a declaration in the spec's ImportRowResultSchema / ImportResponseSchema. The options, costs and a recommendation are in the report comment on #20701.

Verification (head 901010d4d8)

All runs on this worktree, SQLite :memory:, heavy steps under scripts/pm/os-verify-lock.sh.

  • Pins (import-row-report-field-20701.test.ts) at 901010d4d8: Test Files 1 passed (1), Tests 9 passed (9), and pnpm --filter @objectstack/rest typecheck exit 0 (check:test-typecheck: OK; tsc --listFiles over tsconfig.test.json lists the pin file).
  • Package suite (pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2) at 6eb88d7028, the merge of origin/main: Test Files 238 passed (238), Tests 4658 passed | 103 skipped (4761). The one commit after it removes two as any casts from the pin file, and the pin file and the typecheck were re-run on it (above). The package's one repo-project test does not read the files changed here.
  • Ablation, fix committed first, through scripts/ablation-replace.mjs WRAP mode (the anchor must hit, the blob must move, and the restore is proven as blob == HEAD plus an empty git diff HEAD). The subject is read from packages/rest/src by relative import, so no dist/ is involved.
    • Leg A, import-runner.ts: anchor : e?.field; 1 to 0, blob 60e13a17efec to 6ba5cf4c20cd. The row goes back to reading field off a finding only. Result: 4 failed, 5 passed. The four are the unknown-column cells (insert and upsert), the row-versus-door cell and the unique-conflict cell, each missing field. Restored: blob == HEAD (60e13a17efec), git diff HEAD empty.
    • Leg B, error-response.ts: anchor has no column for field 1 to 0, blob 2db9a02d90f4 to a427b6b19471. The sentence says "Unknown field" again. Result: 1 failed (the drift cell), 7 passed. Restored: blob == HEAD (2db9a02d90f4), git diff HEAD empty.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 901010d4d8 derives 62 commands. Each was run with its exit code recorded before any pipe. --ran reads "62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED". NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, both PREREQUISITE NOT MET (exit 3), because each needs every package's dist/, which is the full build CI runs. The first pass at 6eb88d7028 caught one real red, check:query-options-erasure (test surface 236 to 238, the pin file's two casts); 901010d4d8 fixes it and the gate reads 236 again.
  • Lint, narrowed with proof. eslint's own config lints the three .ts files in this diff, and isPathIgnored is true for the changeset .md, so those three files are the diff's whole lint population. eslint --no-inline-config --format json over them at 901010d4d8: files=3 errors=0 warnings=0. calculateConfigForFile shows parserOptions.project and projectService null on all three, so type-aware linting is off: this diff cannot change eslint's verdict on any file it does not touch.
  • Repro before the change at base ca5408c629: a throwaway probe (never committed) produced the "before" cells in the table above.

Acceptance notes

  • A declared field with no column still fails an import row with the driver's text. The import commit row for late reads code: 'SQLITE_ERROR', error "table proj has no column named late", while the create door answers 400 INVALID_FIELD with the sentence above. The dry run was not measured; validate does not touch the driver, so it cannot see the missing column. This is the same row-versus-door agreement family as rest(import): a column for a formula field passes the dry run, then fails the row at commit with the driver's SQL error, where the create door answers 400 INVALID_FIELD #20701, reached only through schema drift, and it is left for the seat to fold into that card's remaining half. Not filed.
  • The REST row result already carries a key the spec does not declare. ImportRowResult.warnings (packages/rest/src/import-runner.ts) is absent from ImportRowResultSchema in @objectstack/spec/api, so the typed client (client.data.import, typed by the spec's ImportResponse) cannot see it. That matters for where the drop signal is declared. Noted, not filed.
  • Dispatch deviations. The first line is Part of #20701, not Fixes, because the drop signal is not here. The Clause-② line is no, not the claim's yes (widening), because the widening key is not here either.

Generated by Claude Code

…d the driver-string branch stops calling a declared field unknown

toFailedResult read `field` only off a ValidationError finding, so a row the
engine's declared-field door refused (INVALID_FIELD, the column in the
envelope's `field`, bare names in `fields`) named no column on the dry run or
on the commit, while POST /data/:object named it. The row now takes the
envelope's `field` when no finding names one; a finding still wins.

mapDataError's driver-string branch answered a missing column with
"Unknown field", but it only sees the driver's text, and the engine refuses
an undeclared key before any driver runs, so what reaches it is usually a
DECLARED field whose column is missing (schema drift). It now says the table
has no column for the field and names the reconcile step. code, status,
field and object are unchanged.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…409 names; changeset

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…f casting them

The two `as any` casts grew check:query-options-erasure's test surface
236 -> 238; the options are plain EngineQueryOptions.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m 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 5 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object), data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/api/data-flow.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object))
  • content/docs/api/environment-routing.mdx (via data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/api/error-catalog.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object))
  • content/docs/deployment/troubleshooting.mdx (via data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object), data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/permissions/authentication.mdx (via data.find (sdk, the route ledger binds it to GET /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to GET /data/:object))
  • content/docs/protocol/kernel/error-handling.mdx (via structuredCodeAnswer (symbol, a top-level function))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via data.create (sdk, the route ledger binds it to POST /api/v1/data/:object, selected by route anchor /data/:object; the route ledger binds it to POST /data/:object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 33b6e8bece79e70156dc2c1880813f8d88af61d4 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 33b6e8bece79e70156dc2c1880813f8d88af61d4

⚠️ 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 33b6e8bece79e70156dc2c1880813f8d88af61d4 → 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: 901010d4d8a577742867e4f6384c7e231746e76b
Local-runs: none

Inputs read: card #20701 body and all ten comments (governing: 5897601448, 5906762084, 5909519064, 5910777625, 5914220480, 5916019187); #20805 and PR #20834 (merged as b28054654, an ancestor of the merge-base); PR #20905 body, file list (4 files) and the net diff 4edb61449b...901010d4d8 (265 insertions, 9 deletions); the check-runs on the head, polled to convergence. Source premises were read on the merge-base 4edb61449b by git show; nothing was built, run or checked out.

① Derived judgments

(a) toFailedResult reads field off the envelope — right, and within item 2.

  • Same field as the create door, on both halves. The engine's declared-field door stamps err.field = undeclared[0] and err.fields = undeclared (bare strings) (packages/objectql/src/engine.ts:1844-1870). The door's structuredCodeAnswer INVALID_FIELD arm ships that same error.field (packages/rest/src/error-response.ts:1261-1272), and classifyDataError consults the structured arms before anything else (:1295-1296). On the dry run, ObjectQL.validate throws the same envelope from the same function (engine.ts:12251-12256), it propagates out of previewVerdict (import-runner.ts:534-541) into the row's catch and toFailedResult (:878-881). On the commit, the bulk path carries the door's per-row envelope as outcomes[i].error (engine.ts:12852, :13408-13410; protocol.ts:13579-13598 passes outcomes through) into toFailedResult(rowNo, res.error) (import-runner.ts:765); the upsert update path throws through updateData into the same catch. Both halves submit the same coerceRow payload — the bulk flush adds only id (:641), a platform-provisioned key the door tolerates — so undeclared[0] is identical on dry run, commit and door. Pinned by the row-versus-door case in import-row-report-field-20701.test.ts.
  • Unique conflict: true. DuplicateRecordError carries field from uniqueViolationColumn (packages/objectql/src/duplicate-record-error.ts:79-101, index names refused), and the 409 arm reads field off that envelope ("the envelope is the contract", error-response.ts:1085-1088). The row's error sentence already named the column in prose (sanitizeRowError, import-runner.ts:285-288); the structured key now matches. Item 2 as written (5906762084, restated in 5910777625) is "names the field from the error envelope's field member", with no code qualifier, so this is inside item 2, not beyond it.
  • Non-column field: none found. Every field-stamping producer reachable from the runner names a field: the write door (engine.ts:1866), the read doors the upsert match lookup can reach through findData (engine.ts:1719; protocol.ts:9969, 10077, 10152, 10232, 10712, 10920, 11023, 11176, each err.field = first unknown or unmaterializable field name), the duplicate envelope. ValidationError carries fields[] only (record-validator.ts:226-243), so a finding keeps priority; raw driver errors carry no field. The new guard (head !== null && typeof head === 'object') stops a string entry of fields being read as a finding, which the base code did not guard against (it merely got undefined).

(b) The driver-string branch — right; code, status, field, object verified unchanged. The body literal's status: 400, code: 'INVALID_FIELD', ...(field ? { field } : {}), ...(object ? { object } : {}) are untouched in the diff (error-response.ts:1795-1805); only the two error strings move. The new sentence is truthful: it states what the driver said (the table has no such column) and conditions the drift diagnosis on "If the object declares", which is the one case the door "by construction cannot see" (engine.ts:1806-1810). Exposure: nothing new — object was already in the old sentence ("on object 'X'"), field is the same regex capture, and the raw driver text is not interpolated. os migrate is a real command (packages/cli/src/commands/migrate/) and the sibling not-null branch already prescribes it (:1820-1822). An undeclared key is answered exactly as before wherever the door has an opinion: the engine refuses it before any driver and its message "Unknown field 'nope' on object 'proj_20701'" ships verbatim through the structured arm; pinned by the control case. Two residues, neither wrong and neither a verdict mover: (i) the no-capture fallback sentence inside the same branch also changed (reachable only when the capture ends in a dot) and neither the PR body nor the changeset mentions it; (ii) on a registry-less host where the door has no opinion (engine.ts:1817-1835) an undeclared key reaches the driver and now gets the conditional sentence rather than "Unknown field", with code, status, field and object unchanged — the PR's "untouched" claim is about the engine's sentence and is true. No hand-written doc quotes the branch's old sentence; content/docs/protocol/kernel/error-handling.mdx:536 shows the engine door's wording, still true.

(c) Item 1 not delivered — each premise holds on the merge-base.

  • Premise 1 HOLDS: ObjectStackProtocolImplementation.validateData forwards mode and context only, no listener (packages/metadata-protocol/src/protocol.ts:11793-11799), while ObjectQL.validate reports drops only through its onFieldsDropped option (engine.ts:12194-12208, :12346-12358).
  • Premise 2 HOLDS: ValidateDataResponseSchema declares object, mode, valid, results, posture and nothing else (packages/spec/src/api/protocol.zod.ts:2122-2148); validate returns that type, so the engine's own return carries no drop either.
  • Premise 3 HOLDS: insertManyData collects every event into one listener and returns mergeDroppedFieldEvents(dropped) at batch level (protocol.ts:13577-13598, merge at :2277-2310); its docblock says "Read a name here as AT LEAST ONE row dropped this field, never this row dropped it" and "Restoring row precision means giving the engine's drop report a per-row channel ... never a reconstruction at this call site" (:13517-13564); ObjectQL.insertMany's docblock says the same (engine.ts:13470-13483). The runner takes this path whenever the protocol offers insertManyData (import-runner.ts:577, :663), which the real protocol does. Nuance the PR states correctly: the non-bulk seams (createData protocol.ts:11831-11836, updateData) report per call and the runner discards it today (:847, :871), but item 1 says "dry run and commit alike" and the dry run has no channel at all, so the item is undeliverable inside packages/rest.
  • Options as written: A names objectql (InsertManyRowOutcome is objectql's own type, engine.ts:190, so a per-outcome drop is one package), spec (ValidateDataResponseSchema, ImportRowResultSchema — ImportJobResultsSchema at export.zod.ts:429 reuses the row schema, so the async sample follows), metadata-protocol (validateData forwarding, per-outcome droppedFields on insertManyData) and rest: complete. One accuracy nuance: "the async job persists it through its existing results JSON" — sys_import_job.results is a CAPPED sample, failures first (packages/platform-objects/src/audit/sys-import-job.object.ts:71-74), so a drop on an ok row survives the async path only inside the sample; A does not say so. B names spec (ValidateDataResponseSchema, ImportResponseSchema, ImportJobResultsSchema), metadata-protocol (validateData), platform-objects (sys_import_job column plus generated bundles) and rest (sync route rest-server.ts:9122, job worker :9286, results route): complete and accurate. Neither owes a packages/client edit (typed from spec); both leave objectui unmeasured; spec artifact regeneration is routine, not a layer. No choice between A and B is made here.

(d) Changeset (@objectstack/rest patch): accurate, and it claims nothing undelivered. Title follows the existing (#N) convention. Paragraph 1 (INVALID_FIELD rows carry field on dry run and commit; a finding still wins; a unique conflict row names its column "when the database said which column it was, as the 409 does") and paragraph 2 (the drift sentence; "code, status, field and object are unchanged") are each pinned or verified above. The drop signal is not mentioned. Omission only, not a false claim: the fallback-sentence change in (b)(i).

② Semver level

Clause-②: no with patch is right. field is an existing optional key of ImportRowResultSchema (packages/spec/src/api/export.zod.ts:328, z.string().optional()), now filled in more cases; no key is added, no accept set moves, and the 400 body's free-text error is not a keyed surface; no code is minted. The claim's yes (widening) (5914220480) presupposed the drop-signal key, which is not in this diff, and the claim itself told the dev to re-read against the real diff. AGENTS.md: a bug fix in a released package takes patch. check-widening-tells NOT MEASURED (no declared surface covers the four files) is consistent with that reading.

③ Boundary flags

Dev deviations, each answered:

  1. Part of #20701, not a closing keyword — right; item 1 is undelivered and the card must stay open; Part-of PR must not also close its card is green.
  2. Clause-②: no — answered in ②.
  3. Stop line held — the file list is three paths under packages/rest/src plus one .changeset; the net diff against the merge-base confirms it; no governed surface (Governed Surface Queue Guard success).
  4. origin/main merged before opening — the net diff is the four files; fine.
  5. vitest bare -- trap, own recorded process group stopped, nothing cited — within multi-agent §8 (a recorded pid); accepted.
  6. Container restart, everything re-run on the head — accepted; CI on the head is the gate.
  7. check:query-options-erasure red then fixed by typing the options — Lint & Repo Gates success on the head.
  8. Pre-push refusal on a Part of commit line, unpublished commit amended — accepted; ceadff28fd, 8872905c9f, 901010d4d8 carry the model-free trailer pair; the merge commit 6eb88d7028 carries none, which the queue's squash makes moot.
  9. Attribution — the PR body ends with the session-URL footer; no model identifier in title, body, commits or changeset. Accepted.
  10. label-write for the PR assignee only — mirrors the card's assignee; accepted.
  11. Two throwaway probe files created and deleted uncommitted — not in the file list; accepted.
  12. check:dual-build-cjs-loads and check:type-check-debt NOT MEASURED locally — Lint & Repo Gates, Type Check · debt ledger and Build Core are success on the head; accepted.

Dev out_of_scope_findings:

  1. Schema-drift row (declared field, no column, import commit answers the driver's SQLITE_ERROR text) — ESCALATED to the dispatching seat. Triage's 5897601448 does NOT already cover it: that sentence is scoped to "a column the check can judge", and a declared field whose column is missing is exactly what the declared-field door "by construction cannot see" (engine.ts:1806-1810); the split 5906762084 fixed the REST half at three items, none of which is this, and the maintainer's 「20701 同意」 confirmed that scope with "nothing about its scope changes". Class (a) is right (reproducible, probe named, the pin file's docblock records the drift cell). Reach is right but understated: the same runImport serves the async job (rest-server.ts:9122 and :9286), so job row results carry the same text; measured on SQLite only, and Postgres or MySQL arrive with their own dialect text, unmeasured. The dev's "fold it into the remaining half" follows the os-dev playbook's same-family rule (name the family's closing card, no single-point card), but the fold itself is a scope change on a maintainer-confirmed split and is triage's call, not the dev's or this record's. Not a verdict mover: the finding is outside this diff.
  2. ImportRowResult.warnings undeclared on the spec's ImportRowResultSchema — carrier conditionally right; ESCALATED. The finding is accurate: the runner emits warnings on dry-run ok rows (import-runner.ts:838), ImportRowResultSchema declares no such key (export.zod.ts:322-331) and ImportJobResultsSchema reuses it (:429). The named carrier (the drop-signal PR) will edit packages/spec/src/api/export.zod.ts under either option, so it is "the PR that will touch that file"; but under B the row schema is otherwise untouched and declaring warnings there becomes a rider, and a carrier binds only if that child's claim names it. It is a contract-first (spec lane) served-but-undeclared key; "no consumer measured" makes it un-exercised drift, which the playbook keeps off the board. The seat should write it into the drop-signal child's claim as a declared surface (spec plus rest), or file it domain:spec if that child is scoped B.

Check-runs on 901010d4d8 (converged 2026-09-30T17:15:40Z; 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, 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. No governed surface is in the file list; the PR is a draft, per the dispatch.

Implemented-by: claude/issue-20701-import-drop-signal
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 17:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 165c1d4 Sep 30, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20701-import-drop-signal branch September 30, 2026 17:50
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…he create door answers (objectstack-ai#20941)

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

Item 4 of the REST half of objectstack-ai#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 objectstack-ai#20922, which waits on objectstack-ai#20921.
objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#20905 pins
stayed green, as predicted. Restored: `blob == HEAD (e8012ff)`,
`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](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