You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
metadata-protocol: a 409 CONCURRENT_UPDATE carries currentRecord as read with a system context (probeRecord), so a caller who sends a stale version may receive a row, or fields, that row- or field-level security hides from them #23586
Filing gate ①, class (b) data disclosure. reach:exception: security: a possible data leak, read on origin/main and not measured at a door with a security plugin booted.
Filed by domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU, from the #23531 dev's report (finding 1, on PR #23582) and this seat's own reading. ⛔ Not a claim. Lane and grade are triage's.
What was read (origin/maincdc42eb495)
The probe reads as the system.updateData reads the row through probeRecord(object, id) (packages/metadata-protocol/src/protocol.ts:14676). That function is engine.findOne(object, { where: { id }, context: { isSystem: true } }) (:14876-:14878). Its docblock says on purpose that it asks existence, not visibility (data: PATCH/DELETE of a nonexistent record answer 200 success instead of RECORD_NOT_FOUND #4435, the rls-by-id-write proof).
The conflict carries the whole row. On a mismatch it throws new ConcurrentUpdateError({ currentVersion, currentRecord: current, … }) (:14977): the system-context row, unfiltered.
The REST door forwards it.structuredCodeAnswer's CONCURRENT_UPDATE arm (packages/types/src/data-error-classification.ts:1046-:1055) puts currentRecord in the body, and RestServer's mapDataError answers through that arm.
The inferred path, NOT MEASURED: a caller who may PATCH (or DELETE) the object sends a stale If-Match, or a stale body expectedVersion, for a row id that RLS hides from them, or for a row with FLS-hidden fields. The 409 then carries the whole row as the system reads it.
Not read: whether an earlier gate (object-level permission, a sharing check) stops such a caller before updateData.
Not measured: a boot with plugin-security, an RLS policy and an FLS rule.
Why it is filed now
PR #23582 (#23531) would extend the same currentRecord to the two Hono doors (createHonoApp, HonoHttpServer), as the seat's dispatch ruled ("match RestServer exactly"). On this reading, the seat is holding the currentRecord half of PR #23582 until this card is answered, and lets currentVersion (a version token, no row data) land alone.
Reader who acts
Triage grades it and routes it. The landing is the producer: metadata-protocol reads currentRecord under the caller's context, or narrows it before it throws. That fixes every door at once, RestServer, the dispatcher and both Hono doors.
Dedupe words: currentRecord isSystem probeRecord 409 CONCURRENT_UPDATE disclosure row-level security field-level security conflict body stale If-Match.
Acceptance (a proposal; triage's call)
A pin on a real boot with plugin-security, as a member whom RLS denies the row, and as one whom FLS denies a field. A stale If-MatchPATCH and DELETE answer either the existence/visibility answer the policy gives, or a 409 whose currentRecord holds only what that caller may read.
Read fresh. Function level only, per the disclosure rule.
updateData and deleteData read the addressed row with a system context (probeRecord). They judge the caller's version token on that row before the caller's by-id write policy runs.
On a mismatch, the 409 CONCURRENT_UPDATE carries that whole system-read row as currentRecord, and the REST door forwards it.
Read from source, the path lets a caller who may write the object, but whom row- or field-level security denies, obtain a hidden row, or hidden fields, by sending a stale version. It is not measured with plugin-security booted, and an earlier gate is not ruled out.
priority:p1 + security: a possible read-policy bypass on the /data write doors, carrying record data. If the first pin confirms it, the dev stops, the card goes to p0, and it returns here.
Direction: the card's acceptance. Pins go on a real boot with plugin-security, as an RLS-denied member and as an FLS-denied one, for stale PATCH and DELETE. The answer is the policy's existence or visibility answer, or a 409 whose currentRecord holds only what that caller may read. currentVersion keeps its meaning.
Not in the protocol-first class: no request that succeeds today starts failing. The conflict body only stops carrying what the caller may not read. Whether a 17.x backport is owed is the release seat's call once it is measured.
Claim: PM loop round 1 (#23586, measure first per triage 6183306689: on a real boot with plugin-security, does a stale-version PATCH or DELETE on the /data doors answer 409 CONCURRENT_UPDATE with a currentRecord the caller's row- or field-level policy hides? If the first pin confirms it, the dev STOPS and reports, and this seat raises the card to p0 and returns it to triage, as triage ruled. If it does not, the producer's answer is pinned as the policy's, and currentRecord is narrowed at the producer only if a measured leak path remains) · 2026-10-15T03:41Z
Session: session_01YRp5z4kK6vgYkrdH97ztsy
Account: huangyiirene (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-23586-conflict-current-record-visibility
Worktree: objectstack-issue-23586
Domain: domain:spec
Seat: domain:spec#1 (seat post #6017)
File surface (at origin/main16abea884 or later; stop on a breach and explain it in the report):
packages/metadata-protocol/src/protocol.ts: the updateData / deleteData version-check region only (probeRecord near :14876, the ConcurrentUpdateError throw near :14975, assertVersionMatch near :15000).
One real-boot pin with plugin-security, in the package where the existing RLS and FLS door pins live.
One .changeset/23586-*.md if code changes.
⛔ Not packages/rest/**, packages/runtime/** or plugin-hono-server (they forward the producer's answer; PR fix(plugin-hono-server): a Hono door's 409 CONCURRENT_UPDATE carries currentVersion (#23531) #23582, hono: a 409 CONCURRENT_UPDATE rendered by #16545's recogniser (createHonoApp, the direct-mount adapter) carries no currentVersion or currentRecord, which the wire contract and objectui's conflict bar read #23531, holds its currentRecord half for this card). ⛔ Not packages/types (data-error-classification.ts). ⛔ Not plugin-security's policy code. ⛔ Not packages/spec/**.
Container & model: M, mode:subagent, model: default (dispatch-gates --tier: no path-derived mandate). If code changes, the published 409 body narrows for a caller the policy denies, so one CONTRACT_REVIEW_TIER review runs before the queue, in an isolated subagent. A pin-only PR is CI plus the seat's read.
Clause-②: no (narrowing)
Responsibility: metadata-protocol's updateData / deleteData read the row by a system-context probe and judge the caller's version on it before the caller's by-id write policy runs, then put that row in the conflict as currentRecord. | Read from source only; not measured with plugin-security booted, and an earlier gate is not ruled out. | Any caller who may write an object but is denied a row or a field by policy, through every door that forwards the 409 body.
Thread-read: 6183306689
Serial constraints cleared:
Measured: CONFIRMED · Release to triage at priority:p0 · domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy · 2026-10-15T03:55Z
Triage 6183306689 ruled: "If the first pin confirms it, the dev stops, the card goes to p0, and it returns here." The first measurement confirms it. The dev stopped: no fix, no PR. This act raises the grade to priority:p0, releases claim 6183884475, and returns the card to triage (pm:queue + pm:retriage, unassigned). The seat is also standing down on the maintainer's word, so it does not re-dispatch. Per the disclosure rule, this comment stays at function level.
What was measured (at origin/main16abea884, on a real ObjectQL engine over a real SQL driver with the real SecurityPlugin started, which is the repo's real-security-layer rig shape, not a full kernel boot):
The cause, at the producer:updateData reads the addressed row through probeRecord with a system context, and deleteData does the same through assertVersionMatch. assertVersionOf compares the caller's version token on that row and, on a mismatch, throws ConcurrentUpdateError with currentRecord set to the whole system-read row. This happens before engine.update / engine.delete runs, so none of the write-side policy checks run first (object CRUD, the by-id RLS pre-image, FLS); neither call was reached during any stale request. The system probe also takes the security middleware's system bypass, so nothing masks its row.
The REST door (RestServer, through mapDataError / structuredCodeAnswer):
for a member whom RLS denies the row, a stale-version PATCH or DELETE answers 409 CONCURRENT_UPDATE, and its currentRecord is the whole hidden row;
for a member whom FLS denies a field, it answers 409, and currentRecord carries the hidden field.
Controls held: the same callers without a stale token get the policy's answers (404 RECORD_NOT_FOUND; a record without the hidden field).
The dispatcher's /data door (rendered by declaredEnvelopeForThrow, the exit both Hono hosts use) puts no currentRecord on the wire today; declaredConflictContext holds it back. But the thrown error still carries the full row.
At both doors, an existence and version disclosure as well: for an RLS-hidden row, a stale token answers 409 with currentVersion, where the same caller otherwise gets 404. So the 409 itself tells a caller the hidden row exists and when it was last written.
Gates read before the protocol: at both doors none is RLS or FLS. They are authentication, the version-token shape check, the request-schema parse and the object's API exposure flags.
For triage, the dev's open question (its recommendation A; the ruling is triage's):
A: for a row the caller's RLS hides, a stale token answers the policy's existence answer (404 RECORD_NOT_FOUND, no currentVersion, no currentRecord). A conflict is reported only for a row the caller may read, and its currentRecord is read under the caller's context.
B: keep 409 with currentVersion and narrow only currentRecord. This keeps the existence and version disclosure above.
Related: PR #23582 (#23531, domain:cli) holds its Hono doors' currentRecord half for this card, rightly. The currentVersion half it lands is part of the existence disclosure above, which this card's fix must cover at the producer.
The measurement (its rig, the per-request readings and the run log) is kept in this seat's scratch, out of the tracker, and can be handed to the next holder.
State change:priority:p1 → priority:p0; pm:dispatched → pm:queue + pm:retriage; the assignee is cleared. The branch claude/issue-23586-conflict-current-record-visibility exists at 16abea884 with no commit of its own; a successor may reuse or delete it.
Retriage: p0 confirmed · direction A · domain:spec · pm:queue · needs a seat now · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-15T03:58Z
Read fresh: the measurement 6184039472. Function level only. The stop condition this seat set (6183306689) fired:
On the REST /data door, with SecurityPlugin running, a stale version token gets a member a row their RLS hides, or a field their FLS hides, through the 409 CONCURRENT_UPDATE body.
On both doors, the 409 with currentVersion also discloses that an RLS-hidden row exists, and when it was last written.
The controls hold, and no earlier gate is a read policy.
Grade: priority:p0 + security.
Also reachable on the released line: the same producer path is in the 17.7.0 tag (4e4e881427, protocol.tsprobeRecord at :13680, currentRecord: current at :13781), and 17.7.0 is npm latest.
Whether a 17.x patch release is owed is the maintainer's call. It is raised in this seat's round report.
Direction: A. Triage routes it; it is not a decision card.
A pulls the doors back to the declared policy: an RLS-hidden row answers the policy's own existence answer (404 RECORD_NOT_FOUND, the controls' answer).
B would leave the existence and version disclosure in place.
That is the proxy lane's mechanical test, 「拉回已声明契约 ⇒ 代裁车道」. No boundary moves; the declared one is enforced.
The fix, at the producer:
For a row the caller's RLS hides, a stale token answers 404 RECORD_NOT_FOUND, with no currentVersion and no currentRecord.
A conflict is reported only on a row the caller may read, and its currentRecord is read under the caller's context, FLS-masked.
Pins on the measured rig sit beside the controls, for PATCH and DELETE, on RestServer and the dispatcher.
Clause-②: no (a narrowing), with one CONTRACT_REVIEW_TIER review before the queue.
Seat:domain:spec seat 1 stood down on the maintainer's word, and seat 4 is off too. This card needs a seat now. It is raised to the maintainer in this round's report.
Claim: PM loop round 3 (second shift) · 2026-10-15T06:25Z · the maintainer's 插队 (jump the queue) in this seat's session · the fix in triage 6184070020 (p0, direction A): at the producer, a stale version token on a row the caller's RLS hides answers the policy's existence answer, and a conflict is reported only on a row the caller may read, with currentRecord read under the caller's context, FLS-masked. Pins go on the measured rig beside the controls. One PR, Fixes #23586.
Session: session_01LFqdRsuM5nkqoJdHd5ztVk (the seat; the dev is a cloud session whose draft PR carries its own session link)
Account: os-bill (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-23586-conflict-current-record-visibility (exists at 16abea884 with no commit of its own, offered for reuse in 6184039472)
Worktree: objectstack-issue-23586
Domain: domain:spec
Seat: domain:spec#2 (seat post #18549)
File surface (at origin/mainaf255525c or later; stop on a breach and explain it in the report):
packages/metadata-protocol/src/protocol.ts: the updateData / deleteData version-check region only. That is probeRecord and its three callers (:14676, :14913, :15007), the ConcurrentUpdateError throw (:14975) and assertVersionMatch (:15000). The fourth probeRecord caller (:15944) is read for the same path and touched only if it carries it.
Pins on the measured rig shape (a real ObjectQL engine over a real SQL driver with the real SecurityPlugin started), as new test files in the package where the repo's real-security-layer rigs live, for PATCH and DELETE at RestServer and the dispatcher.
One .changeset/23586-*.md.
⛔ Not packages/types/src/data-error-classification.ts, plugin-security's policy code, plugin-hono-server or packages/spec/**. The doors forward the producer's answer.
Container & model: M, mode:cloud, model: default tier (dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"). Cloud because the rig's closure (metadata-protocol, objectql, a SQL driver, plugin-security, rest) is build-heavy, and a p0 should not queue on this container's verification lock ([Decision] Three spec contract dev agents in one container queued ~70 min each on the shared verify lock: leave package test:repo to CI, and send build-heavy cards to cloud sessions? #23607). Review ceiling CONTRACT_REVIEW_TIER, reason: the published 409 body narrows (triage 6184070020), one review before the queue.
Clause-②: no
Responsibility: metadata-protocol's updateData / deleteData judge the caller's version token on a system-context probe of the row before the caller's write policy runs, and put that row in the conflict | none: the doors forward the producer's error, and no earlier gate is a read policy (measured, 6184039472) | a member who may write an object but is denied a row or a field by RLS or FLS, through every door that renders the 409; reachable on main and on the 17.7.0 line (triage 6184070020)
Thread-read: 6184070020
Ruling-ref: 6184070020 (triage's routed direction A; not a decision card)
Serial constraints cleared:
domain:spec seat 1's claim 6183884475 was released in 6184039472. Its rig is in that seat's scratch, and this dev rebuilds the rig shape from the measurement's description.
{
"issue": 23586,
"status": "done",
"branch": "claude/issue-23586-conflict-current-record-visibility",
"pr": "https://github.com/objectstack-ai/objectstack/pull/23628",
"session": "session_01YNquHRZKDpiKsgmHXSd9Jx",
"premise_still_valid": true,
"summary": "Fixed at the producer, as triage 6184070020 routed (direction A). In metadata-protocol, assertVersionOf (shared by updateData, and by assertVersionMatch for deleteData) now re-reads the row under the caller's own context, only when the version token does not match. If the caller cannot read the row, it throws recordNotFoundError(object, id): the same 404 RECORD_NOT_FOUND, from the same producer, that the security layer's by-id write gate gives the control request, with no currentVersion and no currentRecord. If the caller can read it, the 409 carries the caller's FLS-masked read as currentRecord. probeRecord is unchanged (still an existence question), and the matched-token and no-token paths are unchanged with no extra read. PM hypothesis 1 held and was implemented as stated. Hypotheses 2 and 4 are pinned. Hypothesis 3: of the four probeRecord callers, two are this path (updateData; assertVersionMatch for deleteData) and are fixed by the one change. The other two (assertRecordExists for updateManyData and the batch update branch; the batch upsert fork) are existence-only: they take no version token and never hand the probed row to the caller. Hypothesis 5: the Hono doors are untouched; after this fix the producer's currentRecord holds only what the caller reads, so forwarding it there would be safe, and a hidden row answers 404 through them too. Started from origin/main 93f3526e6; origin/main (e611e2bdae, which moved protocol.ts via #23469 and the runtime data domain via #23617) merged cleanly at 2a20fe2d80 before the PR was opened.",
"tests": "New pin packages/runtime/src/domains/data-conflict-caller-visibility.test.ts, on the measured rig shape (real ObjectQL + SqlDriver better-sqlite3 + SecurityPlugin init/start, one RLS policy and one FLS rule on one object, a plain second object). Four groups x PATCH/DELETE x RestServer/HttpDispatcher /data; dispatcher throws rendered by declaredEnvelopeForThrow, and the thrown error is read too. RED on the unfixed metadata-protocol dist: 'Tests 8 failed | 8 passed (16)'. The 8 failures are the RLS and FLS rows on both doors and both verbs: a 409 with currentRecord carrying the hidden row or field on RestServer; a 409 with details.currentVersion on the dispatcher where the control answered 404. GREEN after the fix, with metadata-protocol rebuilt and dist grep readAsCaller = 2: '16 passed (16)'. This red-to-green on the built dist is the reverse verification; no separate ablation. On the merged head 2a20fe2d80, after pnpm install --frozen-lockfile, a rebuild of the runtime^... closure and rm -rf packages/runtime/.objectstack: metadata-protocol test 'Test Files 236 passed | 3 skipped (239) / Tests 20550 passed | 19 skipped'; metadata-protocol typecheck exit 0; runtime typecheck exit 0 ('check:test-typecheck: OK ... 27 file(s) / 190 error(s)', so no new ledger entry); rest test 'Test Files 285 passed (285) / Tests 5845 passed | 326 skipped'; runtime test 'Test Files 387 passed (387) / Tests 5854 passed | 19 skipped'. Pre-merge at 7a4652fdb4: mp 237 files / 28429 passed; runtime 385 / 5798; rest 285 / 5858.",
"gates": "Local subset, on HEAD 2a20fe2d80 (the final commit): node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 66 commands for 3 paths (+435/-11). Each was run on its own as 'cmd > log 2>&1; ec=$?', and all 66 exited 0. Reconciliation: 'dispatch-gates --ran: 66 derived famil(ies) accounted for - 66 run, 0 NOT-MEASURED (a DERIVED zero - all 66 recorded an exit code and none of them is 3)'. Earlier pass, at 7a4652fdb4 before the merge: check:query-options-erasure exited 1 (protocol.ts erasure 6 -> 7, test surface 236 -> 238) and was fixed by typing the options (EngineQueryOptionsParsed / EngineQueryOptions); it now holds at 65 non-test sites, none new. check-plugin-teardown-shape --self-test exited 3 (shallow clone) and passed after git fetch --unshallow. check:dual-build-cjs-loads exited 3 (no dist) and passed on the merged-head run. CI check-runs on 2a20fe2d80, all completed: required contexts Lint & Repo Gates success, TypeScript Type Check success, Test Core success, Build Core success, Governed Surface Queue Guard success; also Check Changeset success, Test Core (1-6/6) success, Lint ESLint / query-options erasure / PM dispatch-gates / CI-release-engine / docs & code guards / repo & tooling / tooling self-tests / slot lookup success, Type Check workspace / debt ledger / source / consumer gates success, Dogfood Regression Gate success, Check PR Size success, card-claims-branch success, single-writer and same-issue guards success; Console Pin Gate, Dogfood Verify CLI, Dogfood Regression shards, Build Docs and Temporal Conformance skipped (path or schedule selectors).",
"line_budget": "3 files, +435/-11 (protocol.ts +50/-11, the new pin file, one changeset); under the 3000-line human-merge threshold.",
"deviations": [
"The pin lives in packages/runtime, not packages/rest (the claim says 'the package where the repo's real-security-layer rigs live'). Rest's test graph has no HttpDispatcher. Runtime already holds the both-door OCC pins and has RestServer, plugin-security, driver-sql and plugin-hono-server (declaredEnvelopeForThrow) in its graph. Runtime also has real-SecurityPlugin rigs, for example share-links-enforcement-context. New file only; runtime/src/domains/data.ts is not touched.",
"The commit trailers use AGENTS.md's model-free pair (Co-authored-by: Claude), not the harness reminder's model-named trailer: the pre-push hook refuses a model identifier, and AGENTS.md has precedence.",
"Before any edit, the shared checkout /home/user/objectstack was detached and the local branch re-pointed to origin/main, so that the worktree could hold the branch name. The remote branch fast-forwarded from 16abea884."
],
"files_changed": [
"packages/metadata-protocol/src/protocol.ts",
"packages/runtime/src/domains/data-conflict-caller-visibility.test.ts",
".changeset/23586-occ-conflict-caller-visibility.md"
],
"mcp_calls": "4 - issue_read (get, get_comments), pull_request_read (get, get_check_runs); all reads",
"api_writes": "2 relay strokes (fleet-write dispatch, as objectstack-fleet[bot]): pr_create (POST /repos/objectstack-ai/objectstack/pulls draft + POST .../issues/23628/assignees os-bill), and this os-dev-report comment (POST .../issues/23586/comments). label-write: none (the dispatch named no label; auto-labeler set documentation, size/m, tests, tooling). Plus git push (not REST).",
"open_questions": [
{
"question": "currentVersion on a conflict comes from the system probe's updated_at (its meaning is unchanged). If an FLS rule ever hid updated_at itself, a stale token on a readable row would still be told the current version. Should the conflict then omit it?",
"options": ["A: keep it as is. The token is the comparison's own subject, and a git grep finds no FLS entry keyed on .updated_at (0 hits).", "B: take currentVersion from the caller's read, and answer null when FLS hides updated_at."],
"recommendation": "A. Four axes: no real business scenario hides updated_at (0 hits), so B serves a speculative surface. A keeps one meaning for currentVersion, which clients echo as the next If-Match, while B gives it a second, null meaning that AI-written clients could mishandle. Startup focus says not to grow a case nobody pulls."
},
{
"question": "Should the Hono doors' hold on currentRecord (declaredConflictContext, #23587 / PR #23616) now be lifted?",
"options": ["A: lift it on that card. The producer's record is now the caller's read.", "B: keep holding it."],
"recommendation": "A, on #23587's own PR. It is outside this card's file surface, and this PR does not touch plugin-hono-server."
},
{
"question": "Is a 17.x backport owed? The same producer path is in the 17.7.0 tag.",
"options": ["A: backport", "B: no"],
"recommendation": "This is the maintainer's call, as dispatched. No backport PR was opened and no release branch was touched."
}
],
"out_of_scope_findings": []
}
ACCEPT → PR #23628 comment 6187592340 · domain:spec seat 2 · session session_01LFqdRsuM5nkqoJdHd5ztVk · 2026-10-15T08:12Z · on 2a20fe2d80; the fix at the producer, red-to-green on the measured rig, CI green; contract review at CONTRACT_REVIEW_TIER before ready.
Landing record · domain:spec seat 2 · session session_01LFqdRsuM5nkqoJdHd5ztVk · 2026-10-15T09:01Z · claim 6185927212 ends here: pm:dispatched is removed in this act, and the card is closed (completed, by PR #23628's Fixes)
7dc7d2213 "fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read" (git merge-base --is-ancestor → on origin/main)
merge queue
no gh-readonly-queue/main/* ref names it. One earlier queue build failed in a test that PR #23504 adds, stacked ahead of it (queue anchor #23641); the rebuild on main passed and merged
squash vs reviewed head
tree 82daa759d equals the clean merge of its parent with the reviewed head 2a20fe2d8 (ACCEPT 6187592340; contract review PASS 6187787956 on that head)
What closed, at function level: a stale version token on a row the caller's row-level policy hides now gets the same 404 as the request without a token. A conflict on a readable row carries only the caller's own field-masked read. It ships on the 18 line only (the maintainer's no-17.x ruling, relayed in 6186913121).
Handed on: the Hono doors' currentRecord hold (#23587, its PR #23616 merged with the hold) may now lift, because the producer's record is the caller's read. That is noted on #23587.
Activity
fix(plugin-hono-server): hold currentRecord on the Hono doors' 409 CO…
objectstack-fleet commentedon Oct 15, 2026
Triage: first grade ·
bug·security·priority:p1·domain:spec·pm:queue· measure first, p0 on confirmation · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-15T02:52ZRead fresh. Function level only, per the disclosure rule.
updateDataanddeleteDataread the addressed row with a system context (probeRecord). They judge the caller's version token on that row before the caller's by-id write policy runs.On a mismatch, the
409 CONCURRENT_UPDATEcarries that whole system-read row ascurrentRecord, and the REST door forwards it.Read from source, the path lets a caller who may write the object, but whom row- or field-level security denies, obtain a hidden row, or hidden fields, by sending a stale version. It is not measured with
plugin-securitybooted, and an earlier gate is not ruled out.priority:p1+security: a possible read-policy bypass on the/datawrite doors, carrying record data. If the first pin confirms it, the dev stops, the card goes to p0, and it returns here.Lane
domain:spec(packages/metadata-protocol/src/protocol.ts). The fix sits at the producer, so every door is fixed at once:RestServer, the dispatcher, and both Hono doors via hono: a409 CONCURRENT_UPDATErendered by #16545's recogniser (createHonoApp, the direct-mount adapter) carries nocurrentVersionorcurrentRecord, which the wire contract and objectui's conflict bar read #23531's remainder.Direction: the card's acceptance. Pins go on a real boot with
plugin-security, as an RLS-denied member and as an FLS-denied one, for stalePATCHandDELETE. The answer is the policy's existence or visibility answer, or a409whosecurrentRecordholds only what that caller may read.currentVersionkeeps its meaning.The
domain:cliseat's hold is right: PR fix(plugin-hono-server): a Hono door's 409 CONCURRENT_UPDATE carries currentVersion (#23531) #23582 landscurrentVersionalone, and forwards nocurrentRecordon the Hono doors until this card lands.Not in the protocol-first class: no request that succeeds today starts failing. The conflict body only stops carrying what the caller may not read. Whether a 17.x backport is owed is the release seat's call once it is measured.
findingleaves on grading.objectstack-fleet commentedon Oct 15, 2026
Claim: PM loop round 1 (#23586, measure first per triage
6183306689: on a real boot withplugin-security, does a stale-versionPATCHorDELETEon the/datadoors answer409 CONCURRENT_UPDATEwith acurrentRecordthe caller's row- or field-level policy hides? If the first pin confirms it, the dev STOPS and reports, and this seat raises the card to p0 and returns it to triage, as triage ruled. If it does not, the producer's answer is pinned as the policy's, andcurrentRecordis narrowed at the producer only if a measured leak path remains) · 2026-10-15T03:41ZSession:
session_01YRp5z4kK6vgYkrdH97ztsyAccount:
huangyiirene(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-23586-conflict-current-record-visibilityWorktree:
objectstack-issue-23586Domain:
domain:specSeat:
domain:spec#1(seat post #6017)File surface (at
origin/main16abea884or later; stop on a breach and explain it in the report):packages/metadata-protocol/src/protocol.ts: theupdateData/deleteDataversion-check region only (probeRecordnear:14876, theConcurrentUpdateErrorthrow near:14975,assertVersionMatchnear:15000).plugin-security, in the package where the existing RLS and FLS door pins live..changeset/23586-*.mdif code changes.packages/rest/**,packages/runtime/**orplugin-hono-server(they forward the producer's answer; PR fix(plugin-hono-server): a Hono door's 409 CONCURRENT_UPDATE carries currentVersion (#23531) #23582, hono: a409 CONCURRENT_UPDATErendered by #16545's recogniser (createHonoApp, the direct-mount adapter) carries nocurrentVersionorcurrentRecord, which the wire contract and objectui's conflict bar read #23531, holds itscurrentRecordhalf for this card). ⛔ Notpackages/types(data-error-classification.ts). ⛔ Notplugin-security's policy code. ⛔ Notpackages/spec/**.Container & model:
M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate). If code changes, the published409body narrows for a caller the policy denies, so oneCONTRACT_REVIEW_TIERreview runs before the queue, in an isolated subagent. A pin-only PR is CI plus the seat's read.Clause-②: no (narrowing)
Responsibility:
metadata-protocol'supdateData/deleteDataread the row by a system-context probe and judge the caller's version on it before the caller's by-id write policy runs, then put that row in the conflict ascurrentRecord. | Read from source only; not measured withplugin-securitybooted, and an earlier gate is not ruled out. | Any caller who may write an object but is denied a row or a field by policy, through every door that forwards the409body.Thread-read: 6183306689
Serial constraints cleared:
protocol.tsoutside:14000–:15200: ordinary concurrency.409 CONCURRENT_UPDATErendered by #16545's recogniser (createHonoApp, the direct-mount adapter) carries nocurrentVersionorcurrentRecord, which the wire contract and objectui's conflict bar read #23531,domain:cliseat 2) waits on this card for its Hono doors'currentRecord; the answer lands here, at the producer.Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
Measured: CONFIRMED · Release to triage at
priority:p0·domain:specseat 1 (#6017) ·huangyiirene· sessionsession_01YRp5z4kK6vgYkrdH97ztsy· 2026-10-15T03:55ZTriage
6183306689ruled: "If the first pin confirms it, the dev stops, the card goes to p0, and it returns here." The first measurement confirms it. The dev stopped: no fix, no PR. This act raises the grade topriority:p0, releases claim6183884475, and returns the card to triage (pm:queue+pm:retriage, unassigned). The seat is also standing down on the maintainer's word, so it does not re-dispatch. Per the disclosure rule, this comment stays at function level.What was measured (at
origin/main16abea884, on a real ObjectQL engine over a real SQL driver with the realSecurityPluginstarted, which is the repo's real-security-layer rig shape, not a full kernel boot):updateDatareads the addressed row throughprobeRecordwith a system context, anddeleteDatadoes the same throughassertVersionMatch.assertVersionOfcompares the caller's version token on that row and, on a mismatch, throwsConcurrentUpdateErrorwithcurrentRecordset to the whole system-read row. This happens beforeengine.update/engine.deleteruns, so none of the write-side policy checks run first (object CRUD, the by-id RLS pre-image, FLS); neither call was reached during any stale request. The system probe also takes the security middleware's system bypass, so nothing masks its row.RestServer, throughmapDataError/structuredCodeAnswer):PATCHorDELETEanswers409 CONCURRENT_UPDATE, and itscurrentRecordis the whole hidden row;409, andcurrentRecordcarries the hidden field.404 RECORD_NOT_FOUND; a record without the hidden field)./datadoor (rendered bydeclaredEnvelopeForThrow, the exit both Hono hosts use) puts nocurrentRecordon the wire today;declaredConflictContextholds it back. But the thrown error still carries the full row.409withcurrentVersion, where the same caller otherwise gets404. So the409itself tells a caller the hidden row exists and when it was last written.For triage, the dev's open question (its recommendation A; the ruling is triage's):
404 RECORD_NOT_FOUND, nocurrentVersion, nocurrentRecord). A conflict is reported only for a row the caller may read, and itscurrentRecordis read under the caller's context.409withcurrentVersionand narrow onlycurrentRecord. This keeps the existence and version disclosure above.Related: PR #23582 (#23531,
domain:cli) holds its Hono doors'currentRecordhalf for this card, rightly. ThecurrentVersionhalf it lands is part of the existence disclosure above, which this card's fix must cover at the producer.The measurement (its rig, the per-request readings and the run log) is kept in this seat's scratch, out of the tracker, and can be handed to the next holder.
State change:
priority:p1→priority:p0;pm:dispatched→pm:queue+pm:retriage; the assignee is cleared. The branchclaude/issue-23586-conflict-current-record-visibilityexists at16abea884with no commit of its own; a successor may reuse or delete it.Generated by Claude Code
3 remaining items
objectstack-fleet commentedon Oct 15, 2026
Retriage: p0 confirmed · direction A ·
domain:spec·pm:queue· needs a seat now · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-15T03:58ZRead fresh: the measurement
6184039472. Function level only. The stop condition this seat set (6183306689) fired:/datadoor, withSecurityPluginrunning, a stale version token gets a member a row their RLS hides, or a field their FLS hides, through the409 CONCURRENT_UPDATEbody.409withcurrentVersionalso discloses that an RLS-hidden row exists, and when it was last written.Grade:
priority:p0+security.17.7.0tag (4e4e881427,protocol.tsprobeRecordat:13680,currentRecord: currentat:13781), and17.7.0is npmlatest.17.xpatch release is owed is the maintainer's call. It is raised in this seat's round report.Direction: A. Triage routes it; it is not a decision card.
404 RECORD_NOT_FOUND, the controls' answer).The fix, at the producer:
404 RECORD_NOT_FOUND, with nocurrentVersionand nocurrentRecord.currentRecordis read under the caller's context, FLS-masked.PATCHandDELETE, onRestServerand the dispatcher.Clause-②: no(a narrowing), with oneCONTRACT_REVIEW_TIERreview before the queue.Coordination:
409 CONCURRENT_UPDATErendered by #16545's recogniser (createHonoApp, the direct-mount adapter) carries nocurrentVersionorcurrentRecord, which the wire contract and objectui's conflict bar read #23531) merged at 03:18Z withcurrentVersionon the Hono doors. The producer fix covers it, because a hidden row no longer reaches a conflict.currentRecordstands until this card lands.Seat:
domain:specseat 1 stood down on the maintainer's word, and seat 4 is off too. This card needs a seat now. It is raised to the maintainer in this round's report.pm:retriageleaves.pm:queue, unassigned,priority:p0.fix(plugin-hono-server): a Hono door's structured refusal carries Res…
objectstack-fleet commentedon Oct 15, 2026
Claim: PM loop round 3 (second shift) · 2026-10-15T06:25Z · the maintainer's 插队 (jump the queue) in this seat's session · the fix in triage
6184070020(p0, direction A): at the producer, a stale version token on a row the caller's RLS hides answers the policy's existence answer, and a conflict is reported only on a row the caller may read, withcurrentRecordread under the caller's context, FLS-masked. Pins go on the measured rig beside the controls. One PR,Fixes #23586.Session:
session_01LFqdRsuM5nkqoJdHd5ztVk(the seat; the dev is a cloud session whose draft PR carries its own session link)Account:
os-bill(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-23586-conflict-current-record-visibility(exists at16abea884with no commit of its own, offered for reuse in6184039472)Worktree:
objectstack-issue-23586Domain:
domain:specSeat:
domain:spec#2(seat post #18549)File surface (at
origin/mainaf255525cor later; stop on a breach and explain it in the report):packages/metadata-protocol/src/protocol.ts: theupdateData/deleteDataversion-check region only. That isprobeRecordand its three callers (:14676,:14913,:15007), theConcurrentUpdateErrorthrow (:14975) andassertVersionMatch(:15000). The fourthprobeRecordcaller (:15944) is read for the same path and touched only if it carries it.SecurityPluginstarted), as new test files in the package where the repo's real-security-layer rigs live, forPATCHandDELETEatRestServerand the dispatcher..changeset/23586-*.md.packages/types/src/data-error-classification.ts,plugin-security's policy code,plugin-hono-serverorpackages/spec/**. The doors forward the producer's answer.Container & model:
M,mode:cloud,model: default tier(dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"). Cloud because the rig's closure (metadata-protocol,objectql, a SQL driver,plugin-security,rest) is build-heavy, and a p0 should not queue on this container's verification lock ([Decision] Three spec contract dev agents in one container queued ~70 min each on the shared verify lock: leave packagetest:repoto CI, and send build-heavy cards to cloud sessions? #23607). Review ceilingCONTRACT_REVIEW_TIER, reason: the published409body narrows (triage6184070020), one review before the queue.Clause-②: no
Responsibility:
metadata-protocol'supdateData/deleteDatajudge the caller's version token on a system-context probe of the row before the caller's write policy runs, and put that row in the conflict | none: the doors forward the producer's error, and no earlier gate is a read policy (measured,6184039472) | a member who may write an object but is denied a row or a field by RLS or FLS, through every door that renders the409; reachable onmainand on the17.7.0line (triage6184070020)Thread-read: 6184070020
Ruling-ref: 6184070020 (triage's routed direction A; not a decision card)
Serial constraints cleared:
protocol.tsbetween:14000and:16000: PR fix(metadata-protocol)!: an uninstall that would drop a table another stored row still serves is refused 409 before anything is removed #23504 is at~26086, PR feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469's nearest hunk is at~12103, and this seat's [finding] after #23406: the publish-side first write over a sealed item still reads the create remedy; a stale packagedBaseRefusal docblock; the clone door's lost-race 409 names an If-Match it does not take #23577 cloud dev works at~17400–~22115. PR fix(rest,runtime)!: a JSON null /data create or query is refused 400 on both doors; the dispatcher query validates FindDataRequestSchema and asks exposure first #23617 (/data request contracts, the family remainder after PR #23571: a JSONnullcreate still makes an empty row on both doors; the dispatcherPOST /data/:object/queryvalidates noFindDataRequestSchema#23574) editsrest-server.tsandruntime/src/domains/data.ts; this card's pins go in new files. PR fix(plugin-hono-server): a Hono door's 409 DELETE_RESTRICTED, and every structured refusal, carries RestServer's arm fields (#23587) #23616 (hono: a 409 DELETE_RESTRICTED answered by createHonoApp carries no developerMessage, dependentObject, dependentCount or object, which RestServer's structuredCodeAnswer arm carries (the #23531 family beyond CONCURRENT_UPDATE) #23587) holds its Hono doors'currentRecorduntil this card lands, as triage ruled.domain:specseat 1's claim6183884475was released in6184039472. Its rig is in that seat's scratch, and this dev rebuilds the rig shape from the measurement's description.objectstack-fleet commentedon Oct 15, 2026
Maintainer ruling: no
17.xrelease · the fix ships on the 18 line only · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-15T07:30ZThe maintainer, verbatim, in this seat's session on 2026-10-15: 「17.x 不再发版本。」
6184070020: no17.xpatch is owed.17.xtag.security, direction A. The fix under claim6185927212(os-bill) lands onmainand ships in the next 18 prerelease.17.7.0(npmlatest) keeps the path, and a user leaves it by upgrading to 18.17.xuser reads it in the 18 upgrade notes.objectstack-fleet commentedon Oct 15, 2026
os-dev-report
{ "issue": 23586, "status": "done", "branch": "claude/issue-23586-conflict-current-record-visibility", "pr": "https://github.com/objectstack-ai/objectstack/pull/23628", "session": "session_01YNquHRZKDpiKsgmHXSd9Jx", "premise_still_valid": true, "summary": "Fixed at the producer, as triage 6184070020 routed (direction A). In metadata-protocol, assertVersionOf (shared by updateData, and by assertVersionMatch for deleteData) now re-reads the row under the caller's own context, only when the version token does not match. If the caller cannot read the row, it throws recordNotFoundError(object, id): the same 404 RECORD_NOT_FOUND, from the same producer, that the security layer's by-id write gate gives the control request, with no currentVersion and no currentRecord. If the caller can read it, the 409 carries the caller's FLS-masked read as currentRecord. probeRecord is unchanged (still an existence question), and the matched-token and no-token paths are unchanged with no extra read. PM hypothesis 1 held and was implemented as stated. Hypotheses 2 and 4 are pinned. Hypothesis 3: of the four probeRecord callers, two are this path (updateData; assertVersionMatch for deleteData) and are fixed by the one change. The other two (assertRecordExists for updateManyData and the batch update branch; the batch upsert fork) are existence-only: they take no version token and never hand the probed row to the caller. Hypothesis 5: the Hono doors are untouched; after this fix the producer's currentRecord holds only what the caller reads, so forwarding it there would be safe, and a hidden row answers 404 through them too. Started from origin/main 93f3526e6; origin/main (e611e2bdae, which moved protocol.ts via #23469 and the runtime data domain via #23617) merged cleanly at 2a20fe2d80 before the PR was opened.", "tests": "New pin packages/runtime/src/domains/data-conflict-caller-visibility.test.ts, on the measured rig shape (real ObjectQL + SqlDriver better-sqlite3 + SecurityPlugin init/start, one RLS policy and one FLS rule on one object, a plain second object). Four groups x PATCH/DELETE x RestServer/HttpDispatcher /data; dispatcher throws rendered by declaredEnvelopeForThrow, and the thrown error is read too. RED on the unfixed metadata-protocol dist: 'Tests 8 failed | 8 passed (16)'. The 8 failures are the RLS and FLS rows on both doors and both verbs: a 409 with currentRecord carrying the hidden row or field on RestServer; a 409 with details.currentVersion on the dispatcher where the control answered 404. GREEN after the fix, with metadata-protocol rebuilt and dist grep readAsCaller = 2: '16 passed (16)'. This red-to-green on the built dist is the reverse verification; no separate ablation. On the merged head 2a20fe2d80, after pnpm install --frozen-lockfile, a rebuild of the runtime^... closure and rm -rf packages/runtime/.objectstack: metadata-protocol test 'Test Files 236 passed | 3 skipped (239) / Tests 20550 passed | 19 skipped'; metadata-protocol typecheck exit 0; runtime typecheck exit 0 ('check:test-typecheck: OK ... 27 file(s) / 190 error(s)', so no new ledger entry); rest test 'Test Files 285 passed (285) / Tests 5845 passed | 326 skipped'; runtime test 'Test Files 387 passed (387) / Tests 5854 passed | 19 skipped'. Pre-merge at 7a4652fdb4: mp 237 files / 28429 passed; runtime 385 / 5798; rest 285 / 5858.", "gates": "Local subset, on HEAD 2a20fe2d80 (the final commit): node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 66 commands for 3 paths (+435/-11). Each was run on its own as 'cmd > log 2>&1; ec=$?', and all 66 exited 0. Reconciliation: 'dispatch-gates --ran: 66 derived famil(ies) accounted for - 66 run, 0 NOT-MEASURED (a DERIVED zero - all 66 recorded an exit code and none of them is 3)'. Earlier pass, at 7a4652fdb4 before the merge: check:query-options-erasure exited 1 (protocol.ts erasure 6 -> 7, test surface 236 -> 238) and was fixed by typing the options (EngineQueryOptionsParsed / EngineQueryOptions); it now holds at 65 non-test sites, none new. check-plugin-teardown-shape --self-test exited 3 (shallow clone) and passed after git fetch --unshallow. check:dual-build-cjs-loads exited 3 (no dist) and passed on the merged-head run. CI check-runs on 2a20fe2d80, all completed: required contexts Lint & Repo Gates success, TypeScript Type Check success, Test Core success, Build Core success, Governed Surface Queue Guard success; also Check Changeset success, Test Core (1-6/6) success, Lint ESLint / query-options erasure / PM dispatch-gates / CI-release-engine / docs & code guards / repo & tooling / tooling self-tests / slot lookup success, Type Check workspace / debt ledger / source / consumer gates success, Dogfood Regression Gate success, Check PR Size success, card-claims-branch success, single-writer and same-issue guards success; Console Pin Gate, Dogfood Verify CLI, Dogfood Regression shards, Build Docs and Temporal Conformance skipped (path or schedule selectors).", "line_budget": "3 files, +435/-11 (protocol.ts +50/-11, the new pin file, one changeset); under the 3000-line human-merge threshold.", "deviations": [ "The pin lives in packages/runtime, not packages/rest (the claim says 'the package where the repo's real-security-layer rigs live'). Rest's test graph has no HttpDispatcher. Runtime already holds the both-door OCC pins and has RestServer, plugin-security, driver-sql and plugin-hono-server (declaredEnvelopeForThrow) in its graph. Runtime also has real-SecurityPlugin rigs, for example share-links-enforcement-context. New file only; runtime/src/domains/data.ts is not touched.", "The commit trailers use AGENTS.md's model-free pair (Co-authored-by: Claude), not the harness reminder's model-named trailer: the pre-push hook refuses a model identifier, and AGENTS.md has precedence.", "Before any edit, the shared checkout /home/user/objectstack was detached and the local branch re-pointed to origin/main, so that the worktree could hold the branch name. The remote branch fast-forwarded from 16abea884." ], "files_changed": [ "packages/metadata-protocol/src/protocol.ts", "packages/runtime/src/domains/data-conflict-caller-visibility.test.ts", ".changeset/23586-occ-conflict-caller-visibility.md" ], "mcp_calls": "4 - issue_read (get, get_comments), pull_request_read (get, get_check_runs); all reads", "api_writes": "2 relay strokes (fleet-write dispatch, as objectstack-fleet[bot]): pr_create (POST /repos/objectstack-ai/objectstack/pulls draft + POST .../issues/23628/assignees os-bill), and this os-dev-report comment (POST .../issues/23586/comments). label-write: none (the dispatch named no label; auto-labeler set documentation, size/m, tests, tooling). Plus git push (not REST).", "open_questions": [ { "question": "currentVersion on a conflict comes from the system probe's updated_at (its meaning is unchanged). If an FLS rule ever hid updated_at itself, a stale token on a readable row would still be told the current version. Should the conflict then omit it?", "options": ["A: keep it as is. The token is the comparison's own subject, and a git grep finds no FLS entry keyed on .updated_at (0 hits).", "B: take currentVersion from the caller's read, and answer null when FLS hides updated_at."], "recommendation": "A. Four axes: no real business scenario hides updated_at (0 hits), so B serves a speculative surface. A keeps one meaning for currentVersion, which clients echo as the next If-Match, while B gives it a second, null meaning that AI-written clients could mishandle. Startup focus says not to grow a case nobody pulls." }, { "question": "Should the Hono doors' hold on currentRecord (declaredConflictContext, #23587 / PR #23616) now be lifted?", "options": ["A: lift it on that card. The producer's record is now the caller's read.", "B: keep holding it."], "recommendation": "A, on #23587's own PR. It is outside this card's file surface, and this PR does not touch plugin-hono-server." }, { "question": "Is a 17.x backport owed? The same producer path is in the 17.7.0 tag.", "options": ["A: backport", "B: no"], "recommendation": "This is the maintainer's call, as dispatched. No backport PR was opened and no release branch was touched." } ], "out_of_scope_findings": [] }Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
ACCEPT → PR #23628 comment 6187592340 ·
domain:specseat 2 · sessionsession_01LFqdRsuM5nkqoJdHd5ztVk· 2026-10-15T08:12Z · on2a20fe2d80; the fix at the producer, red-to-green on the measured rig, CI green; contract review atCONTRACT_REVIEW_TIERbefore ready.objectstack-fleet commentedon Oct 15, 2026
Landing record ·
domain:specseat 2 · sessionsession_01LFqdRsuM5nkqoJdHd5ztVk· 2026-10-15T09:01Z · claim6185927212ends here:pm:dispatchedis removed in this act, and the card is closed (completed, by PR #23628'sFixes)origin/main7dc7d2213"fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read" (git merge-base --is-ancestor→ onorigin/main)gh-readonly-queue/main/*ref names it. One earlier queue build failed in a test that PR #23504 adds, stacked ahead of it (queue anchor #23641); the rebuild onmainpassed and merged82daa759dequals the clean merge of its parent with the reviewed head2a20fe2d8(ACCEPT6187592340; contract review PASS6187787956on that head)What closed, at function level: a stale version token on a row the caller's row-level policy hides now gets the same
404as the request without a token. A conflict on a readable row carries only the caller's own field-masked read. It ships on the 18 line only (the maintainer's no-17.xruling, relayed in6186913121).Handed on: the Hono doors'
currentRecordhold (#23587, its PR #23616 merged with the hold) may now lift, because the producer's record is the caller's read. That is noted on #23587.