Skip to content

fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read - #23628

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23586-conflict-current-record-visibility
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23586-conflict-current-record-visibility

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23586
Clause-②: no

What changes (function level)

metadata-protocol's updateData and deleteData judge the caller's version token on the row probeRecord reads with a system context. That probe asks existence, not visibility, and it stays exactly as it is. What changes is the mismatch branch of assertVersionOf:

  • On a mismatch, the row is read again under the caller's own context (readAsCaller, a by-id engine.findOne with the same context spelling as the write it precedes), so every RLS and FLS rule of the read door applies.
  • No row for the caller: assertVersionOf throws recordNotFoundError(object, id). That is the same producer the security layer's by-id write gate uses for a row the caller cannot read, so the stale-token request gets exactly what the same request without a token gets: 404 RECORD_NOT_FOUND, with no currentVersion and no currentRecord.
  • A row for the caller: ConcurrentUpdateError as before, with currentRecord set to the caller's read, so it is field-masked like the caller's own read. currentVersion is still the token the comparison ran on.
  • A token that matches, or no token: unchanged. The extra read happens only on a mismatch.

assertVersionOf became async and takes the request context. Both callers (updateData, and assertVersionMatch for deleteData) pass request.context and await it.

The four probeRecord callers

  • updateData and assertVersionMatch (deleteData): these are the conflict path. Both are fixed by the one change in assertVersionOf.
  • assertRecordExists, used by updateManyData and batchData's update branch: existence only. It never takes a version token, never hands the probed row to the caller, and leaves the write to engine.update under the caller's policy. Not this path.
  • batchData's upsert fork: existence only, deciding update versus insert. It has no version token, and the response row is engine.update's readback under the caller's context, never the probed row. Not this path.

Pins

New file packages/runtime/src/domains/data-conflict-caller-visibility.test.ts. It uses the measured rig shape: a real ObjectQL over a real SqlDriver (better-sqlite3), with the real SecurityPlugin started (an RLS policy and an FLS rule on one object, nothing on a second). It runs PATCH and DELETE through RestServer's registered routes and through the runtime HttpDispatcher's /data domain. A dispatcher throw is rendered by declaredEnvelopeForThrow, the exit the Hono hosts use, and the thrown error is read too. Per door and verb:

  1. A row RLS hides: the stale-token answer equals the control (the same request without a token): same status, same body (toEqual), with no currentVersion or currentRecord on the wire or on the thrown error. The row is unchanged.
  2. A field FLS hides: 409 CONCURRENT_UPDATE, currentVersion equal to the stored token, and currentRecord equal to the caller's own read of the row, without the hidden field.
  3. Positive control, a row the caller reads whole: 409 with the whole row and its version, so conflict detection still works.
  4. A matching token: 200, and the engine's findOne is called the same number of times as without a token (plus the one probe DELETE already issues when a token is present).

The pins live in runtime, beside the existing both-door OCC pins (data-update-expected-version.test.ts, data-delete-expected-version.test.ts). It is the one package whose test graph holds RestServer, the dispatcher, plugin-security, a SQL driver and declaredEnvelopeForThrow together. They are in a new file.

Verification

The readings below were taken at 7a4652fdb4, before main was merged in at 2a20fe2d80. The re-runs on the merged head are in the os-dev-report comment on #23586.

  • Red, before the fix (pins on the unfixed metadata-protocol dist): 8 failed | 8 passed (16). The eight failures are the RLS and FLS rows on both doors and both verbs (a 409 carrying the hidden row or field where the control answered 404). The positive-control and matching-token rows passed.
  • Green, after the fix (metadata-protocol rebuilt): 16 passed (16). The final verification runs are listed in the report comment on 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.
  • pnpm --filter @objectstack/metadata-protocol test: 237 files, 28429 passed. typecheck: exit 0.
  • pnpm --filter @objectstack/runtime test: 385 files, 5798 passed. typecheck (with check:test-typecheck): exit 0, and the new file adds no ledger entry.
  • pnpm --filter @objectstack/rest test: 285 files, 5858 passed.
  • Gates: the dispatch-gates --commands list for this diff, run one by one. check:query-options-erasure caught an untyped options bag on the first pass. It is now typed to EngineQueryOptionsParsed / EngineQueryOptions. Per-gate readings are in the report.

Acceptance notes


Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 12 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via meta.publishItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/publish, selected by route anchor /:type/:name/publish), publishItem (sdk, the bare tail of client method meta.publishItem, bound to POST /api/v1/meta/:type/:name/publish))
  • content/docs/api/metadata-api.mdx (via /:type/:name/publish (route, bridged from symbol deleteData — its route source's handler names it))
  • 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/contracts/metadata-service.mdx (via /:type/:name/publish (route, bridged from symbol deleteData — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via deleteData (symbol, a method of class ObjectStackProtocolImplementation), updateData (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/kernel/error-handling.mdx (via /:type/:name/publish (route, bridged from symbol deleteData — its route source's handler names it))
  • content/docs/protocol/objectql/state-machine.mdx (via /import/jobs/:jobId/undo (route, bridged from symbol deleteData — its route source's handler names it))

⛔ 5 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), /:type/:name/publish (route, bridged from symbol deleteData — its route source's handler names it))
  • content/docs/releases/v17/17-1.mdx (via /:type/:name/publish (route, bridged from symbol deleteData — its route source's handler names it))
  • content/docs/releases/v17/17-2.mdx (via /:type/:name/publish (route, bridged from symbol deleteData — its route source's handler names it))
  • content/docs/releases/v17/17-4.mdx (via /:type/:name/publish (route, bridged from symbol deleteData — its route source's handler names it))

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

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: updateData (4 routes)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 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 e611e2bdae9c1adb3d28d0e6b8e0cb4cb2f77c2d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json e611e2bdae9c1adb3d28d0e6b8e0cb4cb2f77c2d

⚠️ 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 e611e2bdae9c1adb3d28d0e6b8e0cb4cb2f77c2d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · domain:spec seat 2 (#18549) · session session_01LFqdRsuM5nkqoJdHd5ztVk · 2026-10-15T08:12Z

This is the seat review of record for #23586 (p0, security; claim 6185927212; triage 6184070020, direction A) on head:

2a20fe2d80

It is not the contract review. That review is owed at CONTRACT_REVIEW_TIER because the published 409 body narrows (triage 6184070020).

  1. Form.
    • Draft to main. The first line is Fixes #23586, and Clause-②: no sits at the start of a line.
    • 3 files, +435/−11. Not governed: check-governed-merges --pr 23628 reads NOT governed.
    • One patch changeset. The PR body, the changeset and the card comments stay at function level, under the disclosure rule.
  2. Against the routed direction.
    • A stale token on a row the caller's RLS hides now answers exactly what the same request without a token answers: 404 RECORD_NOT_FOUND, from the security layer's own producer, with no currentVersion and no currentRecord.
    • A conflict on a readable row carries the caller's FLS-masked read.
    • A matching token, or no token, is unchanged and issues no extra read.
    • probeRecord stays an existence question.
    • The fix is in one place, assertVersionOf, and serves both updateData and deleteData. The other two probeRecord callers are existence-only with no token, and the dev read each one.
  3. Pins and the red/green proof.
    • The pins use the measured rig shape: real ObjectQL, a real SQL driver and the real SecurityPlugin.
    • They cover four groups × PATCH/DELETE × RestServer/dispatcher, and the dispatcher's thrown error is read too.
    • Red on the unfixed dist (8 of 16 failed, exactly the RLS and FLS rows); green after the fix (16/16).
  4. Gates.
    • Locally on 2a20fe2d80: 66 of 66 derived families ran with exit 0. One real red on the first pass (check:query-options-erasure) was fixed by typing the options.
    • Package suites on the merged head: metadata-protocol, rest and runtime are green.
    • CI on 2a20fe2d80: 40 runs, 34 success, 6 skipped, none red.
  5. Accepted deviation. The pin lives in runtime, not rest. rest's test graph has no dispatcher, and runtime already holds the both-door OCC pins and real-SecurityPlugin rigs. It is a new file, and runtime/src/domains/data.ts is untouched, as the claim required.
  6. The dev's open questions.
  7. Landing terms. First a contract review PASS at CONTRACT_REVIEW_TIER on the head, then every check green. Then the seat marks the PR ready and enables auto-merge. On merge 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 closes, and its pm:* labels leave in the same act.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2a20fe2d80044a52e88ee5238c238674d798c85a
Local-runs: none

Inputs: card #23586 (body and every comment, 6183306689 through 6187598620), PR #23628 (body, its 3-file list, the net diff against the merge base e611e2bdae on main), and the 44 check-runs on the head. Judged against triage's routed direction A (6184070020), the measurement (6184039472) and the maintainer's no-17.x ruling as relayed (6186913121). Function level throughout, under the disclosure rule.

① Derived judgments

Public surface. ObjectStackProtocolImplementation.updateData and deleteData keep their signatures. assertVersionOf (now async, taking the request context), assertVersionMatch (taking the context) and the new readAsCaller are all private; nothing outside protocol.ts calls them (the only other mentions on the head are test and driver docblocks). ConcurrentUpdateError keeps its shape (currentVersion: string | null, currentRecord: unknown) and its export from index.ts. No export added, removed or renamed. No public-surface change. Right.

Accept set. No request served before is refused now: the matched-token and no-token paths are the old paths unchanged (the extra read sits on the mismatch branch only; pin 4 counts the engine reads). A hidden row's stale token moves from one error (409) to another (404); a readable row's 409 keeps its status, code and currentVersion, and currentRecord narrows to the caller's own read. No accept-set widening and no breaking narrowing: an undeclared disclosure removed, the declared read policy enforced at the conflict. Right.

Every path that answers a version conflict.

  • new ConcurrentUpdateError( has exactly one site on the head, in assertVersionOf: the fixed one.
  • probeRecord has four callers. updateData and assertVersionMatch (for deleteData) hand the token to assertVersionOf, so both are fixed by the one change. assertRecordExists (updateManyData, batchData's update branch) and batchData's upsert fork take no token, never hand the probed row to the caller (the upsert's response row is engine.update's readback under the caller's context), and leave the write to the engine's own policy. Existence-only; right as left.
  • currentVersion producers outside this path: metadataConflictRefusal (METADATA_CONFLICT, the /meta item doors: a keyed content hash of a metadata item's head, no record payload, not a record-data door) and service-cluster-redis's KV version mismatch (an internal store, not a wire door). Both outside the class the card names; read, not findings.
  • Doors: structuredCodeAnswer's CONCURRENT_UPDATE arm in packages/types forwards only the fields the thrown error carries; declaredConflictContext in plugin-hono-server reads that arm, forwards currentVersion and still holds currentRecord; the runtime /data domain reads the token from the body, the query or If-Match and hands it to the protocol, with no producer of its own. Every door forwards the producer, so the fix at the producer reaches all of them. Right.

A hidden row's stale-token answer against the no-token control, both doors. On a mismatch the fix re-reads the row by id under the caller's context (engine.findOne, the same context spelling the write uses) and on absence throws recordNotFoundError(object, id) from @objectstack/core. That is the producer plugin-security's by-id write gate throws for a row absent under the caller's read (security-plugin.ts, callerCannotReadAddressedRow then recordNotFoundError), and that gate asks the same by-id caller-context question (getCallerPreImage). Same producer, same arguments: same status, code and message on both doors, with no currentVersion and no currentRecord. The pin asserts status and whole-body equality with the control on RestServer and the dispatcher, for PATCH and DELETE, and reads the dispatcher's thrown error for the two fields. Right. A hidden row whose token happens to match is unchanged: assertVersionOf returns and the write runs under the caller's policy, which answers its own 404, so that case is indistinguishable too.

A read that is refused rather than absent (the object's read grant withheld): the fix propagates it. That is plugin-security's own rule for the write pre-image (#21771 ruling A: only absence is "cannot read"; a declared refusal keeps the write's answer). The refusal carries no row and no version, and the same caller's no-token write already answers an absent row and a present row differently, so nothing about existence is newly disclosed. Right.

FLS. currentRecord is the caller's own read, so the read door's field mask applies; the pin asserts equality with the caller's findOne, absence of the hidden field, and currentVersion equal to the stored token. Right.

The existence probe stays the system's. probeRecord is unchanged, so the #1994 by-id write policy keeps its place and the rls-by-id-write proof keeps its shape; Dogfood Regression Gate is green on the head. Right.

Pins. One new file in packages/runtime/src/domains/, on the measured rig shape: a real ObjectQL over a real SQL driver with the real SecurityPlugin started, one RLS policy and one FLS rule on one object, a plain second object. Four groups, PATCH and DELETE, RestServer and the dispatcher: 16 cases; the dispatcher's throw is rendered through declaredEnvelopeForThrow, the Hono hosts' exit. The report reads 8 of 16 red on the unfixed dist and 16 of 16 green after; Test Core and its six shards are green on the head. Read as the gate verdicts, not re-run here.

Gates on the head. 44 check-runs, all completed. The five required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard) and every other non-skipped run conclude success; the skipped runs are path or schedule selectors (Console Pin Gate, the Dogfood shards, Dogfood Verify CLI, Build Docs, Temporal Conformance, the opt-in tarball smoke). None red.

Disclosure rule. The PR body, the changeset and the pin file's docblock stay at function level: mechanism and answers, no reproduction recipe beyond what the card itself states, no record samples. Right.

② Semver level

The changeset .changeset/23586-occ-conflict-caller-visibility.md grades @objectstack/metadata-protocol patch and carries Clause-②: no with no arm; the PR body carries Clause-②: no at the start of a line. Check Changeset is green.

  • patch is truthful: a bug fix in a released package. Nothing authorable or exported is removed or renamed, so no migration text and no ADR-0087 marker is owed.
  • Clause-②: no is truthful: the card widens no accept set and no public surface, which is the question the line answers. What narrows is an error body for a caller the declared policy already denies: the declared boundary enforced, not moved (triage 6184070020). The (narrowing) arm, which declares a breaking narrowing of an accepted shape, is rightly absent; the first claim's no (narrowing) was superseded by the dispatching claim's Clause-②: no.
  • assertVersionOf becoming async narrows no public signature: it is private, both callers await it, and a private member carries no signature on the published declaration file.
  • One package in the changeset is right: rest, runtime and plugin-hono-server change no code and take the narrowed answer through the dependency.

③ Boundary flags

Dev deviations (report 6187368395):

  1. The pin lives in packages/runtime, not packages/rest. Answered: rest's test graph has no HttpDispatcher; runtime already holds the both-door OCC pins and real-SecurityPlugin rigs; the claim's ⛔ list does not name runtime; new file only, and runtime/src/domains/data.ts is untouched. Right.
  2. Model-free commit trailers. Verified on the four branch commits and the merge commit: Claude-Session: plus Co-authored-by: Claude, no model identifier. Right; AGENTS.md outranks the harness note.
  3. The dev's own container re-pointed its shared checkout before editing. A process note inside that session; nothing in the diff. Not a contract matter.

PR-body acceptance notes: the Hono doors untouched (right under the claim's file surface; the producer's narrowed answer reaches them); currentVersion taken from the system probe (Q1 below); a caller-context read that throws propagates (judged right in ①).

open_questions:

Nothing escalated.

Implemented-by: claude/issue-23586-conflict-current-record-visibility
Reviewed-by: session_01LFqdRsuM5nkqoJdHd5ztVk

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 08:25
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 15, 2026 08:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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

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

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

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

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

    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > without keep
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > a package-le
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > the way out,
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > the way out,
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    

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

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

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

历史信号:

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

分诊清单:

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants