Skip to content

fix(rest): a NOT NULL refusal's hint leads with the remedy for a declared column and names schema drift only as a condition - #21001

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20963-notnull-hint
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20963-notnull-hint

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20963
Clause-②: no

The hint on a driver's NOT NULL refusal no longer asserts that the schema has drifted. It first says the column requires a value (provide it, or declare the field required), then names drift in one sentence that is conditional on the object declaring neither required nor storage: { notNull: true }. Prose only, in the one branch of the driver-string classifier in packages/rest/src/error-response.ts; the branch's comment gets the same correction (ADR-0113 D1 / D2). The status, code, error, fields and object are unchanged, no key is added, no registry is read, and the import row is untouched (PR #20956 pins its key set).

Repro (hint, same answer on both doors)

Object proj_notnull_hint_20963 declares must: { type: 'text', storage: { notNull: true } } with no required. POST /api/v1/data/:object with must omitted, and PATCH /api/v1/data/:object/:id writing must: null, over a real ObjectQL + SQLite :memory: (the pin's harness). Both answer 400 VALIDATION_FAILED, error: "must is required", fields: [{ field: 'must', code: 'required', message: 'must is required' }], object.

  • Before (origin/main 05be352, the pin run red on it): "If 'must' is optional in your object metadata, the database column is still NOT NULL — the physical schema has drifted from metadata. Run 'os migrate' to reconcile (or reset the dev database)."
  • After: "The database column for 'must' requires a value: provide it, or declare the field required in the object metadata. If the object declares neither required nor storage: { notNull: true } for 'must', the physical schema has drifted from metadata instead: run 'os migrate' to reconcile (or reset the dev database)."

What is pinned

packages/rest/src/data-not-null-hint-20963.test.ts, three tests, by kind and not by wording:

  • the fixture is the ADR-0113 posture: the engine's own insert is refused by the database for the missing value, and the field declares no required;
  • the control, on both doors: status 400, code, error, the required finding and object as before, and the body's key set is exactly code, error, fields, hint, object;
  • the hint: its first sentence says the column requires a value, tells the caller to provide it or declare required, and names no drift; drift appears in exactly one later sentence, which begins with a condition naming storage and notNull; the update door answers the create door's hint byte for byte.

Not pinned: telling the two cases apart. That needs a registry read, a wider change than this card asks.

Verification (head 5920691)

  • Red on the old text, green on the new. The pin on origin/main before the edit: 2 failed, 1 passed (the fixture test). With the fix: 3 passed.
  • Ablation. With the fix committed (cf4bd61, before the rebase onto 7fa67da), scripts/ablation-replace.mjs put the old hint text back (anchor hit 1 time, blob 0b1a39350 to 3197ecfe1; the mutated file resolves by relative source import from the test, so no rebuild applies) and the pin went red: 2 failed, 1 passed (the control and fixture held; the hint tests failed). The restore proved blob == HEAD and an empty git diff HEAD. Direction: turned red, the usual one.
  • Package. pnpm --filter @objectstack/rest typecheck exit 0 (tsc --noEmit, and check:test-typecheck over tsconfig.test.json: 0 files, 0 errors; the new test is in that program). vitest run --project local on @objectstack/rest: 250 files passed, 4964 tests passed, 114 skipped. The ten files that read this mapper, this file included, were also run alone: 10 passed, 567 tests.
  • Gates. dispatch-gates --commands derived 61 commands at this head; all 61 were run with exit codes recorded and --ran reconciled: 61 derived, 59 run, 2 NOT MEASURED, 0 unrun. The two NOT MEASURED exited 3, PREREQUISITE NOT MET: check:dual-build-cjs-loads and check:type-check-debt read every package's built output and need the full-repo build, which was not run on the shared box; Build Core and Lint & Repo Gates cover them in CI. The diff adds no package, no export and no ledger entry, and the package's own test-layer typecheck holds 0 debt.
  • Lint. pnpm lint (eslint over the whole repo, --no-inline-config) exit 0 at this head, no narrowing.

Acceptance notes

Noted, not filed (no defect, polish only):

  • packages/rest/src/rest.test.ts (the test "maps SQLite NOT NULL constraint → 400 VALIDATION_FAILED with required field", about :2514) keeps a comment that says reaching this branch means the metadata did not require the field, so it is physical-schema drift. Its assertions still hold (the hint keeps os migrate and "drifted from metadata" in the conditional sentence), so only the comment restates the corrected reading. It sits outside this claim's file surface, so it is untouched.
  • packages/rest/src/error-response.ts :176 lists "not-null drift" as a label for the branch in a docblock about a different branch. A name, not a claim about the hint.
  • Branch note: rebased once onto origin/main 7fa67da (only scripts/pm/fleet-write/ moved) with --force-with-lease, before any PR existed, so the dispatch-gates families were derived from a current tree.

Generated by Claude Code

…mn that requires a value, and names schema drift only as a condition

A field declaring storage.notNull without required is a column the database keeps
NOT NULL on purpose (ADR-0113 D1 / D2). A write with no value for it is refused by
the database and answered 400 VALIDATION_FAILED with a required finding, which is
right; the hint said the schema had drifted and sent the author to os migrate, which
changes nothing for that column. The hint now says the column requires a value
(provide it, or declare the field required) and names drift in a second sentence,
conditional on the object declaring neither required nor storage.notNull. The branch
comment gets the same correction. code, status, fields and the sentence are unchanged,
no registry is read, and the import row is untouched.

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 Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 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 8d329f02eeb571170bba3b275df3c2fc3cb749b3 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8d329f02eeb571170bba3b275df3c2fc3cb749b3

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 592069136c016aec922b78fd9b977c80f870b062
Local-runs: none

Inputs read: card #20963 body and its three comments (governing: triage's direction 5921865209, the seat's claim 5922265892, the newest os-dev-report 5922713698); the seat's serial note 5922376322 on #20919 and, since it answers it, the cross-lane note 5922173894 on #6024 that names the move; ADR-0113 (D1, D2, D3, D5, Q1) at origin/main; PR #20956's record 5921191584, ③ item 2; PR #21001 body, file list (3 files) and the net diff 7fa67dada3..592069136c (+201/−7, one commit); the check-runs on the head, polled to convergence. Source premises (error-response.ts at the head and at origin/main 315888d660, import-runner.ts, import-row-schema-drift-20701.test.ts, rest.test.ts, rest-server.ts, record-validator.ts, packages/rest/package.json, the repo-wide git grep for the old hint text and for notNull reads) were read by git show / git grep; nothing was built, run, checked out or re-run.

① Derived judgments

(a) The not-null branch of mapDataError (classifyDataError, error-response.ts:1813-1845 at the head).

  • Leads with the remedy, drift as a condition: the hint is two sentences. First: "The database column for 'FIELD' requires a value: provide it, or declare the field required in the object metadata." Second: "If the object declares neither required nor storage: { notNull: true } for 'FIELD', the physical schema has drifted from metadata instead: run 'os migrate' to reconcile (or reset the dev database)." Triage's direction (5921865209) and the card's shape are met literally.
  • True in both cases. Declared storage.notNull without required: the first sentence is true (the column requires a value by declaration; D2 makes that the author's own DDL, D3 says a NOT NULL column over storage.notNull: true is no drift), and the second is a conditional whose condition is false, so it asserts nothing about this column. Genuinely drifted (column NOT NULL, metadata declares neither): the first sentence is true (the database refused; either remedy stops the refusal reaching the driver), and the second is true (D3/D5: a column stricter than its declaration is drift, needs_confirm, and os migrate is the door that reports and reconciles it; the remedy words are the old hint's own and the same sentence the unknown-column branch above it uses). The branch's reachable inputs partition into those two cases: the third cell, required: true over a NOT NULL column (D5's silent pre-17 case), cannot reach the branch, since record-validator.ts:908 refuses a missing or null required value before any driver runs (D1). One residue outside the card's two cases, pre-existing and unchanged in kind: Q1's engine-populated storage.notNull-only column (an audit or tenant column such as organization_id, the very fixture rest.test.ts:2505 uses) reaches this branch when the engine failed to populate it, and "provide it" is then not the caller's remedy; the old hint was no better there, and it is not a claim of this card. Not a verdict mover.
  • No implied registry read or server check: the drift sentence is addressed to the reader as a condition to evaluate against the declaration ("If the object declares neither..."); the branch asserts nothing about what the object declares, which matches the branch's actual inputs (error, object). ⛔ No registry read was added (the diff touches no import and no lookup).
  • Byte-unchanged envelope: the hunk removes five comment lines and the two old hint lines only; status: 400, error, code: 'VALIDATION_FAILED', fields: [{ field, code: 'required', message }] and ...(object ? { object } : {}) are context lines, and no key is added. The three dialect regexes above the branch are untouched. Verified on the diff, and pinned by the exact key set (below).
  • The branch comment is accurate against ADR-0113: "judges required only; nothing before the driver reads storage.notNull" holds at origin/main (the only non-test notNull reads outside driver-sql and spec are cli/generate.ts DDL emission, generated form labels, a plugin-sharing comment and rest's own two regex sites; record-validator.ts reads required and requiredWhen); "D1 / D2 keep the write contract and the column constraint apart on purpose" is D2 plus Q1's orthogonality argument; "lands here on a healthy schema, and os migrate changes nothing for it" is D3; "Drift (Schema sync is additive-only: non-additive metadata changes (required→optional, type, drop, rename) silently diverge from existing DBs; need drift detection + os migrate #2186) is the other way in: the column is NOT NULL and the metadata declares neither" is D3/D5. The retained #2186 is in a code comment, not a runtime string, and was there before; the hint carries no tracker number.

(b) The pin packages/rest/src/data-not-null-hint-20963.test.ts (three tests).

  • Real door, real driver: RestServer.registerRoutes() (rest-server.ts:4357) with the handlers taken from getRoutes() (:13766) and invoked for POST /api/v1/data/:object and PATCH /api/v1/data/:object/:id, over a real ObjectQL + SqlDriver (better-sqlite3 :memory:, syncSchemas) + ObjectStackProtocolImplementation; the same harness as import-row-schema-drift-20701.test.ts (169 rest tests use getRoutes()), and every import is a declared devDependency of @objectstack/rest. No cross-package file read, no clocked window.
  • The fixture test proves the ADR-0113 posture rather than assuming it: must carries storage.notNull and no required, and engine.insert without it rejects on /not null/i, so the refusal is the database's, after the validator.
  • Asserts the correction by kind: the first sentence matches requires a value, provide, contains the literal backticked required and does not match drift|os migrate; exactly one sentence matches drift|os migrate, it begins with If, names storage, notNull and os migrate, and is not the first; the PATCH door's hint equals the POST door's byte for byte.
  • Holds the control: status 400, code, error, the exact required finding, object, and the body's key set exactly code, error, fields, hint, object on both doors.
  • Would red on the old hint, derived from the text rather than taken from the report: the old string splits on the pin's sentence rule into "If 'must' is optional ... has drifted from metadata." and "Run 'os migrate' ... database).", so sentences[0] fails requires a value and the drift-sentence count is 2, not 1; both hint tests red, the fixture test green, which is the 2-failed / 1-passed shape the dev's ablation reports (scripts/ablation-replace.mjs exists at origin/main). The pin's one fragility is cosmetic: its sentence splitter is a period followed by whitespace, so an abbreviation added to the hint later would need the pin adjusted; not a defect.

(c) Confined to the claim's surface. Three paths. error-response.ts moves only inside the not-null branch; the unknown-column branch, the unique-violation arm, the sandbox doors and the terminals are untouched. import-runner.ts is not in the diff, and the import row cannot inherit the change: adoptDoorVerdict copies only code, field and the sentence, and the ROW_KEYS pin (import-row-schema-drift-20701.test.ts:186) holds the row's key set without hint, so PR #20956's key set stands. Grep of origin/main for the old text ("optional in your object metadata", "is still NOT NULL", "has drifted from metadata. Run"): only error-response.ts:1829-1831, the lines this PR replaces. Every other consumer of the hint: rest.test.ts:2518-2519 asserts /os migrate/ and /drifted from metadata/, both of which the new second sentence still satisfies, so it stays green; rest-unclassified-fault-status.test.ts:250 asserts status only. No page under content/docs, docs/ or skills/ quotes the hint (cli.mdx:764 and cli/migrate/plan.ts:67 use "drifted from metadata" for os migrate plan; .changeset/20701-import-row-schema-drift-door-answer.md:13 quotes the unknown-column sentence). Nothing reds; one comment goes stale (③).

(d) The changeset 20963-not-null-hint-declared-column.md (@objectstack/rest patch) is accurate. Its quoted "now reads" text is the code's string with 'FIELD' for the interpolation; "the first sentence holds for every NOT NULL refusal" holds for the two cases the card names (with the engine-populated residue in (a)); the two doors it names are the two the pin drives; "status, code, error, fields and object unchanged, no key added" is pinned exact; "the import row is untouched: it does not carry a hint" is the ROW_KEYS pin; "nothing reads the field map" is the diff. Clause-②: no is a bare line at the start of a line, the spelling check-changeset-no-major.mjs reads. The title carries the card number in the form every changeset here uses. It claims nothing undelivered. Check Changeset is success.

② Semver level

Clause-②: no with patch is right. No packages/spec/src/** in the file list; no route, parameter, key or code is added or removed; the envelope's key set is unchanged (pinned exact); hint is guidance prose, never a declared vocabulary (ADR-0112's ledger governs code, which does not move); no request moves between accepted and refused. A bug fix in a released package takes patch, never none (AGENTS.md Post-Task Checklist 3), and the note needs no ADR-0087 marker. The claim's own reading (5922265892, Clause-②: no) agrees.

③ Boundary flags

Dev deviations, each answered:

  1. One rebase onto 7fa67dada3 and a push with --force-with-lease, before any PR existed. AGENTS.md Multi-agent discipline 3's five conditions, checked: ① the branch is claude/issue-20963-notnull-hint; ② the claim names the worktree objectstack-issue-20963 that created it; ③ the author and committer set of 7fa67dada3..592069136c is the one agent identity, one commit; ④ the PR was created at 01:10:49Z, after the push, so it carried no reviewer or approval; ⑤ the lease was spelled branch:cf4bd61490... with the sha. The merge base of the head with origin/main is 7fa67dada3, consistent with the stated target. Accepted.
  2. check:dual-build-cjs-loads and check:type-check-debt NOT MEASURED (prerequisite not met on the shared box): Build Core and Type Check · debt ledger are success on the head. Accepted.
  3. Local builds limited to the rest closure: CI builds the repo. Accepted.
  4. The pin is a new file, not an edit of rest.test.ts: that is the claim's "one pin under packages/rest/src/", and it leaves the existing test and its comment outside the surface by design. Accepted.
  5. Commit trailers: verified on 592069136c, the model-free pair (Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB and the Co-authored-by: Claude line); no model identifier in the title, body, changeset, test or code. Accepted.
  6. The filing seat, the claim and the run share one session id, and the newest Claim: (5922265892) names the PR's branch; The card this PR closes must claim this branch is success. Accepted; this record is signed by that same session, the shared-account blind spot AGENTS.md names, not a verdict matter.

Dev out_of_scope_findings, each answered:

  1. rest.test.ts:2514-2516: the comment above the SQLite NOT NULL test still says reaching the branch means the metadata did not require the field, so it is drift. Its assertions (/os migrate/, /drifted from metadata/) hold against the new second sentence, so nothing reds, and the comment is now the reading the branch retired. Triage said "prose only, in that one file" and the claim's surface is the branch plus one new pin, so touching rest.test.ts would have breached the claim; "carrier: none" (an acceptance note, Prime Directive 10) is right. For the seat: the next PR that edits that test block, or connector sync, stage ② of #20281: an executor in service-automation pulls a mapping whose connectorSource names a rest/openapi connector and writes through the import runner's upsert-by-match-key #20919's move (which relocates the classification this test reads), restates the comment and, ideally, pins the two sentences of the hint the way this pin does.
  2. error-response.ts:176: "not-null drift" as a label in UNCLASSIFIED_FAULT's docblock list of the 4xx branches above it. A name, in another branch's docblock, outside the claimed branch; "carrier: none" is right. Not a verdict mover.

Serial interaction with #20919: the cross-lane note 5922173894 (00:24Z) states that #20919's build moves the classification half of error-response.ts to types with rest re-exporting every moved name, as one PR sent no earlier than an hour after it; the seat's serial note 5922376322 (00:41Z) declares the serialization (whichever lands second carries or re-applies the corrected comment and hint). At convergence, #20919 has no closing PR and no issue-20919 remote head, and No other open PR may claim the same single-writer path is success, so this PR has no live conflict. If the build lands first, the rebase of this PR re-applies the two strings at the moved location; the pin imports RestServer from ./rest-server, not mapDataError, so it is unaffected by the move. The landing seat reads origin/main for the move before queueing. 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 Fixes #20963 and the card lists #21001 as its closing PR; +201/−7, one commit, size/m. The Docs Drift Check comment on the PR lists nothing (one derived anchor, no page names it); the grep in ①(c) covers the hand re-read it prescribes.

Check-runs on 592069136c (converged 2026-10-01T01:24:02Z; 34 runs, collapsed to latest-per-name, 34 names): 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 (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. Test Core green on the head is also the first run of the new pin outside the dev's box.

Implemented-by: claude/issue-20963-notnull-hint
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 01:28
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 01:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit e161ad3 Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20963-notnull-hint branch October 1, 2026 01:47
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…s a mapping's connectorSource through the import runner, moved beside bulkWrite (objectstack-ai#21084)

Fixes objectstack-ai#20919
Clause-②: yes (widening)

Stage ② of ruling A on objectstack-ai#20281: the **connector sync executor**.
`service-automation` pulls a `mapping` whose `connectorSource` names a
declared `rest` / `openapi` connector, and writes the records through
the import runner's upsert-by-match-key. Built to the seat's stage-②
direction (`5922160386` on objectstack-ai#20281) and the amended claim surface
(`5922189483`).

## What changed

**1. The move (destination A, 0 new `package.json` edges).**
`service-automation` → `rest` would invert a layer, so the runner's
shared pieces moved down. ⛔ None of them is a copy.
- To `@objectstack/core` (`src/utils/`, beside `bulkWrite`):
`import-runner.ts` and `import-coerce.ts` (whole files), the mapping
apply (`applyMappingToRows`, `refuseUnknownMappingTargets`,
`MappingArtifactLike`, `MappingFailure`) as `import-mapping.ts`, and the
field meta (`ExportFieldMeta`, `buildFieldMetaMap`) as
`import-field-meta.ts`. Their suites moved with them: 6
`import-runner-*` suites, `import-coerce`, `import-mapping`.
- To `@objectstack/types`: the classification half of
`rest/src/error-response.ts` (`CLIENT_MESSAGE_MAX` through
`classifyDataError`, 1,892 lines) as `data-error-classification.ts`.
`rest` keeps the emitters.
- `rest` re-exports every moved name: from `export-format.ts`,
`import-mapping.ts`, `error-response.ts` and the package index. Its
production callers are unchanged: `rest-server.ts`'s two import routes,
and `plugin-auth`'s `admin-import-users.ts` through the index. The one
exception is an import line in `rest-server.ts` (`runImport` now comes
from `@objectstack/core`).
- Two suites stayed in `rest` because they need `objectql` /
`metadata-protocol`, which `core` cannot depend on:
`import-runner-historical-readonly-insert`,
`import-runner-unique-violation-row`. They now import from the new home.

**2. The executor:**
`packages/services/service-automation/src/connector-pull.ts`. It is
exposed as `pullConnectorSource(deps, opts)` and as
`AutomationServicePlugin.pullConnectorSource({ mapping, context })`.
- It reads the mapping through the protocol's `getMetaItem({ type:
'mapping', name })`, as the import door does.
- It resolves `connectorSource.connector` to a **declared** instance.
The plugin now records each materialized instance's provider, because
the registered def carries none. Only `rest` / `openapi` is accepted.
- It makes **one** action call per pull. `watermark.param` goes into the
call's `query`. The records are read at `recordsPath` (default `body`).
- **W1 watermark.** The starting point is the highest value stored in
the target field that a transform-`none` `fieldMapping` entry copies
`watermark.field` onto. It is one read, under the caller's context.
Nothing else stores it.
- It writes through the moved `runImport`. Every knob `connectorSource`
does not declare takes the door's default: `runAutomations` true,
`trimWhitespace` true, the rest false.
- Nothing schedules a pull. That is stage ③ (a `job`).

**3. The texts the executor makes true** (`domain:spec`, the amendment's
list):
- the `connectorSource` and `watermark` describes;
- the `mapping.json` ledger rows: `planned` → `live`, citing
`connector-pull.ts#pullConnectorSource`. `authorWarn` stays, with the
hint reworded to "pulled when a job drives it; nothing schedules it
until stage ③";
- the `syncConfig` prescription and its pin;
- the D3 entry (it drops "validated at authoring");
- `SYNC_ARCHITECTURE.md`: how a pull runs, the one-response limit, and
the two stale Best Practices bullets rewritten;
- the `packages/lint` group-C comments and test title.

The migrations registry region and `content/docs/references/**` were
regenerated with `check:generated --fix` and `gen:schema` / `gen:docs`.
⛔ No new key, cadence key, credential or paging convention.

## The executor's refusals

Every refusal is a typed `ConnectorPullError` (`code`, `status`,
`reason`). It is raised **before anything is written**, and each pin
asserts that no write reached the protocol. The codes are
standard-catalog members or the code the import door already answers
with, so no new ledger code was minted.

| Condition | `code` | `status` | `reason` |
|---|---|---|---|
| action answered `ok: false` | `EXTERNAL_SERVICE_ERROR` | 502 |
`upstream_not_ok` |
| non-array at `recordsPath` | `INTEGRATION_ERROR` | 502 |
`records_not_array` |
| a record that is not an object | `INTEGRATION_ERROR` | 502 |
`record_not_object` |
| plugin-origin connector (not a `connectors[]` entry) |
`VALIDATION_ERROR` | 400 | `connector_plugin_origin` |
| unmapped `watermark.field` (none, or only through a transform) |
`VALIDATION_ERROR` | 400 | `watermark_field_unmapped` |
| `update` / `upsert` with an empty `upsertKey` | `VALIDATION_ERROR` |
400 | `upsert_key_empty` |
| unknown mapping | `MAPPING_NOT_FOUND` | 404 | `mapping_not_found` |
| mapping without `connectorSource` | `VALIDATION_ERROR` | 400 |
`no_connector_source` |
| connector not registered | `VALIDATION_ERROR` | 400 |
`connector_not_registered` |
| degraded instance | `SERVICE_UNAVAILABLE` | 503 | `connector_degraded`
|
| provider not `rest` / `openapi` | `VALIDATION_ERROR` | 400 |
`connector_provider_unsupported` |
| action the connector does not declare | `VALIDATION_ERROR` | 400 |
`connector_action_unknown` |
| `javascript` transform | `UNSUPPORTED_TRANSFORM` | 400 |
`unsupported_transform` |
| `connectorSource.input` / `input.query` not an object (watermarked
pull) | `VALIDATION_ERROR` | 400 | `input_query_not_object` |
| target that is no field of the object | `INVALID_FIELD` (the door's) |
400 | `mapping_target_refused` |

The first six are the order's mandated refusals.

## Pins

- `connector-pull.test.ts` (18 cases). Every refusal above, plus the
watermark read: the target field, highest first, `limit: 1`, merged into
the declared `input.query`. A first incremental pull sends no starting
point.
- `connector-pull.integration.test.ts`: **end to end through a real
`rest` connector**.
- A declared `connectors[]` entry is materialized by the real
`ConnectorRestPlugin` provider. It reads a local `node:http` fixture
server over a real socket, and writes into a real ObjectQL engine on
SQLite.
  - Pull 1 sends no `since` and creates 2 rows.
- Pull 2 sends `since=2026-01-02T00:00:00.000Z`, the max `synced_at`
**read from the target**. It **updates** `c2` by `upsertKey` and creates
`c3`.
- It imports `connector-rest`'s source by relative path. That package
already dev-depends on `service-automation`, so a manifest edge back
would close a cycle. The read is declared in
`scripts/cross-package-test-inputs.mjs` and `turbo.json`, and
`service-automation`'s type-check `rootDir` is widened to admit it (tsup
emit unaffected; `check-dts-emitted` 2/2).
- Red first: `4f4260ee` committed the pins alone. The run against that
state failed both files: `Cannot find module './connector-pull.js'` and
`automation.pullConnectorSource is not a function`. `4b301797` then
added the code. The executor file's hash was identical before and after
that red run (`26a83b0d4a20fdab`).
- The moved suites are green at their new homes: `core` 69 files / 2044
tests, `rest` 241 / 4576.

## Verification

### Move proof (a move is a move)

An extraction script compared every moved range at base `e161ad35` with
its destination:
```
IDENTICAL  packages/rest/src/export-format.ts:35-65 (31 lines) -> packages/core/src/utils/import-field-meta.ts
IDENTICAL  packages/rest/src/export-format.ts:139-182 (44 lines) -> packages/core/src/utils/import-field-meta.ts
IDENTICAL  packages/rest/src/import-mapping.ts:37-55 (19 lines) -> packages/core/src/utils/import-mapping.ts
IDENTICAL  packages/rest/src/import-mapping.ts:117-328 (212 lines) -> packages/core/src/utils/import-mapping.ts
IDENTICAL  packages/rest/src/error-response.ts:66-1957 (1892 lines) -> packages/types/src/data-error-classification.ts
WHOLE-FILE packages/rest/src/import-runner.ts -> packages/core/src/utils/import-runner.ts: 7 changed line(s), all import lines
WHOLE-FILE packages/rest/src/import-coerce.ts -> packages/core/src/utils/import-coerce.ts: 9 changed line(s), all import lines
```

`git diff -M --color-moved=zebra` over the split files, counting `+`/`-`
lines painted as moved against those not:
```
packages/types/src/data-error-classification.ts: moved+=1896 non-moved+=49   (header, import lines, trailing export list)
packages/rest/src/error-response.ts:             moved-=1896 non-moved+=28 non-moved-=5 (header note, import/re-export lines)
packages/core/src/utils/import-mapping.ts:       moved+=241  non-moved+=14   (header, import lines)
packages/rest/src/import-mapping.ts:             moved-=241  non-moved+=11   (import/re-export lines)
packages/core/src/utils/import-field-meta.ts:    moved+=75   non-moved+=13   (header)
packages/rest/src/export-format.ts:              moved-=75   non-moved+=4 non-moved-=1 (import/re-export lines)
```

The eight classification helpers the REST emitters compose are exported
through a **trailing** `export { … }` list, so no declaration line was
edited. Never a copy: no declaration of `mapDataError`,
`classifyDataError`, `structuredCodeAnswer`, `declaredHttpStatus`,
`applyMappingToRows`, `refuseUnknownMappingTargets`,
`buildFieldMetaMap`, `runImport`, `coerceRow`, `ExportFieldMeta` or
`MappingArtifactLike` is left in `packages/rest/src` (0 each). The
control: the same grep finds each one at its new home.

R2 held: the moved half has 0 code references to the emission half or to
`log.js`. Its only mentions of the emitters are `{@link}` prose in
docblocks.

### objectstack-ai#20963 (PR objectstack-ai#21001)

It had landed (`e161ad35`) before the split, so the branch starts from
it. The moved half carries objectstack-ai#21001's 18 added lines byte-equal (`18 of
18` found verbatim in `data-error-classification.ts`). There was no git
conflict.

### Tests and typechecks

Run on head `ec59b16a`, after merging `main`:
- `types` 22 files / 685 tests;
- `core` 69 / 2044;
- `rest` 241 / 4576 (+135 skipped), `test:repo` 5 / 179;
- `service-automation` 162 / 2021;
- `spec` 587 / 17235;
- `plugin-auth` import suites 2 / 50;
- `lint` 118 / 5452;
- `runtime` vocabulary suites 4 / 70.

`typecheck` is green for `types`, `core`, `rest`, `service-automation`,
`spec`, `lint`, `runtime` and `plugin-auth`. `spec check:generated`: all
15 artifacts up to date. `check:liveness`: green.

### Gates

`dispatch-gates --ran` reconciles: 139 derived, 138 run, 1 NOT-MEASURED,
0 UNRUN, run on head `ec59b16a`.
- **NOT MEASURED: `check:pm-dispatch-gates`.** Its non-self-test half
outlives the ~10-minute foreground cap here (killed at 595s); its
`--self-test` half exits 0. CI runs it.
- **One red, not this PR's: `check:platform-checklist`.** The only
finding is `areas/identity-auth.json` →
`plugin-auth/src/auth-plugin.ts#twoFactor`, and it is red identically at
base `e161ad35` and on `origin/main`. This PR's own checklist anchors
(`classifyDataError`, the mapping `transform` switch) are repointed and
resolve.
- The 11 ⛔ roster families the derivation printed are all exit 0.

## Changed lines

**6,032** (`additions + deletions`, rename detection on: 3,672 / 2,360,
64 files), over the 5,000 line. Per AGENTS.md this PR takes the
**human-merge path** (an authorised approval, then the owning seat, or
the maintainer's merge), not the queue alone. About 4,100 of those lines
are relocation, and GitHub shows the whole-file moves as renames.

## Acceptance notes

**Carriers, not touched here:**
- **ADR-0114**'s consumer line names `@objectstack/rest`'s
`import-coerce.ts` / `import-runner.ts`. That is `docs/adr` (Tier H),
left for a docs follow-up.
- `packages/objectql` comments cite `packages/rest/src/import-runner.ts`
(`index.ts`, `summary-errors.ts`, and two `plugin-audit-created-*` test
comments). They are outside the surface.
- Further comment-only path citations of the moved files live in
`driver-turso`, `metadata-protocol`, `plugin-security`, `spec`
(`conversions/registry.ts`, `api/export.zod.ts`, `data/mapping.zod.ts`'s
older docblocks), `scripts/check-dispatcher-error-vocabulary.mjs`,
`check-error-status-conformance.mjs` and
`check-durability-degradation-log-level.mjs`. They are prose only, and
every gate is green.
- `.changeset/20281-connector-sync-moved-to-mapping.md` (stage ①,
unreleased) still says the binding is "declared, not yet executed". This
PR's spec changeset supersedes it; whoever compiles the release notes
should read the two together.

**Surface extensions, each a mechanical consequence of the move or of
the pin:**
- `turbo.json` and `scripts/cross-package-test-inputs.mjs`: `rest`'s
source-reading pins read the moved runner and classification, and
`service-automation`'s integration pin reads `connector-rest` source;
- `packages/rest/vitest.repo-tests.json`;
- `packages/services/service-automation/tsconfig.json` (`rootDir`);
-
`docs/qa/platform-checklist/areas/{attachments-storage,records-forms}.json`
anchors;
- `packages/spec/liveness/state-counts/mapping.md` (generated).

**Choices the direction left open, settled here:**
- The refusal codes come from the standard catalog, so the ADR-0112
ledger is not edited. `reason` is the discriminator.
- The watermark only reads a transform-`none` `fieldMapping` entry: a
transformed target does not hold the pulled value.

**Not measured:**
- a real third-party `openapi` target (the pin uses `rest`);
- the paging behaviour of any real upstream (no measured target exists;
the one-response limit is documented);
- `spec test:repo` was narrowed to the 7 repo suites that read this
diff's files. The full project outlives the foreground cap here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

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.

rest: a NOT NULL refusal's hint says the schema has drifted and to run os migrate, even for a column the object declares storage.notNull on purpose

2 participants