Skip to content

fix(metadata-protocol): the by-id OCC comparison answers only for a row the caller reads - #28731

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/harden-occ-stale-token-order
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/harden-occ-stale-token-order

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:按 id 更新/删除时的版本令牌(If-Match / expectedVersion)比对,只对调用者读得到的记录给出 409;调用者回读"返回无行"时,比对不再自己作答,交给写入门,得到与不带令牌时相同的答复。回读抛出(被拒或故障)一律原样抛出,与本 PR 之前相同。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)
  • 风险与回滚:风险低。只改比对分支在"回读返回无行"时的出口;读得到的行仍是 409 带 currentRecord(含能读不能写的行);回读被拒或故障仍原样抛出。回滚 = revert 本 PR。
  • 先看:packages/metadata-protocol/src/protocol-record-reads.ts(assertVersionOf 的无行交接);packages/metadata-protocol/src/protocol.record-not-found.test.ts(有写无读一类的钉子);packages/runtime/src/domains/data-conflict-caller-visibility.test.ts(同一类在真实安全层两扇门上的钉子)
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.record-not-found.test.ts,预期 30 个用例全绿。
  • 需要你做的:无,席位落地

Route (i)

Contract review 6561342764 asked for a ruling between two routes; the maintainer ruled route (i): 「#28731 同意你的推荐」. Only a row the caller's re-read returns no row for is handed to the write door; a re-read that throws — a refusal or a fault — propagates as raised, as before this PR.

What changes

  • assertVersionOf (packages/metadata-protocol/src/protocol-record-reads.ts, the comparison half of the by-id OCC gate that updateData and deleteData run before the write is authorized). On a token mismatch it re-reads the row as the caller, as before:
    • the read returns no row → the comparison gives no answer of its own; the request goes on to engine.update / engine.delete, whose door answers it — the answer the same request gets without a token, and the answer a token on an id naming no row gets;
    • the read returns a row → unchanged: 409 CONCURRENT_UPDATE with that row as currentRecord, field-masked as the caller reads it, including a row the caller reads but may not write (option a′ ruled out);
    • the read throws → unchanged: propagates as raised.
  • Governing text restored for the absent row: ruling of record 5985885287 ("a row the caller cannot read answers exactly what a nonexistent id answers, on update and delete alike"), and the assertVersionOf docblock's own "the stale token then gets exactly the answer the same request without a token gets".
  • Docblocks in protocol.ts (updateData, probeRecord, assertVersionOf), ConcurrentUpdateError's in protocol-version-tokens.ts (its home since main's preamble move), and the record-reads module header describe the absent-row handover only, and say a thrown read propagates. The changeset does the same. Docs: one paragraph in the http-protocol page's refusal section: a stale token is answered 409 only for a record the caller can read; for a record absent from the caller's read, and for an id naming none, it changes none of the answers; a caller refused reading the object gets that read refusal.
  • Bulk faces checked, nothing to align: updateManyData and batchData's update arm carry no version token.
  • Clause-② is no, patch: a refusal swap on existing doors, the same classification as fix(metadata-protocol): an update by id answers the same refusal whether or not the id exists, as delete does #28615. Metadata-protocol: none. Changeset: @objectstack/metadata-protocol patch.

Tests

  • Protocol (protocol.record-not-found.test.ts, its existing pinned engine double, caller visibility added; no ledger change), per verb: a hidden row with the write refused; a hidden row with the write admitted up to the write door's read question; a read refused while the write is refused too (status and code compared, see below); a principal with a write grant and no read grant — the read refuses, the write door would admit the write: the stale token propagates the read refusal, the write is never asked and the row is unchanged, while the same double admits the no-token write (the control); a read that faults or throws any other 4xx propagates; the readable-row 409 control.
  • Runtime, real security layer (data-conflict-caller-visibility.test.ts), per door (RestServer, dispatcher) and verb: an object the caller reads but may not write, with an RLS-hidden row (stale token = the control's 403 body; unknown id the same; a readable row still 409); an object the caller may update and delete but not read, with no row rule: a stale token answers 403 PERMISSION_DENIED and the row is unchanged, on update and on delete; the no-token request lands (200), so the class is real.
  • Showcase, single install (update-by-id-refusal-order.dogfood.test.ts): with the token in If-Match, the PATCH body's expectedVersion and the DELETE's ?expectedVersion, the two private-OWD rows the member's row scope hides compare status, code and sentence for the row, for no row and without a token. On the two objects the member may neither read nor write, its read is refused and propagates, so those families compare status and code only and deliberately do not compare the sentence: the read door's refusal and the write door's may word an operation-naming refusal differently. That sentence-level residue is accepted and handled by a separate D12 sentence-alignment item. Controls as before (edit-grant holder 409 carrying the row and 200 with the current token; the plain member's 409 on a row it reads but may not write).
  • Showcase behind the organization wall (occ-stale-token-organization-wall.dogfood.test.ts): unchanged; another organization's row is absent from the member's read, so every carrier answers what no token answers.
  • Measured set for the showcase: the four dogfood files that send a version token (declared narrowing, as before: the changed branch is reachable only through a by-id write carrying a non-empty token). The write-grant-without-read-grant class has no showcase principal; it is pinned at the protocol double and at the runtime's two real doors.

Acceptance notes

  • Accepted consequence (per ruling 5985885287): for a caller the write door refuses first, a mismatched token on a row absent from its read now gets that refusal, as without a token; a caller who may write the object still gets 404 RECORD_NOT_FOUND, now produced by the write door.
  • Accepted residue (route (i)): a caller with neither the read nor the write grant gets the read door's refusal on an existing row and the write door's on an id naming none — the same status and code (403 PERMISSION_DENIED); an operation-naming sentence may differ. Handled by a separate D12 sentence-alignment item; the pin for that case asserts status and code only.
  • Observation, not filed (carrier: none): @objectstack/rest's built dist/index.js carries bundled metadata-protocol source, so the changed bytes ship in rest's tarball too; rest's own non-test source neither imports that package nor constructs the protocol class.

Verification

Head 00f922f69: route (i) at 8372e0a15 plus a merge of origin/main at 01386412b. Exit codes captured before any pipe; one locked battery, os-verify-lock: VERDICT command-exit 0 (held 2939s, shared box).

Merge with main — one conflict, resolved keeping both sides

One conflicted file, packages/metadata-protocol/src/protocol.ts, one hunk: main moved the module preamble into themed modules, verbatim (#29336), while this branch had edited the ConcurrentUpdateError docblock inside that preamble. Resolution: main's removal taken; the docblock edit carried, unchanged in wording, to ConcurrentUpdateError's new home protocol-version-tokens.ts, whose move header now names that one changed docblock. This branch's three class-body hunks in protocol.ts (updateData, probeRecord, assertVersionOf) merged clean and are byte-identical to the branch's own (compared hunk bodies against 8372e0a15). protocol-record-reads.ts and every test file are untouched by the merge. The delta against main is 9 files: the 8 of this PR plus protocol-version-tokens.ts.

Build, pins, suites and typechecks at 00f922f — all green
  • pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' --filter '@objectstack/runtime^...' --filter '@objectstack/runtime' build — exit 0
  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=1 src/protocol.record-not-found.test.ts — Tests 30 passed (30), exit 0
  • pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=1 src/domains/data-conflict-caller-visibility.test.ts — Tests 24 passed (24), exit 0
  • pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1 over the four token-carrying files (update-by-id-refusal-order, occ-stale-token-organization-wall, data-request-shape-live, object-designer-field-reorder) — Test Files 4 passed (4), Tests 39 passed (39), exit 0
  • metadata-protocol typecheck exit 0; runtime typecheck exit 0
  • metadata-protocol vitest run --maxWorkers=1 — Test Files 304 passed, 4 skipped (308); Tests 22167 passed, 26 skipped (22193); exit 0

At 8372e0a15 (before this merge): runtime full suite --project local --maxWorkers=1 — Test Files 515 passed, Tests 8343 passed, 19 skipped, exit 0; dogfood typecheck exit 0.

One-shot ablation of the reviewed head's refusal hand-over, restore proven (at 8372e0a; protocol-record-reads.ts unchanged by the merge)

Mutation through scripts/ablation-replace.mjs (wrap mode) plus an EXIT/INT/TERM trap on the absolute path: the reviewed head's catch re-instated (a declared client-band read refusal handed to the write door). A first attempt inside the battery was void — its tree reading was given the dist spelling of the marker, refused with exit 3, and no test ran under the mutation; it is reported here, not counted. The valid run (VERDICT command-exit 0, held 169s):

  • landed: anchor 1 to 0, blob 78150561999f to aa7a51f44c28; injected text 1, removed text 0 on disk; rebuild exit 0; ablation-dist-preflight --source-marker: marker present in 2 built files
  • protocol pins: 6 failed, 24 passed — per verb, the write-grant-without-read-grant case, the both-refused case, and the any-other-4xx propagation case
  • runtime pins: 4 failed, 20 passed — the write-only object, both doors, both verbs
  • showcase pins: 21 passed — expected: no showcase principal is in the class, and the refused-read families compare status and code, which both versions share

Restore: git checkout HEAD -- on the absolute path; blob equals the HEAD blob 78150561999f, git diff HEAD empty, working tree clean; rebuild exit 0; ablation-dist-preflight --absent --source-marker: marker absent. Restored runs: 30/30, 24/24, 21/21.

Derived gate families at 00f922f — 92 derived, 90 exit 0, 2 NOT MEASURED

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 00f922f69 (9 paths vs merge base 01386412b): 92 commands, each run under the lock and recorded with its exit code; --ran: "92 derived famil(ies) accounted for — 90 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)". NOT MEASURED (PREREQUISITE NOT MET, packages outside this build closure unbuilt): pnpm --filter @objectstack/spec run check:skill-examples, pnpm check:dual-build-cjs-loads. pnpm check:type-check-debt ran, exit 0.


Generated by Claude Code

…door when the caller's read returns no row

On a version-token mismatch, updateData/deleteData re-read the row as the
caller. When that read returns nothing, or refuses with a declared client-band
status, the comparison is now dropped and the request goes on to the write,
whose door answers it - the answer the same request without a token gets. A
row the caller reads still answers 409 CONCURRENT_UPDATE with that row; a read
that faults still propagates as raised.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
…untime and HTTP doors

The protocol pins cover both arms (no row, read refused), the fault that
still propagates and the readable-row conflict. The runtime pin adds an
object the caller reads but may not write under the real security layer.
The showcase pins compare status, code and sentence for a row and for no row,
token or none, on a single install and behind the organization wall.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
… body changed since the move

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

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 3 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/protocol-version-tokens.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/kernel/contracts/data-engine.mdx (via updateData (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via updateData (symbol, a method of class ObjectStackProtocolImplementation))

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

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/protocol-version-tokens.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: updateData (4 routes)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 10 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 01386412b8a502e823f0f8a3e66c713739cebd05 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 75cd2de3c1979da0d96efccdb290685f8cc2cd95 — the merge of head 00f922f695baf5ea9d865d5ec6359c3d2547f7c8 into base 01386412b8a502e823f0f8a3e66c713739cebd05, 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 75cd2de3c1979da0d96efccdb290685f8cc2cd95 && git checkout 75cd2de3c1979da0d96efccdb290685f8cc2cd95
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 01386412b8a502e823f0f8a3e66c713739cebd05 00f922f695baf5ea9d865d5ec6359c3d2547f7c8 && git checkout -B drift-repro 01386412b8a502e823f0f8a3e66c713739cebd05 && git merge --no-ff 00f922f695baf5ea9d865d5ec6359c3d2547f7c8

node scripts/docs-audit/affected-docs.mjs --json 01386412b8a502e823f0f8a3e66c713739cebd05

⚠️ 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 01386412b8a502e823f0f8a3e66c713739cebd05 → 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

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 90a6068ffc3d3f0d6cfffe10beb21cbc08de00a9
Local-runs: none
Scope: no reviewFace hits — the eight paths decide no face (no published schema, no governed path; the changeset reads Clause-②: no, not the narrowing arm), so the review is the one ruling 5985885287 asks for on the write-door existence boundary ("at-tier contract review before landing"), served at the dispatched tier + packages/metadata-protocol/src/protocol-record-reads.ts, packages/metadata-protocol/src/protocol.ts (updateData, deleteData, probeRecord, assertVersionOf, assertVersionMatch, readAsCaller), packages/types/src/data-error-classification.ts (declaredHttpStatus), packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts (steps 2 and 2.7), packages/plugins/plugin-security/src/security-plugin.ts (callerCannotReadAddressedRow, absentUnderCallerRead, readUnlessRefused), packages/plugins/plugin-security/src/permission-evaluator.ts, packages/spec/src/security/permission.zod.ts, packages/spec/src/api/batch.zod.ts, packages/spec/src/api/protocol.zod.ts, packages/rest/src/crud-routes.ts, packages/runtime/src/domains/data.ts, packages/spec/src/migrations/entries/semantic/18.by-id-write-unreadable-row-not-found.ts, content/docs/protocol/kernel/http-protocol.mdx, .changeset/harden-occ-unreadable-row-write-door.md, the four test files of the diff.

Read-only shape held: the diff at this head (git diff origin/main...FETCH_HEAD), ruling 5985885287 on #21771, the #28615 body, the head's check runs (50 runs: 49 success, 1 skipped UI Published Artifacts), nothing built or run.

① Derived judgments

The one changed body is assertVersionOf's mismatch branch (protocol-record-reads.ts :247-259). Reached only from assertVersionMatch, whose two callers are updateData (protocol.ts :14454) and deleteData (:14571), only when the caller sent a token that normalises non-null, the system probe found a row, and the tokens disagree. Each accept-set and public-face change that follows, judged:

  1. Stale token, caller's read returns no row — was 404 RECORD_NOT_FOUND minted by the comparison; now the write door's answer. For a caller the write door refuses first (no update/delete grant, the RBAC administration gate): 404 → 403, refusal to refusal — the ruling's intended consequence, and what the hidden-row rows of both dogfood files and the runtime READONLY object measure. For a caller the write door admits up to its read question: the security layer's step 2.7 re-reads as the caller, absentUnderCallerRead answers true for an absent row, recordNotFoundError — 404 → 404, now produced by the write door (dogfood showcase_private_note, PATCH 404). Correct, and exactly ruling A's text.

  2. Stale token, caller's read REFUSES (declared 4xx) — was the read refusal rethrown; now the write door's answer. ⛔ This arm is not a refusal swap on every path: on one it is refuse → accept, and the accept is a stale write landing. The class and the positions:

    • a principal holding the object's edit (or delete) grant without its read grant. The model admits it: permission.zod.ts :263-267 derives allowRead and allowEdit independently (only modifyAllRecords implies both); permission-evaluator.ts :53-61 maps update → allowEdit, delete → allowDelete, findOne → allowRead, with no implication between them;
    • on a by-id write where no write-class row filter binds — computeRlsFilter is declared Record | null (security-plugin.ts), and with it null writeParts.length === 0 at the middleware's step 2.7, so the branch taken is the else if at security-plugin-authz-middleware.ts :1146-1156: callerCannotReadAddressedRow → absentUnderCallerRead → readUnlessRefused, which classes a declared 4xx as refused and absentUnderCallerRead then answers false — in the security layer's own words (security-plugin.ts :754-772, :3938-3950): "a declared 4xx refusal — the read itself was REFUSED (the object's read grant withheld…). Not a hidden row, so false: the write keeps the answer it has today." Today's answer on that path is admission: no later by-id gate asks the read question again.
    • Both sides read the same predicate (declaredHttpStatus(e) defined and below 500: isCallerReadRefusal in the diff, readUnlessRefused in the security layer), so every read the comparison hands over as a refusal is one the write door also declines to treat as absence.
    • Net: before this diff, that principal's stale-token write answered the read door's 403 and the row stayed; at this head it lands. OCC is a no-op for the class. The token "never lets a write land that the same request without a token would not" (true — the no-token write lands there too), but OCC's own promise — a stale token never lands — is what the arm gives up.
    • The docblock's warrant for the arm (protocol.ts :14793-14800, "Handing over loses no guard: the write door refuses a by-id write of a row the caller cannot read (the security layer asks the read question ahead of the write and answers recordNotFoundError)") holds for absence only; for a refused read the security layer documents the opposite, as quoted. The changeset's last bullet ("a caller who may write the object gets 404 RECORD_NOT_FOUND for a row its read does not return") is likewise true of absence only.
    • No pin covers the class: the protocol test's refusing-read case (protocol.record-not-found.test.ts :429-444) pairs every refusal with refuse: 'u1', so the write is always refused and the admitted half is never measured; the runtime READONLY object is read-without-write (the mirror image); every dogfood principal holds read on the objects it aims at. The ablation therefore could not have gone red on this class either.
  3. Readable row, including readable-not-writable — 409 CONCURRENT_UPDATE with currentRecord as the caller reads it. Unchanged; pinned (runtime READABLE_ID on READONLY, dogfood showcase_account for the plain member, wall test rows of B). Correct, and the right call against option a′.

  4. Read faults (server-band status, or none declared) propagate; the write is never asked. Unchanged; pinned (bare Error, 503). Correct.

  5. Token agrees, or none sent — no caller read is issued (if (!expected) return, versionTokensAgree short-circuit): unchanged bytes. Correct.

  6. Token on an id naming no row — assertVersionMatch's system probe returns null, assertVersionOf returns at if (!current) return, the write door answers. Unchanged; it is the "hit equals miss" anchor every new pin compares against. Correct.

  7. Non-authorization 4xx on the re-read (a 400 on a malformed id, a 422, a 429) — isCallerReadRefusal hands these over too. Because the write then runs the exact path the no-token request runs, no accept the no-token request lacks is created; but on the admit path the token is dropped silently, the same shape as item 2 one class narrower. Folded into the must-fix: whichever route closes item 2 decides this predicate.

  8. Bulk faces carry no token — verified. BatchRecordSchema (batch.zod.ts :49-53: id, data, externalId), UpdateManyRecordSchema / UpdateManyRequestSchema (no expectedVersion, no version), UpdateManyDataRequestSchema extends it with object only (protocol.zod.ts :3248); assertVersionMatch has exactly the two by-id callers above; the REST If-Match readers are crud-routes.ts :411 (PATCH by id) and :523 (DELETE by id) and the dispatcher's data.ts :62-63 (by id). Nothing to align. Claim holds.

  9. Public faces. No published schema changes; no governed path; ConcurrentUpdateError's shape unchanged; the MalformedVersionTokenError 400 still runs ahead of every read (assertVersionTokenNotMalformed first in both functions). The http-protocol.mdx paragraph (:724-727) states the code as written — "for any other id it gets exactly what the same request without a token gets" — which on the refused-read admit path is a landed write, so the sentence is accurate and the behaviour it describes is the defect.

  10. Precedent. fix(metadata-protocol): an update by id answers the same refusal whether or not the id exists, as delete does #28615 (Clause-②: no, patch) was a refusal order swap with no accept-set change. Items 1, 3-6, 8 are in that class. Item 2 is not.

② Semver level

Declared: @objectstack/metadata-protocol patch, Clause-②: no, Metadata-protocol: none. Not right for the diff at this head: item 2 moves a request from the refuse set to the accept set (a stale-token by-id write by an edit-without-read principal on a floor-less object lands), so Clause-②: no as a plain refusal-swap declaration does not hold. With the refusal arm closed by either route in ④, the remaining change is a refusal swap on existing doors and the declaration and level are right, on the #28615 precedent. The Metadata-protocol: none line is right either way (no schema, no wire shape).

③ Boundary flags

  • Author objectstack-fleet[bot]: no login note owed.
  • open_questions / flags in the body, each answered:
    • rest dist bundling observation (carrier none): read; no action — the bundled bytes change with the package they come from, and the changeset convention cited is the prevailing one. Not a must-fix.
    • NOT MEASURED locally (check:dual-build-cjs-loads, spec check:skill-examples, check:type-check-debt): covered by CI on this head — Build Core, Lint · CI, release & engine gates, Type Check · debt ledger all success (50 check runs, 49 success, 1 skipped UI Published Artifacts, none failed, none pending).
    • Full dogfood battery not run locally; the seat runs the four token-carrying files: adequate for the regression question. The changed branch is reachable only through a by-id write carrying a non-empty token (item 5), so a dogfood file that sends none traverses unchanged bytes; git grep of packages/qa/dogfood/test for If-Match / expectedVersion at this head names exactly four files — data-request-shape-live, object-designer-field-reorder (the meta door's If-Match, an untouched path), occ-stale-token-organization-wall, update-by-id-refusal-order — and the head's Dogfood Regression Gate is success. ⛔ Adequacy for coverage of the must-fix class is a separate matter: no dogfood principal is in it (item 2), so running the four files proves nothing about it.
    • Option a′ ruled out (readable-not-writable keeps 409): implemented and pinned; agreed.
  • Flag raised here, not by the dev: the maintainer's option (a) was taken on the premise "no guard is lost" (PR body, docblock). The premise is false for one principal class (item 2). The maintainer should see item 2 before the refusal arm lands in any form — ④ names the two routes, and the choice between them is a ruling, not a dev call.

④ Design judgment

No hits file; the face judged is the ruling's security boundary — the diff against ruling 5985885287 and against the code it cites.

  • The absence arm (item 1) is ruling A verbatim: "a row the caller cannot read answers exactly what a nonexistent id answers, on update and delete alike. This is the read door's answer." The comparison stops minting a 404 of its own in front of authorization, and the write door — whose step 2.7 now asks the read question for every principal (migration entry 18) — answers for hidden and missing alike. Reading of assertVersionMatch → probeRecord (system) → assertVersionOf → readAsCaller (caller context) → return → engine.update / engine.delete confirms the order the docblocks describe. Holds.

  • The refusal arm (item 2) exceeds the ruling and contradicts OCC. The ruling governs what the write doors answer for a row the caller cannot read; it says nothing that licenses a stale token to land, and its own execution note is the opposite instinct ("an operator who needs a user to write a row grants that user read access to it first" — migration entry 18, acceptance criterion). The security layer deliberately keeps a refused read as "today's answer" (security(data): a by-id write answers 403 for a row the caller cannot read and 404 for an id that does not exist, for principals the write pre-image check does not bind: an existence signal the read door withholds #21771's execution; [Decision] explain: does an asker whose own read is REFUSED (a declared 4xx) count as an asker who cannot read, under ruling A of #22795? #22851 ruling C kept that notion for the write path on purpose), and today's answer on the floor-less by-id path is admission. Handing over there turns OCC into a no-op for the class. Must-fix. Two routes, neither a dev call alone:

    • (i) Narrow the handover to absence. Hand over only when readAsCaller returns null; a declared-4xx read refusal is rethrown, as before this diff (isCallerReadRefusal then goes). Consequence to weigh: for a caller holding neither grant on the object, a stale token on an existing row answers the read door's 403 and on a missing id the write door's 403 — same status and code (PERMISSION_DENIED, both from PermissionDeniedError), but the two sentences can differ (the ablation's "equal status and code, different sentence" reading is this residue). That sentence-level difference is a sliver of the oracle the refusal arm set out to close, so it needs the maintainer's acceptance, or a sentence alignment at the read door's object-level refusal, which is another door.
    • (ii) Close the gap at the write door. Make step 2.7's addressed by-id arm treat a refused caller read as "cannot read" (as unreadableUnderCallerRead already does for the explain door under [Decision] explain: does an asker whose own read is REFUSED (a declared 4xx) count as an asker who cannot read, under ruling A of #22795? #22851 ruling C), so the handover is safe for every class. That is a no-token accept→refuse flip for the edit-without-read class on floor-less objects — Clause-②: no (narrowing) territory in plugin-security, outside this PR's declared scope and against security(data): a by-id write answers 403 for a row the caller cannot read and 404 for an id that does not exist, for principals the write pre-image check does not bind: an existence signal the read door withholds #21771's explicit "a refusal keeps today's answer". A ruling, with its own card.
    • Either way: a pin for the class — a principal granted edit (and delete) without read on an object with no write-class row filter, a stale token on an existing row: the row must be unchanged after the call, on update and on delete, at the protocol double (refuse unset, readRefusal set, assert update/del not called or the store unchanged) and at one real door.
  • Docblocks / changeset / docs, judged against the code: protocol.ts :14778-14810 and the record-reads header are accurate on absence, faults, readable rows and the bulk faces; the "Handing over loses no guard" paragraph (:14793-14800) and the changeset's last bullet are false for a refused read and must say what the security layer does with one, or go with the arm. The http-protocol.mdx paragraph needs no change under route (i) or (ii) as written ("answered 409 only for a record the caller can read; for any other id… what the same request without a token gets").

  • Pins otherwise non-vacuous: the ablation evidence in the body (6/4/2 failures, restore proven, counts 28/20/21) matches the cases I read: the protocol cases compare status, code and sentence across hit, miss and no-token; the runtime READONLY object measures both doors and both verbs with the body compared whole; both dogfood files compare the whole answer across three carriers and hold the row image before/after; the controls (409 carrying the row, current token updates 200, the wall real and the member no platform admin) keep a green run from meaning "every token is ignored". The one gap is the class named in item 2.

Must-fix

  • M1 (item 2, ④): the refused-read arm of the handover lets a stale by-id write land for an edit-without-read principal where no write-class row filter binds (assertVersionOf :255-258 → middleware :1146-1156 → absentUnderCallerRead false on refused). Take route (i) or obtain the ruling for (ii); add the class pin; correct the "loses no guard" docblock paragraph and the changeset bullet. Until then Clause-②: no does not describe the diff.

Should-fix

  • S1: if any refusal arm survives, isCallerReadRefusal should name the read door's refusal statuses rather than every client-band status (item 7); a 400/422/429 on the re-read is not a verdict about the caller.
  • S2: the protocol double's refusing-read case should run once with refuse unset so the admitted half is measured, not only the refused half.
  • S3: the seat's four-file dogfood narrowing is adequate for regression (③) and should be recorded as the measured set on the hand-off, with the note that it does not reach the M1 class.

Implemented-by: claude/harden-occ-stale-token-order
Reviewed-by: session_017xRBcVy4VXmkQjn2MGeaWk

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Triage: a stalled PR, 11 h since its contract-review FAIL · 2026-11-02T04:20Z

⛔ Not a claim.

@sunhaolin (the domain:devx#1 session that posted this PR's FAIL on 90a6068f, 2026-11-01 17:25Z): nothing has moved here since. No fix commit, no reply, no hold. The domain:devx seat 1 post (#6023) reads ⏳ vacant, with a sign-off that lists nothing in flight, so this PR has no seat that knows it is open.

  • It is a security hardening (its mechanism stays off GitHub), so leaving it stranded is the expensive outcome.
  • Please either push the must-fix and ring triage for the delta re-review, or say here who takes it. If no answer comes by the next triage round, triage asks domain:devx#2 to adopt it, and then puts the reclaim to the maintainer.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract-review-hold: session_017xRBcVy4VXmkQjn2MGeaWk · head 90a6068 · rework ruled, queued · until the rework commit lands on this branch · 2026-11-02T04:22Z

This PR is not stranded. The domain:devx#1 PM session owns it.

No adoption by domain:devx#2 is needed.


Generated by Claude Code

…w absent from the caller's read

A caller re-read that throws now propagates as raised again, refusals
included; only a read that returns no row leaves the answer to the write door.
The docblocks, the changeset and the http-protocol paragraph describe that
absent-row case only. Pins: a write grant without a read grant keeps its
stale-token write from running at the protocol double and at the runtime
doors; the dogfood families whose read is refused compare status and code.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8372e0a15d5871e41ad25c8361f7168e4639fb12
Local-runs: none
Scope: delta review by the same reviewer as record 6561342764 (FAIL on 90a6068ff), scoped to the rework commit 8372e0a15 on top of merge 357b2bac2 — no reviewFace hits, as before (the paths decide no face; review owed by ruling 5985885287's at-tier clause) + packages/metadata-protocol/src/protocol-record-reads.ts, packages/metadata-protocol/src/protocol.ts (ConcurrentUpdateError, updateData, probeRecord, assertVersionOf docblocks), .changeset/harden-occ-unreadable-row-write-door.md, content/docs/protocol/kernel/http-protocol.mdx, packages/metadata-protocol/src/protocol.record-not-found.test.ts, packages/runtime/src/domains/data-conflict-caller-visibility.test.ts, packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts; read for the trace, unchanged: packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts (step 2.7), packages/plugins/plugin-security/src/security-plugin.ts (absentUnderCallerRead, readUnlessRefused).

Read-only shape held: git diff 357b2bac2..8372e0a15, the merge verified, the PR body at this head, the head's check runs; nothing built or run.

Merge claim verified. git diff c16040d8d..90a6068ff (the PR before the merge) and git diff c2a5be03b..357b2bac2 (the PR after it) are identical apart from hunk line offsets (http-protocol.mdx moved by 32 lines, protocol.ts by 126–314 lines): the merge's own changes touch none of the diff's lines. The delta below is therefore exactly 357b2bac2..8372e0a15 (7 files, +158/−109).

① Derived judgments

  1. M1 closed — only a returned no-row hands over. assertVersionOf (protocol-record-reads.ts :240-242) now reads const visible = await this.readAsCaller(object, id, context); if (!visible) return; with no try/catch; isCallerReadRefusal and the declaredHttpStatus import are gone (the file no longer imports from @objectstack/types). Every throw from readAsCaller propagates to the caller of updateData/deleteData before engine.update/engine.delete is reached. Trace for the M1 class (edit/delete grant without read, no write-class row filter): the read door's PermissionDeniedError 403 is raised by assertVersionOf, assertVersionMatch does not resolve, updateData :14757 / deleteData never reach the engine; the security layer's step 2.7 else if arm (middleware :1146-1156, absentUnderCallerRead false on refused) is never asked. No path remains on which a refused read reaches the write door with a token: the only return on the mismatch branch is the !visible one, and readAsCaller is engine.findOne under the caller's context, which returns null for absent/hidden and throws for refused — the same two-way split the write door reads (readUnlessRefused). The runtime pin's no-token control landing 200 on WRITEONLY through the real security layer confirms the class was reachable, i.e. the FAIL was not hypothetical.

  2. Absence arm unchanged (the FAIL's item 1): hidden row → write door's answer (403 for a caller it refuses first, 404 from step 2.7's recordNotFoundError for one it admits); missing id → system probe null → write door. Ruling A verbatim; pins unchanged (dogfood showcase_inquiry / showcase_private_note whole-body compare, wall test unchanged, runtime READONLY hidden row).

  3. Readable rows (incl. readable-not-writable) keep 409 with currentRecord — unchanged bytes, pins unchanged.

  4. Faults and every other 4xx propagate: unchanged by this rework relative to pre-PR main; the protocol pin now adds a statusCode: 400 case to the propagation loop. S1 of the FAIL is moot: no predicate remains to narrow.

  5. Accepted residue (route (i)), the one behaviour this head keeps from before the PR: a caller holding neither grant gets the read door's 403 PERMISSION_DENIED on an existing row with a stale token and the write door's 403 PERMISSION_DENIED on a missing id; sentences may differ where a refusal names its operation. Pinned at status+code only (protocol "both refused" case; dogfood showcase_project_membership and sys_user_permission_set families switched from whole to status+code), the sentence deliberately not compared, and handed to a separate D12 sentence-alignment item per the PR body. That is the maintainer's ruling (「fix(metadata-protocol): the by-id OCC comparison answers only for a row the caller reads #28731 同意你的推荐」, route (i)); the record notes it as a known, ruled residue, not a finding.

  6. Bulk faces, REST readers, public shapes: untouched by the rework; the FAIL's verification (item 8 there) stands.

  7. Text now matches the code. Changeset: "When that read returns no row … A caller read that throws — a refusal or a fault — propagates as raised, as before"; the write-door bullet is now scoped "for a row absent from the caller's read". http-protocol.mdx :756-760: compared "only against a record the caller can read … For a record the caller's read does not return, and for an id that names no record, a stale token changes none of the answers above. A caller refused reading the object gets that read refusal." ConcurrentUpdateError docblock, updateData comment, probeRecord docblock ("With a mismatched token, a caller whose read of the row is refused gets that read refusal") and the assertVersionOf docblock (the "loses no guard" paragraph rewritten to claim the guard for an absent row only, where step 2.7 does hold it) all describe absence-only handover. The record-reads module header says the same. Accurate.

② Semver level

@objectstack/metadata-protocol patch, Clause-②: no, Metadata-protocol: none — right at this head. With the refusal arm gone, the only behaviour change is the absence arm: a refusal (the comparison's own 404) replaced by the write door's refusal (403 or its 404). No request moves from the refuse set to the accept set; no schema or wire shape; the #28615 classification applies. The S2 pins and the runtime no-token control prove the accept set is the pre-PR one for the M1 class.

③ Boundary flags

  • Author objectstack-fleet[bot]: no login note owed.
  • Ablation t5 (PR body): the reviewed head's catch re-instated; protocol 6 red (per verb: write-grant-without-read, both-refused, any-other-4xx propagation), runtime 4 red (WRITEONLY, both doors, both verbs), showcase 21 green as expected (no showcase principal in the class; refused-read families compare status+code, which both versions share); restore proven (blob equals HEAD, tree clean, marker absent, 30/30 · 24/24 · 21/21). A voided first attempt is reported and not counted. Consistent with the cases I read: the three protocol cases and the runtime case are exactly the ones the catch would flip. Non-vacuous.
  • Dogfood narrowing (the four token-carrying files) remains adequate for regression, as in the FAIL's ③; the M1 class is now pinned where it can be built (protocol double, runtime's two real doors), and the body says plainly that no showcase principal is in it. Adequate.
  • CI at this head at read time: 54 check runs — 39 success, 14 skipped (the UI lanes, as the body states), 1 in progress: Check Changeset (started 2026-11-02T13:06Z, a re-run; the body records it success on an earlier run). No failure. Landing ② ("全绿") is the seat's to confirm once it completes.
  • Contract-review-hold on 90a6068ff (comment 6568990786) is cleared by this head; the triage note 6568974959 is answered by the rework.
  • Nothing on open_questions remains open from the FAIL; S3 (record the measured set) is in the body's "Measured set for the showcase".

④ Design judgment

Against ruling 5985885287 and the code it cites: the handover now covers exactly the ruling's subject — a row the caller's read returns nothing for, hidden or missing, answered by the write door whose step 2.7 asks the same read question and holds the guard — and nothing beyond it. A refused read is the read door's verdict on the caller, not on the row; propagating it keeps OCC's promise for the write-without-read class (a stale token never lands) while disclosing nothing about the row, since the refusal is object-level and identical for every id. The one residue (sentence wording for a neither-grant caller) is ruled, bounded to a sentence, pinned at the level the ruling accepts, and routed to its own item. The design holds; the M1 must-fix is closed; S1 is moot; S2 is done at the protocol double (refuse unset, readRefusal set, update/del not called, store unchanged, no-token control lands) and at the runtime's two doors (WRITEONLY: stale 403 without currentRecord, row unchanged; no token 200 and the write visible). No must-fix. No should-fix.

Implemented-by: claude/harden-occ-stale-token-order
Reviewed-by: session_017xRBcVy4VXmkQjn2MGeaWk

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

ACCEPT → head 8372e0a15d5871e41ad25c8361f7168e4639fb12 · 2026-11-02T13:09Z

维护者速读

  • 改了什么:PATCH / DELETE 按 id 写入并带版本号(If-Match / expectedVersion)时,如果版本号对不上,系统会以调用方身份重读这一行。只有在重读结果"读不到"时,才不再比对版本,直接交给写入口,由写入口按权限给出回答。这样一来,带着过期版本号访问一行"存在但看不到"的记录,和访问一个根本不存在的 id,得到的回答相同,也和不带版本号时相同。如果重读本身被拒绝,或者出了故障,照原来的方式抛出。能读到这一行的调用方,仍然收到 409 冲突,并附带 currentRecord。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)。
  • 风险与回滚:改的只是拒绝的种类:读不到这一行的调用方,原来拿 404,现在拿写入口的回答。可读的行、乐观锁本身、批量入口(本来就不收版本号)都不受影响。回滚即 revert 本 PR。
  • 先看:packages/metadata-protocol/src/protocol-record-reads.ts 里的 assertVersionOf。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/data-conflict-caller-visibility.test.ts,预期 24/24 通过。
  • 需要你做的:无,席位落地。

席位意见:收。本 PR 落实维护者 2026-11-01 的 ① 裁定(选 a),返工时按路线 (i) 处理复核 FAIL 6561342764 指出的问题,维护者原话「#28731 同意你的推荐」。同一复核者对增量的复核 6577938158 为 PASS,没有 must-fix,也没有 should-fix。

席位核对

  1. 形态:draft,目标 main。按披露裁决不建卡,正文没有关闭关键词。正文由席位按 dev 暂存的稿子写入,首行是签名,写明了路线 (i),也注明了 D12 文案上的残余差异由后续单独处理。
  2. 前提实测:ablation t5 把复核时那个 head 上的"拒绝也交给写入口"放回去:protocol 6 项变红,runtime 4 项变红(那个真实入口回了 200)。恢复后 blob 与 HEAD 一致,dist 中的 marker 已清除。
  3. 门禁:本地派生 92 项,跑了 90 项、exit 0,另 2 项是缺前置条件的 exit 3,未测。全量测试:metadata-protocol 22139 项、runtime 8343 项通过,4 个带版本号的 dogfood 文件 39/39 通过。本 head CI 共 54 项:40 success、14 skipped、0 失败。
  4. 契约面:没有碰契约四面,Clause-②: no。按裁决 5985885287 在写入口"是否暴露存在"这条边界上做了达档复核:原记录 6561342764 为 FAIL,M1 已经关闭,增量复核 6577938158 为 PASS。复核核实,不存在"读取被拒的请求带着版本号进入写入口"的路径。
  5. 范围外去向:路线 (i) 留下一处差异。一个既没有读权也没有写权的调用方,访问存在的行和访问不存在的 id,状态码和错误码相同,但个别会点出操作名的表上,拒绝文案不同。这一处由后续 D12 文案对齐处理,已私下交给车道。
  6. 落地:不属于受管面,688 行,低于 3000 行上限 → pr_ready + automerge_enable。

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 13:10
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 13:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 2, 2026
One conflict, packages/metadata-protocol/src/protocol.ts: main moved the
module preamble into themed modules (#29336) while this branch had edited the
ConcurrentUpdateError docblock inside it. Resolved by taking main's removal
and carrying the docblock edit to protocol-version-tokens.ts, whose move
header now names that one changed docblock. The branch's three class-body
hunks (updateData, probeRecord, assertVersionOf) merged clean.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

ACCEPT → head 00f922f695baf5ea9d865d5ec6359c3d2547f7c8 · 2026-11-02T14:45Z

维护者速读

  • 改了什么:本 head 只是把 main(01386412b)合并进来,解决了一处冲突。PR 的实际改动与上一次 ACCEPT 的 head 8372e0a15 相同。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)。
  • 风险与回滚:与上次 ACCEPT 相同。回滚即 revert 本 PR。
  • 先看:packages/metadata-protocol/src/protocol-version-tokens.ts 的 ConcurrentUpdateError 注释。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.record-not-found.test.ts,预期 30/30 通过。
  • 需要你做的:无,席位落地。

席位意见:收。本 head 与 8372e0a15 的差别只有注释,按 landing-operations「diff 只改注释者:派发席 ACCEPT 确认,逐句引改前与改后,免第二次同 head 复核」处理,不再交复核。

席位核对

  1. 形态:未变。正文的 Verification 部分已按本 head 更新,新增 "Merge with main" 一节。
  2. 冲突与解法:只有 protocol.ts 一处冲突。main 的 refactor(metadata-protocol): move the protocol.ts preamble into 22 themed modules, verbatim (#25467 E0) #29336 把该文件开头的声明原样搬进了若干主题模块,而本分支改过其中 ConcurrentUpdateError 的注释。解法是采用 main 的搬移,再把这段注释改动原样带到它的新位置 protocol-version-tokens.ts。本分支在 protocol.ts 里的三处类内改动(updateData、probeRecord、assertVersionOf)干净合入,与 8372e0a15 逐字节一致;protocol-record-reads.ts 和全部测试文件没有被合并改动。
  3. 逐句对照:
  4. 门禁:dev 在本 head 上做了一组加锁验证,结论 VERDICT command-exit 0。其中构建通过;protocol 钉子 30/30、runtime 钉子 24/24、4 个带版本号的 dogfood 文件 39/39 通过;metadata-protocol 全套 22167 项通过;两个 typecheck 都是 exit 0。本地派生门禁 92 项:90 项 exit 0,2 项因前置条件不满足未测。本 head CI 共 50 项:38 success、12 skipped、0 失败。
  5. 契约面:没有变化,仍是 Clause-②: no。上一次的增量复核 6577938158(PASS)对应的逻辑在本 head 上逐字节未变。
  6. 落地:合并冲突使 auto-merge 被摘下,现在重新开启 automerge_enable。

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit f95d0db Nov 2, 2026
54 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/harden-occ-stale-token-order branch November 2, 2026 15:10
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant