Repository navigation
A database NOT NULL refusal answers differently per door: the dispatcher /data twin answers 500 INTERNAL_ERROR, and REST batch/import/form/action rows name the column key (RestServer POST/PATCH /data answer 400 with the label after #24715) #24768
Description
Filing gate ① a product defect with a named locus and a measured reproduction. Filed by domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T, from #24715's dev report (PR #24759, open_questions[0] and out_of_scope_findings). ⛔ Not a claim. Domain, grade and route are triage's.
Reader (who acts): triage routes it. The candidate fix sites are in two lanes, packages/objectql (domain:engine) and packages/runtime (domain:cli), so the route is the choice.
What happens
A write that a database NOT NULL constraint refuses gets a different answer depending on the door.
| door | answer for the same refused write (a field declared storage: { notNull: true }, no required, value null) |
|---|---|
RestServer POST /data/:object, PATCH /data/:object/:id |
400 VALIDATION_FAILED, fields[0].code: required. After PR #24759 it names the field by its label, in the caller's locale |
the dispatcher twin (packages/runtime HttpDispatcher → domains/data.ts, rendered by @objectstack/hono's catch-all or dispatcher-plugin's errorResponseBase) |
500 INTERNAL_ERROR, "Internal server error" |
RestServer /data/:object/batch, /batch rows, import rows, the public form submit, data actions |
mapDataError's NOT NULL sentence `${column} is required`: the column key, not the label. Not measured with a labelled field |
Measured by the dev at origin/main 7e7e605de, on a real store (@objectstack/driver-sql + better-sqlite3, ObjectQL, ObjectStackProtocolImplementation):
HttpDispatcher.dispatch('PATCH', '/data/OBJECT/ID', { unit_price: null }), andPOST /data/OBJECTwithout the field, let the rawSqliteError(SQLITE_CONSTRAINT_NOTNULL) out ofdispatch().declaredEnvelopeForThrowdeclines it,resolveThrownHttpErroranswers500 INTERNAL_ERROR, andlooksLikeInternalErrorLeakis true. So both exits answer500 { code: INTERNAL_ERROR, message: 'Internal server error' }.RestServeranswers400 VALIDATION_FAILEDfor the same write.- The reading went through
dispatch()plus the exits' own renderers, not through a booted Hono app.
Reach: any host whose /data is served by the dispatcher catch-all, and every client of the other REST doors above. A client error answers as a server fault, or names the column key, depending on the door. That is the inconsistency AGENTS.md's "one operation, one implementation" rule exists for.
Proposed direction (the dev's option A, not ruled)
The engine translates a driver NOT NULL refusal on a field the object declares into its own ValidationError, built the way the record validator builds required, so it is labelled and localized by construction. Every door then answers 400 VALIDATION_FAILED with the label: RestServer, the dispatcher twin, batch and import rows, and MCP.
- The engine already holds the field map and the caller's localization context.
- One translation covers every door, with no consumer-side reclassification. That is the contract-first direction.
- PR fix(types,rest): a /data write's NOT NULL refusal names the field by its label #24759's REST-side relabel (
packages/rest/src/not-null-refusal-label.ts) would then cover only non-engine paths, or retire. - Cost: an
objectqlchange, and the dispatcher door's status moving from500to400, which is a wire change at that door.
Alternatives the dev weighed:
- B: the dispatcher exits run
mapDataErroron raw driver errors. This moves that door's status for every driver-text class at once. - C: record the divergence and leave it.
Governing text
- ADR-0113 D1/D2:
requiredandstorage.notNullare kept apart on purpose, so a healthy schema reaches the driver's refusal. - ADR-0112: declared envelopes; a client refusal is not a
500. - AGENTS.md: two implementations of one operation that disagree, so align them.
Dedupe
I ran semantic searches on this repo: "dispatcher data door NOT NULL constraint driver error answers 500 INTERNAL_ERROR instead of 400 validation" and "HttpDispatcher raw driver error not classified mapDataError parity with RestServer". The nearest hits are all closed and none covers this class: #23491 (the dispatcher PATCH request-schema gate), #8016 (the direct-mount door and coded 4xx), #23532 (the hono catch-all leak filter), #24662 (an unevaluable hook condition).
Dedupe words: dispatcher data door NOT NULL 500; INTERNAL_ERROR not-null constraint hono catch-all; driver refusal classification dispatcher parity mapDataError; SQLITE_CONSTRAINT_NOTNULL dispatcher; batch import rows not null column key label.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 26, 2026
Claim: PM loop round 5 · card thread
Session:
session_01R1q2xdtYgU6FKrZ7LgjPCC(the seat); the card thread issession_01E1oXN1ZJ8NnZKKhzWeCZ6qAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-otcr21(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree:
objectstack-issue-24768(a linked worktree in the card thread's container)Domain:
domain:serverSeat:
domain:server#1Ruling-ref: the seat's ruling among the card's options: A (the engine translates a driver NOT NULL refusal on a declared field into its own labelled, localized
ValidationError, so every door answers400 VALIDATION_FAILED); ⛔ not B, ⛔ not C; ADR-0113 D1/D2 keeprequiredandstorage.notNullapartFile surface:
packages/objectql/**(the driver-refusal translation beside the record validator'srequired), the dispatcher/dataexits only if a test pins them,packages/rest/src/not-null-refusal-label.ts(retired or narrowed to non-engine paths), driver error codes read frompackages/plugins/driver-*if needed, their tests, one changeset. ⛔ Nopackages/spec/**(stop on breach; explain in the report)Container & model:
M,mode:thread, default model and effort (set at thread open)Clause-②: no
Metadata-protocol: none
Responsibility: a database NOT NULL refusal answers
500 INTERNAL_ERRORat the dispatcher/datatwin and names the column key at batch/import/form/action doors, whileRestServerPOST/PATCH answers a labelled400(measured by PR #24759's dev at7e7e605de)Thread-read: none
Premise: holds (read 2026-10-26T06:59Z at
origin/main0c37228826: card open, unassigned,pm:queue, no comment and no other seat's claim; the dev reproduces red first)Serial constraints cleared: no open PR names #24768; the dev re-measures open PRs on
packages/objectql/**andpackages/rest/**beforepr_createobjectstack-fleet commentedon Oct 26, 2026
Claim amendment (file surface) ·
domain:server#1· seat sessionsession_01R1q2xdtYgU6FKrZ7LgjPCC· 2026-10-26T07:12Z · amends claim 6410180146, same session, same branchclaude/project-thread-otcr21.File surface adds, at the dev's measured request:
packages/types/src/data-error-classification.tsand its tests (one new sibling test): export ONE cause-walking dialect NOT NULL reader built from the module-privatenotNullViolationSite, so the engine reads the same dialect vocabulary (SQLite / libsql / PostgreSQL23502/ MySQLER_BAD_NULL_ERROR1048) and objectql adds none of its own.packages/rest/src/not-null-refusal-label.ts, its two call sites inpackages/rest/src/crud-routes.tsandrest-server.ts, the REST pins of the relabelled body (data-not-null-field-label-24715.test.ts,data-not-null-hint-20963.test.ts, moved to the engine's answer, never loosened), and the then-unusednotNullRefusalSite/relabelNotNullRefusal/declaredNotNullHintand their WeakMap inpackages/types. Condition: before removing an export from@objectstack/types, grepui/**,examples/**and objectstack-ai/hotcrm for each name; any consumer keeps the export and is named in the report. The changeset declares the removed exports.packages/runtime/src/dispatcher-not-null-refusal-24768.test.ts(new).⛔ Still no
packages/spec/**.Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Claim amendment (file surface) ·
domain:server#1· seat sessionsession_01R1q2xdtYgU6FKrZ7LgjPCC· 2026-10-26T08:17Z · amends claim 6410180146 (after amendment 6410372925), same session, same branchclaude/project-thread-otcr21.File surface adds, at the dev's measured request, test only:
packages/plugins/plugin-auth/src/last-admin-guard.anchor-parity.test.ts: the case "fresh tenant column, no organization object, single: refused-not-null" moves its pin from the raw driver code to the engine's answer under ruling A (VALIDATION_FAILED,fieldsnamingorganization_id/required,cause.codestillSQLITE_CONSTRAINT_NOTNULL, no rows), so it still proves the column refused the grant and stays distinct from the refused-d9 case. Moved, never loosened.⛔ No production code in
packages/plugins/plugin-auth/**. ⛔ Still nopackages/spec/**.Generated by Claude Code
test(plugin-auth): the anchor-parity NOT NULL case reads the engine's…
objectstack-fleet commentedon Oct 26, 2026
os-dev-report
{
"issue": 24768,
"status": "done",
"branch": "claude/project-thread-otcr21",
"pr": "#26026",
"session": "session_01E1oXN1ZJ8NnZKKhzWeCZ6q",
"premise_still_valid": true,
"summary": "Ruling A implemented. The engine's five driver write exits translate a driver NOT NULL refusal on a field the object's field map holds into NotNullRefusalError extends ValidationError. It is built by the record validator's buildFieldError with the validator's message context (label, locale, required on insert, required_cleared on update), and the driver's error rides cause. Every door answers it through its existing ValidationError arm: the dispatcher /data POST and PATCH move from 500 INTERNAL_ERROR to 400 VALIDATION_FAILED, and the actions door from 400 VALIDATION_ERROR with driver text to 400 VALIDATION_FAILED. A column no field declares, another table's refusal, and a NOT NULL code with no column keep today's answer. No pre-write check reads storage.notNull. @objectstack/types gains notNullViolationSite (codes per dialect, then text, then cause chain) and retires notNullRefusalSite and relabelNotNullRefusal (zero consumers in ui/, examples/, hotcrm). PR #24759's REST relabel retires with its host member and call sites. Departures to review: (1) Clause-② is declared yes (narrowing), measured, against the claim's no. (2) The declared field's RestServer body drops the classification hint and reads required_cleared on PATCH, the validator's own shape. (3) A drifted column that a field still declares is now labelled. (4) Surface amendments 6410372925 (types reader, REST relabel retirement, runtime test) and 6411384588 (plugin-auth anchor-parity pin: the declared organization_id refusal is now VALIDATION_FAILED with SQLITE_CONSTRAINT_NOTNULL on cause).",
"tests": "Red first: packages/runtime/src/dispatcher-not-null-refusal-24768.test.ts on main dist 3 failed (POST/PATCH: expected VALIDATION_FAILED, the thrown SqliteError rendered 500; action: expected 'VALIDATION_ERROR' to be 'VALIDATION_FAILED'), on the fix 3 passed. New objectql engine-not-null-refusal.test.ts 18 passed. types data-error-classification.not-null-label.test.ts 14 passed. rest data-not-null-field-label-24715.test.ts 8 passed, data-not-null-hint-20963 green. driver-fault-boundary-redaction 168 passed. plugin-auth anchor-parity 13 passed. Full --no-bail run at the pre-pin head: types 781, objectql 8756, rest 6469 (+326 skipped), runtime 7214 (+19 skipped), plugin-audit 993, service-package 125 all passed, plugin-auth 1 failed / 3720 passed, the one pin since moved. Ablation: envelopeDriverWriteRefusal mutated to return the unique translation only via ablation-replace --hold, objectql rebuilt, ablation-dist-preflight exit 0 (marker in dist). Red: runtime 3/3, rest 24715 7/8, objectql 12/18, plugin-auth anchor-parity 1/13. Restored: git diff HEAD empty, hash-object equals HEAD blob, preflight --absent exit 0, rebuilt, all four files green.",
"gates": "Head 50c3929. dispatch-gates --commands derived 77. All 77 ran with exit codes captured before any pipe and exit 0. check:dual-build-cjs-loads exited 3 (no dist/) before a full turbo run build (121/121 tasks), then 0 on rerun. dispatch-gates --ran: 77 derived, 77 run, 0 NOT-MEASURED. eslint --no-inline-config over types/objectql/rest/runtime src plus the plugin-auth test: exit 0. typecheck types/objectql/rest/runtime/plugin-auth: exit 0 each. check-platform-checklist --changed-from origin/main: OK, 33 anchors, 0 lost. No open PR touches packages/objectql/** or packages/rest/** (23 open PRs read). PR check-runs: not yet reported, the seat holds the PR's events.",
"dogfood_battery": "50c3929ba3 · os-verify-lock: VERDICT command-exit 1 · held the lock 1132s · Test Files 1 failed | 295 passed | 3 skipped (299) · Tests 1 failed | 2522 passed | 18 skipped (2541) · failure: 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). Not this change: the census reads source text, and this PR touches neither it nor i18n.ts. origin/main's i18n.ts holds handleI18nRequest 4 times since 69fa23d (#25956), against 3 at its parent, so it is red on main by construction.",
"mcp_calls": "3 — mcp__github__issue_read (get, get_comments ×2); no write tool",
"api_writes": "3 — git push of branch claude/project-thread-otcr21 (not a REST write); relay pr_create (POST /repos/objectstack-ai/objectstack/dispatches → fleet-write run 40299835664 → PR #26026 with assignee os-project-manager); this os-dev-report comment through scripts/pm/post-stamped.mjs on #24768",
"open_questions": [
{
"status": "needs_decision",
"question": "Should platform-filled columns (the tenant scope organization_id, audit and owner columns: hidden, readonly, the platform stamps them) be excluded from the translation and keep the driver's raw error?",
"options": [
"A — keep as shipped: every field in the object's field map is translated. A tenant-column refusal (for example an organization-less grant under a posture with no organization object) answers 400 VALIDATION_FAILED, 'Organization is required'.",
"B — exclude fields declared system: true (the registry's injected tenant/audit/owner defs). Their refusal stays the raw driver error (500 at the dispatcher). An object that declares organization_id itself (sys_user_permission_set does, without system: true) is still translated, so the line is drawn by a flag, not by role."
],
"recommendation": "A. Real business need: the measured case is a grant insert the platform could not stamp, and a 400 naming the field is truer than a 500, which tells the operator nothing. No caller is measured that needs the raw code; plugin-auth's pin reads it from cause. Long-term soundness: B keys behaviour off the system flag, which the injected defs carry but explicitly declared tenant columns do not, so the same column would answer two ways depending on who declared it. That is the per-door split this card removes. Guarding AI authoring: A has one rule, a declared field's NOT NULL refusal is the validator's error, with no flag to learn. Startup focus: A adds nothing; B adds an exclusion and a test. The cost of A is that a form receives code 'required' on a field it never shows, which is a platform fault surfacing as a field error. If that is judged worse, B is a one-line follow-up."
}
],
"out_of_scope_findings": [
"carrier: PR #25956 (69fa23d) · the dogfood census test/authz-probe-blind-spot.test.ts pins 3 handleI18nRequest controls in packages/runtime/src/domains/i18n.ts, and main has 4 since that commit, so the full dogfood battery is red on main. This is a stale test census, not a product door. Evidence: grep -c at 69fa23d^ = 3, origin/main = 4. Noted in PR #26026's Dogfood section, not filed. Dedupe words: authz-probe-blind-spot, handleI18nRequest, i18n census",
"carrier: PR #24759 noted the dispatcher /data premise (500) that this card fixes · noted, not filed"
]
}
objectstack-fleet commentedon Oct 26, 2026
os-dev-report
{
"issue": 24768,
"status": "done",
"branch": "claude/project-thread-otcr21",
"pr": "#26026",
"session": "session_01E1oXN1ZJ8NnZKKhzWeCZ6q",
"premise_still_valid": true,
"summary": "Ruling A implemented. The engine's five driver write exits translate a driver NOT NULL refusal on a field the object's field map holds into NotNullRefusalError extends ValidationError. It is built by the record validator's buildFieldError with the validator's message context (label, locale, required on insert, required_cleared on update), and the driver's error rides cause. Every door answers it through its existing ValidationError arm: the dispatcher /data POST and PATCH move from 500 INTERNAL_ERROR to 400 VALIDATION_FAILED, and the actions door from 400 VALIDATION_ERROR with driver text to 400 VALIDATION_FAILED. A column no field declares, another table's refusal, and a NOT NULL code with no column keep today's answer. No pre-write check reads storage.notNull. @objectstack/types gains notNullViolationSite (codes per dialect, then text, then cause chain) and retires notNullRefusalSite and relabelNotNullRefusal (zero consumers in ui/, examples/, hotcrm). PR #24759's REST relabel retires with its host member and call sites. Departures to review: (1) Clause-② is declared yes (narrowing), measured, against the claim's no. (2) The declared field's RestServer body drops the classification hint and reads required_cleared on PATCH, the validator's own shape. (3) A drifted column that a field still declares is now labelled. (4) Surface amendments 6410372925 (types reader, REST relabel retirement, runtime test) and 6411384588 (plugin-auth anchor-parity pin: the declared organization_id refusal is now VALIDATION_FAILED with SQLITE_CONSTRAINT_NOTNULL on cause). Rework round (#26065, the merge-queue timeout of plugin-security shipped-sets-identity-default-row-scope.test.ts): independent of #26026. The file makes zero calls into the changed write-refusal path (stderr probe on the source the suite aliases: 0 hits). At 24 busy loops on 4 cores, its restored-copies sweep crossed the 5000ms default on main bbf7061 without #26026 (2/2 rounds, 7468ms and 7130ms for the isolated-baseline case) exactly as on this branch (2/2, 7850ms and 6891ms). At 8 busy loops the two trees were equal, 2370 to 2881ms against 2397 to 2489ms. Profile: about 3ms per real-stack read, spread across security resolution, no hotspot. The case did about 400 reads (9 personas × 23 objects × 2 stacks). Per the seat's ruling (fix the cost, not the clock), commit 9852ed8 splits both sweeps into one case per object, each over the full persona list. The reads and verdicts are the same, at most 18 reads per case, and no timeout is raised. Under the same 24-loop load the slowest case is now 453 to 463ms (2/2 runs, 137/137 passed), where it was 5.7 to 7.9s. The total reads are unchanged: the sweep's sum is still 7.1 to 7.6s, now spread over 23 cases. The commit carries no closing keyword because pre-push check:commit-card-trailers refuses one, so
Fixes #26065belongs in the PR body (a seat write).","tests": "Red first: packages/runtime/src/dispatcher-not-null-refusal-24768.test.ts on main dist 3 failed (POST/PATCH: expected VALIDATION_FAILED, the thrown SqliteError rendered 500; action: expected 'VALIDATION_ERROR' to be 'VALIDATION_FAILED'), on the fix 3 passed. New objectql engine-not-null-refusal.test.ts 18 passed. types data-error-classification.not-null-label.test.ts 14 passed. rest data-not-null-field-label-24715.test.ts 8 passed, data-not-null-hint-20963 green. driver-fault-boundary-redaction 168 passed. plugin-auth anchor-parity 13 passed. Full --no-bail run at the pre-pin head: types 781, objectql 8756, rest 6469 (+326 skipped), runtime 7214 (+19 skipped), plugin-audit 993, service-package 125 all passed, plugin-auth 1 failed / 3720 passed, the one pin since moved. Ablation: envelopeDriverWriteRefusal mutated to return the unique translation only via ablation-replace --hold, objectql rebuilt, ablation-dist-preflight exit 0 (marker in dist). Red: runtime 3/3, rest 24715 7/8, objectql 12/18, plugin-auth anchor-parity 1/13. Restored: git diff HEAD empty, hash-object equals HEAD blob, preflight --absent exit 0, rebuilt, all four files green. Rework head 9852ed8 (main merged at a52cfaf): --no-bail types 781, objectql 8768, plugin-security 4699 before the split and 4804 after, rest 6469, plugin-auth 3742, runtime 7408, all passed. Full build 121 tasks green.",
"gates": "Head 9852ed8. dispatch-gates --commands derived 79. All 79 ran and exit 0, with exit codes captured before any pipe. dispatch-gates --ran: 79 derived, 79 run, 0 NOT-MEASURED. eslint --no-inline-config on the changed test file: 0. check:commit-card-trailers over origin/main..HEAD: clean. Earlier round at 50c3929: 77/77, typecheck and lint as before.",
"dogfood_battery": "50c3929ba3 · os-verify-lock: VERDICT command-exit 1 · held the lock 1132s · Test Files 1 failed | 295 passed | 3 skipped (299) · Tests 1 failed | 2522 passed | 18 skipped (2541) · failure: 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). Not this change: the census reads source text, and this PR touches neither it nor i18n.ts. origin/main's i18n.ts holds handleI18nRequest 4 times since 69fa23d (#25956), against 3 at its parent, so it is red on main by construction. Not re-run on 9852ed8: the rework changes one plugin-security test file and merges main.",
"mcp_calls": "5 — mcp__github__issue_read (get, get_comments ×2), mcp__github__get_job_logs ×2 (jobs 121516883667, 121526898046); no write tool",
"api_writes": "5 — git push ×2 (50c3929, 9852ed8; not REST writes); relay pr_create (PR #26026); this report through post-stamped on #24768, posted twice: 6412700035, then this one for the rework head",
"open_questions": [
{
"status": "needs_decision",
"question": "Should platform-filled columns (the tenant scope organization_id, audit and owner columns: hidden, readonly, the platform stamps them) be excluded from the translation and keep the driver's raw error?",
"options": [
"A — keep as shipped: every field in the object's field map is translated. A tenant-column refusal (for example an organization-less grant under a posture with no organization object) answers 400 VALIDATION_FAILED, 'Organization is required'.",
"B — exclude fields declared system: true (the registry's injected tenant/audit/owner defs). Their refusal stays the raw driver error (500 at the dispatcher). An object that declares organization_id itself (sys_user_permission_set does, without system: true) is still translated, so the line is drawn by a flag, not by role."
],
"recommendation": "A. Real business need: the measured case is a grant insert the platform could not stamp, and a 400 naming the field is truer than a 500, which tells the operator nothing. No caller is measured that needs the raw code; plugin-auth's pin reads it from cause. Long-term soundness: B keys behaviour off the system flag, which the injected defs carry but explicitly declared tenant columns do not, so the same column would answer two ways depending on who declared it. That is the per-door split this card removes. Guarding AI authoring: A has one rule, a declared field's NOT NULL refusal is the validator's error, with no flag to learn. Startup focus: A adds nothing; B adds an exclusion and a test. The cost of A is that a form receives code 'required' on a field it never shows, which is a platform fault surfacing as a field error. If that is judged worse, B is a one-line follow-up."
}
],
"out_of_scope_findings": [
"carrier: PR #25956 (69fa23d) · the dogfood census test/authz-probe-blind-spot.test.ts pins 3 handleI18nRequest controls in packages/runtime/src/domains/i18n.ts, and main has 4 since that commit, so the full dogfood battery is red on main. This is a stale test census, not a product door. Evidence: grep -c at 69fa23d^ = 3, origin/main = 4. Noted in PR #26026's Dogfood section, not filed. Dedupe words: authz-probe-blind-spot, handleI18nRequest, i18n census",
"carrier: PR #24759 noted the dispatcher /data premise (500) that this card fixes · noted, not filed",
"carrier: #26065 · plugin-security shipped-sets-identity-default-row-scope, the 5000ms queue flake, independent of #26026 and fixed in this PR by 9852ed8"
]
}
objectstack-fleet commentedon Oct 26, 2026
Landed
PR #26026 merged at 2026-10-26T13:34Z by the merge queue as
48e84a90afonmain(Fixes #24768; ACCEPT6416623973).git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26026-*'origin/mainby contentgit cat-file -e origin/main:packages/objectql/src/not-null-refusal-error.tsgit merge-base --is-ancestor 48e84a90af origin/mainState: this card closed by
Fixes(completed);pm:dispatchedremoved.Generated by Claude Code