Repository navigation
fix(plugin-auth)!: an organization cannot require MFA while two-factor enrollment is off (#30201) - #30293
Conversation
…or is off; the MFA gate holds only enrollable users Claude-Session: https://claude.ai/code/session_01Utp6CYRZxPEjGeTbGfW4YC Co-authored-by: Claude <noreply@anthropic.com>
…ment guard Claude-Session: https://claude.ai/code/session_01Utp6CYRZxPEjGeTbGfW4YC Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CYRZxPEjGeTbGfW4YC Co-authored-by: Claude <noreply@anthropic.com>
…nt guard on kernel:ready (#30201) The guard is registered on sys_organization for insert and update, after the ADR-0092 D2 identity write guard, and it asks the live auth manager per write, so a settings pass that turns two-factor on admits the next write. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CYRZxPEjGeTbGfW4YC
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CYRZxPEjGeTbGfW4YC
📓 Docs Drift CheckThis PR changes 1 package(s): 46 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b0cadad1dc48de8323409ab84244e096bf13b914 && git checkout b0cadad1dc48de8323409ab84244e096bf13b914
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bfca0efb79ac5df2049f392cdecd3bbeeea9ec61 05e6e0bd2330f2cc10f98d0bbb0d1e9241885529 && git checkout -B drift-repro bfca0efb79ac5df2049f392cdecd3bbeeea9ec61 && git merge --no-ff 05e6e0bd2330f2cc10f98d0bbb0d1e9241885529
node scripts/docs-audit/affected-docs.mjs --json bfca0efb79ac5df2049f392cdecd3bbeeea9ec61
|
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL Contract reviewServed-tier: Inputs read: card #30201 body and all four comments (6648424134, 6648643507, 6650356190, 6650376678); PR #30293 body, file list and the net diff against main at merge-base bb86ff9 (10 files, +692/-12); the check-runs on this head. Check-runs at judgement (05:29:46Z, after a ten-minute wait from 05:18Z): 32 success, 13 skipped, 0 failure; still in progress: Test Core (1/8) and Test Core (3/8). Every other required context is green (Lint and Repo Gates, Build Core, Governed Surface Queue Guard, Test UI, Live E2E UI; Type Check UI, Lint UI and Build and E2E UI skipped by path filter; Type Check workspace, consumer, source and debt gates success; Test Core shards 2, 4, 5, 6, 7, 8 success). The landing still waits on the two shards; this record judges the diff. ① Derived judgmentsAccept-set change, the one narrowing. On a deployment where two-factor is off (resolveTwoFactorEnabled: OS_AUTH_TWO_FACTOR env, then plugins.twoFactor, then off), a write to sys_organization whose payload sets require_mfa to anything but false, 0, '0', 'false', null or undefined is refused. Right: the card's own fallback, and the value an administrator could save before is the one that produced the whole-tenant lockout. Doors, read in the tree, each right:
Gate fail-safe. resolveUserAuthGates now holds a user for MFA only while mfaRequired and unenrolled and isTwoFactorEnabled(); otherwise it calls noteMfaRequirementWithoutEnrollment() (one warn per manager naming the remedy) and neither holds nor stamps mfa_required_at. Right: the hold answered MFA_REQUIRED to a user whose remediation endpoint did not exist; the twoFactor plugin is pushed directly into the better-auth plugin list (auth-manager-plugin-list.ts line 271, not through addOptionalPlugin), so config on means the endpoint is wired and the four readers (plugin list, features.twoFactor, gate, guard) cannot disagree. Enrolled users were never held and still are not. Response code and shape. validationFailure from @objectstack/types builds name ValidationError, code VALIDATION_FAILED, one fields[] entry { field: 'require_mfa', code: 'invalid_value' }; mapDataError in packages/rest keys 400 on that code; invalid_value is a member of the closed ADR-0114 catalog (errors.zod.ts line 285). Right. Public surface. resolveTwoFactorEnabled is exported from auth-manager.ts but not re-exported from index.ts (the named list is unchanged), right. AuthManager.isTwoFactorEnabled() is a NEW public method on a class index.ts does export, so the published surface of @objectstack/plugin-auth gains one method; the PR body's "No published export ... is added; nothing is widened" misses it. A should-fix on the prose (or mark the method internal), not a semver change: the narrowing arm already carries the BREAKING banner, and an additive method on a patch-level narrowing changes no level. ② Semver levelChangeset .changeset/30201-org-mfa-needs-enrollment.md: @objectstack/plugin-auth patch, summary line with the ! marker, a BREAKING banner naming the accept-set narrowing, Before / Now / The gate / One answer / Unchanged, and a Remedy paragraph. Level is right by the Post-Task Checklist rule: Metadata-protocol none (sys_organization.require_mfa is a data row value, nothing a metadata author writes changes) takes patch; a non-protocol narrowing ships patch and keeps its BREAKING banner and ADR-0087 disposition, which it does. Declaration lines. PR body and changeset both read Clause-②: no (narrowing) and Metadata-protocol: none; the one reader (scripts/pm/clause2-line.mjs) reads the value and the arm, and Check Changeset is green. The two Clause-② lines are not byte-identical beyond the value: the body's tail ends "No published export, error code or settings key is added; nothing is widened." and the changeset's ends "Nothing is widened." Should-fix, cosmetic: make the tails one sentence (and drop the "no published export" claim, see ① on isTwoFactorEnabled). ADR-0087 marker: exactly one HTML-comment marker, adr-0087: not-required (no-migration-prescription), a spelling check-adr-0087-registration.mjs recognises (its line 931). The justification holds against the tree: no spec key, export, stored shape or stored row is removed, renamed, rewritten or dropped; the remedy is a deployment setting (OS_AUTH_TWO_FACTOR or auth.mfa_required), which no migration ledger can derive; the other categories are closed on facts the diff shows (the package publishes, no ADR-0087 id, no export removed or retyped). Right. ③ Boundary flags
④ Design judgmentThe narrowing against the published spec on main. ADR-0069 D3 declares the per-organization capability as "an org may require MFA above the global floor", with the mechanism row for mfa_required resting on the twoFactor plugin ("twoFactor plugin already provides TOTP"), and its hard constraint (ADR-0049, line 10) says a Require-MFA toggle that does not actually block is worse than nothing. On main the organization toggle met neither half honestly: it blocked everyone, and the enrollment half of D3's mechanism was absent, so what the consumer had was not the declared capability but a lockout. This diff takes nothing the spec declares away at the consumer: the org still tightens above the floor whenever the floor's own mechanism (the twoFactor plugin) is present, and it makes the spec's own precondition real by refusing the write when it is not, with the remedy in the refusal. The gate fail-safe is the same reading applied to rows that already exist. PASS on the design, with the findings below, none must-fix. Breaking attempts:
Findings, each named:
Implemented-by: VERDICT: PASS |
…e settings remedy works Contract review follow-ups: - The guard judges the change, not the payload: an update is refused only for a row whose stored value (the engine's `previous`, bound on the by-id and the per-row multi-row paths) is off. Re-sending `require_mfa: true` for a row that already stores it passes, so a whole-row update, an import re-run or a save with no change no longer blocks the row's other edits. A multi-row update is judged row by row; a context with no `previous` is judged as a change. - The refusal and the gate's warning offer the `auth.mfa_required` remedy only where it can work: the OS_AUTH_TWO_FACTOR env var outranks it. - AuthManager.isTwoFactorEnabled is marked @internal. - The organization-declared held-session block turns two-factor on itself instead of inheriting it from the held state that ran before. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CYRZxPEjGeTbGfW4YC
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CYRZxPEjGeTbGfW4YC
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL Contract reviewServed-tier: Delta review of the head the full review passed (record 6650579682, head 8c4430f). Inputs read: card #30201 body and its four comments; PR #30293 body as it stands and its pending replacement (server-seat2/30201/pr-body-v2.md); the file list, the net diff against main at this head (10 files, +794/-12) and the delta 8c4430f..f643025 (6 files, +127/-25); the check-runs on this head. The range 8c4430f..05e6e0b holds exactly two commits: f643025 (the fix) and 05e6e0b, one merge of main at 8b117c0 (the earlier head was itself the merge at bb86ff9, so this PR carries two main merges in all). The diff f643025..05e6e0b over plugin-auth, packages/verify and the changeset is empty: the merge touches no path of this PR; what it brings to packages/objectql/src/engine.ts is main's own, and every engine citation below was read at this head. Check-runs at judgement (2026-11-05T06:49Z): after five readings over ten minutes from the first at 2026-11-05T06:40Z to the last at 2026-11-05T06:49Z: 29 success, 13 skipped, 0 failure or other; still in progress: 5 (Test Core (1/8), Test Core (2/8), Test Core (3/8), Test Core (4/8), Test Core (7/8)). Green and complete: Governed Surface Queue Guard, Check Changeset, PR-body guards, every Lint job, Type Check consumer, source and debt gates, Build Core, Test UI, Live E2E UI, Dogfood Regression Gate; Type Check UI, Lint UI and Build and E2E UI skipped by path filter. The shards still running are the landing's wait; this record judges the diff, as the earlier one did. ① Derived judgmentsThe delta replaces the payload rule with a transition rule. Every accept-set change it makes, judged:
② Semver levelChangeset .changeset/30201-org-mfa-needs-enrollment.md at this head: @objectstack/plugin-auth patch, the summary with the ! marker, the BREAKING banner, Before / Now / The gate / One answer / Unchanged, the Remedy paragraph, and exactly one HTML-comment ADR-0087 marker reading not-required (no-migration-prescription), a category check-adr-0087-registration.mjs lists. The delta rewrites the Now bullet, the Remedy paragraph and the Clause-② line to the transition rule; nothing moves the level. Patch stays right: Metadata-protocol none (a data-row value, nothing an author writes), a non-protocol narrowing ships patch and keeps its banner and disposition, and the delta adds no export, code or key. The disposition's justification still holds against the tree: no spec key, export, stored shape or stored row is removed, renamed or rewritten, and the remedy is a deployment or settings change no ledger can derive. Declaration lines. The two line-initial declarations in the replacement body (pr-body-v2.md) and in the changeset are byte-identical: Clause-②: no (narrowing) with the transition-rule tail, and Metadata-protocol: none. The earlier record's tail-mismatch should-fix is closed in the replacement. The PR body as it stands on GitHub still carries the earlier head's Clause-② line (the payload rule, 25 tests), so until the replacement is written in, the live body and the changeset differ in their tails and the body describes a rule the head no longer implements; the reader (clause2-line.mjs) reads the value and the arm, both unchanged, so Check Changeset is unaffected. Should-fix, process: write pr-body-v2.md in once its DOGFOOD_PLACEHOLDER is filled, and read the body back. The @internal mark. No tsconfig or tsup config in the repository sets stripInternal, and plugin-auth's tsup emits dts, so AuthManager.isTwoFactorEnabled() is still present in the published .d.ts; @internal changes the published-surface reading by convention (a TSDoc contract, visible to a consumer who reads it) and not mechanically. The Clause-② line's "No published export ... is added" is literally true (the named list in index.ts is unchanged; a method on an exported class is not an export) and the replacement body now discloses the method and its mark in the same paragraph, which is the honest reading. The level is unchanged either way: an additive method on a patch-level narrowing. Closed. packages/verify's pin calls it on an any-typed handle, as it already does _orgMfaCache; no published consumer binds to it. ③ Boundary flagsEach should-fix of record 6650579682:
New flags in the delta:
④ Design judgmentThe transition rule against the published spec on main. ADR-0069 D3 declares the per-organization requirement above the global floor on the twoFactor plugin's mechanism; the earlier record found the refusal makes that precondition real at the write door and the gate fail-safe applies the same reading to stored rows. The delta narrows the refusal to the writes that create the state the spec cannot honour and lets every write that leaves it as stored through, which is the right boundary: a value the gate does not enforce is not made more dangerous by being echoed, and refusing the echo took the row's other edits hostage. PASS on the design. Attempts to break it:
Findings, each named:
Implemented-by: VERDICT: PASS |
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL 维护者速读
席位核对 — ACCEPT · domain:server#2 · head
|
Requested by Bill · project thread
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL
维护者速读
require_mfa)不能从关改成开(新建即开同样不行),返回 400 并说明原因和出路(先开两步验证;环境变量没把它钉成关时,也可以开全局的auth.mfa_required,它会连同注册入口一起打开)。已经存了开的组织行原样回存照常放行,不挡这一行的其它编辑。同时 MFA 闸门只拦「能去注册的人」:两步验证关着时,任何 MFA 要求都不拦人、不启动宽限期倒计时,服务端只打一条带修法的警告。POST /api/v1/auth/two-factor/enable因为插件没开直接 404;Setup 本身也在同一道闸后面,产品里没人能撤回,整个租户锁死。全局开关一直把「要求」和「注册入口」作为一整块打开,组织字段没有这层配对。no (narrowing)):两步验证关着的部署上,以前能存的require_mfa = true现在被拒。已经存了true的旧组织行不改动、也不再锁人;一旦打开两步验证,它就开始照常要求未注册成员(宽限期从那时起算)。两步验证开着的部署行为完全不变。回滚即撤本 PR。packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts(拒存规则与文案);auth-manager-password-session-policy.ts里resolveUserAuthGates的 MFA 分支(两步验证关时不拦、不盖戳);auth-manager.ts的resolveTwoFactorEnabled(插件列表、features.twoFactor、闸门、拒存四处同一个答案)。pnpm --filter @objectstack/plugin-auth exec vitest run src/organization-mfa-enrollment-guard.test.ts,预期 29/29 通过,其中端到端一组断言「两步验证关:注册接口 404,会话不被 MFA_REQUIRED 拦住」。Fixes #30201
Clause-②: no (narrowing) — while the two-factor feature is off, a write that turns
sys_organization.require_mfaon (an insert with it on, or an update of a row that stores it off) is refused400 VALIDATION_FAILED, a value an administrator could save before; re-saving a row that already stores it on passes. No published export, error code or settings key is added; nothing is widened.Metadata-protocol: none
Skills-docs: follow-up owed —
content/docs/permissions/authentication.mdx(themfa_requiredrow saysrequire_mfais settable per organization with no condition; it needs two-factor on). Not edited here: outside this card's file surface.Why
sys_organization.require_mfais a generically editable extension column, so an administrator could tick Require Multi-Factor Auth on an organization's edit form and it saved, with two-factor off (the default,features.twoFactor: false). Once the grace period ended, the ADR-0069 D3 gate held every unenrolled member403 MFA_REQUIREDand sent them to enroll, and the enrollment endpoint (POST /api/v1/auth/two-factor/enable) answered 404 because the twoFactor plugin that serves it was never wired. Every member, the owner included, was locked out, and nobody could undo it from inside the product, because Setup sits behind the same gate. The globalauth.mfa_requiredsetting never had this hole: its settings piece applies{ mfaRequired: true, plugins: { twoFactor: true } }as one refusable patch. The organization column had no such pairing.What changes
beforeInsert/beforeUpdatehook onsys_organization(organization-mfa-enrollment-guard.ts, priority 15, right after the ADR-0092 D2 identity write guard at 10) refuses, while two-factor is off, a write that turnsrequire_mfaon: an insert with it on, or an update of a row whose stored value is off.validationFailurefrom@objectstack/types,400 VALIDATION_FAILED, onefields[]entry{ field: 'require_mfa', code: 'invalid_value' }(the closed ADR-0114 catalog). The message says what was refused, why, who would have been locked out (every member, the owner included) and the ways forward; theauth.mfa_requiredremedy is offered only "unlessOS_AUTH_TWO_FACTORis set to false", because the env var outranks the settings. It is judged for every write context, the system's included (the lockout does not depend on who wrote the row). The stored value is the update context'sprevious, which the engine binds beforebeforeUpdateon both update paths (the by-id row it read, and one per-row context per matched row on a multi-row update, before anything is written), so a multi-row update is judged row by row and one row turning it on refuses the whole write with nothing written. An update context without apreviousis judged as a change. Re-sendingrequire_mfa: truefor a row that already stores it (a whole-row SDK or MCP update, an import re-run, a save with no change) passes, so the row's other edits are not blocked; the gate does not enforce that stored value while two-factor is off anyway. Clearing the column and writing other columns pass untouched. Two-factor state is read per write from the liveAuthManager, so a settings pass that turns two-factor on admits the next write with no restart.OS_AUTH_TWO_FACTORenv, re-applied at every boot and read by the plugin-list builder. An organization row is data. Turning the plugin on from an organization write would hold only in the process that took the write, would not survive a restart or reach another node (nothing that builds the plugin list reads organization rows), and turning it back off when the last organization clears the column would silently drop every enrolled member's second factor. So the card's fallback is taken; the global switch keeps its existing pairing.resolveUserAuthGatesnow holds a user for MFA only while they can enroll. With two-factor off, an MFA requirement (an organization'srequire_mfa,auth.mfa_requiredunder anOS_AUTH_TWO_FACTOR=falsepin, or a host config withmfaRequiredbut noplugins.twoFactor) yields noMFA_REQUIREDand stamps nomfa_required_at; the manager logs one warning per process naming the remedy. An organization row storedrequire_mfa = truebefore this lands therefore locks nobody out. Enrolled users were never held and still are not.resolveTwoFactorEnabled(envOS_AUTH_TWO_FACTOR, thenplugins.twoFactor, then off) is now the single decision point for the better-auth plugin list,features.twoFactorin/api/v1/auth/config, the gate and the refusal; the two copies of the env read it replaces are gone. It is not re-exported fromindex.ts. The live reading isAuthManager.isTwoFactorEnabled(), a new public method on an exported class, marked@internal(read by the gate and by the guardAuthPluginregisters; not a supported entry point).AuthPluginregisters the guard in thekernel:readyidentity-guard block, right afterregisterApiKeyRestoreGuard. This hunk is far from theemail_password_enabledsettings apply that Authentication settings: turning off email and password with no other sign-in method saves without warning, and everyone is locked out #30202 edits.mfaRequiredwithoutplugins.twoFactor(twoauth-manager.test.tshelpers, the impersonation policy fixture, and twopackages/verifyheld-session pins) now setplugins: { twoFactor: true }, which is the only configuration in which the hold they pin can be complied with. Inheld-session-vendor-admin-doors, the block whose hold only the session's organization declares turns two-factor on in its ownbeforeAll(and puts it back after) instead of inheriting it from the held state that ran before; run alone it now passes 4/4, and without that line its organization write is refused by the guard.held-session-platform-adminalready setOS_AUTH_TWO_FACTOR=true..changeset/30201-org-mfa-needs-enrollment.md(patch,!summary, BREAKING accept-set banner, ADR-0087not-required (no-migration-prescription)marker).Tests
organization-mfa-enrollment-guard.test.ts(new, 29 tests):false,0,'0','false',null,undefinedare off), refusal while off, admission while on, an update refused for a row stored off or with noprevious, re-sending it for a row already on admitted (an insert never is), other writes never judged, the message's facts (the conditional settings remedy included) and no tracker number;AuthPluginregistration:kernel:readyregisters both hooks onsys_organizationabove D2's priority, and the captured handler refuses while two-factor is off and admits afterapplyConfigPatch({ plugins: { twoFactor: true } });SysOrganizationand the D2 guard: the administrator's by-id save is refused and the row keeps its value, admitted once two-factor is on, refused for a system write, a multi-row update and an insert; a row that already requires MFA re-saved with it and another column passes while two-factor is off (the row stored off is still refused); a multi-row update over one row on and one off is refused with nothing written, and over rows all on it lands; other writes pass;require_mfa = true, a member past the grace window,active_organization_idset. Two-factor off:POST /two-factor/enableanswers 404, the session'sauthGateis notMFA_REQUIRED,resolveUserAuthGatesreturns[], the warning fires once and the clock is not restamped. Two-factor on:authGateisMFA_REQUIREDand/two-factor/enableanswers 200 with atotpURI.auth-manager.test.ts: "only a user who can enroll is held" (no hold, no stamp, one warning per manager whose settings remedy is conditional on the env var; anOS_AUTH_TWO_FACTOR=falsepin outranksplugins.twoFactorandfeatures.twoFactorreads false; control with two-factor on), plus the per-org case (no block and no clock while off).9b256807(every negative pin, viascripts/ablation-replace.mjsin wrap mode, each restore proven blob == HEAD; plugin-auth tests import their source, so no build sits between mutation and run):throwdeleted: 5 red (the rule's refusal, the registration pin, the real-engine admin, system and multi-row refusals).expected 'MFA_REQUIRED' not to be 'MFA_REQUIRED'.auth-plugin.tsdeleted: the registration pin red (expected [] to deeply equal [[beforeInsert…],[beforeUpdate…]]).resolveTwoFactorEnabled: the env-pin test red (expected true to be false).f643025e, same method:previoustreated as already on: 3 red (the write was admitted).packages/verify, run alone): itsbeforeAllfails on the guard'sValidationError, 28 skipped.Acceptance notes
domain:uihalf is not here (the UI lane is off duty): the Setup copy on the organization form (who is affected, when the block starts), and a localized enrollment refusal saying what to do. The server's refusal text above is what the form shows until then.OS_AUTH_TWO_FACTOR=falsepin, the settings console still showsauth.mfa_requiredas saved; the gate now does not enforce it (and says so in the log). Before this PR such a deployment held users it could not enroll.mfa_required_atwas stamped before this lands, on a deployment that later turns two-factor on, is held as soon as it is on, because the stamp is older than the grace window. They can enroll at that point, which is the difference from today.Verification
Suites, typecheck, gates and the full dogfood battery ran on the final tree, head
05e6e0bd(the review fixesf643025emerged withmainat8b117c0e; neithermainmerge touched a file inplugin-authorpackages/verify). The first-round ablations ran on9b256807, the review-round ones onf643025e.Suites and typecheck (head 05e6e0b, under scripts/pm/os-verify-lock.sh)
pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: Test Files 221 passed (221), Tests 3909 passed, 10 skipped. VERDICT command-exit 0.pnpm --filter @objectstack/plugin-auth typecheck: VERDICT command-exit 0 (the test-typecheck ratchet holds).pnpm --filter @objectstack/verify exec vitest runonheld-session-impersonation-target,held-session-vendor-admin-doorsandheld-session-platform-admin: Test Files 3 passed, Tests 49 passed. VERDICT command-exit 0. The organization-declared block alone (-t 'organization declares'): 4 passed, 24 skipped.Full dogfood battery (owed by the narrowing arm)
pnpm --filter @objectstack/dogfood teston05e6e0bd, underscripts/pm/os-verify-lock.sh: VERDICT command-exit 0 (held the lock 1182s, shared box); Test Files 419 passed, 3 skipped (422); Tests 3492 passed, 36 skipped (3528); no failure. The one red the first round saw on8c4430f5(permission-set-clone-door.dogfood.test.ts, from #30222'smember_defaultgrant, measured green with that file put back) is gone:mainreverted #30222 (#30262) and this head carries the revert.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon05e6e0bd: 64 derived, each run with its exit code recorded before any pipe. A session restart cut the first pass after 60 (all exit 0); the other 4 were re-run:check:type-check-debtexit 0 under the lock,check:undeclared-dep-importsandcheck:where-matcherexit 0, andcheck:watch-hint-literalexit 1 on apackages/spec/tsup.config.bundled_*.mjstemp file that the concurrent locked build wrote and removed mid-scan (ENOENT), then exit 0 re-run with no build in flight.--ranover the final record: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN, exit 0.pnpm lintexit 0.Human Minimum
packages/specholds no runtime logic (packages/specnot touched).pnpm lintpasses (reading under Verification).main, one pull request.packages/spectouched: n/a.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code