Skip to content

fix(plugin-hono-server): a Hono door's 409 CONCURRENT_UPDATE carries currentRecord, the caller's own read of the row (#23531) - #23667

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23531-lift-current-record-hold
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23531-lift-current-record-hold

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23531
Clause-②: no

This lands the remainder of #23531: the currentRecord half that PR #23582 held back. The hold waited on #23586, whose answer landed as PR #23628 (7dc7d2213): the protocol now reads that row as the caller.

What this does

A stale If-Match (or expectedVersion) on PATCH or DELETE /data/:object/:id is refused by the protocol with ConcurrentUpdateError (409 CONCURRENT_UPDATE). Both Hono doors render it through declaredEnvelopeForThrow. They are @objectstack/hono's createHonoApp and a route mounted on HonoHttpServer. That function reads the structured fields off RestServer's classification (structuredCodeAnswer) in declaredArmContext. That helper dropped currentRecord by name, because the record used to be the row the existence probe (probeRecord) reads with a system context.

Since PR #23628, on a version mismatch metadata-protocol re-reads the row under the caller's own context. A row the caller's row-level rules hide answers 404 RECORD_NOT_FOUND with neither field. A readable row's currentRecord is the caller's field-masked read. So the hold is lifted:

  • declaredArmContext (packages/plugins/plugin-hono-server/src/adapter.ts) now drops only the arm's own error and code. currentRecord rides error.details beside currentVersion, as RestServer forwards it flat.

  • The docblock lines that said the field is held are rewritten. The field is forwarded because the producer reads it as the caller. An arm that grows a field read with a system context must still be held there by name until its producer reads it as the caller.

  • FROM {"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…"}}}

  • TO {"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…","currentRecord":{…}}}}

No other arm's behaviour changes. metadata-protocol, packages/types and packages/adapters/hono/src/index.ts are not edited. Changeset: @objectstack/plugin-hono-server and @objectstack/hono, patch, as PR #23582 did (one fixed version group, and createHonoApp's answer is the measured door).

Pins

  • packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts (direct-mount route): the [#23531] rows flip from absence to presence. error.details must equal { currentVersion, currentRecord } exactly, as the producer stated them, so a field the door drops or invents turns them red. The controls are kept: the stored version is accepted, and a conflict stating no current state keeps the two-key envelope.
  • packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts (a real createHonoApp over the real dispatcher and protocol, on SQLite WASM):
    • PATCH and DELETE with a stale If-Match: error.details equals the currentVersion and currentRecord the real protocol threw, read off the protocol call, and the record names the stored row. The currentVersion echo pins are kept: echoed as the next If-Match it is accepted (PATCH writes, DELETE deletes).
    • New, PATCH and DELETE: a row whose secret field the caller's read masks. currentRecord carries the row's id and name and no secret, and no secret text is on the wire. The record equals both the caller's own read and the record the protocol threw, while the store still holds the field.
    • The mask is an engine middleware that rewrites a caller-context read after next(). That is the seam @objectstack/plugin-security's field-level rules rewrite a read at. The security plugin itself is not booted there, because http-conformance does not depend on it, and adding it means package.json and the lockfile, outside this claim. The real field- and row-level rules are pinned by packages/runtime/src/domains/data-conflict-caller-visibility.test.ts (PR fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read #23628), and are measured on this head below.

Verification (at 4bc9b0c08b)

The branch merged origin/main at 286eb9ef3 (4 commits, none touching these files) before the PR opened. Every reading below is on 4bc9b0c08b unless it names another head.

  • Packages, after a full turbo run build of the workspace (75/75):
    • pnpm --filter @objectstack/plugin-hono-server typecheck (tsc, tsconfig.typecheck.json, check:test-typecheck OK with 0 debt) and test: Test Files 29 passed, Tests 354 passed.
    • pnpm --filter @objectstack/http-conformance typecheck (check:test-typecheck OK, ledger unchanged) and vitest run: Test Files 13 passed, Tests 144 passed.
    • Consumer pins in packages/runtime, which resolves @objectstack/plugin-hono-server through dist/: data-conflict-caller-visibility, data-update-expected-version and data-delete-expected-version gave 3 files, 115 passed.
  • Ablation (at 7a8e9bf386): node scripts/ablation-replace.mjs in wrap mode, plus an outer trap restore on EXIT INT TERM over the absolute path. The exclusion was put back: anchor x1 to x0, replacement carrying the marker ABLATION_23531_RESTORE_HOLD x0 to x1, blob 4b7ca0f62478 to 3e3313e4060c. plugin-hono-server was rebuilt, and ablation-dist-preflight found the marker in dist/index.js and dist/index.mjs. The direction was as predicted:
    • unit file: 2 failed, 35 passed. PATCH and DELETE gave expected { Object (currentVersion) } to deeply equal { …(2) };
    • conformance file: 4 failed, 16 passed. These are the two presence rows and both masked-field rows (expected undefined to match object).
    • Restore: the blob equals HEAD and git diff HEAD is empty. The rebuild was 6/6 turbo cache hits. Preflight --absent found the marker gone from all 6 built files, with a clean tree. Both files were green again (37/37, 20/20).
  • Door parity with the real security layer (at 7a8e9bf386, a scratch test in packages/runtime, deleted after the run and never committed): the same stale PATCH and DELETE ran on the FLS-masked row and on the plain row. The Hono exit's error.details equals RestServer's { currentVersion, currentRecord }, and neither wire carries the masked value: 4/4. The committed data-conflict-caller-visibility.test.ts was green beside it (16/16).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (7 paths vs merge base 286eb9ef3) derived 95 commands. All 95 ran, each exit captured before any pipe: 95 x exit 0. --ran reports 95 derived, 95 run, 0 NOT-MEASURED and 0 UNRUN.
    • Pass 1 at 7a8e9bf386 caught check:query-options-erasure: two as any on findOne options in the new pin took the test surface from 236 to 238. Fixed in bc83077a4f, back to 236.
  • Lint, narrowed: eslint --no-inline-config on the 3 changed TS sources exited 0, with 0 errors and 0 warnings each.
    • Each file was really linted: 6/5/5 active rules by --print-config.
    • eslint.config.mjs is unchanged from base to origin/main.
    • The 3 .mdx pages fall outside ESLint's config (File ignored), as they do under pnpm lint.
    • The full pnpm lint is CI's (Lint · ESLint).

Acceptance notes

  • Docs corrected, outside the claim's listed file surface. Three pages said a Hono door never sends currentRecord. PR fix(plugin-hono-server): a Hono door's 409 DELETE_RESTRICTED, and every structured refusal, carries RestServer's arm fields (#23587) #23616 wrote them, and this change makes them false: content/docs/api/index.mdx, content/docs/api/wire-format.mdx ("Hono hosts nest the extras", whose example now shows currentRecord), and content/docs/protocol/kernel/error-handling.mdx (the Hono-door table and the "never sends" line). They are corrected here, with one line each saying the record is the row as the caller reads it.
  • Two unreleased changesets still say currentRecord stays held: .changeset/23531-hono-conflict-body.md and .changeset/23587-hono-structured-arm-fields.md. They are not edited here (outside this claim). This PR's changeset says it supersedes that note. If one CHANGELOG story is preferred, those two sentences can be cut before the next release. Carrier: the seat.
  • The runtime pin's dispatcher-door rows read currentRecord off the thrown error, not the wire, because the wire did not carry it before this change. On this head the wire carries it, and it equals RestServer's, measured with a scratch test that was not committed (below). Tightening those rows to the wire is a possible later edit in packages/runtime. Noted, not filed. Carrier: none.
  • Producer-side ablation not run. That would mean reverting PR fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read #23628's read and watching the masked-field rows go red. This diff does not change the producer, so it is not owed here. The rows' not.toHaveProperty('secret') and equality with the caller's read are what would catch it.

Generated by Claude Code

…currentRecord, the caller's own read of the row

The protocol now re-reads a conflicting row under the caller's context
before it throws ConcurrentUpdateError, so the record holds only what
the caller may read. declaredArmContext stops holding currentRecord
back, and both Hono doors forward it in error.details as RestServer
forwards it. The absence pins flip to presence; the composed-host pin
shows a field the caller's read masks staying off the record. The docs
pages that said a Hono door never sends currentRecord are corrected.

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
…stead of casting them

check:query-options-erasure counts an `as any` on engine query options in
test code; the two reads the #23531 masked-field row takes need no cast.

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
…ft-current-record-hold

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-hono-server, touching 2 documentable anchor(s).

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

  • content/docs/api/wire-format.mdx (via currentRecord (literal, a string literal in declaredArmContext))
  • content/docs/protocol/kernel/error-handling.mdx (via currentRecord (literal, a string literal in declaredArmContext))
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 — 9 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 1ced0f82bd93260b660e6f448c7dc5952bc82371 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 42c6507374ae802063d32b5d0de6e36d9736e049 — the merge of head 4bc9b0c08be1b3398bd5b3d73136b17f5e8b5a49 into base 1ced0f82bd93260b660e6f448c7dc5952bc82371, 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 42c6507374ae802063d32b5d0de6e36d9736e049 && git checkout 42c6507374ae802063d32b5d0de6e36d9736e049
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1ced0f82bd93260b660e6f448c7dc5952bc82371 4bc9b0c08be1b3398bd5b3d73136b17f5e8b5a49 && git checkout -B drift-repro 1ced0f82bd93260b660e6f448c7dc5952bc82371 && git merge --no-ff 4bc9b0c08be1b3398bd5b3d73136b17f5e8b5a49

node scripts/docs-audit/affected-docs.mjs --json 1ced0f82bd93260b660e6f448c7dc5952bc82371

⚠️ 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 1ced0f82bd93260b660e6f448c7dc5952bc82371 → 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

席位核对

✅ ACCEPT: PR #23667 at 4bc9b0c08b (#23531's remainder), round 0. Every check on this head is green or a roster skip; no contract review is owed · 2026-10-15T11:52Z

domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6189624811. Report: the os-dev-report 6191045281 on #23531.

Checklist, read against GitHub and origin/main, not against the report:

  • PR shape: draft, base main, title fix(plugin-hono-server):, assignee sunhaolin. Line 1 is Fixes #23531, the body's only closing keyword; the card closes with this PR. Line 2 is Clause-②: no.
  • Scope: 7 files, +178 / −67.
    • Inside the claim's surface:
      • packages/plugins/plugin-hono-server/src/adapter.ts: declaredArmContext's filter and the three docblock passages that said the field is held;
      • the unit and conformance pin files;
      • one new changeset.
    • Outside it: three docs pages (accepted below).
    • check-governed-merges --pr 23667: NOT governed. Merge-tree is clean against origin/main 1ced0f82bd.
  • The fix, read in the diff:
    • declaredArmContext now drops only the arm's own error and code, so CONCURRENT_UPDATE's currentRecord rides error.details beside currentVersion on both Hono doors.
    • The premise holds on main: PR fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read #23628 (7dc7d2213) re-reads the row under the caller's context on a version mismatch. A hidden row answers 404 with neither field, and a readable row's record is the caller's masked read.
    • The docblock keeps the rule that matters: an arm that grows a field read with a system context is held by name until its producer reads it as the caller.
    • metadata-protocol, packages/types and packages/adapters/hono/src/index.ts are untouched.
  • Pins and reverse verification:
    • Unit pins: both rows assert error.details equal to { currentVersion, currentRecord } exactly.
    • Conformance pins: a real createHonoApp over the real protocol. They compare the door's details with what the protocol threw (spied). New PATCH / DELETE rows show a masked field absent from currentRecord, which equals the caller's own read, while the store still holds the field.
    • Ablation: putting the exclusion back turns 2 unit and 4 conformance rows red. The restore is proven against HEAD, with the dist preflight marker gone.
    • Parity: a scratch test on PR fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read #23628's runtime rig (real SecurityPlugin, real RestServer) found the Hono door's details equal to RestServer's, 4/4. It was never committed.
  • Changeset prose, sentence by sentence:
  • Gates:
    • Local, at 4bc9b0c08b: 95 derived, 95 run, 0 unmeasured, all exit 0. Pass 1 caught check:query-options-erasure (two as any, 236 → 238), and it was fixed in bc83077a4f.
    • Lint: narrowed with its three pieces of evidence. The full pnpm lint is CI's Lint · ESLint.
    • Gate evidence: the check-runs pinned to 4bc9b0c08b, read at this stamp. 41 distinct checks: 36 success, including Lint · ESLint, the Type Check family, the Test Core shards, Build Core and Check Changeset, and 5 skipped. All 5 skips are roster names: Console Pin Gate, Dogfood Regression Gate (matrix), Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance. Nothing is running and nothing failed.
  • Contract review: none owed. The change is the Hono doors' forwarding of a field the REST door already sends: Clause-②: no, with no schema face and no governed path.

Accepted deviations:

  • Three docs pages edited outside the claim's listed surface: content/docs/api/index.mdx, content/docs/api/wire-format.mdx and content/docs/protocol/kernel/error-handling.mdx.
  • The two earlier unreleased changesets stay byte for byte (.changeset/23531-hono-conflict-body.md:15 and .changeset/23587-hono-structured-arm-fields.md:14, which say currentRecord stays held).
  • The FLS on the conformance rig is an engine-middleware stand-in. http-conformance does not depend on plugin-security, and adding that dependency would mean a lockfile edit outside the claim. The real field- and row-level rules are pinned at the producer (data-conflict-caller-visibility.test.ts, 16/16), and the parity measurement covers the door.
  • origin/main 286eb9ef3 was merged once before the PR opened, by a merge commit. None of its commits touches these files.

Out-of-scope, Acceptance notes, 「承接者:无」:

  • data-conflict-caller-visibility.test.ts's dispatcher-door rows read currentRecord off the thrown error, not the wire. They could now assert the wire.
  • wire-format.mdx's flat Concurrent Update — 409 Conflict example omits currentRecord, which the flat door sends and whose Fields list names it. This is example drift, untouched by this diff.

Landing: pr_ready + automerge_enable through the relay, in this act.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 11:53
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 15, 2026 11:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 5bb9a83 Oct 15, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23531-lift-current-record-hold branch October 15, 2026 12:21
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/m tests tooling

Projects

None yet

2 participants