Skip to content

fix(runtime,types)!: a NOT NULL refusal on a drifted column answers RestServer's body at the dispatcher and actions doors - #26171

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-tlzgz7
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-tlzgz7

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26031

Clause-②: yes (widening: @objectstack/types adds the export driverNotNullRefusalAnswer)
Metadata-protocol: none

What was wrong

#24768 (ruling A, PR #26026, merged as 48e84a90af) has the engine translate a driver NOT NULL refusal on a field the object's field map holds into the record validator's own ValidationError, which every door already answers alike. A refusal on a column no field declares — drift, the field left the metadata and its NOT NULL column stayed in the table — is outside that ruling and still leaves the engine as the driver threw it, so the doors answered it three ways. Measured on origin/main 40bba68c (which carries #26026), on a real better-sqlite3 store under driver-sql with the real engine and protocol:

door answer on main
RestServer POST /data/:object 400 VALIDATION_FAILED, the column's sentence, its required field entry and the drift hint (mapDataError's NOT NULL arm)
dispatcher POST /data/:object the raw SqliteError left dispatch(); the Hono exit declined it, so 500 INTERNAL_ERROR
POST /actions/:object/:action 400 VALIDATION_ERROR, the driver's text (insert into [statement and bound values redacted] - NOT NULL constraint failed: drift_line_26031.legacy_code) as message, and declaredCode: 'SQLITE_CONSTRAINT_NOTNULL'

The fix

@objectstack/types exports driverNotNullRefusalAnswer(error, object?): classifyDataError's answer when its NOT NULL arm is the arm that answered, undefined for every other answer. The arm's answer is now built in one place and held in a WeakSet the question reads by identity, so recognition is not a second reading of the driver's text and not a shape probe on a body: every arm ahead of the NOT NULL one (a declared status, a sandboxed body's throw, a unique violation, an unknown column, the record validator's ValidationError) still decides first, and this answers exactly where mapDataError would. mapDataError's own answers are byte-unchanged.

Both runtime exits ask it and answer the arm's body in their own envelope — the sentence as error.message, VALIDATION_FAILED as error.code, and fields, hint and object in error.details:

  • HttpDispatcher.dispatch()'s catch, for the /data/:object routes only (routeObjectFromPath, the same read its PERMISSION_DENIED arm makes, so the body's object is the route's as RestServer's is). Every other throw leaves exactly as before.
  • the actions door's catch, asked ahead of the fault predicate, so a driver class carrying no string code is still the refusal it is and never the sanitised 500.

⛔ No second copy of the recognition: neither door reads a driver code or a NOT NULL phrase. The declared-field answers #26026 pinned are unchanged, and the driver's text and code reach neither wire.

PATCH cannot reach such a column

A PATCH naming the dropped field is refused before any driver runs, as an undeclared field (400 INVALID_FIELD), on both /data doors alike; a beforeUpdate hook writing it is refused the same way (probed); an update that leaves the column alone keeps the row's stored non-null value. So the PATCH case in the new pin is a control, asserted on both doors and unchanged.

Clause-② — the measurement the order asked for

Declared yes (widening), not no (narrowing). By clause2-line.mjs's own definitions the value answers 「本卡放宽接受集或扩大公开面吗」, and this diff does widen the published face: @objectstack/types' entry (packages/types/src/index.ts:98 re-exports this module) gains the export driverNotNullRefusalAnswer. Nothing published is removed or narrowed — no export retires, no accept set shrinks, and no request that was served is now refused (every write in the measured class was already refused at all three doors; only the status, code and body move). The (narrowing) arm is therefore not owed: the two wire-answer changes make a refusal more informative (500 INTERNAL_ERROR → 400 VALIDATION_FAILED, and VALIDATION_ERROR with driver prose → VALIDATION_FAILED with the column's field entry). A consumer branching on error.code at those two doors is still affected, which is what the **BREAKING** banner in the changeset states, with the ADR-0087 disposition not-required (no-migration-prescription); AGENTS.md's level rule puts a non-protocol change at patch, which is what ships.

Metadata-protocol: none, as expected: no spec key, authorable spelling, object definition, stored row or stored shape moves.

Tests

Red first, on origin/main 40bba68c, packages/runtime/src/dispatcher-not-null-drift-26031.test.ts: 2 failed / 2 passed of 4 — the dispatcher POST (expected { thrown: SqliteError } to deeply equal { returned: { status: 400, … } }) and the actions door ("code": "VALIDATION_ERROR" against "VALIDATION_FAILED") red, the RestServer reference and the PATCH control green. On the fix, 4 passed. Each case asserts the whole body with toEqual, and the two dispatcher-door bodies are compared against RestServer's own answer read in the same run, so the hint's words are never spelled here.

packages/types/src/data-error-classification.not-null-label.test.ts adds 8 cases for the new export (22 passed): the arm's answer equals mapDataError's for all six dialect shapes and with no object, and undefined for the record validator's ValidationError, a throw declaring its own status, a sandboxed throw and node-postgres' fields-only shape — each of those carrying the NOT NULL text or code, so the pin measures the arm and not the words.

Neighbours green on the fix: dispatcher-not-null-refusal-24768.test.ts, domains/actions-fault-vs-rejection.test.ts, dispatcher-unique-violation-wire-code.test.ts, rest/src/data-not-null-hint-20963.test.ts, rest/src/data-not-null-field-label-24715.test.ts. Full package suites at 97265b90: @objectstack/types 30 files / 800 tests, @objectstack/runtime 459 files / 8359 tests (19 skipped), @objectstack/rest 327 files / 6749 tests (344 skipped), all passed.

Ablation, one line, on the behaviour the new pins assert: driverNotNullRefusalAnswer's return NOT_NULL_ARM_ANSWERS.has(answer) ? answer : undefined; replaced with a marker expression that always answers undefined (ablation-replace --hold, injected marker grep -c = 1, the anchor text grep -c = 0), @objectstack/types rebuilt, ablation-dist-preflight @objectstack/types ABLATE_26031 exit 0 (marker live in dist/index.js and dist/index.mjs). Red: the drift pins 2/4 failed (the two door cases), the types pins 7/22 failed. Restored: ablation-replace --restore reports blob == HEAD (68e3d40ab2f6), git diff HEAD empty, whole-tree git status --porcelain empty, rebuilt, --absent preflight exit 0, and both files green again (4/4 and 22/22).

Gates

Local subset, the dispatch-gates.mjs --commands --repo objectstack-ai/objectstack list on this diff (66 commands, each exit code captured before any pipe, reconciled with --ran): 65 exit 0.

  • node scripts/check-plugin-teardown-shape.mjs --self-test exit 3 on the first run — a shallow checkout cannot read its pinned positive control 621a487607. After git fetch origin 621a487607, exit 0 (48 cases pass).
  • pnpm check:dual-build-cjs-loads exit 3: PREREQUISITE NOT MET, 33 packages have no dist/, which is a property of this container, not of the diff. A full pnpm build was run and the gate re-read; see the re-run line in the report.

pnpm lint is the repo-level scan CI owns; scoped runs on the touched packages are in the report. test:repo is CI's (Test Core). The families CI runs are cited from this PR's check-runs, not re-run locally.

Acceptance notes


Generated by Claude Code

…stServer's body at the dispatcher and actions doors

A driver NOT NULL refusal on a column no field declares (drift) left the
engine as the driver threw it. RestServer answered it with mapDataError's
NOT NULL arm (400 VALIDATION_FAILED, the column's required entry and the
drift hint); the dispatcher's /data exit let it reach the Hono exits, which
answered 500, and the actions door relayed the driver's text as a
400 VALIDATION_ERROR with the driver's code as declaredCode.

@objectstack/types exports driverNotNullRefusalAnswer: classification's own
answer when its NOT NULL arm is the one that answers, held by identity, so
every arm ahead of it still decides first. The dispatcher's /data exit and
the actions door ask it and answer the arm's body in their envelope. The
declared-field answers #24768 pinned are unchanged.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/types, touching 7 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/permissions/system-context.mdx (via handleActionsRequest (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
  • 2 anchor(s) matched too much of the corpus to be a work list: VALIDATION_FAILED (literal, 33 pages), /data/:object (route, 69 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 30 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 c7aa9cde24ca7d17563caa4e01037f3ab1b9e990 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 c7aa9cde24ca7d17563caa4e01037f3ab1b9e990 → 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: TIER_DEFAULT
Head-sha: 97265b90be619fff80d2d32c2294f03347653fa6
Local-runs: none

① Derived judgments

  • Public face (widening, declared): @objectstack/types gains one export, driverNotNullRefusalAnswer(error, object?). It calls the same classifyDataError that mapDataError reads and answers only when the returned object is one the NOT NULL arm built (held by identity in a module-private WeakSet), so it answers exactly where mapDataError's NOT NULL arm answers and nowhere else: every arm ahead of it still decides first. ⛔ No second reading of the driver's text: correct. mapDataError's own answers are unchanged (the arm's body is built by one function, notNullArmAnswer, with the same fields in the same order as before).
  • Wire answers (status and code move, accept set unchanged): the dispatcher /data/:object routes answer this refusal 400 with error.code: 'VALIDATION_FAILED' (was a thrown 500 INTERNAL_ERROR), and the actions door answers the same body (was 400 VALIDATION_ERROR with the driver's text and declaredCode). Every write refused before is still refused and every write served is still served, so no accepted input narrows: Clause-②: yes for the widened export, not no (narrowing). The claim was amended to say so (6419626356).
  • Door scope: the dispatcher asks only when routeObjectFromPath(cleanPath) names an object, i.e. the /data/:object routes RestServer answers this arm for; every other throw leaves as before. The actions door asks after the declared-status branch and before the fault predicate, so a status-declaring throw is still served by its own status and a driver class without a string code is not swallowed into the sanitised 500.
  • Pins: one toEqual on the whole body per door (RestServer as the reference, dispatcher, actions), each also reading that no row was written; the PATCH control pins 400 INVALID_FIELD before the driver on both /data doors. The declared-field answers PR fix(objectql,types,rest)!: a database NOT NULL refusal on a declared field is the record validator's ValidationError at every door #26026 pinned are untouched by the diff.

② Semver level

patch on @objectstack/types and @objectstack/runtime with the BREAKING banner for a wire-answer change and Metadata-protocol: none: consistent with the level table (no metadata protocol kind moves). The ADR-0087 marker reads not-required (no-migration-prescription) and its category reasons hold on the diff (nothing exported removed or narrowed; no stored shape moves).

③ Boundary flags

  • The card's asked-for PATCH pin became a control: the dev measured that a PATCH naming a drifted column is refused before the driver on both doors. Accepted; it is the stronger reading.
  • No open_questions.

Implemented-by: claude/project-thread-tlzgz7
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-26T15:17Z · head 97265b90be619fff80d2d32c2294f03347653fa6 · #26031

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape: a draft against main, Fixes #26031. Clause-②: yes (widening …) and Metadata-protocol: none are line-initial in the body and the changeset. One changeset, patch for @objectstack/types and @objectstack/runtime, with the BREAKING banner and the ADR-0087 not-required marker. check-governed-merges --pr 26171: NOT governed, 410 changed lines; no packages/spec/**.
  2. Premise and review: the claim said Clause-②: no (the dev measures); the dev measured yes (one new export, nothing narrowed), and the claim was amended to match (6419626356). The face is the declaration alone, so the review is TIER_DEFAULT, recorded by this seat as 6419680146 (PASS on this head). The diff makes classifyDataError's NOT NULL arm askable by identity, so the dispatcher /data/:object routes and the actions door answer RestServer's 400 VALIDATION_FAILED body for a drifted column, with no second reading of the driver's text. Each door is pinned with toEqual on the whole body and a zero row count; the PATCH control holds on both /data doors.
  3. Gates: at this stamp the head's 47 check-runs read 39 success and 8 skipped (path-filtered), with none failing or in progress; mergeable, clean. The dev reports all 66 derived gate families exit 0 (two after their prerequisite), pnpm lint green, and the full dogfood battery green at this head.
  4. Accepted deviations: the claim's Clause-② line, amended as above. The card's PATCH pin is a control, because a PATCH cannot reach a drifted column.
  5. Out of scope: none filed; no open questions.
  6. Landing: this seat runs pr_ready and then automerge_enable through the relay, confirms MERGED on both readings, and records the landing on A NOT NULL refusal on an undeclared (drifted) column still answers differently per door: REST 400 VALIDATION_FAILED with a drift hint, dispatcher /data 500 INTERNAL_ERROR, actions 400 VALIDATION_ERROR with the driver's text #26031, which the merge closes.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 26, 2026 15:18
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 26, 2026 15:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit 1af2cea Oct 26, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-tlzgz7 branch October 26, 2026 15:57
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

2 participants