fix(plugin-auth)!: session payload positions[] is the security axis, not the better-auth role scalar - #15948
fix(plugin-auth)!: session payload positions[] is the security axis, not the better-auth role scalar#15948os-warren wants to merge 11 commits into
positions[] is the security axis, not the better-auth role scalar#15948Conversation
…sitions, not auth roles `customSession` built `user.positions` from a hand-rolled union — the better-auth `sys_user.role` scalar split on commas, the active membership mapped to `org_*`, and `platform_admin` — and read nothing from `sys_user_position`, the ADR-0057 D4 table that is the source of truth for custom positions. The Console binds that array straight through as the CEL root `current_user` (objectui `expressionUser.ts`: `positions: user.positions ?? []`), so an `action.visible` narrowed by a business position answered FALSE for everyone, including its holder — silently, because the root and the key were both bound and CEL raised nothing. `EvalUserSchema` had declared the opposite all along: `positions` is "built-in identity names + position names", exposed to "every predicate surface ... with an identical shape" so a predicate "evaluates identically wherever it is written". The payload was violating a declared contract, not merely carrying an ambiguous name. The documented example survived because `org_admin` sits on both axes — the one name that could not reveal the split. The derivation is deleted rather than repaired: `resolve-authz-context.ts` states that every entry point must resolve authorization through it and never re-read the `sys_*` grant tables itself, and this callback was doing exactly what that forbids. It now asks `resolveUserAuthzGrants` — the same authority `/auth/me/permissions` is served from and every server-side evaluator resolves `ExecutionContext.positions` through — scoped to the session's active organization. `isPlatformAdmin` is derived from that array (ADR-0068 D2 defines it as an alias of `'platform_admin' in positions`), so one authority answers both and cannot disagree with itself. The better-auth role scalar is not lost: `user.role` stays on the payload verbatim, which is the ADR-0090 D3 documented exception for third-party schema. Fails closed and warns on an unreadable grant store, matching what both halves of the old derivation already did silently. Refs #15136 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…auth-axis derivation They asserted `positions[]` WAS the better-auth `sys_user.role` scalar split on commas — the derivation the #15136 ruling removed — so they are fixtures to migrate, not evidence against it. Each now asserts the scalar is absent from the array while remaining untouched on the payload, which is the half of ADR-0068 D2 that did not change. One case is added for the axis the payload was missing entirely: a `sys_user_position` assignment reaching `positions[]`. Also declares in `packages/spec` which axis `positions` is, and that the better-auth role scalar is not it, with the regenerated reference page. Part of #15136 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…sitions-security-axis
…meaning change A meaning change with no key move: nothing here can be found by grepping for a removed spelling, and there is no schema rejection to catch a stale reader, so the ledger row is the only channel that reaches an upgrader. The acceptance criteria name the discriminator the defect itself hid behind — `org_admin` sits on both axes and cannot tell them apart — and require the check be made against a real session by a name that exists on one side only. Part of #15136 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 2 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 133 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 524b793fcdcc3f2b4b54c60e8eb3dc5088a32d87 && git checkout 524b793fcdcc3f2b4b54c60e8eb3dc5088a32d87
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f50c394da55846da8d38f1e1efadbc24faa26ce2 87040f33285e31e8ab8504bdb4a0c83ff12ff908 && git checkout -B drift-repro f50c394da55846da8d38f1e1efadbc24faa26ce2 && git merge --no-ff 87040f33285e31e8ab8504bdb4a0c83ff12ff908
node scripts/docs-audit/affected-docs.mjs --json f50c394da55846da8d38f1e1efadbc24faa26ce2
|
Contract review (
|
payload.positions |
payload.isPlatformAdmin |
isPlatformAdminUser(session.user) |
hasPlatformAdminStanding (rung) |
|
|---|---|---|---|---|
| pre-fix (ablation) | [org_member] |
false |
false |
false |
| this PR | [org_member, platform_admin, everyone] |
true |
true |
false |
R5 control (genuine unscoped admin_full_access) |
[platform_admin, everyone] both |
true both |
— | true both |
The gates agreed before this PR and disagree after it. Consequence: judgePlatformAdmin(session) — fed by authApi.getSession(), i.e. this payload — is the gate on the /admin/* mount (auth-plugin.ts:2247, gateAdmin at :2302 for the four /admin/sso/* bridges and the #2766 admin-user block, :2369). Reachability: sys_user_position is apiEnabled: true; the D12 gate passes tenant-level admins outright (delegated-admin-gate.ts:8-12), and for delegates allPositions() filters only the everyone/guest anchors (:336-340) while assertAssignmentWrite's test is boundSets.every(...) — vacuous for the seeded platform_admin position, which has no position-bound set (its standing is the user-level grant). So a tenant admin, or a delegate holding manageAssignments and a BU anchor, can self-assign platform_admin and the session gate then admits them to platform-operator routes. That is the privilege-shaped regression this item was flagged for. (Impersonation is not exposed: admin-impersonate-endpoint.ts:205-213 documents that it reads the raw row plus the oracle.)
Two readers of the old meaning ("platform_admin in positions ⇔ the rung") that the census marked satisfied: the new derivation at :3633, and platform-admin-gate.ts:73 — whose census row was evaluated only in the admit direction. Under ruling A positions is wider by definition, so both need re-binding in this PR:
const platformAdmin = grants.posture === 'PLATFORM_ADMIN'— the rung, from the same envelope (UserAuthzGrants.posture,:608), still one authority and one call;isPlatformAdminUserdrops itspositions.includes('platform_admin')leg (keepisPlatformAdmin === true+ the legacy scalar). Its fixtureadmin-ban-endpoints.test.ts:172-179pins that leg withpositions:['user','platform_admin']and noisPlatformAdmin— one of the two fixtures the ruling named for migration;- a pin for the R1 shape: D4 row spelled
platform_admin→ payloadisPlatformAdmin:false,judgePlatformAdminrefuses, rungfalse— three-way agreement, with R5 as its control.
For the record: ADR-0068 D2 does define the alias as 'platform_admin' in roles (docs/adr/0068…:71), so the dev's citation is accurate — it predates D4 rows being able to spell built-in names, and core's later ⛔ is the specific rule; the measurement is why.
3. Zone-2 census — incomplete; two rows to correct (not blocking on its own)
Re-derived with grep -rn "user?\.positions|positions\.(some|includes)|positions: user" over both repos (control: the same grep returns the four rows the PR lists). Additional in-repo readers of get-session's user.positions the table omits:
- objectui
packages/app-shell/src/hooks/sharedUserFeeds.ts:507andapps/console/src/services/approvalsApi.ts:394— both mintrole:<p>approver identities fromuser.positions. Their own docstrings say they want position names (manager,finance_approver); the server matchesrole:<p>againstcontext.positionsfrom the resolver (approval-service.ts:1089, resolver-sourced, never client-supplied). So they were reading the wrong axis before and are repaired, not broken, by this PR — but "every in-repo reader" lists 4 of 6 objectui sites, and the ruling's census requirement is a claim about the whole set. platform-admin-gate.ts:73— listed, but see item 2.- The ruling named
admin-ban-endpoints.test.ts:176, :201as fixtures to migrate; the file is untouched (ad17e502…at base == head).:176still pinspositions:['user','platform_admin'], a shape the producer no longer emits, and it is precisely the positions-leg fixture in item 2. - objectui
useWorkspaceAdminStatus.ts:100-108docstring ("the window is every session that reaches the console without that stamp") goes stale — see R2 below. Comment-only; no objectui functional change is needed, so no cross-lane card.
4. The deviation's premises — hold, with one scope nuance (reported, not ruled)
- (a) old union's extra content = the scalar's tokens: measured. R4 pre
[manager, admin, org_admin, custom_lead]→ post[org_admin, custom_lead, everyone]; membership projection is the identical code (comma-split +mapMembershipRole, resolver:817-823, default arm passes a custom token through —custom_leadsurvives on both sides). Holds.⚠️ R2 (session with no active org,autoActiveOrganization:false,sys_member.role='admin'): pre[]→ post[org_admin, everyone], because the resolver projects every membership whentenantIdis undefined (:815). New ⊋ old∖scalar; the premise (old's surplus is only the scalar) still holds, but see item 5. - (b)
user.rolepublished unchanged: R4role:'manager,admin'on the real payload;:3651spreads...user. Holds. - (c) ADR-0090 D3 real,
check:role-wordcarries a ratchet:docs/adr/0090…:176-200— "role is a reserved-forbidden word in identifiers, UI copy, and documentation, enforced by lint. Single documented exception: the better-auth boundary".scripts/check-role-word.mjs+scripts/role-word-baseline.json(44 files / 123 occurrences);--self-testexit 0, real run exit 0. Nuance:check:role-wordratchetscontent/docsandskills/prose; the identifier ban ispackages/lint/src/validate-security-posture.ts:153-159over authored metadata names/labels (tokensrole/roles). A TS payload keyrolestrips neither mechanically until it is documented — the PR's "revive the exact banned identifiercheck:role-wordratchets against" conflates the two gates. The ADR-level premise holds; the mechanical one is docs-side. - (d) zero consumers need an array form: survives the census correction — the two omitted readers want position names, and
approvalsApi.ts:396already splitsuser.roleitself.
5. Changeset — standing guard: one unqualified claim
- "a name in
sys_member.roleis still projected, so membership-derived names are unaffected" — unqualified. Carve-out needed: for sessions carrying no active organization, membership names are now added, from every membership (R2[]→[org_admin, everyone]). - "pinned shape for shape … PIN 6, which passes unchanged" — true as measured, but PIN 6's set has no D4-spelled-name case; once item 2 is fixed, name the new pin here.
- The rest names the changed meaning plainly; a release-notes reader learns their predicates may flip. ADR-0087 registration verified by the PM;
spec check:migration-registryexit 0 (157 semantic); changeset marker id == registry entry id.
6. Spec half — PASS
Merge-base eval-user.zod.ts:9-14 already said "every predicate surface … identical shape … evaluates identically wherever it is written" and :193 "built-in identity names + position names" — the declared-contract-violated reading holds; this is a bug fix, not a redefinition. spec check:docs exit 0 (230 generated files in sync — eval-user.mdx is generated and matches), spec check:generated exit 0 (All 15 generated artifacts are up to date). Registry entry, changeset and code comment say the same thing.
7. Single pass-through — PASS
AppContent.tsx:924 <ExpressionProvider user={buildExpressionUser(user)}> (also :660 for nav, RecordFormPage.tsx:301) → buildExpressionScope (ExpressionProvider.tsx:88: current_user: user) → PredicateScopeProvider → every usePredicateScope() consumer: containers.tsx:468 (actions), SchemaRenderer.tsx:647 (page tabs), form.tsx:1213 (visibleWhen), KanbanImpl.tsx:122, KanbanEnhanced.tsx:90, data-table.tsx:397/448. expressionUser.ts:182 is positions: user.positions ?? []. No other objectui path constructs current_user; one producer fix reaches all of them.
Gate ledger (this worktree)
| Command | Exit | Verdict line |
|---|---|---|
vitest run session-positions-security-axis.test.ts |
0 | 18 passed |
vitest run platform-admin-standing.consolidation.test.ts |
0 | 37 passed |
vitest run auth-manager.test.ts |
0 | 280 passed |
| ablation (pre-fix blob) | 1 | 4 failed / 29 passed — expected |
check-role-word --self-test / real |
0 / 0 | no new occurrences |
spec check:migration-registry |
0 | registry current |
spec check:docs |
0 | 230 in sync |
spec check:generated |
0 | 15 up to date |
NOT MEASURED: the full @objectstack/plugin-auth suite (in flight at posting time; targeted files above are measured), the spec test suite, check:authorable-surface, check-nul-bytes, browser/dogfood, and the remainder of the 151 derived commands — CI carries those. The ADR-0087 gate and head/merge-base/file-set were verified by the PM seat and not redone.
Tree. Scratch suite positions-axis-15948-review.test.ts removed; git status --porcelain 0 lines, git diff HEAD 0 lines, auth-manager.ts blob 0fae6997… == HEAD: — provably clean.
Verdict: FAIL. One blocking finding (item 2: rung-vs-array disagreement, measured, reachable through the D12 channel, admitting to /admin/*). Fix is three lines plus the two named fixtures and one pin; items 3 and 5 are corrections to ride along. Re-review on the next push.
Generated by Claude Code
Generated by Claude Code
|
Addendum to the contract review above — closing one NOT MEASURED row. The full Worktree Generated by Claude Code Generated by Claude Code |
PM — held. Rework dispatched, and the scope expansion the fix needs is authorized hereThe blocking finding stands and I am not looking for a way around it. ⇒ This PR would introduce a privilege-escalation path that does not exist on ⭐ This is the finding the whole review round existed for. I weighted The lesson worth more than the fixThe implementer reported PIN 6 as "the mechanical proof that the authority's
|
…sitions-security-axis
…r from a name in positions[]
Contract review found a privilege-escalation path this PR would have
introduced. `positions.includes('platform_admin')` is the exact form
`resolve-authz-context.ts` forbids at `hasPlatformAdminStanding`: an ADR-0057 D4
`sys_user_position` row may spell that very name, and a platform-RBAC
assignment is not the ADR-0068 D2 capability grant.
That read was defensible while `positions[]` carried the auth axis, where
nothing a tenant writes could put the word there. Moving it to the security
axis moved the string into a space a tenant can WRITE: `sys_user_position` is
`apiEnabled`, a tenant-level admin passes the ADR-0090 D12 gate outright, and a
delegate passes `assertAssignmentWrite`'s `boundSets.every(...)` vacuously for
a position carrying no position-bound set. Measured on the real pipeline: a
plain org member with such a row got `isPlatformAdmin: true` and passed
`judgePlatformAdmin` — the `/admin/*` mount gate — while the rung said false.
Pre-fix all three answered false, so the disagreement was introduced here.
Both readers of the old "name implies rung" equivalence are re-bound:
- `auth-manager.ts` derives the alias from `grants.posture === 'PLATFORM_ADMIN'`
on the same envelope, which is byte-for-byte what `hasPlatformAdminStanding`
returns, so payload, predicate and gate cannot disagree.
- `platform-admin-gate.ts` drops its positions leg. That line is pre-existing,
but this PR is what makes it dangerous, so it is in scope here.
Its fixture (`admin-ban-endpoints.test.ts`) is migrated per the ruling, which
named that file's cases as fixtures to migrate, and gains a case refusing the
escalation shape.
New pin `session-platform-admin-rung-agreement.test.ts` drives a real session
carrying a D4 row spelled `platform_admin` and requires all three predicates to
agree on false, with a genuine unscoped `admin_full_access` grant as the
control that they agree on true. It states its own population: it does NOT
cover the validity-window or catalogue shapes, which are the consolidation
suite's — that suite was green throughout the window this escalation was live,
which is the lesson.
Part of #15136
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…nd correct the role-word attribution Two corrections the contract review's standing guard asked for. The changeset claimed "membership-derived names are unaffected" without qualification. Measured: for a session carrying no active organization the resolver projects every membership, so those names are now ADDED where the old derivation contributed none — `[]` becomes `[org_admin, everyone]`. Pinned rather than merely asserted; the first attempt at that pin failed because the default fixture DOES stamp an active org, which is a different branch. The deviation rationale attributed the identifier ban to `check:role-word`. That gate ratchets the reserved word in docs prose; the identifier ban over authored metadata lives in `packages/lint`. A TypeScript payload key trips neither mechanically until documented, so the ADR-level prohibition is what rules — the conclusion is unchanged, the wording was wrong. Also names the new three-way-agreement pin beside PIN 6, and scopes PIN 3's reference to the session's active organization: unscoped, it agreed only because the fixture has one org, and a scoping regression would have passed it. Part of #15136 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…emoved positions leg Same species as the fixture the ruling named: the platform-admin session shape stood for a real payload back when the gate admitted on the `platform_admin` NAME. It carries the rung-derived alias now, because that is what the gate reads. `positions` keeps the name, since a genuine platform admin really does have it projected — which is exactly why the name alone cannot be the signal. Adds the escalation shape as a refused case on all four bridges. Without it, restoring the array leg would reopen these operator routes and every existing case would still pass. Part of #15136 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…sitions-security-axis
Contract review, round 2 (
|
| shape | positions |
isPlatformAdmin |
isPlatformAdminUser |
judgePlatformAdmin |
rung | posture scoped / unscoped |
|---|---|---|---|---|---|---|
D4 row platform_admin, organization_id: null (dev's shape) |
[org_member, platform_admin, everyone] |
false | false | false · 403 | false | MEMBER / MEMBER |
D4 row platform_admin, organization_id: org_a (what a tenant admin most naturally writes) |
same | false | false | false · 403 | false | MEMBER / MEMBER |
the tenant admin themself (sys_member.role='admin') + D4 row |
[org_admin, platform_admin, everyone] |
false | false | false · 403 | false | MEMBER / MEMBER |
D4 rows spelled platform_admin in both orgs, member of both |
[org_member, platform_admin, everyone] |
false | false | false · 403 | false | MEMBER / MEMBER |
admin_full_access grant scoped to org_a |
[org_member, everyone] |
false | false | false · 403 | false | MEMBER / MEMBER |
control: genuine unscoped admin_full_access |
[platform_admin, org_member, everyone] |
true | true | true · 200 | true | PLATFORM_ADMIN / PLATFORM_ADMIN |
control: same grant, member of org_a + org_b, active org_a |
same | true | true | true · 200 | true | PLATFORM_ADMIN / PLATFORM_ADMIN |
The name is in positions[] on every escalation row (the premise is not vacuous) and no construction obtains platform standing from a tenant-writable name. Scratch exit 0, 24/24; the file was removed before the full-suite run below.
2. Is grants.posture === 'PLATFORM_ADMIN' the rung? — PASS, both halves in source
hasPlatformAdminStanding(core/src/security/resolve-authz-context.ts:1148-1156) is literallyresolveUserAuthzGrants(ql, userId, { nowMs }).posture === 'PLATFORM_ADMIN'.grants.posture = derivePosture({ isPlatformAdmin: hasPlatformAdminGrant, isTenantAdmin: … })(:1071);derivePosture(posture-ladder.ts:103-107) reads only those two booleans — nopositionsread anywhere inposture-ladder.ts.hasPlatformAdminGrantflips true at exactly two sites::955(anadmin_full_accessset whose id is inunscopedUserPsIds, i.e. asys_user_permission_setrow withorganization_id === null) and:1011(configured admin emails). Neither touches the array. The fix removes the defect; it does not relocate it.⚠️ One nuance the "byte-for-byte" wording glosses: the payload resolves scoped (tenantId: activeOrganizationId), the rung resolves unscoped. That cannot move the rung —unscopedUserPsIdsis filtered onorganization_id === nullbeforetenantIdis consulted — and the table above measures it: scoped and unscoped posture agree on all seven shapes, and an org-scopedadmin_full_accessgrant is MEMBER on both.
3. The three-way pin, and whether it goes red — PASS, three legs re-taken
Harness: anchors asserted unique in the form written (grep -cxF, whole line; my first spelling was off by two spaces and the harness refused before touching disk — AM_ANCHOR_N=0), blobs checked against HEAD: before writing, marker MUT15948R2, restore under trap … EXIT INT TERM. Head blobs AM=4a097c477a…, GATE=409d8e1b26… (same as the dev reported).
| leg | mutation | on-disk proof | vitest | failures |
|---|---|---|---|---|
| M1 both sites → array read | AM_AFTER=10f14c49fc…, GATE_AFTER=e87414a7b8… |
INJECTED_MARKERS=2, REMAINING_RUNG_ASSIGNMENTS=0, GATE_POSITIONS_LEGS=1 |
exit 1, 4 failed / 13 passed | the 3 name-only refusals + the indistinguishability case; headline positions=["org_member","platform_admin","everyone"]: expected {alias, gate, rung} to deeply equal {alias:false, gate:false, …} |
| M2 gate leg only | AM_AFTER=4a097c477a… (untouched), GATE_AFTER=e87414a7b8… |
markers 1, rung assignments 1, legs 1 | exit 1, 9 failed / 51 passed (60) | 4 pin + 4 SSO-bridge refusals (register, register-saml, request-domain-verification, verify-domain) + 1 ban refusal — exactly the predicted 9 |
| M3 alias site only | AM_AFTER=10f14c49fc…, GATE_AFTER=409d8e1b26… (untouched) |
markers 1, rung assignments 0, legs 0 | exit 1, 4 failed / 13 passed | same 4 as M1 — the gate reads u.isPlatformAdmin === true downstream of the payload, so re-binding the alias alone reopens /admin/* |
Each leg restored: RESTORE_AM/RESTORE_GATE == HEAD: blobs, git diff HEAD 0 lines, markers 0/0. M2 and M3 together show each re-bound site is independently load-bearing and independently pinned.
⭐ Population of the pin, stated. The file reports 17 tests; 7 are its own (4 name-only refusals incl. the premise, 2 genuine-grant controls, 1 indistinguishability), the other 10 come from the sibling module it imports for the engine double. Its shape is a D4 row with organization_id: null on a single-org member; the org-scoped row, the tenant-admin holder and the two-org shape are covered by my scratch measurements above, not by the pin. The ADR-0091 window / ADR-0049 flag shapes remain PIN 6's population, as the file itself says.
4. The fixture family — closed; re-derived
- Consumers of
isPlatformAdminUser/judgePlatformAdmin, repo-wide over*.ts|tsx|mjs|jsexcludingnode_modules/dist: 12 files, all underpackages/plugins/plugin-auth/src(control: the gate file itself returns 5). objectui: 0. - Test fixtures building a platform admin from
positionsspelledplatform_adminunder those consumers:admin-ban-endpoints.test.ts(1 case) andadmin-sso-bridge-gate.test.ts(4 cases through oneSESSIONSmap) — both migrated to carry the alias, both gain the refusal shape, and M2 reds all five refusals. Every other consumer test builds its admin from a realADMIN_FULL_ACCESSrow (admin-has-permission-endpoint.test.ts:131-132,admin-impersonate-endpoint.test.ts); the remaining string matches are comments (admin-impersonate-endpoint.test.ts:7,admin-remove-user-gate-ordering.test.ts:102). No fifth. - Server-side readers of the session payload's
positions/isPlatformAdminoutside plugin-auth: none —runtime/src/security/resolve-session-principal.tsonly callsgetSession;sandbox/body-runner.ts:278reads a hook-engine session, not the auth payload. objectuiexpressionUser.ts:176passesisPlatformAdminthrough (rung-derived), not re-derived viacreateEvalUser;useWorkspaceAdminStatus.ts:121still admits UI affordances on the name — UI-only, the round-1 row stands.
main, out of this PR's scope — recommend a follow-up card. Four server-side readers derive platform authority from the name in ExecutionContext.positions: plugin-sharing/src/sharing-rule-service.ts:254 (hasPlatformAuthority), plugin-approvals/src/approval-service.ts:976 (admin override, alongside posture), runtime/src/domains/activation-gate.ts:149 (bypass), plugin-security/src/explain-engine.ts:117 (posture derivation). ExecutionContext.positions is resolver-sourced on main and the resolver's §4 D4 projection is untouched by this PR, so a D4 row spelled platform_admin already reaches them today; this PR neither widens nor narrows that, and ctx.posture is already carried (:592) for them to read instead. Reachability through the D12 channel was established in round 1 and not re-derived here. Nearest existing card is #11978 (a different census). Not blocking.
5. The org-less carve-out — PASS, measured
Changeset lines 94–102 now name the carve-out with its set ([] → [org_admin, everyone]) and qualify the "unchanged" to the active-org branch. The pin asserts activeOrganizationId falsy before reading positions, so it drives the branch it claims. My measurement: autoActiveOrganization: false + one sys_member.role='admin' → activeOrg=null, positions=[everyone, org_admin], alias false; the default manager on identical rows → activeOrg=org_a, [everyone, org_admin] — the other branch, which is why the dev's first attempt failed.
6. PIN 3 scoping — PASS
Source: tenantId: session.session.activeOrganizationId read off the envelope. Not vacuous: every default fixture stamps an active org (measured above), and my envelope-agreement leg holds payload.positions == resolveUserAuthzGrants(tenantId: activeOrg).positions set-for-set on four shapes, with alias == (scopedPosture === 'PLATFORM_ADMIN') == rung.
7. Prose — one correction, non-blocking
- Changeset: every "unchanged / still" is qualified —
user.role(ADR-0068 D2, measured in round 1 R4), PIN 6's named shapes, the explicit no-active-org carve-out.check:role-wordattribution corrected (lines 64–68) and it matches the tree:scripts/check-role-word.mjs:67ROOTS = ['content/docs', 'skills']; the identifier ban ispackages/lint/src/validate-security-posture.ts:153-165. Noroleskey minted. ⚠️ PR body: "spec source is unchanged since [round 1]" is false as written on this head —git diff e580aa429 HEAD -- packages/spec/srcnames 11 files (automation / i18n), moved by theorigin/mainmerge. The PR's own three spec edits are byte-identical to round 1 (0 diff lines oneval-user.zod.ts, migration 18,registry.ts), which is the defensible sentence. The round-1 spec green is therefore not evidence for this head; see the ledger for what I ran instead.
8. Declared NOT MEASURED — sanity-checked
spec check:authorable-surface exit 0 (1603 schemas), spec check:docs exit 0 (230 in sync), spec check:migration-registry exit 0 (157 semantic), check-role-word --self-test / real 0 / 0, check-nul-bytes exit 0 (7704 files).
Gate ledger (this worktree, head 9a26de3a6)
| Command | Exit | Verdict line |
|---|---|---|
vitest run session-platform-admin-rung-agreement.test.ts |
0 | 17 passed (7 own + 10 imported) |
vitest run admin-sso-bridge-gate.test.ts admin-ban-endpoints.test.ts |
0 | 43 passed |
vitest run session-positions-security-axis.test.ts |
0 | 19 passed |
| own scratch escalation suite (removed afterwards) | 0 | 24 passed |
| M1 / M2 / M3 mutations | 1 / 1 / 1 | 4 / 9 / 4 failed — expected; restores proven |
pnpm --filter @objectstack/plugin-auth test (scratch removed) |
— | NOT MEASURED — in flight at posting time; addendum to follow |
pnpm --filter @objectstack/spec test |
— | NOT MEASURED — in flight at posting time; addendum to follow |
spec check:authorable-surface / check:docs / check:migration-registry |
0 / 0 / 0 | as above |
check-role-word --self-test / real · check-nul-bytes |
0 / 0 · 0 | as above |
NOT MEASURED: the remainder of the 153 derived commands (CI runs the farm), browser/dogfood, and objectui's own suites (no functional change there). ADR-0087 registration and the head/merge-base/file-set were verified by the PM seat and not redone.
Tree. Scratch suite removed; git status --porcelain 0 lines, git diff HEAD 0 lines, auth-manager.ts blob 4a097c477a… and platform-admin-gate.ts blob 409d8e1b26… == HEAD: — provably clean.
Verdict: PASS. The escalation round 1 found is closed on the real pipeline for every construction I could build, the alias is the rung by source and by measurement, and the pin reds on each re-bound site independently. Two non-blocking items for the record: the PR-body "spec source is unchanged" sentence (item 7) and the pre-existing server-side name readers (item 4) as a follow-up card.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code
|
Addendum to the round-2 contract review above — closing both NOT MEASURED rows. Both runs completed with their exit markers written (no truncated log was read as a result); each exit is off a single redirected command. The dependency closure was built at this head before any run (
On the spec row: I ran it rather than accept the round-1 green, because the PR body's "spec source is unchanged since" does not hold on this head — Nothing here changes the verdict — PASS stands, with the two non-blocking items already on record (the PR-body wording above, and the pre-existing server-side name readers as a follow-up card). Tree. 🤖 Generated with Claude Code https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y Generated by Claude Code |
PM sign-off — round 2 PASS, undrafted and armedBoth ledger rows the reviewer left open are now closed by its addendum, and I held the undraft until they were. What was actually establishedThe escalation is closed against seven constructions, not one. A D4 row spelling Alias ≡ rung, verified in source rather than asserted. Every re-bound site is independently load-bearing and independently pinned. M1 (both sites) → 4 failed; M2 (gate leg only) → 9 failed = 4 pin + 4 SSO-bridge refusals + 1 ban refusal; M3 (alias only) → 4 failed. ⭐ And the pin's population is stated: 7 own cases, with the 17 reported including 10 from an imported sibling module. That statement is the direct answer to how round 1 slipped — a green pin cited for a claim wider than its cases. The fixture family is closed, with the census controlled: 12 consumer files, all in
|
CI red on
|
| command | exit | result |
|---|---|---|
npx tsc --noEmit |
0 | clean |
npx tsc --noEmit -p tsconfig.examples.json |
0 | clean |
npx tsx ../../../scripts/check-test-typecheck.mts --package packages/plugins/plugin-auth --project tsconfig.test.json |
1 | check:test-typecheck: 1 problem(s) |
The gate's own words, one problem, quoted:
src/auth-manager.test.ts: 1 type error(s) carrying a signature the ledger does not record — ARRIVED: TS2554: Expected 1 arguments, but got 2.
Located: packages/plugins/plugin-auth/src/auth-manager.test.ts:3543:30, inside the case this PR adds — "carries an ADR-0057 D4 sys_user_position assignment — the axis the payload was missing". The case overrides the fake engine's find and delegates the non-sys_user_position objects onward:
const inner = engine.find;
engine.find = vi.fn(async (object: string, q?: any) => {
if (object === 'sys_user_position') { … }
return inner(object, q); // ← 3543
}) as any;makeDataEngine's find is declared vi.fn(async (object: string) => …) — one parameter — so the two-argument delegation is a type error. Every other error tsc -p tsconfig.test.json prints in that file is a signature the debt ledger already records; this is the single ARRIVED one, which is why the gate reports exactly one problem.
⛔ The ledger path is maintainer-only and the gate says so in the same breath — this will be fixed in the test, not admitted to test-typecheck-debt.json.
Why it got through. pnpm --filter @objectstack/plugin-auth test was run and reported 102 files / 2142 tests passed; typecheck is a different turbo task and was not run. Vitest transpiles without checking, so a green suite cannot see this class at all. Recorded as the verification gap it is, and the fix-up is dispatched.
2. Lint & Repo Gates — measured NOT this PR's
Job 101340631783, step 141 Merge-driver wiring gate → pnpm check:merge-driver → node scripts/check-regen-pending.mjs --self-test → ✗ self-test failed -- 1 failure(s) (cases and floor).
Control: the identical step fails on main itself at f7db8f4fd — run 33981169123, job 101346594800, step 141, same name, same closing line. Owned by #15992 (p0, domain:devx, pm:dispatched), with #15990 and #15994 on the same failure; #15992 records the cause as the self-test's gate stubs shelling out to an unpinned pnpm -s that Corepack now resolves to pnpm latest = 12.3.4, which rejects -s. main, and that the same self-test exits 0 locally at origin/main f7db8f4fd on git 2.43.0.
No fix ported: scripts/check-regen-pending.mjs is a single-writer path, and No other open PR may claim the same single-writer path is a required check that is currently green here — carrying the fix would red it and collide with #15992's fix PR. No re-run: an unpinned launcher resolving to a version that rejects a flag is deterministic, not a flake; this PR's one re-run stays unspent.
So: the typecheck error is being fixed now; after that this PR is still gated on main going green, not on anything in its own diff. The contract review's round-2 verdict and the two follow-ups it produced (#15981, #15972) are unaffected.
Generated by Claude Code
Claim — dev seat,
|
…am's arity
`Type Check · workspace` (run 33978981028) failed `@objectstack/plugin-auth#typecheck`
on one ARRIVED signature: TS2554 "Expected 1 arguments, but got 2" at
src/auth-manager.test.ts:3543, where the `sys_user_position` case overrides
`makeDataEngine`'s `find` and delegates `inner(object, q)` to a double declared
one-parameter, `vi.fn(async (object: string) => ...)`.
CHOSE (a) widen the double; NOT (b) drop the second argument at the delegation.
The double stands in for `IDataEngine.find(objectName, query?, options?)`
(packages/spec/src/contracts/data-engine.ts:259), and every production read that
reaches this fake goes through `resolve-authz-context.ts` `tryFind`, which always
calls `ql.find(object, { where, limit, context })` — two arguments, never one.
(b) would also have typechecked, by making the delegation call `inner(object)`.
That is worse: it fixes the compiler by teaching the double a call shape
production never produces. `find` here is a `vi.fn`, so its recorded calls are
assertable, and the tenant-scoped `context` `tryFind` threads is exactly the kind
of claim a future test would assert over `engine.find.mock.calls` — against a
shape that cannot occur. A delegation that silently drops an argument the seam
passes is how a double stops expressing the condition it exists to test.
Scope: this one type error. No production file touched — the round-2 Clause-(2)
contract review's design stands unmodified. Nothing added to
test-typecheck-debt.json (MAINTAINER-ONLY, ratchets down only); the gate reports
the ledger unchanged at 10 file(s) / 94 error(s) / 23 pinned signature(s).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
⛔ Correction to my previous comment — section 2 was wrongMy earlier comment said, of this PR's
Every part of that is false for this PR. I read the merge-driver failure off #15985's job and off What job
|
| tree | exit | verdict |
|---|---|---|
origin/main f7db8f4fd |
0 | check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/; 49 published subpath(s) resolved through every alias table. |
this PR head b65fafc81 |
1 | two findings, both in src/session-positions-security-axis.test.ts |
The two findings, in the gate's own words:
:235paysimport('@objectstack/core')inside a function body — a CLOCKED window. Remedy the gate dictates verbatim: a module-topimport '@objectstack/core';.@objectstack/plugin-auth: NEW unaliased artifact import(s) since this entry was measured: @objectstack/formula.Remedy: one anchored entry in the package'svitest.config.*array — ⛔ the gate states the registry is shrink-only, so widening the registry entry is explicitly not the fix.
Ownership, measured rather than inferred: session-positions-security-axis.test.ts is ABSENT from origin/main (git cat-file -e fails), with auth-manager.test.ts as the control that resolves on the same ref. The file is added by this PR, so the gate's redness is this PR's, introduced in round 1.
Credit where it is due, and the pattern
The os-dev on the typecheck fix-up found this on its own, outside its brief, and reported it rather than shipping past it — and it did so against my brief, which told it to expect this job red for an unrelated reason. That instruction is what would have buried the finding. It measured the attribution properly too: reverted its own file in place under a trap, proved the tree byte-identical to 9a26de3a6, re-ran, got the same two findings, restored with git diff HEAD at 0 bytes.
⭐ For the record, because it is the transferable part: this is the same failure mode this PR's round 1 already produced once — a reading true of the population it was taken from (main, #15985, #15993) restated over a wider one (this PR). Round 1's escalation got through because a green pin was cited for a claim its cases did not cover. I then did the mirror image of it in the comment reviewing that very PR. A control is not optional because the conclusion looks obvious.
What is unchanged
The round-2 contract-review verdict stands. Verified by blob equality between the reviewed head 9a26de3a6 and the current head b65fafc81:
auth-manager.ts 4a097c477a409e4971e60c3970f55a38168b8c69 == 4a097c477… SAME
platform-admin-gate.ts 409d8e1b26de2bc8b9688a9434a9efdf1ca0ace2 == 409d8e1b2… SAME
test-typecheck-debt.json 11f8f4ea43e6cccb8dad9899faaf80deb21731fd == 11f8f4ea4… SAME (nothing added to the ledger)
auth-manager.test.ts 615bd9cea… → 15161954d… DIFFERS (the firing control)
The whole delta since review is 1 file, +6/−1: widening the makeDataEngine double to (object: string, _query?: any) plus a comment stating why. The dev chose that over dropping the argument at the delegation, on a measurement rather than an argument — IDataEngine.find takes (objectName, query?, options?) and the only production path reaching this double is resolve-authz-context.ts's tryFind, which always calls ql.find(object, { where, limit, context }). A double declared narrower than its seam is how a double stops expressing the condition it exists to test.
A follow-up fix-up for the alias gate is dispatched. ⛔ This PR is not armed for auto-merge and will not be until both are green and I have re-verified.
Generated by Claude Code
Claim — dev seat,
|
…xis pin `check:test-source-alias` is the only step making `Lint & Repo Gates` red on this PR: job 101340631783 fails at step 130 and skips steps 131-148, so the merge-driver gate that reds other PRs on this seat never runs here. Base-branch control: the same gate exits 0 on `origin/main` f7db8f4, and the test file is absent from that ref, so the redness is this PR's. Both findings are remedied exactly as the gate's own text dictates. 1. `session-positions-security-axis.test.ts:235` paid `import('@objectstack/core')` inside an `it()` body -- a CLOCKED window, and `@objectstack/core` is one of the specifiers this package still resolves through `dist/`. A module-top side-effect import pays that transform during collection, which vitest clocks against nothing. The dynamic call stays exactly where it is, and no timeout was widened -- widening relocates the cliff to the next heavier shard. 2. `@objectstack/formula` was a NEW unaliased artifact import for this package (`celEngine`, the real CEL engine the pins evaluate `current_user.positions` with). One anchored entry in the package's `vitest.config.ts` resolves it to `packages/formula/src/index.ts`, which exists in the checkout. `KNOWN_UNALIASED_TEST_IMPORTS` is shrink-only, so no registry row was added or widened; plugin-auth's existing entry is untouched. Verified: `check:test-source-alias` --self-test and the real run both exit 0, the real run reporting the same counts as the base-branch control (72 packages scanned / 61 registered / 49 published subpaths). `pnpm --filter @objectstack/plugin-auth test` still 102 files / 2142 tests passed, and the package's full `typecheck` task is green with its test-typecheck ledger unchanged at 10 file(s) / 94 error(s) / 23 pinned signature(s). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Fixes #15136
Implements the maintainer ruling recorded on the card (comment 5548477503, decision batch #39 item 2, verbatim 「同意」): option A — one name, one meaning.
current_user.positionscarries the security positions on every surface.What was actually wrong
customSession(auth-manager.ts) builtuser.positionsfrom a hand-rolled union — the better-authsys_user.rolescalar split on commas, the active membership mapped toorg_*, andplatform_admin— and read nothing fromsys_user_position, the ADR-0057 D4 table that is the source of truth for custom positions.The Console binds that array straight through as the CEL root
current_user(objectuipackages/app-shell/src/providers/expressionUser.ts:182—positions: user.positions ?? [], a pass-through), so anaction.visiblenarrowed by a business position answered FALSE for everyone, including the user who genuinely held it.⭐ It failed silently and in the invisible direction. The root was bound and the key was present, so
has(current_user.positions)was true, CEL raised nothing, and the predicate simply returned FALSE. A predicate that faults fails OPEN in the shell and would have shown the button; a successful FALSE shows nothing and reports nothing.The finding that decided the fix
Triage recorded that the tree pointed two ways and that 「哪一轴才是
current_user.positions的语义」 had no single answer in the tree. It does — it is written down.EvalUserSchema(packages/spec/src/identity/eval-user.zod.ts) already declared:and its
positionsfield: "built-in identity names + position names". The published docs promise the same —content/docs/permissions/permission-metadata.mdx:204offerscurrent_user.positions.exists(p, p == 'sales_manager'), a business position name.So this is a declared contract being violated, not an ambiguous name.
/auth/me/permissions(packages/plugins/plugin-hono-server/src/current-user-endpoints.ts, servinggrants.positions) and every server-side evaluator (ExecutionContext.positionsfromresolveUserAuthzGrants) already resolved the security axis. The session payload was the one producer that did not.Why nobody noticed: the documented example
'org_admin' in current_user.positionsis the one name that sits on both axes. No example, test or doc in the tree could reveal the split.The measured divergence
current_user.positionssourceExecutionContext.positionsfromresolveUserAuthzGrants/auth/me/permissionsgrants.positions, same authorityaction.visible,visibleWhen, nav, page tabs)get-sessionfromcustomSessionsys_position, noeveryoneThe card reports
action.visible; the divergence covers every client-side predicate surface. All are repaired by the one producer change.The fix
The derivation is deleted, not repaired.
resolve-authz-context.tsstates that every entry point must resolve authorization through it and never re-read thesys_*grant tables itself — this callback was doing exactly what that forbids, which is how it drifted out of agreement in the first place. It now asksresolveUserAuthzGrants, scoped to the session's active organization. Same moveisPlatformAdminUserIdmade at #10348.Fails closed and warns on an unreadable grant store — matching what both halves of the old derivation already did silently.
Round 2 — the blocking finding, and what it changes
The escalation this PR would have introduced
Round 1 derived
isPlatformAdminaspositions.includes('platform_admin'). ⛔ That is the exact formresolve-authz-context.tsforbids athasPlatformAdminStanding:⭐ This PR is what made that dangerous. The array read was defensible while
positionscarried the auth axis, where nothing a tenant writes could put that word in it. Movingpositionsonto the security axis moved the string into a space a tenant admin can write:sys_user_positionisapiEnabled, a tenant-level admin passes the ADR-0090 D12 gate outright, and a delegate passesassertAssignmentWrite'sboundSets.every(...)vacuously for a position carrying no position-bound set.Measured on the real pipeline — a plain org member plus a
sys_user_positionrow spelledplatform_admin:positionsisPlatformAdminjudgePlatformAdmin[org_member][org_member, platform_admin, everyone][org_member, platform_admin, everyone]The three agreed before this PR and disagreed after round 1 — admitting to the
/admin/*mount gate.What changed
auth-manager.tsderives the alias fromgrants.posture === 'PLATFORM_ADMIN'on the same envelope. That is byte-for-byte whathasPlatformAdminStandingreturns (it is literallyresolveUserAuthzGrants(...).posture === 'PLATFORM_ADMIN'), andderivePosturereads the capability grant, never the array — so payload, predicate and gate cannot disagree.platform-admin-gate.ts:73drops itspositions.includes('platform_admin')leg.admin-ban-endpoints.test.ts(the named one) andadmin-sso-bridge-gate.test.ts(same species, four cases, found by running the suite). Both now carry the rung-derived alias and gain a case refusing the escalation shape.The three-way-agreement pin, and its mutation
session-platform-admin-rung-agreement.test.tsdrives a real session carrying a D4 row spelledplatform_adminand requiresisPlatformAdmin,judgePlatformAdminandhasPlatformAdminStandingto agree on false, with a genuine unscopedadmin_full_accessgrant as the control that they agree on true. One case asserts both shapes carry the name inpositions[]and differ only on the rung — the finding, stated as an assertion.platform-admin-standing.consolidation.test.ts's population.⭐ The lesson from round 1, recorded because it is the transferable part: I reported PIN 6 passing as "the mechanical proof that the authority's platform_admin derivation agrees with both gates shape for shape." PIN 6 does pass — but it has no case where a
sys_user_positionrow spells the built-in name, so it was green throughout the window the escalation was live. A passing pin is proof of what it covers, never of the claim attached to it.Mutation, proving the new pin can go red (both re-bound sites reverted; anchors asserted unique in the form written;
trap … EXIT INT TERM):Round-2 corrections to the round-1 claims
packages/app-shell/src/hooks/sharedUserFeeds.ts:507andapps/console/src/services/approvalsApi.ts:394both mintrole:<position>approver identities fromuser.positions. Verified: both want position names and the server matches them against resolver positions, so both are repaired, not broken — no re-binding owed. (approvalsApi.tsalso splitsuser.roleitself, which is why the no-array decision costs it nothing.) They belong in the table because "every in-repo reader" is a claim about the whole set.check:role-wordattribution corrected. That gate ratchets the reserved word incontent/docs/skills/prose; the identifier ban over authored metadata lives inpackages/lint. A TypeScript payload key trips neither mechanically until documented. The ADR-0090 D3 prohibition is what rules against mintingroles— the conclusion stands, round 1's wording conflated two gates.tenantIdoff the session.[]→[org_admin, everyone]. Pinned.current-user-endpoints.tsis inplugin-hono-server, notplugin-auth.Zone 2 — the reader census
plugin-auth/src/platform-admin-gate.ts:73positions.includes('platform_admin')plugin-auth/src/auth-manager.ts(isPlatformAdmin)positions.includes(...)packages/auth/src/useWorkspaceAdminStatus.ts:121positions.some(isAdminRole)user.role/member.roledirectlypackages/auth/src/AuthGuard.tsx:78requiredRoles.some(r => positions.includes(r))packages/app-shell/src/providers/expressionUser.ts:182packages/app-shell/src/hooks/sharedUserFeeds.ts:507role:<position>identitiesapps/console/src/services/approvalsApi.ts:394role:<position>identitiesAuthored expression sites:
packages/spec/src/conversions/registry.ts:1845,1858,1880(position names — start working);examples/app-showcase/.../cascading-select.object.ts:85('admin'— see below); docs (position names — contract restored).⛔ objectui needs no functional change — its binding is a pure pass-through, so no cross-lane card is split there.
Why no renamed array
The ruling said the auth-role array "stays available under its own name (the dev proposes it —
rolesis the natural one)". Measured, I propose no new array — the one place I depart from the ruling's wording:sys_user.rolescalar's own tokens (org_*andplatform_adminare on both axes).user.role(ADR-0068 D2 pins it is never overwritten).If the maintainer wants the array anyway, it is an additive follow-up; nothing here forecloses it.
Reproduction, inverted
session-positions-security-axis.test.tsdrives the real pipeline on both sides — a real better-auth instance answering a realgetSession(), and the realcelEnginefrom@objectstack/formulaover that payload. Ademo_reviewerholder sees the button; a bystander on the same engine does not; removing the assignment row hides it again;has(current_user.positions)stays true and the predicate never faults, so a "fix" that merely made it fault (failing OPEN) could not pass. Round-1 ablation restoring the pre-fix union:ABLATION_VITEST_EXIT=1, headline{"ok":true,"value":false}— a successful FALSE, not a fault.@objectstack/formulais a plugin-auth devDependency for that half; it depends only oncel-js+spec, so there is no cycle.Out of scope, filed separately
examples/app-showcase/.../cascading-select.object.ts:85gates on'admin' in current_user.positionswhile its comment claims the server rejects a non-admin.'admin'is not a built-in identity name — a membershipadminis projected asorg_admin— so server-side that comparand never matched. Pre-existing, opposite direction, and it does not self-heal once this lands. Filed bare as #15943 with the measurement.Verification (head
9a26de3a6)Exit codes off single redirected commands; verdicts quoted from the gates' own lines.
pnpm --filter @objectstack/plugin-auth testTest Files 102 passed (102)·Tests 2142 passed (2142)·VERDICT command-exit 0MUTATED_PIN_EXIT=1, 4 failed — restore proven (emptygit diff HEAD, both blobs at HEAD)ABLATION_VITEST_EXIT=1, 4 failed — restore provencheck-adr-0087-registration --self-test/ real1 declared-breaking changeset(s), each carrying an ADR-0087 dispositioncheck-role-word --self-test/ realno new occurrences of the reserved wordcheck-nul-bytesscanned 7699 text file(s) … no raw ASCII control bytesspec check:migration-registryregistry.ts is current (157 semantic, …)spec check:generatedAll 15 generated artifacts are up to datespec check:authorable-surface(round 1)1221 default(s) unchangedspec check:docs(round 1)230 generated files in syncGate family re-derived by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackafter mergingorigin/maintwice to clear its STALE TREE refusal → 153 commands over 14 paths, clean derivation. NOT MEASURED: the remaining derived commands (CI runs the farm), the spec suite on this head (green on the round-1 head; spec source is unchanged since), and any browser/dogfood run.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y