Repository navigation
fix(runtime,types)!: a NOT NULL refusal on a drifted column answers RestServer's body at the dispatcher and actions doors - #26171
Conversation
…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
… door Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W99jaevefxLsRd8BwgvkKv
📓 Docs Drift CheckThis PR changes 2 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 30 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #26031
Clause-②: yes (widening:
@objectstack/typesadds the exportdriverNotNullRefusalAnswer)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 ownValidationError, 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 onorigin/main40bba68c(which carries #26026), on a real better-sqlite3 store underdriver-sqlwith the real engine and protocol:mainRestServerPOST /data/:object400 VALIDATION_FAILED, the column's sentence, itsrequiredfield entry and the drifthint(mapDataError's NOT NULL arm)POST /data/:objectSqliteErrorleftdispatch(); the Hono exit declined it, so500 INTERNAL_ERRORPOST /actions/:object/:action400 VALIDATION_ERROR, the driver's text (insert into [statement and bound values redacted] - NOT NULL constraint failed: drift_line_26031.legacy_code) asmessage, anddeclaredCode: 'SQLITE_CONSTRAINT_NOTNULL'The fix
@objectstack/typesexportsdriverNotNullRefusalAnswer(error, object?):classifyDataError's answer when its NOT NULL arm is the arm that answered,undefinedfor every other answer. The arm's answer is now built in one place and held in aWeakSetthe 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'sValidationError) still decides first, and this answers exactly wheremapDataErrorwould.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_FAILEDaserror.code, andfields,hintandobjectinerror.details:HttpDispatcher.dispatch()'s catch, for the/data/:objectroutes only (routeObjectFromPath, the same read itsPERMISSION_DENIEDarm makes, so the body's object is the route's asRestServer's is). Every other throw leaves exactly as before.codeis 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.
PATCHcannot reach such a columnA
PATCHnaming the dropped field is refused before any driver runs, as an undeclared field (400 INVALID_FIELD), on both/datadoors alike; abeforeUpdatehook writing it is refused the same way (probed); an update that leaves the column alone keeps the row's stored non-null value. So thePATCHcase in the new pin is a control, asserted on both doors and unchanged.Clause-② — the measurement the order asked for
Declared
yes (widening), notno (narrowing). Byclause2-line.mjs's own definitions the value answers 「本卡放宽接受集或扩大公开面吗」, and this diff does widen the published face:@objectstack/types' entry (packages/types/src/index.ts:98re-exports this module) gains the exportdriverNotNullRefusalAnswer. 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, andVALIDATION_ERRORwith driver prose →VALIDATION_FAILEDwith the column's field entry). A consumer branching onerror.codeat those two doors is still affected, which is what the**BREAKING**banner in the changeset states, with the ADR-0087 dispositionnot-required (no-migration-prescription); AGENTS.md's level rule puts a non-protocol change atpatch, 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/main40bba68c,packages/runtime/src/dispatcher-not-null-drift-26031.test.ts: 2 failed / 2 passed of 4 — the dispatcherPOST(expected { thrown: SqliteError } to deeply equal { returned: { status: 400, … } }) and the actions door ("code": "VALIDATION_ERROR"against"VALIDATION_FAILED") red, theRestServerreference and thePATCHcontrol green. On the fix, 4 passed. Each case asserts the whole body withtoEqual, and the two dispatcher-door bodies are compared againstRestServer's own answer read in the same run, so thehint's words are never spelled here.packages/types/src/data-error-classification.not-null-label.test.tsadds 8 cases for the new export (22 passed): the arm's answer equalsmapDataError's for all six dialect shapes and with no object, andundefinedfor the record validator'sValidationError, 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 at97265b90:@objectstack/types30 files / 800 tests,@objectstack/runtime459 files / 8359 tests (19 skipped),@objectstack/rest327 files / 6749 tests (344 skipped), all passed.Ablation, one line, on the behaviour the new pins assert:
driverNotNullRefusalAnswer'sreturn NOT_NULL_ARM_ANSWERS.has(answer) ? answer : undefined;replaced with a marker expression that always answersundefined(ablation-replace --hold, injected markergrep -c= 1, the anchor textgrep -c= 0),@objectstack/typesrebuilt,ablation-dist-preflight @objectstack/types ABLATE_26031exit 0 (marker live indist/index.jsanddist/index.mjs). Red: the drift pins 2/4 failed (the two door cases), the types pins 7/22 failed. Restored:ablation-replace --restorereportsblob == HEAD (68e3d40ab2f6),git diff HEADempty, whole-treegit status --porcelainempty, rebuilt,--absentpreflight exit 0, and both files green again (4/4 and 22/22).Gates
Local subset, the
dispatch-gates.mjs --commands --repo objectstack-ai/objectstacklist 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-testexit 3 on the first run — a shallow checkout cannot read its pinned positive control621a487607. Aftergit fetch origin 621a487607, exit 0 (48 cases pass).pnpm check:dual-build-cjs-loadsexit 3:PREREQUISITE NOT MET, 33 packages have nodist/, which is a property of this container, not of the diff. A fullpnpm buildwas run and the gate re-read; see the re-run line in the report.pnpm lintis the repo-level scan CI owns; scoped runs on the touched packages are in the report.test:repois CI's (Test Core). The families CI runs are cited from this PR's check-runs, not re-run locally.Acceptance notes
6412927808, boundary flag 2) named as not driven on a real store — import rows, the public form submit, the generic/batchand MCP — reach this class throughmapDataErrororthrownRefusalEnvelopeand are unchanged by this diff; no pin is added for them here.Generated by Claude Code