Skip to content

fix(plugin-auth)!: implicit account linking requires the standard local-ownership condition; unlink is honoured - #21872

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21846-implicit-account-linking
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21846-implicit-account-linking

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21846

Clause-②: no (narrowing)

BREAKING (@objectstack/plugin-auth minor, under the launch-window convention for accept-set narrowings): an external sign-in that used to link implicitly to an unverified local user, or re-link a provider the user had unlinked, is now refused with error=account_not_linked. The changeset carries the upgrade note and the ADR-0087 not-required (no-migration-prescription) disposition.

What changes

Implicit account linking on external sign-in (OAuth, OIDC, SSO) now requires the library's standard local-ownership condition. The platform identity provider keeps its documented exception, and a user's unlink is honoured. This follows the ruling recorded on the card (「算漏洞,收紧」).

  • Every provider except the platform identity provider (objectstack-cloud) links implicitly only to a local user whose email is verified. Otherwise the callback answers error=account_not_linked, the same code better-auth's own refusal produces. No link is written and the local row stays unverified, so the link no longer sets emailVerified on an unverified row.
  • The platform identity provider still links to an owner-seeded row (those rows are created with emailVerified=false). The exception applies only to its OAuth sign-in path (source.method === 'oauth'), so an SSO provider registered under the same id gets no exception.
  • After a user unlinks a provider, an implicit sign-in through it no longer re-creates the link. This applies to every provider. An explicit, session-authenticated /link-social is still allowed and ends the refusal.
  • Operator override via account.accountLinking.requireLocalEmailVerified:
    • unset (the default): the rules above;
    • true: also handed to better-auth, so the strict form applies to every provider, the platform one included;
    • false: turns off only the local-verification check; the unlink rule stays.

Mechanism (better-auth 1.7.3, measured in the installed dist/)

  • Why not the vendor flag. requireLocalEmailVerified is one global boolean. It has no per-provider form, and trustedProviders does not relax it, so it cannot carry the platform exception. The vendor flag therefore stays false by default, and the requirement is enforced at the user.validateUserInfo seam.
  • Where the gate runs. handleOAuthUserInfo calls user.validateUserInfo with action: 'link-account' and the provider id, right before linkAccount and the emailVerified flip. Every implicit-link entry goes through it: the OAuth callback, id-token sign-in, one-tap, oauth-proxy and SSO.
  • Explicit link. An explicit link uses the same action. It is told apart by the server-written link in the parsed OAuth state (getOAuthState()), and only when that state's link.userId equals the user being linked. generateState writes link after the client's additionalData, so a client cannot forge it.
  • Unlink record. One sys_verification row per user and provider (account-unlinked:<user id>:<provider id>), created in account.delete.before, scoped to the /unlink-account path. A row is only ever created or deleted, never rewritten, so no write passes through a state with less protection and concurrent unlinks each keep their own row. A failed create is logged at error and rethrown, so the unlink fails and the provider stays linked (fail-closed). A landed link deletes only that provider's row, before the identity source is stamped; deleting the user deletes all of that user's rows by prefix. Records go through the database adapter. When a host configures better-auth secondaryStorage, the auth manager now also sets verification.storeInDatabase: true, so the record stays a database row behind the cache and survives eviction; hosts without secondaryStorage are unchanged.
  • Deprecation. better-auth marks the flag deprecated ("the gate will become unconditional"). On that upgrade the platform exception needs another carrier. The platform-provider end-to-end test fails on that bump, on purpose.

New module: packages/plugins/plugin-auth/src/implicit-account-linking.ts. Wiring: auth-manager.ts (validateUserInfo, account.accountLinking, composeDatabaseHooks).

Docs: content/docs/permissions/sso.mdx gains a "Linking to an existing account" section (the verified-email rule, the platform-provider exception, unlink and explicit re-link, the operator override, and what trustedProviders does and does not relax); content/docs/permissions/authentication.mdx points to it from the OAuth callback step. auth-service.mdx and services-checklist.mdx, also named by the docs drift check, say nothing about linking and are unchanged.

Tests

All at head 93ed0240e1 unless noted.

  • pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: 121 files, 2538 passed, 10 skipped. src/implicit-account-linking.test.ts: 23 passed. Besides the decision table, the vendor config and the end-to-end OAuth round trips over the real better-auth pipeline (stubbed IdP, in-memory engine), it covers:
    • an unverified local row with an IdP-verified email is refused; a verified one links; the platform provider still links an unverified row; the operator opt-out works;
    • unlink, then implicit sign-in, is refused; an explicit link-social is then allowed and clears the record;
    • id-token sign-in through /sign-in/social: refused for an unverified user, linked for a verified one;
    • a client-supplied link in additionalData is refused;
    • an explicit link-social for an unverified user links without marking the email verified;
    • a generic OIDC provider configured by discovery: refused, then linked once verified;
    • a store fault during unlink: the unlink answers an error, the account is kept, no record is written;
    • deleting the user with no endpoint context removes the record;
    • a second unlink that hits a store fault keeps the first provider's record and refusal;
    • concurrent unlinks of two providers keep both records and both refusals;
    • with a host secondaryStorage, the record is a database row and survives evicting every verification cache entry;
    • the platform exception is refused for SSO (OIDC, SAML) sources and a missing method.
  • pnpm --filter @objectstack/plugin-auth typecheck (src, examples, check:test-typecheck): exit 0.
  • Ablations via scripts/ablation-replace.mjs, each restored to the HEAD blob with git diff HEAD empty:
    • gate wiring disabled: the unverified-row and unlink refusals fail;
    • explicit-link discrimination disabled: the explicit link after unlink fails;
    • platform exception removed: the pure and end-to-end platform cases fail;
    • the rethrow in account.delete.before removed: the store-fault test fails;
    • the delete-then-create rewrite reintroduced in the unlink hook: the second-unlink fault and concurrent-unlink tests fail.
  • Gates. dispatch-gates --ran at 83010a685e (round 2): 105 derived, 104 run with exit 0, check:dual-build-cjs-loads NOT MEASURED (exit 3, it needs a whole-workspace build; declared to CI). Also exit 0: check:adr-0087-registration, check:error-code-casing, check:durability-log-level, check:startup-registry-verdict.
  • scripts/engine-double-contract.pinned.json is regenerated (--write) for the new test file's pinned double, a coverage-only addition.
  • eslint, narrowed. eslint --no-inline-config --format json over the 3 changed TS files: 0 errors, 0 warnings. The config has no type-aware linting, so this diff cannot change a verdict on an untouched file.

Acceptance notes

  • Refusal on the id-token and one-tap paths. There, the new refusal answers 403 with code account_not_linked. The vendor's own refusal on those paths answers 401 OAUTH_LINK_ERROR. On the browser callback the two are identical (error=account_not_linked). No in-repo or objectui consumer reads either code.
  • Unlink applies to the platform provider too. A user who unlinks objectstack-cloud must re-link from account settings before platform SSO signs them in to that environment again. That follows the ruling's wording, and the platform exception is about the verification precondition only.
  • Future better-auth bump. When requireLocalEmailVerified becomes unconditional, the platform-provider exception needs a new carrier, for example the owner seed. Carrier: the PR that bumps better-auth to that minor, where the pinned end-to-end test turns red.
  • Not run locally. No dogfood real-boot run, because the box is loaded. The end-to-end tests drive the real better-auth pipeline in-process.
  • Defence in depth. The link.userId binding cannot be reached through a real flow today (the explicit-link callback always passes the linking user), so no test turns it red without a synthetic state.

Generated by Claude Code

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

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 48 documentable anchor(s).

24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 15 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 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e

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

claude added 2 commits October 5, 2026 13:22
… user, clear records on user delete

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…OIDC discovery, fail-closed unlink, user delete

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
@github-actions github-actions Bot added size/xl and removed size/l labels Oct 5, 2026
@objectstack-fleet objectstack-fleet Bot changed the title fix(plugin-auth): implicit account linking requires the standard local-ownership condition; unlink is honoured fix(plugin-auth)!: implicit account linking requires the standard local-ownership condition; unlink is honoured Oct 5, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 83010a685e5d5a526a00409208475469a4120c78
Local-runs: none

Inputs: card #21846 (body + 5 comments: triage, claim, two os-dev reports, the Clause-② correction), PR #21872 body and its 7-file list, the net diff origin/main...83010a685e, and the 53 check-runs on the head.

① Derived judgments

  • Accept-set, implicit link (narrowed) — right. On user.validateUserInfo with action: 'link-account', every provider except objectstack-cloud is now refused unless the existing local row is emailVerified: true. This is the ruling's first clause (「算漏洞,收紧」). The refusal writes nothing, so the vendor's later emailVerified flip does not run either. The seam is the right one: the vendor gate is one global boolean and cannot carry the per-provider exception.
  • Accept-set, platform exception (kept) — right. PLATFORM_IDP_PROVIDER_ID is exempt from the verification clause only, as ruled. It stays in trustedProviders as before.
  • Accept-set, unlink honoured (narrowed) — right. One sys_verification row per user is written from account.delete.before, scoped to the /unlink-account path. While a provider is listed there, an implicit link through it is refused for every provider, the cloud one included. The ruling says "a user's unlink must stop the provider from re-linking implicitly" and does not exempt the platform provider, so applying it there is the literal reading, and the PR states it. An explicit, session-bound link-social still passes, because getOAuthState().link.userId must equal the user being linked. account.create.after then clears the provider from the record. Fail-closed handling is right throughout:
    • a record write that fails rethrows and aborts the unlink;
    • an unreadable record, or an adapter or user that is missing at the gate, throws and becomes a refusal.
  • Operator config account.accountLinking.requireLocalEmailVerified — right, no new key. The key is read through as any and is not declared in any spec schema, so no declared accept-set moves.
    • true: vendor-strict for every provider, the same as before.
    • false: the earlier permissive behaviour plus the unlink rule.
    • unset: the default flips from permissive to the gate. This is the narrowing.
    • The spread order changed, so the vendor flag is now computed after the operator spread. This is correct: an operator false can no longer leave the vendor flag diverging from the gate.
  • Refusal code — right. The browser callback answers error=account_not_linked, the same code as the vendor's own refusal. On the id-token and one-tap paths it answers 403 account_not_linked where the vendor answers 401 OAUTH_LINK_ERROR. That change is declared in the acceptance notes, and the dev reports no in-repo or objectui consumer. Accepted as a declared, non-contractual divergence.
  • Public surface — none changed. Right. The @objectstack/plugin-auth exports map has . and ./rate-limit-storage. src/index.ts is untouched, and implicit-account-linking.ts is not re-exported from it. The additions to AuthManager (implicitLinkRequiresLocalEmailVerified and linkingAdapter) are private. Nothing is removed or renamed, so a skipped Console Pin Gate is consistent.
  • Generated ledger — right. scripts/engine-double-contract.pinned.json adds three rows (delete, findOne, update) for the new test file's pinned double. This is coverage only.
  • Docs — right. The new sso.mdx section "Linking to an existing account" and the authentication.mdx pointer match the code: the verified-email rule, the platform exception, unlink plus explicit re-link, all three override values, and trustedProviders relaxing only the IdP-side claim.
  • Governed surface — none. No Tier H or Tier S path is in the file list.

② Semver level

The diff publishes a behaviour narrowing in @objectstack/plugin-auth and nothing else: no export added or removed, no other package touched. The changeset grades it '@objectstack/plugin-auth': minor, with a ! summary line, a BREAKING banner, an upgrade note (what refused users do, and the false opt-out with its takeover warning) and an ADR-0087 not-required (no-migration-prescription) marker. The marker is correct, because no authorable key, export or config field is removed or renamed. minor for a breaking change is the launch-window convention that scripts/check-changeset-no-major.mjs enforces. The PR title carries the ! too. Matches.

Clause-②: no (narrowing) — correct. There is no widening (no new export, no new authorable key), and the narrowing arm comes with banner, ! and minor, as the gate requires. The claim's original Clause-②: no was corrected on the card (comment 5996304603), and the PR body and changeset agree.

③ Boundary flags

  • open_questions: empty in both os-dev reports. Nothing to answer.
  • Better-auth minor that makes requireLocalEmailVerified unconditional. Answered: the carrier is that bump PR, and the pinned platform-provider end-to-end test is designed to go red there. No card needed now.
  • 403/401 divergence on the id-token and one-tap paths. Answered under ① as a declared, non-contractual divergence. No carrier needed.
  • Unlink rule applies to objectstack-cloud. Answered: this is within the ruling's wording, and the PR body and docs disclose it. If the maintainer wants the platform provider exempt from the unlink rule too, that is a new ruling, not a defect here.
  • link.userId binding is defence in depth and has no reachable red test. Accepted. It is a strict tightening on top of the server-written state.
  • sso.mdx OIDC step 2 still names /sign-in/oauth2. This drift predates the PR and is unrelated to linking (out-of-scope finding, round 2). It should get a docs card from the dispatching seat. It does not block this head.
  • Deviations.
    • The file surface grew past the claim (content/docs/**, scripts/engine-double-contract.pinned.json). Both are declared, and neither is single-writer or governed; the "same single-writer path" checks are green.
    • The claimed packages/qa/dogfood/test is untouched.
    • There was no real-boot dogfood run. It is declared, and the Dogfood Regression Gate on the head is green.

Gates: 43 success, 9 skipped (Auto Label, Check PR Size, Packed-tarball smoke, Console Pin Gate: all opt-in or not applicable), and 1 Check Changeset run still in_progress. Three sibling Check Changeset runs on this head are green. Landing waits for every check to be green.

Implemented-by: claude/issue-21846-implicit-account-linking
Reviewed-by: session_018zT8d8NpiQ1ExhuNd5TxY6

VERDICT: PASS

…atform exception bound to the OAuth method

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…alues for hosts with secondaryStorage

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f7814a1b914e3ea2b8b7656d59d066cdff803b6a
Local-runs: none

Inputs read: card #21846 body and all 7 comments (triage 5991270306, claim 5991690180, dev reports 5994181249 / 5996242400 / 5999082055, Clause-② correction 5996304603, cross-lane note 5996720856); PR #21872 body and file list (7 files, none on a governed surface); net diff origin/main...f7814a1b91 (10 commits; the last, f7814a1, adds one changeset bullet over the round-3 head 93ed024); head check-runs.

① Derived judgments

Accept-set changes the diff implies, each judged:

  1. Implicit link to an unverified local user, any provider but the platform IdP: accepted before, refused now (error=account_not_linked, no account row, no emailVerified flip). This is a narrowing. It matches ruling 「算漏洞,收紧」 rule 1. Right.
  2. Implicit re-link after the user's own unlink: accepted before, refused now, for every provider including objectstack-cloud. This is a narrowing. It matches the ruling's unlink clause, which names no exception, and the dev flagged the cloud case for visibility. The refusal stops on an explicit session-authenticated /link-social, whose account.create.after deletes only that provider's record. Right.
  3. Platform IdP exception: before, it rested on requireLocalEmailVerified: false for everyone. Now it is bound to provider id AND source.method === 'oauth'. An SSO (sso-oidc / sso-saml) or method-less source under the same id loses the exception. This narrows what the old global switch allowed, and the ruling's "keeps its documented exception" holds for the documented OAuth path. Right.
  4. Operator override account.accountLinking.requireLocalEmailVerified, same key, no rename:
    • unset now means the gate is on. This flips the shipped default, which is the narrowing in 1.
    • true behaves as before: the vendor gate applies to every provider. The vendor gate runs ahead of validateUserInfo, so a strict operator's existing refusals keep the vendor's code.
    • false keeps the local check off, but the unlink rule still applies. That narrows even an explicit opt-out, and the changeset and sso.mdx both state it. Right.
  5. Unlink (/unlink-account) gains a failure mode: if the record cannot be created in account.delete.before, the unlink errors and the account stays (fail closed). The record is written only on that path and never for credential. Admin or tooling deletes record nothing. A host delete.before returning false is honoured first. This narrows a previously always-successful operation, and it is the conservative direction for an ownership control. Right.
  6. Gate store-unavailable / missing provider or user on link-account: this is a thrown refusal (FORBIDDEN), so it fails closed. The only vendor caller with that action is handleOAuthUserInfo, with an oauth or sso source. A link the old code accepted can be refused only when the store cannot answer. Right.
  7. Hosts that pass secondaryStorage now also get verification.storeInDatabase: true. This is a persistence change, not an accept-set change, and it is limited to host-supplied secondaryStorage: AuthPlugin stopped deriving it in bug(plugin-auth): [auth] no cache service registered 在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772, so the default boot is unchanged. One effect is consumer-visible: verification values that sat only in the cache at deploy time can no longer be consumed afterwards. The last commit's changeset bullet states it, with the remedy (request a fresh link or code). Right.
  8. Refusal wire shape on id-token / one-tap: 403 account_not_linked, where the vendor answers 401 OAUTH_LINK_ERROR. This shape appears only for links the old default accepted, so no existing refusal changes its code. The browser callback is byte-identical. The dev found no consumer in the repo or in objectui. Right (declared in Acceptance notes).

Public surface (package exports map: . and ./rate-limit-storage):

  • src/implicit-account-linking.ts is new and is not re-exported from src/index.ts. Its exports reach no published entry point.
  • AuthManager gains two private methods only.
  • AuthManagerOptions is unchanged.
  • The account.accountLinking.* config keys keep their names.
  • No export added, removed or renamed. The changeset's ADR-0087 claim ("no authorable key, export or config field is removed or renamed") is right.

② Semver level

  • Changeset .changeset/21846-implicit-account-linking-ownership.md: '@objectstack/plugin-auth': minor, bang summary, BREAKING banner, Clause-②: no (narrowing). It carries exactly one ADR-0087 marker, not-required (no-migration-prescription), and an upgrade note.
  • The diff publishes a behaviour narrowing in plugin-auth only (items 1–5 and 7) and no surface removal. Under AGENTS.md Post-Task §3, (narrowing) is BREAKING. minor carries the breaking narrowing under the launch-window convention the claim's correction 5996304603 cites (21197 precedent). No removal or rename exists that would need a FROM → TO mapping.
  • The PR body's Clause-②: no (narrowing) line matches the changeset and the corrected claim. The PR title carries !.
  • No other released package is touched. The docs (content/docs/**) and scripts/engine-double-contract.pinned.json publish nothing.
  • Level matches.

Clause-②: no (narrowing)

③ Boundary flags

Implemented-by: claude/issue-21846-implicit-account-linking
Reviewed-by: session_018zT8d8NpiQ1ExhuNd5TxY6

VERDICT: PASS

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

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

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

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

  • Test Core — 失败步骤: Verify test shard results(日志不可读,点进 job 看)
  • Dogfood Regression Gate — 失败步骤: Verify dogfood shard results(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时(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,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

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

分诊清单:

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

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

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

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

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

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

  • Test Core — 失败步骤: Verify test shard results(日志不可读,点进 job 看)

↳ 失败原因 是判读的关键:超时(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,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 2 个失败构建(不含本次)。

分诊清单:

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

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

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 41a1135 Oct 6, 2026
40 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21846-implicit-account-linking branch October 6, 2026 00:23
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/xl tests tooling

Projects

None yet

2 participants