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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ad on every boot; unlink_account stays (objectstack-ai#21894)

Fixes objectstack-ai#21849
Clause-②: no (narrowing)

Retires the `sys_account` `link_social` action from
`@objectstack/platform-objects` under ADR-0049 enforce-or-remove, as
ruled on the card (ruling D, comment 5995717941; maintainer reply
verbatim: 「同意」). `unlink_account` stays. Self-service linking returns as
a native console surface reading `/auth/config` once a linking need is
named; that is not this PR.

## Why

`link_social` was a `type: 'url'` toolbar action. It navigated to a GET
of better-auth's social sign-in route, which better-auth serves as POST
only, and it offered a fixed list of seven providers whatever the boot
had configured, with no visibility gate. It was dead on every boot: the
earlier measurement on the card (5992085207) read 404 on a provider-less
boot and on a configured one. The ruled direction (options from the
configured providers, hidden when none) cannot be said in today's action
contract, so the ruling retires the action instead of widening four
lanes for one consumer.

Linking stays reachable through the signed-in `POST
/api/v1/auth/link-social`, which is `auth.accounts.linkSocial` in
`@objectstack/client`. That door, its `plugin-auth` route-ledger rows
and the SDK method are untouched.

## What changed

- `packages/platform-objects/src/identity/sys-account.object.ts`: the
action and the comment paragraph that introduced it are gone. A short
comment now records why there is no link action and where linking lives.
`unlink_account` keeps its name, type, target, mode, placement and
row-id param. Its confirm question drops "from their account settings";
see **Deviation from the claim** below.
- Translations, regenerated with the repo's tool (`node
scripts/check-i18n-bundles.mjs --write --filter=platform-objects`). The
diff removes only the `sys_account._actions.link_social` block from each
of the four objects bundles (17 lines each) and its seven provenance
rows from each of the three source-hash tables, and rewrites the `en`
unlink question from source. The three translated unlink questions are
hand-written values, edited by hand to match.
- The three echo-decision ledgers drop their seven `link_social`
provider-brand rows and the now-unused brand reason. The size pins move
with them: zh-CN 45 to 38 rows (42 to 35 echoes), ja-JP 46 to 39 (43 to
36), es-ES 57 to 50 (54 to 47). A header note in each says why.
- New pin
`packages/platform-objects/src/identity/sys-account-link-social-retired.test.ts`.
- Comments that cited the action: `sys-member.object.ts` (the
`add_member` icon note now cites the Account app's Linked Accounts
entry, which uses the same icon) and `apps/account.app.ts` (the
resultDialog list).
- Outside the lane, declared on the claim:
- `packages/spec/src/ui/action.zod.ts`: the `target` docblock sentence
that cited `link_social` and its dead GET target is removed. Docblock
only, no schema line; the interpolation and encoding sentences stay.
- `content/docs/protocol/objectui/actions.mdx`: the URL Actions example
no longer teaches `link_social`. It is replaced by a working
`${param.X}` example (a Maps search URL with an `address` param); the
interpolation prose stays.
- `.changeset/21849-retire-sys-account-link-social.md`:
`@objectstack/platform-objects: minor`, BREAKING, `Clause-②: no
(narrowing)`, ADR-0087 `not-required (no-migration-prescription)`. That
category fits because the withdrawn action is platform-shipped metadata
on a `lock: 'full'` object: no spec key, spelling, export name or config
field is retired, nothing an author wrote needs rewriting, and no stored
row can carry it. No `@objectstack/spec` entry: its change is one
docblock sentence, and no gate asked for one.

### The checklist half left this PR

The `domain:devx` seat objected on the card (5996996202):
`identity-auth.linked-accounts-social` in
`docs/qa/platform-checklist/areas/identity-auth.json` is held by the
claim on objectstack-ai#21851, whose entry adds the item's link fixture through `POST
/api/v1/auth/link-social`. objectstack-ai#21851 had not landed when this PR opened, so
the checklist half is not here. The file is restored to `origin/main`
byte for byte: `git diff origin/main --
docs/qa/platform-checklist/areas/identity-auth.json` is empty, and the
blob at HEAD equals the one on `origin/main` and at the base
(`f0734d3cb7`). The seat files that revision as its own card when this
lands, to be worked once objectstack-ai#21851 has landed. Until then the item's link
step still names the retired action.

### Deviation from the claim

The claim said `unlink_account` stays byte-identical. Its confirm
question told the user they could re-link "from their account settings",
and after this retirement no console surface offers a link (zero hits
for a link affordance in objectui at the pin, see H4). The dev contract
says a shipped text that this change makes false is fixed in the same
change, so the clause is dropped in all four locales. Name, type,
target, mode, placement and params are unchanged and pinned. Reverting
that one sentence is a single-file edit plus a regeneration, if the seat
prefers the claim's reading.

## Census

**H1, every reference** (base `e864db56df`, outside `CHANGELOG.md`).

| Reference | Disposition |
| --- | --- |
| `sys-account.object.ts`: the action (about :58-84) and its intro
comment (:51-56) | removed; a new comment names the retired action as a
record |
| `sys-member.object.ts` :137 | reworded |
| `apps/account.app.ts` :22 | removed from the resultDialog list |
| four `*.objects.generated.ts` | regenerated, leaves removed |
| three `*.source-hashes.generated.ts` | regenerated, 7 rows each
removed |
| three `objects-*-echo-decisions.test.ts` | 7 rows each removed, counts
moved; one header note each names the retirement |
| `packages/spec/src/ui/action.zod.ts` :1035-1036 | sentence removed
(docblock only) |
| `content/docs/protocol/objectui/actions.mdx` :97-100 | example
replaced |
| `docs/qa/platform-checklist/areas/identity-auth.json` (7 lines) | not
touched here (see above) |
| `packages/spec/src/ui/inline-action.test.ts` :307 (the sign-in URL as
a parse-acceptance input) | stays: an incidental fixture, outside the
docblock-only scope |
| `plugin-auth` route-ledger rows for `POST /api/v1/auth/link-social`,
`packages/client` `auth.accounts.linkSocial` | stay: the door is not
retired |

**H2, translations.** The regenerated diff is 90 removed lines and 1
added: 4 times 17 lines of the `link_social` block, 3 times 7 provenance
rows, and the one `en` unlink question rewritten from source. No other
key moved. The echo ledgers drop exactly the seven provider rows each,
and `pnpm check:i18n` and `pnpm check:i18n-stale-fill` are green
(below).

**H3, declaration reach**, measured on built `dist/**/*.d.ts`:
- With the action present (the base shape, rebuilt in the
reverse-verification leg), `link_social` appears in 2 declaration files,
`dist/identity/index.d.ts` and `.d.mts`, once each. It sits inside the
type argument of `SysAccount`'s declared type, which is
`ObjectSchema.create`'s return type: `Omit` of `ServiceObject` without
`fields`, intersected with a `Pick` of the literal that keeps only
`fields`. So no reachable member carries it. A `tsc` probe on that build
compiled `const probe: ActionName = 'zzz_not_an_action'`, where
`ActionName` is the type of `SysAccount.actions[number].name`, which
therefore resolves to `string`. The control line in the same file, a
non-field assigned to `keyof (typeof SysAccount)['fields']`, failed with
TS2322 as expected.
- The translation bundles: 0 declaration hits even while the leaves were
in the JS. The bundles are annotated `NonNullable` of
`TranslationData['objects']`, so no key types reach `./apps` or
`./metadata-translations`.
- At HEAD: 0 declaration files and 0 JS files carry the action. The
comment that names it survives into 4 JS files.

So the exported types are structurally unchanged and the narrowing is
runtime and wire only. The `Clause-②: no (narrowing)` arm stands as
declared.

**H4, consumers.** No workspace code, test, example app or dogfood test
reads the action by name outside the H1 rows. objectui at the pinned
`.objectui-sha` `0abd4f9f87`, from a depth-1 fetch of that commit: `git
grep -e link_social -e 'Link Social' -e linkSocial -e link-social` gives
0 hits (exit 1). The control `git grep sys_account` on the same tree
gives 4 hits (CHANGELOGs), so the grep reached the tree. objectui's own
ActionRunner tests use the sign-in URL as a generic url-action fixture
and import nothing from here. No objectui change and no pin bump are
needed (Post-Task Checklist item 4).

**H5, stored data.** None. The action is code-shipped metadata
registered at boot, never a row. `sys_account` is `protection.lock:
'full'`, so `evaluateLockForWrite` refuses every overlay save with
`ITEM_LOCKED`, and no `sys_metadata` overlay can carry the action. No
migration, seed, example or dogfood fixture names it.

## Tests

- New pin, 11 cases: `pnpm --filter @objectstack/platform-objects exec
vitest run --maxWorkers=2
src/identity/sys-account-link-social-retired.test.ts` reads `Tests 11
passed (11)`.
- Package: `pnpm --filter @objectstack/platform-objects test` reads
`Test Files 60 passed (60) · Tests 960 passed (960)`. `pnpm --filter
@objectstack/platform-objects typecheck` exits 0; `check:test-typecheck`
puts the new pin and the edited ledgers in its program
(`--listFilesOnly`).
- Consumer: `pnpm --filter @objectstack/runtime exec vitest run
--maxWorkers=2 src/action-execution-destructive.test.ts` reads `Tests 66
passed (66)`; it reads the identity actions off their real declarations.
- `pnpm --filter @objectstack/spec check:generated`: all 15 generated
artifacts up to date against the rebuilt spec `dist`.

**Reverse verification** (on committed HEAD `a299763014`, through
`scripts/ablation-replace.mjs`, which restores on exit, INT and TERM):
- **Leg A, the action restored in source.** The anchor hit once and the
blob moved `55baacb20f` to `1faf2af6f8`. `pnpm --filter
@objectstack/platform-objects build` ran, and `ablation-dist-preflight`
found the marker in 6 built files. The pin went red: `Tests 2 failed, 9
passed (11)`, on "declares exactly one action" and "no action targets a
social sign-in or link door". Restored: the blob equals HEAD and `git
diff HEAD` is empty. Rebuilt, preflight `--absent` read the marker
absent from all 66 built files with a clean tree, and the pin is green
again.
- **Leg B, a `link_social` leaf put back in the `en` bundle.** The
anchor hit once and the blob moved `0d214ad760` to `3bddbb31f8`. The pin
went red: `Tests 1 failed, 10 passed (11)`, on "en: sys_account._actions
holds unlink_account and no link_social". Restored the same way: blob
equality and an empty `git diff HEAD`. No build was needed for this leg,
because the pin imports the bundle by relative source path, not through
`exports`.

**Whole-repo pin sweep.** Pins asserting `sys_account`'s action set,
count or `link_social` keys were swept repo-wide. They are the three
echo ledgers (re-pinned on their new row counts, which assert the
substance: 38, 39 and 50 rows) plus `platform-objects.test.ts`,
`action-confirm-one-dialog.test.ts`,
`confirm-question-carryover.test.ts`,
`action-predicate-sparse-face.test.ts` and `runtime`'s
`action-execution-destructive.test.ts`, which read `unlink_account` or
iterate the object's actions and need no change. The new pin asserts the
action set itself (`['unlink_account']`), not only that a name is gone.

## Gates

Taken at HEAD `c2bea2c789`, after the last merge of `origin/main`.

- **Derived.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, with no paths, derived 109 commands, and
all 109 were run. `--ran` reconciles them: `✓ dispatch-gates --ran: 109
derived famil(ies) accounted for — 109 run, 0 NOT-MEASURED`.
- **A first-pass refusal.** Five `@objectstack/spec` gates refused with
`PREREQUISITE NOT MET` (exit 3), because the merge had moved spec test
files and the spec `dist` input digest no longer matched. After `pnpm
--filter @objectstack/spec build`, the whole spec family (22 commands)
was rerun, and all of it is green.
- **The artifact-roster block** the derivation prints outside its total:
54 commands, 52 at exit 0. `check-closing-target-claim` and
`check-single-claim-paths` answer NOT WIRED without a PR number (exit 2)
and are rerun once this PR exists. `check-partof-closing-keyword` was
run against this body through `PR_BODY` and passes.
- **The four symbol-anchor sweeps**, `check:adr-symbol-anchors`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and
`check:adr-anchors`, exit 0.
- Among the green: `check:i18n`, `check:i18n-stale-fill`,
`check:nul-bytes`, `check:adr-0087-registration`,
`check:changeset-no-major`, `check:empty-changeset`,
`check:yaml-examples`, `check:docs`, `check:api-surface`,
`check:keyed-text-bounds` and `check:platform-object-tenancy-census`.
- **An earlier run** of the same list at `cc34db92de`, before the
checklist revert, was green apart from the same three PR-context gates.
- `check:pm-dispatch-gates` is not derived for this diff.
- **NOT MEASURED locally, declared to CI:** the path-scheduled CI jobs
and the type-check lanes that the derivation names outside its list
(Test Core, Dogfood, Build Core, Build Docs, Temporal Conformance and
the workspace type-check).

## Acceptance notes

- Release text in an open sibling PR: PR objectstack-ai#21872's changeset
(`.changeset/21846-implicit-account-linking-ownership.md`) tells a
refused user to "link the provider from account settings". After this PR
no console surface links. It is noted for that PR's holder, not filed.
- `docs/NORTH-STAR.md` (governed) names `linked-accounts-social` on its
identity line. The item id does not change, so no governed edit follows
from this PR.
- `packages/spec/src/ui/inline-action.test.ts:307` keeps the sign-in URL
as a parse-acceptance input. It asserts parsing only, not that the
target works.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ctstack-ai#21926)

Closes objectstack-ai#21885

Clause-②: no

## What changes

`content/docs/permissions/sso.mdx`, the "OAuth flow" section only. Step
2 of the OIDC flow told readers to call `POST
/api/v1/auth/sign-in/oauth2` with `{ providerId }`. Nothing registers
that route, so a reader following the page got a 404. The OIDC steps now
use the two routes the social flow already uses:

- Step 2: `POST /api/v1/auth/sign-in/social` with `{ provider: "okta",
callbackURL }`, where `provider` is the `oidcProviders` entry's
`providerId`.
- Step 4: the provider redirects to `/api/v1/auth/callback/okta`.
better-auth exchanges the code, reads the profile from the ID token or
`userInfoUrl`, creates a session and redirects to `callbackURL`.
- A one-line lead-in says why: better-auth's generic-OAuth plugin
registers each `oidcProviders` entry as a social provider and adds no
endpoints of its own.

No product change, no other page, no changeset (docs do not publish).

## Grounding for each route

**`POST /api/v1/auth/sign-in/social`** (OIDC step 2, changed; social
step 2, re-checked and unchanged)

- Ledger row, `packages/plugins/plugin-auth/src/auth-route-ledger.ts`
line 167:
`{ route: 'POST /api/v1/auth/sign-in/social', family: 'core-auth',
source: 'better-auth', disposition: 'sdk', client:
'auth.signInWithProvider' },`
- Published-route list, same file, line 433: `'POST
/api/v1/auth/sign-in/social',`
- Body shape, installed better-auth 1.7.3, `dist/api/routes/sign-in.mjs`
line 40: `provider: SocialProviderListEnum`. In `@better-auth/core`
`dist/social-providers/index.mjs` line 78 that is
`z.enum(socialProviderList).or(z.string())`, so a generic provider id is
accepted. The handler looks `c.body.provider` up in
`c.context.socialProviders`.
- The generic-OAuth plugin, `dist/plugins/generic-oauth/index.mjs` lines
61-66: "registers any OAuth/OIDC provider as a first-class social
provider. Providers are used through the standard `signIn.social` and
`callback/:id` core endpoints — no plugin-specific endpoints needed."
Its `init` prepends the generic providers to `ctx.socialProviders` (line
272).
- Tests merged with objectstack-ai#21872,
`packages/plugins/plugin-auth/src/implicit-account-linking.test.ts`:
- lines 10-11: "driving a real OAuth round trip (`/sign-in/social` →
`/callback/:id`) through generic-OAuth providers"
- line 246: `{ provider: providerId, callbackURL: AFTER,
disableRedirect: true, ...extraBody },` posted to `sign-in/social`

**`/api/v1/auth/callback/:id`** (OIDC step 4, now named; social step 4
`/callback/google`, re-checked and unchanged)

- Published-route list, `auth-route-ledger.ts` line 321 `'GET
/api/v1/auth/callback/:id',` and line 372 `'POST
/api/v1/auth/callback/:id',`
- Callback path, better-auth 1.7.3 `dist/oauth2/utils.mjs` line 29: `if
(!provider.callbackPath) return` the path `/callback/` plus
`provider.id`. The generic-OAuth plugin sets no `callbackPath`, and
`auth-manager.ts` passes `providerId: p.providerId` straight through
(line 3710).
- Test, `implicit-account-linking.test.ts` line 254: the callback is
requested at
`${BASE}/api/v1/auth/callback/${providerId}?code=code-1&state=…`.

**`POST /api/v1/auth/sign-in/oauth2`** (removed)

- `grep -c "sign-in/oauth2"
packages/plugins/plugin-auth/src/auth-route-ledger.ts` gives `0`, in
both the ledger rows and the published-route list.

The section names no other route.

## Validation

All at head `57258e13b4`. `node scripts/pm/dispatch-gates.mjs` derived
44 gate commands for this one-file change. Every one of them exited 0,
and `--ran` reconciled them: "44 derived famil(ies) accounted for — 44
run, 0 NOT-MEASURED (a DERIVED zero — all 44 recorded an exit code and
none of them is 3)".

- Docs gates that read this page: `check:doc-authoring`,
`check:doc-anchors` (429 fragment links resolve),
`check-doc-route-spelling --advisory` ("every shape-matched literal
spells its ledger row"), `check:docs-single-h1`,
`check:doc-frontmatter`, `check:docs-redirects`,
`check:docs-transcript-drift`, `check:nul-bytes`. All passed.
- Four gates first exited 3 (PREREQUISITE NOT MET, nothing measured):
`check:doc-formula-expressions`, `check:doc-security-posture`,
`check:docs-transcript-drift` and `check:skill-examples`. I built
`@objectstack/lint...`, `@objectstack/spec` and
`@objectstack/client-react...`, then re-ran all four. Each exited 0.

## Acceptance notes

- Outside this section and untouched: the SAML flow names `POST
/api/v1/auth/sign-in/sso`. The ledger's pinned config
(`LEDGERED_PLUGIN_CONFIG`) does not turn on `sso`, so the ledger neither
confirms nor refutes that route. Noting it here only.

Changes 1 file: `content/docs/permissions/sso.mdx`, +8 / -3.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…licit system opt-in (objectstack-ai#21939)

Fixes objectstack-ai#21912
Clause-②: no

This PR is a slice of objectstack-ai#21908, the closure of the security middleware's
principal-less hand-off (ADR-0096). It covers the identity and runtime
producers. objectstack-ai#21908 stays open for the deny, which lands last.

## What moved

Each producer below reached the data engine with no principal and no
`isSystem`. That is the hand-off, and it is not an authorization. Each
one now takes the explicit system opt-in that already exists. ⛔ No new
elevation API, no change to what any door authorizes, no accept-set
change.

| Row | Position (function) | Engine calls | Route taken |
|---|---|---|---|
| 17 | `plugin-auth` `auth-plugin.ts`, the platform-admin OAuth client
toggle route (`/admin/oauth2/toggle-disabled`) | `findOne` + `update`
`sys_oauth_application` | `withSystemContext`, the wrapper better-auth's
adapter already writes these rows through |
| 18 | `plugin-auth` `scim-connection-service.ts`
`verifyScimBearerToken` | `findOne` `sys_scim_connection_credential` |
`isSystem: true` in the read's trailing options |
| 19 | `plugin-auth` `auth-manager.ts`
`organizationHooks.beforeUpdateOrganization` (the slug guard) |
`findOne` `sys_organization`, `find` `sys_environment` |
`withSystemContext` |
| 21 | `runtime` `http-dispatcher.ts` `enforceProjectMembership` |
`find` `sys_environment_member` | `isSystem: true` as the read's query
context |

**Row 20 moves nothing.** I read every site, and each one already runs
with the opt-in:
- `adopt-membership.ts` `adoptExistingMembership`: its only caller hands
it the adapter's `withSystemContext` engine.
- `membership-ended-session.ts` `endSessionClaimsForEndedMembership`:
all four calls pass `{ context: SYSTEM_CTX }` (`isSystem: true`) as the
trailing options, and the engine honours that argument on reads and
writes.
- The `auth-manager.ts` insert helper (`settleSelfRegistrationGrant`,
with `findPermissionSetRows`): it reads and writes through
`withSystemReadContext`, the deprecated alias of `withSystemContext`.

The `auth-manager.ts` edit (row 19) was made after objectstack-ai#21872 landed, on a
merge of `origin/main` that contains it.

## Measured: no gate fires on any moved call today

An `isSystem` context short-circuits the gates the hand-off still runs
before `next()`: package-managed, system-row, curated-capability,
audience-anchor, engine-owned and delegated-administration. A move is
neutral only if none of them fires on the producer's calls.

- **Static.** Each gate is keyed to objects and verbs that none of these
calls touch. The first four guard writes to `sys_permission_set`,
`sys_position`, `sys_capability` and `sys_position_permission_set`.
Engine-owned needs a `userId`. Delegated-administration guards writes to
the RBAC link tables, `sys_permission_set` and `sys_member`. Rows 18, 19
and 21 are reads. Row 17 writes `sys_oauth_application`, which none of
the gates names.
- **Instrumented.** I added a local, uncommitted probe in
`security-plugin.ts`. It recorded each principal-less, non-system
context that reached the hand-off, with its stack, and each gate refusal
of such a context. Over every run below it recorded **0 gate refusals**.
Every call of the card's functions reached the hand-off, so no gate had
stopped it.

Per function, before → after (records at the hand-off):

| Function | dogfood subset | dev boot | runtime harness |
|---|---|---|---|
| toggle route (row 17) | 5 → 0 (`findOne` 3, `update` 2) | 5 → 0 | — |
| `verifyScimBearerToken` (row 18) | 0 → 0 | 1 → 0 | — |
| `beforeUpdateOrganization` (row 19) | 0 → 0 | 1 → 0
(`sys_organization` `findOne`) | — |
| `enforceProjectMembership` (row 21) | — | — | 2 → 0 |
| row 20 functions | 0 → 0 | 0 → 0 | 0 → 0 |
| all records | 1601 → 1596 | 300 → 293 | 39 → 37 |

Before = the base tree with the probe. After = the change with the
probe: the dev boot and the harness on the final tree (`9878b925`), and
the dogfood subset on the pre-merge commit `4239dd47`, whose row 17 code
is the same. The boot's background ticks (the outbox claims) make the
totals differ by a few records between runs. The per-function counts are
the reading.

- **Dogfood subset.** Seven files that reach the card's functions: the
two platform-admin route sweeps, the organization-update door, the two
SCIM-enabled suites, org-admin reach and membership attribution. 57
tests passed both times. Only row 17 appears in the dogfood suite. This
subset reproduces the full-suite census of the measure-first round
(`6003676228`) for these rows exactly.
- **Dev boot.** `pnpm dev -- --fresh` on showcase with SCIM enabled,
driven as the seeded admin. It registers an OAuth client, toggles it
twice, toggles a missing id, sends a SCIM request with an unknown
bearer, and changes the default organization's slug. The answers were
identical before and after: register 201, toggles 200 / 200 / 404, SCIM
401, slug update 200.
- **Runtime harness.** A scratch file, deleted afterwards, booted a real
engine with `SecurityPlugin` and called `enforceProjectMembership` for a
member and a non-member. No open-source composition reaches row 21: no
`KernelResolver` sets `environmentId`, and `sys_environment_member` is a
cloud control-plane object. The answers were `null` and 403 both times.
- **Restored.** The probe was reverted (`security-plugin.ts` blob
`5b4ab280` equals HEAD), `plugin-security` was rebuilt, and
`ablation-dist-preflight --absent` confirms the marker is gone from
`dist/`. The positive control: 4 hits in `dist/` while the probe was
live.

**One difference that is not a gate (row 17).** Under the hand-off, the
engine's static read-only strip ran on the toggle's `update` and dropped
the `updated_at` the route supplies, with a WARN. Under `isSystem` the
strip does not run. I compared the stored rows: on the SQL driver, both
paths store `disabled` and an `updated_at` equal to the driver's own
stamp. The only change is that the WARN line no longer appears on each
toggle.

## Pins (one per package) and ablations

- `plugin-auth/src/principal-less-producers-system-context.test.ts`: a
real engine with a context-recording middleware. The toggle route's
`findOne` and `update` and the SCIM probe's `findOne` are `isSystem`.
The route still answers 200 and flips the stored flag, and still answers
404 `RESOURCE_NOT_FOUND`. The verifier still resolves a known bearer to
its connection, and still answers `null` for an unknown one.
- `plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts`:
both slug-guard reads are `isSystem`, and the guard still refuses with
`FORBIDDEN` / 403 while an active environment exists. **On an engine
that refuses a principal-less, non-system context, the guard still
refuses.** The pre-existing catches are pinned as they stand: a read
that throws ends the hook without refusing.
- `runtime/src/http-dispatcher.membership-system-context.test.ts`: the
membership read is `isSystem`, a member passes, and a non-member gets
403 `PROJECT_MEMBERSHIP_REQUIRED`. **On an engine that refuses a
principal-less, non-system context, the non-member is still refused.**
The pre-existing fail-open catch is pinned as it stands: a read that
throws lets the request through.
- **Ablations.** Each went through `scripts/ablation-replace.mjs`: the
anchor hit once, the mutation was verified on disk, and the restore was
proven (blob equals HEAD, `git diff HEAD` empty). Each pin imports its
subject from `src`, so no build sat between the mutation and the run.
  - A, row 17, `withSystemContext` dropped: 2 red.
  - B, row 18, trailing context dropped: 2 red.
- C, row 21, query context dropped (re-run on `9878b925`): 3 red,
including the refusing-engine non-member case.
- D, row 19, `withSystemContext` dropped: 2 red, including the
refusing-engine case.

## Tests and gates (at `9878b925`)

- New pins, on `9878b925`: plugin-auth 2 files, 9/9 passed. runtime 1
file, 5/5 passed.
- `pnpm --filter @objectstack/runtime exec vitest run --project local
--maxWorkers=2` on `9878b925`: 330 files, 4654 passed, 19 skipped. `pnpm
--filter @objectstack/runtime run typecheck`: exit 0.
- plugin-auth on `60c5f22c`: the suite (126 files, 2607 passed, 10
skipped) and `run typecheck` (exit 0). The only commit since,
`9878b925`, touches `runtime` and the changeset, and plugin-auth imports
neither.
- **The runtime suite caught a spelling of mine.** The membership read
first carried its context as a trailing third argument. Eight existing
assertions read the read's two arguments: `toHaveBeenCalledWith` in
`http-dispatcher.test.ts` and
`http-dispatcher.membership-skip-boundary.test.ts`. They turned red. The
context now rides inside the query instead. The opt-in is the same and
so is the engine's reading (ObjectQL merges the two), and both suites
pass unedited.
- `eslint --no-inline-config` over the 7 changed `.ts` files: 7 files, 0
errors, 0 warnings. These 7 are the whole population whose lint verdict
this diff can move. `eslint.config.mjs` never enables type-aware linting
(no `parserOptions.project`, no typed rules), so no untouched file's
verdict can change. The repo-wide `pnpm lint` is CI's.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on `9878b925` derived 98 commands. All 98
ran and exited 0. The `--ran` reconciliation over the exit-coded record
reads: "98 derived famil(ies) accounted for — 98 run, 0 NOT-MEASURED (a
DERIVED zero — all 98 recorded an exit code and none of them is 3)". The
same 98 also ran green on `60c5f22c`.
- The branch sits 3 commits behind `origin/main` (`9dce6353`). Those
commits touch `content/docs/permissions/sso.mdx` and a `rest` test, none
of this diff's files. objectstack-ai#21902 is merged into `main` and contained in this
branch.

## Acceptance notes

- **The fail-open catches on rows 19 and 21 are unchanged.** They are
pre-existing, and row 21's is documented as deferred. This PR removes
the path by which a principal-less deny would trip them: both reads are
now `isSystem`. A read that throws for any other reason still skips the
slug guard (row 19) or opens the membership gate (row 21). Both
behaviours are pinned as they stand, so the seat can sequence them
before the deny.
- **Row 19 in the open-source composition.** `sys_environment` is not
registered there, so the environment read throws before it reaches the
engine middleware. The catch then ends the hook, and the slug guard
never refuses in an open-source deployment. It acts only where the
object exists. Measured on the dev boot: the slug change answered 200
and recorded no environment read at the hand-off.
- **NOT MEASURED: a cloud composition.** An `isSystem` read also
bypasses any host read hook keyed on the caller, such as a control-plane
org-scope hook. I measured the six named gates only, and only in-repo.
- `mintScimConnectionCredential` inserts without the opt-in. It has no
runtime caller (tests only) and is not exported from the package entry,
so nothing produces through it today. Noted, not changed.
- The census page (`content/docs/permissions/system-context.mdx`) is
current. `--fix` moved its held declaration count from 25 to 26, for the
new trailing-options type on the SCIM probe. It asked for no anchors.

---
_Generated by [Claude
Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…jectstack-ai#21943)

Part of objectstack-ai#21932

Clause-②: no

## What changes

The platform checklist gains items for the rules the 17.7 pre-release
security follow-up landed, and two re-checks from the card are resolved.
All edits are in `docs/qa/platform-checklist/areas/*.json`.
`automation.json` is untouched (open PR objectstack-ai#21928 holds it).

| Card row | Disposition | Item |
|---|---|---|
| objectstack-ai#21792 (PR objectstack-ai#21809) settings audit and secret-valued settings | new
item | `platform-core.settings-audit-secret-fingerprint` |
| objectstack-ai#21846 (PR objectstack-ai#21872) implicit account linking | new item |
`identity-auth.implicit-account-linking-ownership` |
| objectstack-ai#21839 (PR objectstack-ai#21890) share-link password | three clauses added, rev 4 to
5 | `access-security.share-link-capability-tokens` |
| objectstack-ai#21836 (PR objectstack-ai#21879) global search skips unreadable objects, plus the
two cases objectstack-ai#21880 lists | new item |
`search.global-search-skips-unreadable` |
| re-check 1: A2 / A7 and the plugin-driver boundary | rev 2 to 3 |
`integration-system.datasource-credential-refusal-matrix` |
| re-check 2: the objectstack-ai#21845 CLI and quorum N1 notes | already applied by
objectstack-ai#21891, no edit | `cli.scaffold-first-run`,
`cli.scaffold-console-first-paint`, `approvals.quorum-m-of-n` |

Each item states rules, not reproductions. Withheld security detail
stays out.

### Grounding, per row

- **Settings audit fingerprint.** Both ledgers record the keyed digest
for a secret-valued setting, or no fingerprint when none is available,
and never the value or an unkeyed hash. Grounded in
`settings-service.ts#secretAuditDigest`,
`config-change-audit.ts#CONFIG_CHANGE_ACTION` and the contract text at
`crypto-provider.ts#keyedDigest`. The pin is
`settings-audit-secret-digest.test.ts` (7 cases). The offline check
carries a positive control: the non-secret key's unkeyed digest IS
found, so a no-hit on the secret rows means something. The
no-keyed-digest arm cannot be reached on a stock boot, so that clause is
scored from the pin.
- **Implicit account linking.** Four rules: no implicit link to an
unverified local user; an unlink is honoured; an explicit, signed-in
link still works and lifts the refusal; the platform IdP exception holds
only on its OAuth path. Grounded in `implicit-account-linking.ts`
(`decideImplicitLink`, `IMPLICIT_LINK_REFUSED`,
`PLATFORM_IDP_PROVIDER_ID`, `recordUnlinkTombstone`,
`refuseImplicitAccountLink`) and the published `sso.mdx` section. The
pin is `implicit-account-linking.test.ts`. The item reuses the local
OIDC provider recipe from `identity-auth.linked-accounts-social`. The
platform-IdP clause and the operator override are pin-scored, and
knownGaps says why.
- **Share-link password.** The stored hash leaves on no exit (mint,
list, redemption). The password is accepted from the `X-Share-Password`
header, the query form is still accepted, and the default CORS
allow-list carries the header. Both public routes answer `Cache-Control:
no-store` and `Vary: X-Share-Password` on every outcome, and the
authenticated routes do not. Grounded in
`share-link-service.ts#withoutPasswordHash`,
`share-link-routes.ts#SHARE_LINK_PUBLIC_RESPONSE_HEADERS`, the runtime
`share-links.ts#PUBLIC_RESPONSE_HEADERS` and
`adapter.ts#DEFAULT_CORS_ALLOW_HEADERS`. The pins are the `[objectstack-ai#21839]`
blocks in `share-link-password.test.ts`,
`share-links-public-cache-headers.test.ts` and the hono-plugin CORS
case. Existing clause indices are unchanged.
- **Global search.** An unreadable object is never queried, named or
counted. An explicit `objects=` naming one answers exactly as a name
that matches no object. The object stays refused at its own door. Row
scope still narrows a searched object, and a term found only in a field
hidden from the caller yields no hit. Grounded in
`protocol.ts#searchAll` (the `canReadObject` pre-filter and the
`getQueryableFields` narrowing). The pins are the dogfood
`search-skip-unreadable.dogfood.test.ts` and the 12 unit cases in
`protocol.search-skip-unreadable.test.ts`. The two objectstack-ai#21880 cases have no
end-to-end pin yet, and knownGaps says so. The open pinyin-companion
finding on objectstack-ai#21880 is recorded as a knownGap with a flag-off instruction,
at class level only. The persona reuses the area recipe
`qa-contributor-bound-member`.
- **Datasource credential matrix.** A2 / A7 (`acceptance[1]` and
`acceptance[6]`) are recorded as a known environment gap. They need a
reachable credential-protected database of a shipped driver, which no
run has had. No recipe is claimed, because none is proven. A successful
publish alone may not score them, and the stored-credential half of A7
can be read as a partial reading. Separately, the unknown-driver clause,
step 7, its negative and the title now state the ruled boundary from
objectstack-ai#21921 and the docs note objectstack-ai#21927. For a plugin driver, only the fixed
spellings are redacted (the canonical keys, the former aliases and URL
credentials). A non-canonical key served as written is the boundary, not
a FAIL. Grounded in `common.zod.ts#CANONICAL_CREDENTIAL_KEYS` and
`datasource-credential-redaction.ts#redactableConfigKeys`.

### Re-check 2 evidence (no edit)

At the claim ref `9dce635337`:

- `cli.scaffold-first-run` (rev 3) step 0 and
`cli.scaffold-console-first-paint` (rev 3) step 0 both drop the trailing
`npm install` and warn against adding it. Their rev 3 history entries
cite objectstack-ai#21845. No other `npm install` step remains in `cli.json`.
- `approvals.quorum-m-of-n` (rev 4) `negative[0]` requires a
NON-PRIVILEGED repeat actor and names the documented admin override
(objectstack-ai#3424) as never a distinctness FAIL.

## Remaining on objectstack-ai#21932 (held, not in this PR)

- The objectstack-ai#21864 row (public-form withdrawal layering). Its PR is still
open.
- The objectstack-ai#21928 row (run-state trigger record mask). That PR adds its own
item in `automation.json`.

objectstack-ai#21932 remains open for these two rows.

## Validation (at `a72b827e43`)

- `pnpm check:platform-checklist`: exit 0. It reports 15 areas and 273
items (269 active, 2 planned). The baseline was 270. Symbol anchors
resolve 674 of 684 (baseline 657 of 667): all 17 new anchors resolve,
and the objectstack-ai#16898 residual is unchanged at 10.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 13 commands, and all 13 exit 0.
`check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET:
`@objectstack/formula` and `@objectstack/lint` were not built). After
building them it exited 0. `--ran` reconciliation: 13 derived, 13 run, 0
unrun.
- No package source changed, so there is no package build, test or
typecheck. No changeset: `docs/qa/**` publishes nothing.

## Acceptance notes

- Source citations name test cases and symbols, never line numbers,
because `check:platform-checklist` refuses a `file:line` pin.
- `content/docs/data-modeling/drivers.mdx` says a plugin driver's
`config` is "stored and served to administrators as written". The read
redactor still withholds the canonical spellings (`password`,
`authToken`), the former aliases and URL credentials for such a driver
(`redactableConfigKeys`). So the docs sentence is slightly broader than
the code, and the code is the more protective of the two. The checklist
follows the code. This is noted only, with no card. Carrier: none.
- A run of `search.global-search-skips-unreadable` picks the walled
object and the hidden-field value on the live boot, behind premise
guards. The item names likely candidates and does not assume them.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…-per-head re-queue verdict (objectstack-ai#21946)

Part of objectstack-ai#21933 — this PR delivers the classification, the comment, and
the once-per-head re-queue verdict. It does not deliver the automatic
re-queue act: the workflow holds no credential that can enqueue a pull
request (see "Blocker" below). The card should stay open for that half.

## What changed

`.github/workflows/merge-queue-triage.yml` gains limb ③:

1. **Classify, from the job record only.** A job is `infra:no-runner`
when it ended `cancelled` with an empty `runner_name` and no steps. A
cancelled job the record does not decide (it did get a runner) is
checked for the platform's "The job was not acquired by Runner"
check-run annotation. No log text is read. The **build** is
`infra:no-runner` only when that explains every red. Every other red job
must be an aggregate gate whose failed steps are all `Verify … results`,
and whose own shard family (`NAME (k/n)`) has a no-runner member.
Anything else makes the build `failure`, read as before: a real failing
step, a cancelled job that had a runner, an aggregate from a different
family, or an annotation that could not be read.
2. **Re-queue budget: one per PR head.** On an `infra:no-runner` build,
the script reads the PR's current head through `pulls.get`. The comment
carries a durable per-head marker: a hidden HTML comment named
`merge-queue-infra:no-runner` with the PR number, head sha and run id.
The next `infra:no-runner` red on the same head finds that marker. Its
verdict is then "hand to a person, do not re-queue", and it names the
earlier queue build. A `failure` build never gets a verdict and never
writes the marker. If the head or the PR's comments cannot be read, the
verdict is `unknown`, never `once`.
3. **The comment names the class:** `分类:infra:no-runner` or
`分类:failure`. For `infra:no-runner` it lists the no-runner jobs, the
aggregates they explain, and the verdict. For a mixed build it still
lists the no-runner jobs, and it names the red those jobs cannot
explain.

⛔ The attestation rule is unchanged: a shard that never ran still does
not count as passing (objectstack-ai#6082). The comment says so too. Nothing here
changes whether a build is green. The idempotency read of the PR's
comments now paginates, because the per-head markers live in those
comments.

Permission change, declared: the job gains **`checks: read`** (read
only) for the annotation leg. No other grant changes. The harness now
pins that the job holds no `contents:` grant.

## Blocker — the re-queue act

The workflow's verdict cannot be carried out by the workflow.
- **No enqueue credential.** The only enqueue path measured in this repo
is `enablePullRequestAutoMerge` through the fleet App token.
`fleet-write.yml` documents that this needs `contents: write`, which is
"ONE consumer". This workflow's `GITHUB_TOKEN` holds `actions: read`,
`pull-requests: write`, `issues: write` and now `checks: read`.
- **The token's own grant would not be enough either.** Granting
`contents: write` to `GITHUB_TOKEN` and calling `enqueuePullRequest` /
`enablePullRequestAutoMerge` would widen this job's permissions. Even
then, the merge group would be created by `GITHUB_TOKEN`, and GitHub
does not start workflow runs for events that `GITHUB_TOKEN` causes. It
is not established that CI would ever build such a merge group, and this
PR does not measure it.

So the comment says the workflow will not re-queue, and asks a person to
re-queue once. The harness makes an enqueue call an unmodelled API that
fails the battery (mutation M28). Wiring the act needs a decision:
either a second consumer of the fleet App token with `contents: write`,
or a measured `GITHUB_TOKEN` path.

## Done-when, clause by clause

- **A synthetic no-runner cancellation is classified and re-queued
once.**
- Classified: proven. N1 replays the real job records of queue build
37374282440. N2 replays queue build 37371558473, with eight no-runner
jobs and two aggregates. N7 uses the real annotation of check run
111979038621.
- Re-queued once: only the verdict is proven. N1 grants one re-queue,
and the N3 pair (run 2 reads run 1's own comment) sends a second red on
the same head to a person. N4 proves the budget is per head. The act is
blocked, as described above.
- **A real shard failure is not re-queued.** N5 (real failure), N6
(no-runner beside a real failure) and N9 (an aggregate from another
family) all classify as `failure`, with no verdict and no marker.
- **The triage comment names the class.** N1 through N10 all assert the
class line.
- **Test / dry-run fixture for both cases.**
- Ten scenarios cover limb ③. The new fixtures in
`scripts/fixtures/merge-queue-triage/` are trimmed real records, with
provenance in the README: `run-37374282440.jobs.json`,
`run-37371558473.jobs.json` and
`check-run-111979038621.annotations.json`.
- Nine new self-test mutations (M20 to M28) each turn the battery red at
the scenario they name, with a named control scenario that stays green.

## Validation (at 499d0ae)

- `node scripts/check-merge-queue-triage-outcome.mjs` → `OK (133
assertions over 34 scenarios …)`. The base had 90 assertions over 24
scenarios.
- `node scripts/check-merge-queue-triage-outcome.mjs --self-test` → `181
assertions, 32 mutations of the shipped script each driven to red`.
- `node scripts/pm/dispatch-gates.mjs --commands` derived 51 commands.
50 ran with exit 0, including `pnpm check:workflow-status-functions`,
`check:nul-bytes`, `check:required-contexts` and
`check:shard-attestation`. Reconciliation `--ran`: 51 accounted, 0
UNRUN.
- NOT MEASURED: `pnpm check:pm-dispatch-gates`. Its `--self-test` half
alone exceeded the 10-minute foreground cap (exit 124). Its log showed
1785 pass marks and 1 fail mark. The fail mark is on
`packages/qa/dogfood/test/per-file-cwd.setup.ts`, a path this diff does
not touch. Declared to CI.
- Narrowed lint: `eslint --no-inline-config --format json
scripts/check-merge-queue-triage-outcome.mjs` → 1 file, 0 errors, 0
warnings. The repo config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
untouched file. The workflow YAML is not an eslint target.

## Acceptance notes

- The triage workflow listens to `workflows: [CI]` only. Run 37371558492
is the **Governed Surface Guard** workflow, and its no-runner red is
never triaged. The same holds for the sibling guard runs that ejected
objectstack-ai#21872 that afternoon. Out of this card's file surface; noted here only.
- The budget keys on the PR head read at triage time. A push that lands
between the ejection and the triage run would be charged for the earlier
head's red. That is the conservative direction: the next red reaches a
person sooner.
- A run whose conclusion is `cancelled` (not `failure`) still gets no
comment. That is the existing eviction rule, unchanged.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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