Skip to content

fix(objectql,types,rest)!: a database NOT NULL refusal on a declared field is the record validator's ValidationError at every door - #26026

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

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-otcr21

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #24768
Fixes #26065

Clause-②: yes (narrowing: @objectstack/types adds the export notNullViolationSite and retires notNullRefusalSite and relabelNotNullRefusal; 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 } without required is ADR-0113's engine-populated column. The record validator judges required only (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/main 0c37228826 with a real better-sqlite3 store, the real ObjectQL engine and protocol, and HttpDispatcher.dispatch() over a Fetch Request (the rig is packages/runtime/src/dispatcher-not-null-refusal-24768.test.ts):

Door Before After
dispatcher POST /data/:object without the field raw SqliteError (SQLITE_CONSTRAINT_NOTNULL) out of dispatch(); declaredEnvelopeForThrow and validationFailureDetails both decline it, so 500 INTERNAL_ERROR 400 VALIDATION_FAILED, Sales Price is required, details.fields = the validator's envelope with label
dispatcher PATCH /data/:object/:id writing null same 500 INTERNAL_ERROR 400 VALIDATION_FAILED, the clear-out sentence (required_cleared)
actions door, declarative operation: 'update' writing null 400 VALIDATION_ERROR, the driver's text as message, declaredCode: 'SQLITE_CONSTRAINT_NOTNULL' 400 VALIDATION_FAILED with the field envelope, no declaredCode, no driver text
RestServer POST / PATCH 400 VALIDATION_FAILED, relabelled by a second object read (#24715) plus a hint the validator's required body exactly: fields[0] carries label, no hint, PATCH reads required_cleared
RestServer POST /data/:object/batch row the column key (the card's measurement; not re-measured here) row error { code: 'VALIDATION_FAILED', message: 'Sales Price is required' }

Import rows, the public form submit and MCP write through the same engine doors and answer ValidationError through the same arms; they were not driven separately.

The change

  • @objectstack/objectql (not-null-refusal-error.ts, new). envelopeNotNullViolation turns a driver NOT NULL refusal on a field the object's field map holds into NotNullRefusalError extends ValidationError, built by the validator's own buildFieldError with the validator's message context: one field error, code: 'required', named by its label (translation bundle, then declared label, then API name) in the caller's locale, with the sentence the validator gives the same verb (required on insert, required_cleared on update). The driver's error rides cause. envelopeDriverWriteRefusal composes 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 and isUniqueViolationError is not reachable from an app #14095) and replaces envelopeUniqueViolation at all five driver exits in engine.ts (both autonumber-retry sites, the bulkCreate exit, by-id update, updateMany). The two write-failure log lines log cause for this class too, so the log still carries the database's diagnosis. Where the error leaves the engine, redactPropagatedDriverFault cuts its cause like any driver error.
  • Left as the driver threw it: a column no field declares (drift), a refusal the driver attributes to another table, and a NOT NULL code whose column the driver did not name. ⛔ No pre-write check reads storage.notNull: required and storage.notNull stay apart (ADR-0113 D1 / D2).
  • @objectstack/types. New notNullViolationSite(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-sqlite3 SQLITE_CONSTRAINT_NOTNULL; libsql extendedCode / rawCode: 1299; node-postgres 23502 with column / table; mysql2 ER_BAD_NULL_ERROR / errno: 1048), then its text (all sql.js gives), then down its cause chain. An error whose code names another refusal is not read for NOT NULL text. notNullRefusalSite and relabelNotNullRefusal retire. 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.ts and its test) retires with its CrudRouteHost.labelNotNullRefusal member and call sites: every path it served is an engine write. crud-routes.ts goes back to const context inside the try. objectItemReader stays (the exposure gate reads it); its doc no longer mentions the relabel.

Interpretations for review

  • Clause-② departs from the claim's no. Measured: two exports of @objectstack/types' published entry retire, and the dispatcher's 500 becomes a 400 with a different body, so this declares yes (narrowing). The seat's contract review applies.
  • required_cleared on update. Same sentence the validator gives a PATCH that clears a required field, so the two refusals read alike.
  • No hint for a declared field. The validator's required envelope has none. The undeclared-column answer keeps the classification's column sentence and drift hint.
  • A drifted column that a field still declares is now labelled (the 24715 drift control now reads Drift Column with required_cleared).
  • The table check. A nested write's refusal on another object is left for that object's door to name.
  • The tenant column is a declared field. sys_user_permission_set declares organization_id, so plugin-auth's anchor-parity case "fresh tenant column, no organization object, single" now gets VALIDATION_FAILED naming organization_id, with SQLITE_CONSTRAINT_NOTNULL on cause. 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 on main (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 own required, and the controls (undeclared column, other table, 23502 with no column, unique still DuplicateRecordError, required still 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) and data-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 as NotNullRefusalError with a cut cause of the same prototype and code.
  • The duplicate-record controls' NOT NULL fixture now names an undeclared column; plugin-auth's anchor-parity pin as above.
  • Full --no-bail run of types (781), objectql (8756), rest (6469), runtime (7214), plugin-audit (993), plugin-auth (3720 + the moved pin) and service-package (125): green.

Ablation

envelopeDriverWriteRefusal mutated to return the unique translation only (scripts/ablation-replace.mjs --hold), objectql rebuilt and ablation-dist-preflight confirmed the mutation in dist/. Red: runtime 3/3, rest 24715 7/8, objectql 12/18, plugin-auth anchor-parity 1/13. Restored: git diff HEAD empty, file hash equals HEAD's blob, preflight --absent 0, rebuilt, all four green.

Gates

On head 50c3929ba3: dispatch-gates --commands derived 77; all 77 ran and exit 0 (check:dual-build-cjs-loads re-run after a full turbo run build, its first run exit 3 for missing dist/). dispatch-gates --ran: 77 derived, 77 run, 0 NOT-MEASURED. eslint --no-inline-config over the touched packages' src: 0. typecheck for types, objectql, rest, runtime, plugin-auth: 0. check-platform-checklist --changed-from origin/main: OK. No open PR touches packages/objectql/** or packages/rest/**.

Dogfood

pnpm --filter @objectstack/dogfood test under the verify lock, after a full turbo run build on head 50c3929ba3: 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 nor i18n.ts, and origin/main's i18n.ts holds handleI18nRequest 4 times since 69fa23d60b (#25956), against 3 at its parent. It is red on main by construction and is reported, not fixed here.

Acceptance notes

  • Every HTTP door answers a database NOT NULL refusal on a declared field as 400 VALIDATION_FAILED, the field named by its label, in the shape the record validator gives required.
  • A column no field declares keeps today's answer (column sentence, drift hint).
  • No pre-write check reads storage.notNull.
  • The REST relabel from fix(types,rest): a /data write's NOT NULL refusal names the field by its label #24759 is gone; the two @objectstack/types exports it used retire with a declared migration.

Generated by Claude Code

…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>
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/rest, @objectstack/types, touching 36 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/common-patterns.mdx (via unit_price (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/kernel/i18n-standard.mdx (via fieldLabel (symbol, a top-level function))
  • content/docs/protocol/objectui/layout-dsl.mdx (via unit_price (literal, a string literal in a comment on a changed line))
  • content/docs/ui/forms.mdx (via /forms/:slug (route, bridged from symbol resolveI18nService — its route source's handler names it))
  • content/docs/ui/public-data-collection.mdx (via /forms/:slug (route, bridged from symbol resolveI18nService — its route source's handler names it))

⛔ 7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v12.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v16.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v17/17-3.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via /forms/:slug (route, bridged from symbol resolveI18nService — its route source's handler names it))
  • content/docs/releases/v17/17-7.mdx (via /forms/:slug (route, bridged from symbol resolveI18nService — its route source's handler names it))

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
  • 1 anchor(s) matched too much of the corpus to be a work list: VALIDATION_FAILED (literal, 33 pages)
  • 6 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 — 34 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 f6c619329a447f31859da9cad75f6084c430c810 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 61f346e551b77ff956035099ed13f9a0698fa89f — the merge of head 9852ed899dfb3c55df2b79ffbec7379711b925b6 into base f6c619329a447f31859da9cad75f6084c430c810, 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 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

⚠️ 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 f6c619329a447f31859da9cad75f6084c430c810 → 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: CONTRACT_REVIEW_TIER
Head-sha: 50c3929ba378fb3f724a572830a7e96aadc74eae
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/not-null-refusal-error.ts, packages/objectql/src/engine-not-null-refusal.test.ts, packages/objectql/src/driver-fault-boundary-redaction.test.ts, packages/objectql/src/engine-insert-duplicate-record.test.ts, packages/objectql/src/engine-update-duplicate-record.test.ts, packages/types/src/data-error-classification.ts, packages/types/src/data-error-classification.not-null-label.test.ts, packages/rest/src/crud-routes.ts, packages/rest/src/rest-server.ts, packages/rest/src/not-null-refusal-label.ts (removed), packages/rest/src/not-null-refusal-label.test.ts (removed), packages/rest/src/data-not-null-field-label-24715.test.ts, packages/rest/src/data-not-null-hint-20963.test.ts, packages/runtime/src/dispatcher-not-null-refusal-24768.test.ts, packages/plugins/plugin-auth/src/last-admin-guard.anchor-parity.test.ts + packages/types/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/core.ts, packages/rest/src/index.ts, packages/objectql/src/validation/record-validator.ts, packages/types/src/validation-failure.ts, packages/types/src/thrown-http-error.ts, packages/types/src/driver-fault-redaction.ts, packages/plugins/plugin-hono-server/src/adapter.ts, packages/runtime/src/dispatcher-plugin.ts, packages/runtime/src/domains/actions.ts, packages/rest/src/error-response.ts, packages/core/src/utils/import-runner.ts, packages/mcp/src/mcp-http-tools.ts, packages/drivers/driver-sql/src/sql-driver.ts, packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/data/injected-system-column-provenance.ts, packages/objectql/src/registry.ts, packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts, docs/adr/0112-error-code-vocabulary-and-ledger.md, docs/adr/0113-required-write-contract-vs-column-constraint.md, scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, .changeset/24768-not-null-refusal-every-door.md (prose face)

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 judgments

The Clause-② declaration is well-formed and right. Clause-②: yes (narrowing: …) is read by readClause2Line as value: yes, arm: narrowing (the parenthetical's first token is the arm; trailing prose is tolerated), the combination clause2-line.mjs documents as "a diff that widens one surface and narrows another". Both facts are measured on the committed tree:

  • Widening (yes is required): packages/types/src/index.ts:98 is export * from './data-error-classification.js', so the new export function notNullViolationSite(error) is a new member of @objectstack/types' published entry.
  • Narrowing (the (narrowing) arm is required): the same entry loses notNullRefusalSite and relabelNotNullRefusal (both exported on origin/main at data-error-classification.ts:2155 and :2181); the dispatcher /data wire moves 500 INTERNAL_ERROR to 400 VALIDATION_FAILED; RestServer /data POST/PATCH drops the hint key and adds label to fields[0] for a declared field. ADR-0112's own precedent ([finding] error-catalog.mdx publishes some error statuses only in prose or a JSON example, so check:error-status-conformance grades them as nothing — three entries publish no status it can read at all #9266 amendment) names a retirement of this kind "accept-set narrowing".
  • No new error code: VALIDATION_FAILED is a ledger member (error-code-ledger.zod.ts:302); nothing is added to ERROR_CODE_LEDGER or StandardErrorCode, so the "new code ⇒ yes" rule is not what makes this yes.
  • The PR body and the changeset carry the identical line, bare, at line start, and Metadata-protocol: none beside it on both. The claim's no was wrong once the amended surface added the @objectstack/types export change; the dev measured the departure and declared it. The face is therefore the narrowing face, which is CONTRACT_REVIEW_TIER (record-recognisers --paths finds no schema or governed path in the file list and defers to the Clause-② arm).

One answer per declared-field NOT NULL refusal, door by door (status and code read from the exits' code and the PR's pins):

  • Engine (engine.ts, five driver exits: both autonumber-retry sites, bulkCreate/per-row fallback, by-id update, updateMany): envelopeDriverWriteRefusal composes the existing envelopeUniqueViolation first, then envelopeNotNullViolation; a NOT NULL refusal whose column the object's field map holds (own-property check, same-table check) leaves as NotNullRefusalError extends ValidationError, code: 'VALIDATION_FAILED', one field error built by the validator's buildFieldError with the engine's validationMessageContext (label from bundle, then label, then API name; required on insert, required_cleared on update), driver error on cause. Pinned in engine-not-null-refusal.test.ts (18: every exit, five dialect fixtures, zh-CN/zh parity with the validator's own required, and five controls).
  • Dispatcher /data POST and PATCH (HttpDispatcher.dispatch() throws; both exits): resolveThrownHttpError treats the validation shape as a declared 400 with VALIDATION_FAILED (thrown-http-error.ts:197-230); declaredEnvelopeForThrow (plugin-hono-server/src/adapter.ts:354) therefore renders 400 with error.code: VALIDATION_FAILED and details.fields; dispatcher-plugin.ts:697-701 reads the same validationFailureDetails to VALIDATION_FAILED_STATUS. Pinned in dispatcher-not-null-refusal-24768.test.ts on a real better-sqlite3 store: hono.status 400, error.code, error.message from renderValidationMessage, details.fields with label, and validationFailureDetails(thrown).code; was 500 on main (red first, 3 failed).
  • Actions door (domains/actions.ts:1040-1113): a ValidationError is not an unexpectedFault (validationFailureDetails matches) and exits errorFromThrown(err, 400), so error.code is VALIDATION_FAILED with details.fields, no declaredCode; before, the raw driver error's string .code was demoted to declaredCode and error.code fell to the status-derived VALIDATION_ERROR with the driver's text as message. Pinned (status, code, message, fields, declaredCode absent, no driver text on the wire).
  • RestServer POST/PATCH /data and POST /data/:object/batch rows: crud-routes.ts now calls mapDataError only; classifyDataError's validation arm (data-error-classification.ts:1184-1194) answers 400 VALIDATION_FAILED with fields passed through, so label rides and no hint is added. Pinned in data-not-null-field-label-24715.test.ts with toEqual on the whole body (keys code, error, fields, object), both locales, the batch row's { code, message }, and the undeclared-column control keeping the column sentence plus hint.
  • Import rows (core/src/utils/import-runner.ts:521), the form submit (form-routes.ts) and data actions (data-action-routes.ts) classify through the same mapDataError, and the MCP tool error (mcp-http-tools.ts:337-343, thrownRefusalEnvelope reads .code) now sees VALIDATION_FAILED instead of the driver's code. These are the same arm by construction and were not driven on a real store in this PR (the dev says so); see ③.

Tests assert code and status per ADR-0112, never toThrow() alone: every door pin above asserts the HTTP status and the nested or flat code, and the engine pins assert .code, .name and the field envelope. The sentence is asked of renderValidationMessage in every test, not spelled.

Driver text does not leak. The envelope's message is the validator's sentence (engine pin: not.toMatch(/constraint failed|unit_price/)); the dispatcher and actions pins reject /constraint failed|SQLITE_/ on the serialised body; the REST batch pin rejects /constraint failed|unit_price is required/. cause never reaches a wire body (none of the three exits serialises it). At the engine boundary redactPropagatedDriverFault (driver-fault-redaction.ts:1006-1030) walks the own cause property and cuts its statement text; driver-fault-boundary-redaction.test.ts pins, for the three NOT NULL shapes × four write doors, instanceof NotNullRefusalError, code, fields, and a cause of the raw error's prototype and code. The write-failure log lines log the cause (the database's own diagnosis), pinned.

What is public. NotNullRefusalError, envelopeNotNullViolation and envelopeDriverWriteRefusal are not exported from packages/objectql/src/index.ts or core.ts; the public contract a consumer sees is the already-published ValidationError (instanceof true, code: 'VALIDATION_FAILED', fields). CrudRouteHost.labelNotNullRefusal (removed) is on an interface packages/rest/src/index.ts does not export, and RestServer.labelNotNullRefusal was private. The only published-surface movement is in @objectstack/types: + notNullViolationSite, - notNullRefusalSite, - relabelNotNullRefusal (NotNullRefusalSite stays). Zero consumers of the two retired names outside the retired REST relabel (the dev's grep of ui/**, examples/**, hotcrm; the ADR-0087 runtime-export-retired gate re-checks the metadata surfaces at the merge base and is green on this head).

Scope vs the claim. Every production path is inside the claim plus its two amendments (packages/objectql/**, packages/types/src/data-error-classification.ts and test, the REST relabel retirement and its two call sites, the runtime test, the plugin-auth test pin). No packages/spec/**, no production code in plugin-auth, no driver package edited (dialect codes are read in @objectstack/types, as amendment 6410372925 asked). No pre-write check reads storage.notNull (pinned: required still refuses before the driver, driver.create never called).

② Semver level

  • Changeset .changeset/24768-not-null-refusal-every-door.md: @objectstack/objectql: patch, @objectstack/types: patch, @objectstack/rest: patch. Under ruling A (check-changeset-no-major.mjs, level follows the Metadata-protocol: kind alone) none takes patch; a non-protocol widening-plus-narrowing ships patch and keeps its BREAKING carriers. Correct. @objectstack/runtime and @objectstack/plugin-auth receive tests only and are rightly absent.
  • Clause-②: yes (narrowing: …) and Metadata-protocol: none on the PR body and the changeset, byte-identical, bare at line start. Correct (①). Metadata-protocol: none is true: no spec key, value, type or default moves.
  • **BREAKING** banner present, naming both facts (export retirement, wire-answer change). The migration is in the body as FROM → TO lines for both retired exports (notNullRefusalSite(answer) → notNullViolationSite(error); relabelNotNullRefusal(...) → none, write through the engine). The REST wire change (no hint, label on fields[0], required_cleared on PATCH) and the dispatcher 500 → 400 are stated in the body.
  • ADR-0087 disposition: exactly one marker, not-required (runtime-export-retired @objectstack/types#notNullRefusalSite, @objectstack/types#relabelNotNullRefusal) …; runtime-export-retired is a CATEGORIES member of check-adr-0087-registration.mjs, the symbols are named in the package#symbol form that category reads, and the Check Changeset job (which carries the "Require an ADR-0087 disposition" step) is success on this head. No stored metadata moves, so objectstack migrate meta has nothing to rewrite; the disposition is right.

③ Boundary flags

  1. Platform-filled columns (dev open_questions[0], needs_decision) — a contract reading, then a product call. There is no published contract that makes a platform-filled NOT NULL violation answer differently from a caller's. ADR-0113 D1/D2 split required (write contract) from storage.notNull (column constraint) and say nothing about who fills the column. ADR-0112 keys server-fault handling on the status channel and treats a 5xx as the withholding scope; the one measured posture (ADR-0131 D9, "no organization object, single") is one where the engine deliberately leaves the row organization-less and lets the column refuse, and TENANT_SCOPE_FIELD_DEF's own description (injected-system-column-provenance.ts:121) calls that outcome "a write that names no organization is refused", a refusal, not a fault. In every other posture the engine refuses the same write before the driver as ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, a declared 4xx. So on contract grounds option A (as shipped) is consistent, and nothing requires a 500. Whether a hidden, readonly, system: true column should surface to a form as fields[0].code: 'required' is a product decision; I do not decide it. Recommendation: keep A. Two facts argue against B as written: sys_user_permission_set declares organization_id itself without system: true (sys-user-permission-set.object.ts:110), so B would not change the measured case, and B keys behaviour off a flag the injected and the declared spellings of the same column do not share, re-creating a per-declarer split. If the maintainer wants the platform-fault reading, the right seam is the engine's ADR-0131 D9 exit (refuse before the driver in the no-organization-object posture too, with the declared code), not an exclusion in the translation.
  2. Doors not driven on a real store in this PR: import rows, the public form submit, the generic /batch, and MCP. Each reaches the same ValidationError arm (mapDataError for the REST three; thrownRefusalEnvelope for MCP), so the answer follows by construction, but no pin names the NOT NULL class there. Not a defect of this card; a follow-up pin per door would close it.
  3. The undeclared column stays divergent across doors (REST: 400 VALIDATION_FAILED column sentence + drift hint; dispatcher /data: 500 INTERNAL_ERROR; actions: 400 VALIDATION_ERROR with the driver's text in message). Ruling A scoped the translation to "a field the object declares", so this is outside the card, but the card's title ("one answer per refusal across doors") is not fully met for drift. Worth its own card (direction: the dispatcher exits recognising mapDataError's NOT NULL arm, which is the card's option B restricted to that class).
  4. Departures the dev flagged, all accepted here: (a) Clause-② yes (narrowing) against the claim's no (①, correct); (b) RestServer's declared-field body loses hint and reads required_cleared on PATCH (the validator's own shape; a wire narrowing, declared under the banner); (c) a drifted column a field still declares is now labelled (drift_col control re-pinned with required_cleared); (d) the plugin-auth anchor-parity pin moved from the raw driver code to VALIDATION_FAILED + fields [['organization_id','required']] + cause.code: SQLITE_CONSTRAINT_NOTNULL (amendment 6411384588; still proves the column refused the row, distinct from refused-d9).
  5. Test deletions and moved pins: not-null-refusal-label.test.ts goes with the module it tested; its label assertions are re-pinned closer to the code (engine test) and at the REST door (24715 test); its "declared column's hint" assertion is dropped as a contract change, declared. data-not-null-hint-20963.test.ts loses its PATCH case with a stated reason (a PATCH naming an undeclared field is refused before the driver) and now simulates drift by re-registering the object without the column. The [security] A driver error on an auth-table write reaches the auth library's logger unredacted: the server log carries the statement's bound values (credential material among them), while the engine's own line is redacted #21274 door sweep skips the NOT NULL × write-door pairs and re-pins them in a block of their own. The duplicate-record controls' NOT NULL fixture now names an undeclared column, so those identity pins keep measuring what they measured.
  6. statedNotNull reads rawCode before code. A libsql error carrying rawCode: 1299 reads true, as measured; an error whose rawCode were a primary code (19) beside code: 'SQLITE_CONSTRAINT_NOTNULL' would read false and leave as the driver threw it (today's answer), never a wrong translation. Fail-closed; noted, not a defect.
  7. Check-runs on this head at record time: 25 success, 8 skipped, 0 failed, 12 still in_progress with no conclusion: Build Core, Test Core (1/8) through (8/8), Type Check UI, Type Check · consumer gates, Type Check · workspace. Check Changeset, Governed Surface Queue Guard, Check PR Size, the claim/branch guards and Dogfood Regression Gate are success. The landing waits for the remaining twelve to report green; this record judges the contract face and is not a substitute for them.
  8. Dogfood battery (dev-run, not a check-run): test/authz-probe-blind-spot.test.ts is red on main since 69fa23d60b (fix(runtime): GET /i18n/translations/:locale carries an ETag and Cache-Control: private, no-cache, and a matching If-None-Match is answered 304 (#25785) #25956) per the dev's grep -c evidence; not caused here, not fixed here, noted as an out-of-scope finding with a carrier. Not filed; the dogfood seat should card it.

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

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-26T09:43Z

Head 50c3929ba3, report 6412700035 (on #24768), claim 6410180146 with amendments 6410372925 and 6411384588, contract review 6412927808 (PASS, narrowing face, by server seat 2).

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

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 40306457347 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (7/8) — 失败步骤: Run this shard's tests

    @objectstack/plugin-security:test:  FAIL  src/objects/shipped-sets-identity-default-row-scope.test.ts > the shipped sets serve the identity objects as before — 'isolated posture, platform baseline c…'
      ↳ 失败原因: @objectstack/plugin-security:test: Error: Test timed out in 5000ms.
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/objects/shipped-sets-identity-default-row-scope.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 18 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · queue note · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-26T10:44Z

Merge-queue build 40306457347 failed on one test, packages/plugins/plugin-security/src/objects/shipped-sets-identity-default-row-scope.test.ts ('isolated posture, platform baseline …'), with Error: Test timed out in 5000ms., not an assertion.

Read against this diff: plugin-security reads the engine this PR changes (a NOT NULL refusal on a declared field now leaves as ValidationError), so this is not dismissed by package alone. But the head's own CI ran the affected set green before the queue took it, the failure is a wall-clock timeout in the full-suite queue build (three other queue builds today timed out the same way on unrelated tests, anchors #26047 / #26049), and no assertion moved. Re-armed once through the relay. A second failure of this test on this head is treated as real and goes back to the dev.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 26, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as draft October 26, 2026 10:47
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · queue note 2 · review reopened · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-26T10:48Z

Correction to 6414103350: the same test, plugin-security/src/objects/shipped-sets-identity-default-row-scope.test.ts ('isolated posture, platform baseline …', Test timed out in 5000ms.), also failed in the queue build of PR #26014 (40307144724), and that build's tree carries this PR's queue commit 6201f86bf3 beneath it (compare: 6201f86bf3...510d6bbcc1 ahead 2, behind 0). main without this PR runs it green. Two failures, both with this diff in the tree, on a test that drives the isolated posture's identity writes this PR's engine change touches: read as this PR's until shown otherwise.

Auto-merge disabled and the PR returned to draft through the relay; the ACCEPT 6412949475 is suspended. Back to the dev to reproduce on the head (the test under load and alone) and root-cause.


Generated by Claude Code

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 40313981294 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (2/8) — 失败步骤: Run this shard's tests

    @objectstack/cli:test:  FAIL   unit  test/hook-timeout-override-refusal.test.ts > #18788 a CLI timeout override that cannot reach a project is refused, not passed > ⭐ CONTROL — a run naming no overrid
      ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 1 to be +0 // Object.is equality
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 20 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT (re-issued) · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-26T13:00Z

Head 9852ed899d. This lifts the suspension in 6414176016 and re-issues ACCEPT 6412949475. Contract review 6412927808 (PASS) still stands: the product diff is unchanged since it, and the new head adds only main merges plus one test-only commit. Rework report 6416474916 (on #24768); claims: #24768 as before, #26065 6416530527.

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

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/xl tests tooling

Projects

None yet

2 participants