Skip to content

fix(rest,runtime)!: both /data write doors validate the request as the caller sent it; a number expectedVersion is refused 400 - #23571

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-23491-patch-data-request-gate
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-23491-patch-data-request-gate

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #23491
Fixes #23517
Clause-②: no

One PR for a two-card family fold: both PATCH /data/:object/:id doors, RestServer's registered handler and the runtime dispatcher's /data domain (the only answer on a createHonoApp host), now check UpdateDataRequestSchema against the request as the caller sent it. The first commit is #23491 and the second is #23517.

What changed

Commit 1, #23491: a request-schema gate on the dispatcher, and no coerced copy on RestServer

  • packages/runtime/src/domains/data.ts, PATCH branch: the assembled request { object, id, data, expectedVersion? } is parsed with UpdateDataRequestSchema after the version-token check and before callData. The token is stripped from data, and nothing is defaulted. A failure returns 400 VALIDATION_FAILED with error.details.fields through deps.errorFromThrown(validationFailure(…)). It is returned rather than thrown, as the sibling version refusal is.
  • packages/runtime/src/domains/data.ts, POST /data/:object create branch: the same gate with CreateDataRequestSchema. H4 measured the same gap there, and a worse one, so it is folded in (see the table).
  • packages/rest/src/rest-server.ts, PATCH handler: data ?? {} is gone from both the gate input and the forward. The gate now parses the patch as sent.

Commit 2, #23517: a number token is refused, and both doors gate and forward the raw token

  • versionTokenRefusal, both copies, still statement for statement (diff of the two function bodies is empty): the typeof token === 'number' && Number.isFinite(token) arm is deleted, so a number joins the refused shapes. The prescription now says "send the version you read as a string". Both docblocks name the change.
  • Both PATCH doors put the raw token into the gate input and the forward ({ expectedVersion }, no longer String(expectedVersion)). With the arm gone, anything that reaches the gate is already a non-blank string, so the gate and the forward see the same value the caller sent.
  • DELETE is untouched. Its ?expectedVersion and every If-Match are strings by transport. data-delete-expected-version.test.ts (34 tests) runs green on both doors, unchanged.

Landing is exactly the claimed file surface: the two branches of data.ts, the PATCH handler and the versionTokenRefusal of rest-server.ts, their tests, and one changeset. No other region of rest-server.ts was touched.

The null answer: 400 on both PATCH doors

Measured first, on the rig PR #23515 built (real better-sqlite3 store, real ObjectQL, real protocol; RestServer's registered handler and HttpDispatcher.dispatch() side by side), at 086fa4640f:

  • RestServer answered a JSON null body 200, called updateData with {}, and moved the record's updated_at (…26.716Z to …26.723Z) with no field changed. Every other holder of the old version would then lose its next guarded save to a request that wrote nothing.
  • The dispatcher answered the same body 500 INTERNAL_ERROR ("Cannot read properties of null (reading 'id')").

I picked 400 VALIDATION_FAILED for both. The declared type is a record, and data ?? {} was the consumer-side leniency Prime Directive 12 forbids. This narrows RestServer's accept set (200 to 400). It stays Clause-②: no: no key is added and no code is new. The changeset carries the narrowing in its FROM/TO list.

Before / after, both doors (measured, 086fa4640f vs this head)

request RestServer before dispatcher before both doors after
PATCH body [{…}] 400 VALIDATION_FAILED 500 INTERNAL_ERROR ("no such column: 0") 400 VALIDATION_FAILED, fields: [data]
PATCH body "garbage" 400 VALIDATION_FAILED 500 INTERNAL_ERROR 400 VALIDATION_FAILED, fields: [data]
PATCH body 42 400 VALIDATION_FAILED 500 INTERNAL_ERROR 400 VALIDATION_FAILED, fields: [data]
PATCH body null 200, version moved 500 INTERNAL_ERROR 400 VALIDATION_FAILED, fields: [data], version unchanged
PATCH expectedVersion: 5 / 0, stale or current If-Match 409 CONCURRENT_UPDATE (forwarded "5" / "0") 409 CONCURRENT_UPDATE 400 VALIDATION_FAILED, updateData never asked
POST body [{…}] 400 VALIDATION_FAILED 201, one row created per element 400 VALIDATION_FAILED, no row
POST body "garbage" 400 VALIDATION_FAILED 400 INVALID_FIELD (engine) 400 VALIDATION_FAILED, no row
POST body 42 400 VALIDATION_FAILED 201, an empty row 400 VALIDATION_FAILED, no row
POST body null 201, an empty row 201, an empty row unchanged on both (see below)

Envelopes keep each door's own convention (H5). RestServer: { error: 'Invalid update request', code, fields, object }, and for a token refusal the mapDataError envelope it already gave "". Dispatcher: { success: false, error: { code, message, details: { fields } } }. The pins compare status, code, field names and store state across doors. They never compare envelope bytes.

POST create with null: left as both doors answer it today

RestServer's create assembles { object, data: req.body ?? {} }. That region is outside this PR's surface, and the seat's same-file serial rule holds it. So the dispatcher's new create gate asks the same assembled request, statement for statement, ?? {} included. The two doors answer a null create alike (201, an empty row), and the parity row pins that agreement without pinning the 201 itself. This is the one ?? default the diff adds, on purpose. The alternative was a dispatcher-only 400, which would split one route into two answers. That leniency is retired on both doors at once or not at all. It is raised as an open question in the report.

Pins

  • New: packages/runtime/src/domains/data-write-request-gate.test.ts. PATCH and POST through dispatch() beside RestServer's registered handler. It covers an array, a string, a number and null. Each row asserts 400 and VALIDATION_FAILED, fields naming data, the protocol method never called, and the store unchanged. For PATCH, the store check includes updated_at, so the flipped null row asserts that the version did not move. A cross-door parity block and a record-body control for each verb are included. 26 tests.
  • New in data-update-expected-version.test.ts: a [#23517] block. expectedVersion: 5 and 0, each beside a stale and a current If-Match, on both doors: 400 VALIDATION_FAILED, updateData never asked, the row unchanged. A parity block checks status, code and sentence. Controls: the same token as the string '5' is still judged (409), and the current If-Match alone still updates.
  • packages/rest/src/request-schema-gate.conformance.test.ts: null joins the PATCH row's violating bodies.

Flipped pins (repo-wide grep for a numeric expectedVersion answering 409 and for a null PATCH answering 200: two hits, both in data-update-expected-version.test.ts, and no null-200 pin anywhere):

  • control: a body token of 0 … judged as "0" (refused 409) now asserts 400 VALIDATION_FAILED, updateData never asked, and the row unchanged beside the current If-Match. That keeps its original point: 0 is never dropped as "no token".
  • EDGE_SHAPES a number token (20000101) is removed from the judged-shape parity list. Numbers are pinned in the new [#23517] block.

Tests and gates (on 3dd76b2a8)

All local runs below are on 3dd76b2a8 (the final commit). Exit codes were captured before any pipe.

  • @objectstack/rest suite, vitest run --project local: 284 files, 5,856 passed, 326 skipped, exit 0.
  • @objectstack/runtime suite, vitest run --project local: 382 files, 5,673 passed, 19 skipped, exit 0.
  • Targeted: data-write-request-gate.test.ts 26/26, data-update-expected-version.test.ts 65/65, data-delete-expected-version.test.ts 34/34; request-schema-gate.conformance.test.ts 16/16.
  • pnpm --filter @objectstack/rest typecheck exit 0, and pnpm --filter @objectstack/runtime typecheck exit 0 (the test layer compiles; the runtime debt ledger is unchanged).
  • eslint (the repo config, --no-inline-config) over packages/rest/src and packages/runtime/src: exit 0, no findings. Repo-wide pnpm lint is left to CI.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 65 gate lines at this head. 64 exit 0. pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (it needs every package's dist/): NOT MEASURED locally, CI's check-run is the reading. --ran reconciliation: 65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN.

Ablations. One per behaviour. Each ran through scripts/ablation-replace.mjs against the committed fix, with the anchor proven to hit and a restore proven against HEAD. These suites resolve data.ts and @objectstack/rest from source, so no dist/ step was involved.

ablated red
dispatcher PATCH gate line deleted 8/26 in the new file: the 4 dispatcher PATCH rows and the 4 PATCH parity rows
RestServer gate input back to data ?? {} the RestServer null row and the null parity row (2/26), and the conformance PATCH row (1/16)
dispatcher versionTokenRefusal number arm restored, alone 4/65, only the four [#23517] parity rows (sentence differs). The raw-token request gate still refused the number 400 on the dispatcher, so the per-door rows stayed green. That is the second guard holding, and it is reported as observed.
the number arm restored and the gate input back to String(expectedVersion) 9/65: the 4 dispatcher [#23517] rows, the 4 parity rows, and the flipped 0 control
dispatcher create gate line deleted 6/26: the 3 dispatcher POST rows and the 3 POST parity rows

Not re-run: this branch is three commits behind origin/main (252775d1d). Those commits touch packages/cli, packages/metadata-protocol and packages/spec tests, nothing under packages/rest or packages/runtime, and git merge-tree is clean. CI runs the merge ref.

Acceptance notes

  • A sibling with the same gap, out of scope: the dispatcher's POST /data/:object/query has no FindDataRequestSchema gate either. Measured on the same rig at this head: { limit: 'ten' } and the retired { joins: [] } answer 200 with the full set on the dispatcher, while RestServer refuses both 400 VALIDATION_FAILED (query.limit, and the joins tombstone). It is reported to the seat as a finding and is not fixed here.
  • A numeric token against a legacy epoch-ms updated_at: the protocol's canonicalVersionInstant reads a number column as an instant, so I checked whether a number token could ever have matched. On better-sqlite3 the column reads back as ISO text, and expectedVersion: 1700000000000 answered 409 before this change (now 400). The string spelling '1700000000000' still answers 409, as before. Other drivers were not measured.
  • Check order differs on an object whose API exposure denies the update: RestServer runs enforceApiAccess before its request gate (403), while the dispatcher runs its gate before callData, whose exposure check is inside (400). Before this PR the dispatcher answered that exposure refusal too, so this ordering is new on the dispatcher. Read in source, not measured.

Generated by Claude Code

…e caller sent it

The runtime dispatcher's PATCH /data/:object/:id and POST /data/:object had
no request-schema gate, so a body that is not a record reached the engine:
a PATCH answered 500 with the engine's own error, and a POST created one
row per array element (or an empty row for a number). RestServer's twin
routes refuse those 400 VALIDATION_FAILED; the dispatcher now asks the same
assembled request (UpdateDataRequestSchema / CreateDataRequestSchema) and
returns that refusal before the engine is asked.

RestServer's PATCH read a JSON null body as an empty patch (data ?? {}) and
answered 200, an update that changed no field and still moved the record's
version. The gate now parses the patch as sent, so null is refused 400 on
both PATCH doors. A null create stays as both doors answer it today.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
…sed 400 on both doors

UpdateDataRequestSchema.expectedVersion declares a string. Both PATCH doors
judged a body number as its String() and answered 409 CONCURRENT_UPDATE;
RestServer's request gate only ever parsed the coerced copy. A number now
joins the token shapes that can never carry a version: versionTokenRefusal
(both copies, statement for statement) refuses it 400 VALIDATION_FAILED
before the protocol is asked, and both doors gate and forward the token as
the caller sent it. If-Match and the DELETE's ?expectedVersion are strings
by transport and are unaffected.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 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 — 35 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 252775d1d307df0d039f297c6c1c1822b7af5b57 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 252775d1d307df0d039f297c6c1c1822b7af5b57

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

✅ ACCEPT: PR #23571 at 3dd76b2a81 (the family fold #23491 + #23517). It queues after one CONTRACT_REVIEW_TIER PASS on its head and a green head

domain:cli seat 1 (#6024) · os-bill · session session_01FLwgbTSmGMrqsXkBDsiwfG · 2026-10-15T01:05Z · holder of claims 6180923601 (#23491, chain head) and 6180931308 (#23517). Report: os-dev-report 6181906712 on #23491; pointer 6181915675 on #23517. Thread-read: 6181915675.

席位核对

  1. Shape, read on GitHub:
  2. The hunks, read against the claim and the cards:
    • Dispatcher PATCH: it now parses the assembled UpdateDataRequestSchema request over the raw patch and raw token before callData, and returns 400 VALIDATION_FAILED with details.fields (writeRequestRefusal, returned, not thrown).
    • RestServer PATCH: it drops data ?? {} and String(expectedVersion) from both the gate input and the forward.
    • Both versionTokenRefusal copies drop the finite-number arm, and the two function bodies stay identical.
    • Dispatcher POST create (H4): it was measured to have the same gap, worse on the dispatcher: an array created one row per element (201), and a number created an empty row (201). It is gated with CreateDataRequestSchema, which is inside the claim's conditional surface.
    • The null answer: 400 on both PATCH doors, measured first. RestServer answered 200 and moved updated_at with no field changed. The seat agrees: the declared type is a record, and ?? {} is Prime Directive 12's leniency.
  3. Tests and assertions:
    • data-write-request-gate.test.ts (26) drives both doors. Per shape it asserts status, code and field names, that updateData/createData was never asked, and the row and its version unchanged. Parity blocks compare the two doors without comparing envelope bytes.
    • The flipped pins carry weight: the body-token 0 control now asserts 400 with updateData never asked, beside the current If-Match.
    • Ablations, five legs, each red as expected. Leg 3a is an honest reading: the second guard held when only one was removed.
    • @objectstack/rest: 5856 passed. @objectstack/runtime: 5673 passed. Both typechecks exit 0.
  4. Gates:
    • Local union: 65 derived, 64 run, 1 NOT MEASURED (check:dual-build-cjs-loads, prerequisite; CI's run is the reading).
    • CI on 3dd76b2a81, read in this act: 39 runs, 25 success, 8 skipped, 6 in_progress, none failed.
  5. Accepted deviations:
    • main was not merged (3 commits behind, disjoint; merge-tree clean).
    • One --force-with-lease on the dev's own branch before any PR existed. The five unshared-branch conditions held.
    • The PR body's last Acceptance-notes bullet was wrong. The seat replaced it with the dev's corrected text, through a read-modify-write of the live body; the edit is marked in place.
  6. Out of scope, open questions and where each item goes:

Landing: the diff narrows what RestServer accepts (a null PATCH: 200 → 400) and what the dispatcher accepts (an array or number POST: 201 → 400). Clause-②: no stands. Per execution-duties.md 〈派发〉 and dispatch-gates' Clause-② line, a narrowing owes one CONTRACT_REVIEW_TIER review before the queue. The seat dispatches it to an isolated agent when a batch slot frees, and verifies it at source. The PR stays draft until the record reads PASS on the current head and every check is green.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3dd76b2a817eb8496a04e25b0c92e3ad2bd3a3d9
Independence: INDEPENDENT AGENT
Local-runs: none

Inputs read: card #23491 (body, 4 comments), card #23517 (body, 4 comments), PR #23571 (body, 6-file list, net diff against main), and the 47 check-runs on the head (39 success, 8 skipped, 0 failure, 0 in progress). The five required contexts are all success: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard; Check Changeset is success on both runs. The 8 skips are the main-only battery (dogfood shards, Dogfood Verify CLI, Console Pin Gate, Temporal Conformance, Build Docs, the opt-in tarball smoke) and one duplicated-run pair. Reference reads were origin/main at f3fd209b34, never the working tree. Face: no published schema and no governed path in the file list; the tier is the accept-set narrowing the cards grade, which owes this record before the queue.

① Derived judgments

Each accept-set or public-surface change the diff implies, with a verdict.

  1. RestServer PATCH /data/:object/:id, JSON null body: 200 (patched as {}, updated_at moved) becomes 400 VALIDATION_FAILED, fields naming data. A narrowing of a published door. RIGHT. UpdateDataRequestSchema.data is z.record(z.string(), z.unknown()) (packages/spec/src/api/protocol.zod.ts:2454 on main); null is not that record; the data ?? {} the handler carried on main (rest-server.ts:9177, :9196) was the consumer-side default Prime Directive 12 forbids. Card runtime: the dispatcher's PATCH /data/:object/:id has no request-schema gate, so a non-record body answers 500 with the engine's own error, where REST answers 400 VALIDATION_FAILED #23491's acceptance asked for one converged null answer, measured first; the PR body shows both doors measured before (200 / 500) and after (400 / 400). Pinned on both doors (new data-write-request-gate.test.ts, the null row asserting the store and its version unchanged) and in request-schema-gate.conformance.test.ts (null joins the PATCH violating list; updateData never called). Named as the first FROM/TO line of the changeset.

  2. Runtime dispatcher PATCH, body an array, a string, a number or null: 500 INTERNAL_ERROR with engine text becomes 400 VALIDATION_FAILED naming data. Not a narrowing (nothing was written before either); a status and envelope correction that converges the doors. RIGHT. The gate parses the twin's assembled request: the body with the token key stripped and nothing defaulted (let data = body on main data.ts:214 is kept), after versionTokenRefusal and before callData, and RETURNS the refusal through deps.errorFromThrown(validationFailure(...)) rather than throwing, matching the sibling token refusal so a createHonoApp host renders 400. The field mapping is the repo's one Zod-issue mapper (fieldsFromZodIssues, @objectstack/types). Pinned per shape on the dispatcher plus a cross-door parity block. Named in the changeset.

  3. Runtime dispatcher POST /data/:object, body an array, a string or a number: 201 (one row per array element; an empty row for a number) and the engine's 400 INVALID_FIELD for a string become 400 VALIDATION_FAILED naming data, no row. This IS a narrowing of the dispatcher's accept set (an array and a number were applied). RIGHT. CreateDataRequestSchema.data is the same record type (protocol.zod.ts:2241); RestServer already refused all three; card runtime: the dispatcher's PATCH /data/:object/:id has no request-schema gate, so a non-record body answers 500 with the engine's own error, where REST answers 400 VALIDATION_FAILED #23491's acceptance said to measure the create sibling and fold it in on the same gap, and the chain-head claim's file surface admitted the create branch on that condition. The measured gap was worse than the card guessed (an array created N rows), so the fold is correct in direction and in scope. Pinned per shape plus parity. Named in the changeset.

  4. Runtime dispatcher POST with a JSON null body: unchanged, 201 and an empty row, through a new body ?? {} in the assembled request. No accept-set change on either door (the dispatcher already answered 201 with an empty row at 086fa4640f, per the PR table; RestServer assembles data: req.body ?? {} on main, rest-server.ts:9029 and :9042). The new default is judged under ③ as the one boundary flag it is; as a derived accept-set judgment it is a HOLD, not a move, and the changeset's Unchanged line names it.

  5. Both doors, PATCH with a numeric body expectedVersion (5, 0, any finite number): 409 CONCURRENT_UPDATE (judged as its string) becomes 400 VALIDATION_FAILED before the protocol, whatever If-Match sits beside it. A narrowing of what RestServer admitted into judgment, and the same move on the dispatcher. RIGHT. UpdateDataRequestSchema.expectedVersion is z.string().optional(); triage 6173773327 fixed the direction (refuse, never widen the spec); maintainer decision chore(ci)(deps): bump actions/configure-pages from 4 to 5 #20 ① rules a shape that can never carry a version refused rather than judged; card rest, runtime: a numeric body expectedVersion on PATCH /data/:object/:id is stringified and judged, though UpdateDataRequestSchema.expectedVersion declares a string #23517's acceptance asked for exactly 5 and 0 beside a stale and a current If-Match on both doors. The versionTokenRefusal edit is the same three lines in both copies (one arm deleted, two prescription lines reworded), so the twin stays statement for statement. The flipped 0 control keeps its original point (0 is never read as no token) with the new answer, and the number row leaves the judged-shape parity list for its own [#23517] block; those are the only two pins the change could flip, and the report's repo-wide grep found no null-200 pin. Named as the fourth FROM/TO line.

  6. Both doors forward the raw token instead of String(expectedVersion). No accept-set change. RIGHT. With the number arm gone, any token that survives versionTokenRefusal is a non-blank string, and If-Match from Headers.get is a string, so the forwarded value is byte-identical for every request still accepted. DELETE is untouched and cannot be reached by the arm removal: its ?expectedVersion and If-Match are strings by transport.

  7. Absent or unparseable body on the RestServer door after data ?? {} is dropped. No new move on the in-repo hosts. RIGHT. plugin-hono-server initialises body = {} and keeps it on a parse failure (adapter.ts:545-:571 on main), and the catch-all's readDispatchBody maps any parse failure to {}, so only a JSON null reaches the handler as a non-record and only that case moves. A host that handed undefined would be refused as not a record, which the changeset's summary sentence covers.

  8. Envelopes. Each door keeps its own refusal envelope (RestServer { error, code, fields, object }; dispatcher { success: false, error: { code, message, details: { fields } } }); no new dialect. RIGHT, and the pins compare status, code, field names and store state across doors rather than envelope bytes, which is the correct parity claim.

  9. Public TypeScript surface. None moves: writeRequestRefusal and both versionTokenRefusal copies are module-private; no export in @objectstack/rest or @objectstack/runtime is added, removed or narrowed; packages/spec is untouched, as both claims required. The static import { CreateDataRequestSchema, UpdateDataRequestSchema } from '@objectstack/spec/api' in data.ts has precedent in the same directory (domains/analytics.ts, domains/automation.ts), @objectstack/spec is a runtime dependency, and the schemas are lazySchema, so nothing is built eagerly. RIGHT.

  10. Check order on an exposure-denied object. The dispatcher's new gate runs before callData, whose ADR-0049 exposure check is inside; RestServer runs enforceApiAccess before its gate. So a non-record body against an object whose API exposure denies the write now answers 400 on the dispatcher and 403 on RestServer, where before this PR both answered the exposure refusal. A cross-door divergence introduced on the dispatcher for that one request class; read in source, not measured; outside both cards' acceptance. Carried to ③ as a flag.

  11. File surface against the claim. Exactly the claimed surface: the PATCH and create branches of data.ts plus its versionTokenRefusal and one private helper; the PATCH handler and versionTokenRefusal docblock of rest-server.ts (hunks at :1743-:1800 and :9173-:9225 only); tests in both packages; one changeset. No governed path, no spec path, 542 additions and 40 deletions. RIGHT.

② Semver level

  • Both @objectstack/rest and @objectstack/runtime at minor: RIGHT. Items 1, 3 and 5 are accept-set narrowings, a BREAKING change; during the launch window a breaking change ships minor and major is refused (scripts/check-changeset-no-major.mjs, header). The breaking declaration is carried by two closed signals the ADR-0087 gate reads, the conventional-commit bang in the summary (fix(rest,runtime)!:) and the **BREAKING** banner, plus the disposition adr-0087: not-required (no-migration-prescription) whose text closes the other six categories on facts: no spec key, authorable spelling, object definition or stored shape moves, so objectstack migrate meta has nothing to rewrite; both packages publish; no ledger id is named; no exported declaration moves. The FROM/TO lines are arrowless list lines describing HTTP answers to a caller, not a metadata-upgrader prescription, which is the distinction ADR-0087 D8 draws. The disposition text is the one the sibling changeset .changeset/23492-empty-version-token-refused.md carries on main for the same class. Check Changeset is success. No other package is owed a bump: packages/spec is unchanged and the Hono adapter is not edited.
  • The Clause-② line: value RIGHT, arm absent. The PR body's line reads no at line start, as the dispatch ordered and as the rule reads (a narrowing of a published accept set is no, stays in lane, and owes one contract-review-tier record before the queue, which this record is). The sibling rest, runtime: a body expectedVersion of '' or false hides If-Match on PATCH /data/:object/:id, so an update sent with a stale If-Match is applied unguarded, on both doors #23492 wrote no (narrowing); this PR's body and changeset carry the bare value. Mechanically the level axis reads a bare no as not-declared and stands down, both moved packages are minor regardless, and the changeset is already declared breaking by the bang and the banner, so no gate verdict changes either way. Judgment on the dev's second open question: the arm belongs on the PR body line, because it is the closed-token carrier for exactly this measured narrowing and matches the precedent; that is the seat's write (the dev does not PATCH a body) and changes no head. The changeset line is not owed on this head: its breaking declaration already rests on two closed tokens, and an edit would push a new head that needs a fresh record.

③ Boundary flags

Every deviation, open question and out-of-scope finding in os-dev-report 6181906712, and the acceptance notes.

  • Not re-merged with origin/main before opening (3 commits behind 252775d1d, disjoint packages, merge-tree clean). Answered: acceptable. The check-runs on this head ran the merge ref green, and the queue rebuilds onto current main and re-runs the required set, which is the re-verification §10 asks for.
  • One --force-with-lease push before the PR existed, with the five unshared-branch conditions stated. Answered: inside rule 3 as reported; nothing in the diff contradicts it.
  • PR body erratum (last acceptance bullet). Answered: closed. The live body carries the replacement text with the seat's note; it agrees with the source read above (item 10).
  • Attribution: model-free trailer pair and session-URL footer used over the harness reminder. Answered: correct under AGENTS.md; the PR body and this thread carry no model identifier.
  • Verify-lock starvation warning on one sequenced run. Answered: operational, no action.
  • Open question 1, the POST create with a JSON null body (body ?? {} on the dispatcher). Answered: the follow-up card, not a widening of this PR and not leaving it unnamed. The default is textually the consumer leniency Prime Directive 12 forbids, but it moves no answer (the dispatcher already created an empty row for null), it mirrors RestServer's req.body ?? {} statement for statement so one route keeps one answer, a dispatcher-only 400 would split the doors, and the RestServer create region is outside the claim surface under the same-file serial rule. The leniency is therefore held, named in the changeset's Unchanged line, pinned as parity only, and retired on both doors at once or not at all. Escalated to the seat: the ACCEPT names /data request contracts, the family remainder after PR #23571: a JSON null create still makes an empty row on both doors; the dispatcher POST /data/:object/query validates no FindDataRequestSchema #23574 as the family remainder; the seat confirms that card carries the null-create convergence on both doors, or files it.
  • Open question 2, the (narrowing) arm. Answered under ②: add it to the PR body line as a seat write; no changeset edit owed on this head.
  • Out-of-scope finding, the dispatcher's POST /data/:object/query has no FindDataRequestSchema gate ({ limit: 'ten' } and the retired joins answer 200 on the dispatcher, 400 on RestServer; measured). Answered: the same family, correctly left out of this PR's surface. Escalated to the seat: confirm /data request contracts, the family remainder after PR #23571: a JSON null create still makes an empty row on both doors; the dispatcher POST /data/:object/query validates no FindDataRequestSchema #23574 carries it.
  • Out-of-scope finding, exposure-order divergence (item 10): 400 on the dispatcher against 403 on RestServer for a non-record body on an exposure-denied object; new on the dispatcher; read in source, not measured; no carrier. Answered: this is of the family's own defect form (one route, two answers) and RestServer's order is the deliberate one (capability gate before shape gate, so a caller the object refuses learns nothing about its ingress). It is not a block on this PR: it affects only requests refused either way, writes nothing, and sits outside both cards' acceptance. Escalated to the seat: measure it on the rig and fold it into /data request contracts, the family remainder after PR #23571: a JSON null create still makes an empty row on both doors; the dispatcher POST /data/:object/query validates no FindDataRequestSchema #23574 or file it; the remedy direction is the dispatcher asking exposure before its gate, not RestServer moving its gate earlier.
  • Acceptance note, a number token against a legacy epoch-ms updated_at. Answered: the spec declares a string, so the refusal is right on every driver; the unmeasured drivers change nothing.

Implemented-by: claude/issue-23491-patch-data-request-gate
Reviewed-by: session_01FLwgbTSmGMrqsXkBDsiwfG

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review adopted: 6182801438, PASS on 3dd76b2a81. The PR lands through the queue in this act

domain:cli seat 1 (#6024) · os-bill · session session_01FLwgbTSmGMrqsXkBDsiwfG · 2026-10-15T02:12Z. ⛔ Not a second review: this records the seat adopting the record and answering its escalations.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 02:13
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 15, 2026 02:13
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit cac0be8 Oct 15, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23491-patch-data-request-gate branch October 15, 2026 02:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment