Repository navigation
fix(objectql,types,rest)!: a database NOT NULL refusal on a declared field is the record validator's ValidationError at every door - #26026
Conversation
…ield is the validator's ValidationError at every door The engine's write doors translate a driver NOT NULL refusal on a field the object declares into the record validator's own ValidationError, built by buildFieldError with the validator's message context, so the dispatcher /data twin, the actions door and RestServer answer 400 VALIDATION_FAILED by label. @objectstack/types exports the dialect reader notNullViolationSite and retires the relabel helpers; @objectstack/rest retires its door-side relabel. Claude-Session: https://claude.ai/code/session_01E1oXN1ZJ8NnZKKhzWeCZ6q Co-authored-by: Claude <noreply@anthropic.com>
…efusal by label; changeset Claude-Session: https://claude.ai/code/session_01E1oXN1ZJ8NnZKKhzWeCZ6q Co-authored-by: Claude <noreply@anthropic.com>
…LL refusal as the validator's ValidationError with a cut cause Claude-Session: https://claude.ai/code/session_01E1oXN1ZJ8NnZKKhzWeCZ6q Co-authored-by: Claude <noreply@anthropic.com>
… ValidationError (#24768) The grant insert the fresh NOT NULL `organization_id` column refuses now leaves the engine as its own `ValidationError` naming the declared field (`required`), the driver's `SQLITE_CONSTRAINT_NOTNULL` on `cause`. The pin asserts all three, so the case still proves the column refused the row and stays distinct from the D9 refusal. Claude-Session: https://claude.ai/code/session_01E1oXN1ZJ8NnZKKhzWeCZ6q Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 34 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 61f346e551b77ff956035099ed13f9a0698fa89f && git checkout 61f346e551b77ff956035099ed13f9a0698fa89f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6c619329a447f31859da9cad75f6084c430c810 9852ed899dfb3c55df2b79ffbec7379711b925b6 && git checkout -B drift-repro f6c619329a447f31859da9cad75f6084c430c810 && git merge --no-ff 9852ed899dfb3c55df2b79ffbec7379711b925b6
node scripts/docs-audit/affected-docs.mjs --json f6c619329a447f31859da9cad75f6084c430c810
|
Contract reviewServed-tier: Inputs: card #24768 body and all four comments (claim 6410180146, amendments 6410372925 and 6411384588, dev report 6412700035); PR #26026 body, file list (17 paths) and net diff at this head; the head's check-runs. Read-only: no worktree, no build, no test, no gate re-run. ① Derived judgmentsThe Clause-② declaration is well-formed and right.
One answer per declared-field NOT NULL refusal, door by door (status and
Tests assert Driver text does not leak. The envelope's What is public. Scope vs the claim. Every production path is inside the claim plus its two amendments ( ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | draft, base main, Fixes #24768, Clause-②: yes (narrowing) on the body and the changeset (the claim said no; the dev measured the departure and the review confirms it: @objectstack/types gains notNullViolationSite and loses notNullRefusalSite / relabelNotNullRefusal), Metadata-protocol: none, patch ×3 with the BREAKING banner and the ADR-0087 runtime-export-retired marker. |
| Fix (ruling A) | the engine's five driver write exits turn a NOT NULL refusal on a field the object declares into NotNullRefusalError extends ValidationError, built by the validator's buildFieldError, the driver's error on cause. Every door answers it through its existing validation arm: dispatcher /data POST/PATCH 500 INTERNAL_ERROR → 400 VALIDATION_FAILED; actions VALIDATION_ERROR → VALIDATION_FAILED; REST keeps 400 and gains the field label. No new error code; NotNullRefusalError is not public. |
| Open question | platform-filled columns: the seat reads A, as shipped. No published contract asks for a 500 there, B would not change the measured case (sys_user_permission_set declares organization_id without system: true), and B would key one column's answer off who declared it. Reversible by a later card if the product line wants otherwise. |
| Evidence | red first (3 failed on main dist, 3 passed on the fix), new engine / types / REST pins green, 77/77 derived gates exit 0. Landing waits on the head's remaining check-runs (Build Core, Test Core 1–8, Type Check); auto-merge holds until they are green. |
| Follow-ups | the undeclared (drifted) column still answers differently per door; outside ruling A, carded separately by this seat. Import / form / generic /batch / MCP reach the same arm by construction, unpinned on a real store; noted. |
| Carried | the dogfood authz-probe-blind-spot red was main's own, fixed on main by #26002. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready + auto-merge through the relay. |
Generated by Claude Code
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 40306457347 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
席位核对 · queue note ·
|
席位核对 · queue note 2 · review reopened ·
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 40313981294 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
… object The restored-copies sweep read every persona x object pair on two real stacks inside one case: about 400 finds against vitest's 5000ms default. On a loaded runner that single case crossed the default on main as on any branch (24 busy loops on 4 cores: 5.7-7.9s on main and on this branch). Both sweeps now run one case per object, each over the full persona list, with the same reads and the same verdicts. No case carries more than 18 reads; under the same load the slowest case is 463ms, and a failure names its object. No timeout is raised. Claude-Session: https://claude.ai/code/session_01E1oXN1ZJ8NnZKKhzWeCZ6q Co-authored-by: Claude <noreply@anthropic.com>
席位核对 · ACCEPT (re-issued) ·
|
| item | reading |
|---|---|
| Suspension cause | Queue builds 40306457347 and 40307144724 timed out on shipped-sets-identity-default-row-scope.test.ts ('isolated posture, platform baseline …'). |
| Root cause | Independent of this PR. The file makes 0 calls into the changed write-refusal path (stderr probe). Under 24 busy loops on 4 cores, main bbf70614bd without this PR timed out 2 of 2 (7468 ms, 7130 ms), the same as the branch. The cost is about 400 real-stack reads in one case, about 3 ms each, with no hotspot. |
| Fix | Commit 9852ed899d, test-only. Both sweeps run one case per object over the full persona list: the same reads and the same verdicts, at most 18 reads per case, and no timeout raised. Under the same load the slowest case went from 5.7–7.9 s to 463 ms (137/137, 2 of 2). The PR body now carries Fixes #26065. |
| Evidence | 44 check runs success and 10 expected skips on this head, 0 failed; mergeable clean. Dev: full suites green (types, objectql, rest, runtime, plugin-auth, plugin-security); 79/79 gates exit 0. |
| Ruling carried | ACCEPT 6412949475's ruling A on platform-filled columns is unchanged. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay. |
Generated by Claude Code
Requested by Leehom · project thread
Fixes #24768
Fixes #26065
Clause-②: yes (narrowing:
@objectstack/typesadds the exportnotNullViolationSiteand retiresnotNullRefusalSiteandrelabelNotNullRefusal; the HTTP doors' answer for this refusal moves to the record validator's envelope)Metadata-protocol: none
BREAKING (an export retirement and a wire-answer change), shipped as
patch: no metadata protocol kind moves. The changeset carries the ADR-0087 disposition (runtime-export-retired) and the FROM → TO lines for both retired exports.What was wrong
A field declared
storage: { notNull: true }withoutrequiredis ADR-0113's engine-populated column. The record validator judgesrequiredonly (D1 / D2), so a write with no value for that field reaches the driver and the database refuses it. The engine let the driver's error out, and each door classified it from the driver's text its own way.Measured on
origin/main0c37228826with a real better-sqlite3 store, the realObjectQLengine and protocol, andHttpDispatcher.dispatch()over a FetchRequest(the rig ispackages/runtime/src/dispatcher-not-null-refusal-24768.test.ts):POST /data/:objectwithout the fieldSqliteError(SQLITE_CONSTRAINT_NOTNULL) out ofdispatch();declaredEnvelopeForThrowandvalidationFailureDetailsboth decline it, so500 INTERNAL_ERROR400 VALIDATION_FAILED,Sales Price is required,details.fields= the validator's envelope withlabelPATCH /data/:object/:idwriting null500 INTERNAL_ERROR400 VALIDATION_FAILED, the clear-out sentence (required_cleared)operation: 'update'writing null400 VALIDATION_ERROR, the driver's text as message,declaredCode: 'SQLITE_CONSTRAINT_NOTNULL'400 VALIDATION_FAILEDwith the field envelope, nodeclaredCode, no driver textRestServerPOST/PATCH400 VALIDATION_FAILED, relabelled by a second object read (#24715) plus ahintrequiredbody exactly:fields[0]carrieslabel, nohint, PATCH readsrequired_clearedRestServerPOST /data/:object/batchrow{ code: 'VALIDATION_FAILED', message: 'Sales Price is required' }Import rows, the public form submit and MCP write through the same engine doors and answer
ValidationErrorthrough the same arms; they were not driven separately.The change
@objectstack/objectql(not-null-refusal-error.ts, new).envelopeNotNullViolationturns a driver NOT NULL refusal on a field the object's field map holds intoNotNullRefusalError extends ValidationError, built by the validator's ownbuildFieldErrorwith the validator's message context: one field error,code: 'required', named by its label (translation bundle, then declaredlabel, then API name) in the caller's locale, with the sentence the validator gives the same verb (requiredon insert,required_clearedon update). The driver's error ridescause.envelopeDriverWriteRefusalcomposes it after the existing unique translation (An application told to rely on a unique index cannot recognise the violation: ObjectQL rethrows the raw driver error andisUniqueViolationErroris not reachable from an app #14095) and replacesenvelopeUniqueViolationat all five driver exits inengine.ts(both autonumber-retry sites, thebulkCreateexit, by-id update,updateMany). The two write-failure log lines logcausefor this class too, so the log still carries the database's diagnosis. Where the error leaves the engine,redactPropagatedDriverFaultcuts itscauselike any driver error.storage.notNull:requiredandstorage.notNullstay apart (ADR-0113 D1 / D2).@objectstack/types. NewnotNullViolationSite(error): the column (and table, where the dialect names it) of a driver's NOT NULL refusal, read from the error's codes first (better-sqlite3SQLITE_CONSTRAINT_NOTNULL; libsqlextendedCode/rawCode: 1299; node-postgres23502withcolumn/table; mysql2ER_BAD_NULL_ERROR/errno: 1048), then its text (all sql.js gives), then down itscausechain. An error whose code names another refusal is not read for NOT NULL text.notNullRefusalSiteandrelabelNotNullRefusalretire. Before removing them,ui/**,examples/**and hotcrm were grepped: zero consumers (the control legs, the export names in this repo's own sources, matched).mapDataError's NOT NULL branch is unchanged and answers the undeclared column.@objectstack/rest. PR fix(types,rest): a /data write's NOT NULL refusal names the field by its label #24759's door-side relabel (not-null-refusal-label.tsand its test) retires with itsCrudRouteHost.labelNotNullRefusalmember and call sites: every path it served is an engine write.crud-routes.tsgoes back toconst contextinside the try.objectItemReaderstays (the exposure gate reads it); its doc no longer mentions the relabel.Interpretations for review
Clause-②departs from the claim'sno. Measured: two exports of@objectstack/types' published entry retire, and the dispatcher's500becomes a400with a different body, so this declaresyes (narrowing). The seat's contract review applies.required_clearedon update. Same sentence the validator gives a PATCH that clears arequiredfield, so the two refusals read alike.hintfor a declared field. The validator'srequiredenvelope has none. The undeclared-column answer keeps the classification's column sentence and drift hint.Drift Columnwithrequired_cleared).sys_user_permission_setdeclaresorganization_id, so plugin-auth's anchor-parity case "fresh tenant column, no organization object, single" now getsVALIDATION_FAILEDnamingorganization_id, withSQLITE_CONSTRAINT_NOTNULLoncause. The pin moved (claim amendment 6411384588) and still proves the column refused the row. Whether platform-filled columns (tenant, audit, owner, readonly) should be excluded is left as an open question in the report.Tests
packages/runtime/src/dispatcher-not-null-refusal-24768.test.ts(new): red onmain(3 failed), green here (3 passed).packages/objectql/src/engine-not-null-refusal.test.ts(new, 18): every driver exit, the log line, five dialects, zh-CN / zh parity with the validator's ownrequired, and the controls (undeclared column, other table,23502with no column, unique stillDuplicateRecordError,requiredstill pre-driver).packages/types/src/data-error-classification.not-null-label.test.ts(rewritten, 14): the reader per dialect, the cause chain, the other-code refusals, the undefined cases.packages/rest/src/data-not-null-field-label-24715.test.ts(8) anddata-not-null-hint-20963.test.ts: the RestServer doors and batch row by label; the undeclared column keeps its sentence and hint.driver-fault-boundary-redaction.test.ts(168): the NOT NULL shapes × write doors pinned asNotNullRefusalErrorwith a cutcauseof the same prototype and code.--no-bailrun of types (781), objectql (8756), rest (6469), runtime (7214), plugin-audit (993), plugin-auth (3720 + the moved pin) and service-package (125): green.Ablation
envelopeDriverWriteRefusalmutated to return the unique translation only (scripts/ablation-replace.mjs --hold), objectql rebuilt andablation-dist-preflightconfirmed the mutation indist/. Red: runtime 3/3, rest 24715 7/8, objectql 12/18, plugin-auth anchor-parity 1/13. Restored:git diff HEADempty, file hash equalsHEAD's blob, preflight--absent0, rebuilt, all four green.Gates
On head
50c3929ba3:dispatch-gates --commandsderived 77; all 77 ran and exit 0 (check:dual-build-cjs-loadsre-run after a fullturbo run build, its first run exit 3 for missingdist/).dispatch-gates --ran: 77 derived, 77 run, 0 NOT-MEASURED.eslint --no-inline-configover the touched packages'src: 0.typecheckfor types, objectql, rest, runtime, plugin-auth: 0.check-platform-checklist --changed-from origin/main: OK. No open PR touchespackages/objectql/**orpackages/rest/**.Dogfood
pnpm --filter @objectstack/dogfood testunder the verify lock, after a fullturbo run buildon head50c3929ba3:VERDICT command-exit 1, Test Files 1 failed | 295 passed | 3 skipped (299), Tests 1 failed | 2522 passed | 18 skipped (2541).The one failure is not this change's:
test/authz-probe-blind-spot.test.ts>packages/runtime/src/domains/i18n.ts — every positive control is still present in THAT file(control "handleI18nRequest" moved: expected 4 to be 3). The census reads source text; this PR touches neither the census nori18n.ts, andorigin/main'si18n.tsholdshandleI18nRequest4 times since69fa23d60b(#25956), against 3 at its parent. It is red onmainby construction and is reported, not fixed here.Acceptance notes
400 VALIDATION_FAILED, the field named by its label, in the shape the record validator givesrequired.storage.notNull.@objectstack/typesexports it used retire with a declared migration.Generated by Claude Code