Skip to content

fix(plugin-auth)!: session payload positions[] is the security axis, not the better-auth role scalar - #15948

Merged
os-warren merged 12 commits into
mainfrom
claude/issue-15136-positions-security-axis
Sep 5, 2026
Merged

fix(plugin-auth)!: session payload positions[] is the security axis, not the better-auth role scalar#15948
os-warren merged 12 commits into
mainfrom
claude/issue-15136-positions-security-axis

Conversation

@os-warren

@os-warren os-warren commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

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.positions carries the security positions on every surface.

Round 2 — contract review returned FAIL on one blocking finding (a privilege-escalation path this PR would have introduced). Fixed, with the escalation pinned. See Round 2 below; the head is 9a26de3a6.

What was actually wrong

customSession (auth-manager.ts) 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 packages/app-shell/src/providers/expressionUser.ts:182positions: user.positions ?? [], a pass-through), so an action.visible narrowed 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:

the signed-in user exposed to every predicate surface (server formula, server RLS, client UI gates) … with an identical shape … therefore evaluates identically wherever it is written

and its positions field: "built-in identity names + position names". The published docs promise the same — content/docs/permissions/permission-metadata.mdx:204 offers current_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, serving grants.positions) and every server-side evaluator (ExecutionContext.positions from resolveUserAuthzGrants) 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.positions is the one name that sits on both axes. No example, test or doc in the tree could reveal the split.

The measured divergence

Evaluator surface current_user.positions source Content
Server formula / RLS / action body ExecutionContext.positions from resolveUserAuthzGrants security positions
/auth/me/permissions grants.positions, same authority security positions
Console UI (action.visible, visibleWhen, nav, page tabs) get-session from customSession auth roles — no sys_position, no everyone

The 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.ts states that every entry point must resolve authorization through it and never re-read the sys_* 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 asks resolveUserAuthzGrants, scoped to the session's active organization. Same move isPlatformAdminUserId made 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 isPlatformAdmin as positions.includes('platform_admin'). ⛔ That is the exact form resolve-authz-context.ts forbids at hasPlatformAdminStanding:

Read the RUNG — never positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN). The positions list is wider on purpose: an ADR-0057 D4 sys_user_position row may spell that very name, and a platform-RBAC assignment is not the D2 capability grant.

This PR is what made that dangerous. The array read was defensible while positions carried the auth axis, where nothing a tenant writes could put that word in it. Moving positions onto the security axis moved the string into a space a tenant admin 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 plus a sys_user_position row spelled platform_admin:

positions isPlatformAdmin judgePlatformAdmin rung
pre-fix [org_member] false false false
round 1 [org_member, platform_admin, everyone] true true false
round 2 (this) [org_member, platform_admin, everyone] false false false

The three agreed before this PR and disagreed after round 1 — admitting to the /admin/* mount gate.

What changed

  1. auth-manager.ts derives the alias from grants.posture === 'PLATFORM_ADMIN' on the same envelope. That is byte-for-byte what hasPlatformAdminStanding returns (it is literally resolveUserAuthzGrants(...).posture === 'PLATFORM_ADMIN'), and derivePosture reads the capability grant, never the array — so payload, predicate and gate cannot disagree.
  2. platform-admin-gate.ts:73 drops its positions.includes('platform_admin') leg. ⚠️ That line is pre-existing and is deliberately in scope: this PR is what moved the name into tenant-writable space, so any pre-existing reader that treats the name as authority became this card's problem. Removed rather than left unused.
  3. Fixtures migrated — the ruling licensed exactly this ("fixtures to migrate, not evidence against the ruling"): admin-ban-endpoints.test.ts (the named one) and admin-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.ts drives a real session carrying a D4 row spelled platform_admin and requires isPlatformAdmin, judgePlatformAdmin and hasPlatformAdminStanding to agree on false, with a genuine unscoped admin_full_access grant as the control that they agree on true. One case asserts both shapes carry the name in positions[] and differ only on the rung — the finding, stated as an assertion.

⚠️ Its population, stated explicitly: it covers the D4-spelled-built-in-name shape plus a genuine-grant control. It does not cover the ADR-0091 validity window, the ADR-0049 catalogue flag, or the catalogue-page shapes — those are 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_position row 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):

AM_HEAD=4a097c477a409e4971e60c3970f55a38168b8c69   AM_BEFORE=same
GATE_HEAD=409d8e1b26de2bc8b9688a9434a9efdf1ca0ace2 GATE_BEFORE=same
AM_AFTER=6c05637e896a4978f04d9968d0666cdd3b387a05  GATE_AFTER=59570fbc79420ae3dcc56611159f601012502146
INJECTED_MARKERS=2 (expect 2)   REMAINING_RUNG_ASSIGNMENTS=0 (expect 0)
MUTATED_PIN_EXIT=1 -> Tests 4 failed | 13 passed
  AssertionError: positions=["org_member","platform_admin","everyone"]:
    expected { alias, gate, rung } to deeply equal { alias: false, gate: false, rung: false }
restore: git diff HEAD empty; both blobs back at HEAD's; MUT15136 marker count 0/0

Round-2 corrections to the round-1 claims

  • Census, two rows added. objectui packages/app-shell/src/hooks/sharedUserFeeds.ts:507 and apps/console/src/services/approvalsApi.ts:394 both mint role:<position> approver identities from user.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.ts also splits user.role itself, 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-word attribution corrected. That gate ratchets the reserved word in content/docs / skills/ prose; the identifier ban over authored metadata lives in packages/lint. A TypeScript payload key trips neither mechanically until documented. The ADR-0090 D3 prohibition is what rules against minting roles — the conclusion stands, round 1's wording conflated two gates.
  • PIN 3 scoped. It compared the payload against the unscoped resolver, which agrees only on a single-org fixture; it now reads tenantId off the session.
  • Changeset carve-out, measured. "membership-derived names are unaffected" was unqualified. With no active organization the resolver projects every membership, so those names are now added: [][org_admin, everyone]. Pinned. ⚠️ My first attempt at that pin failed — the default fixture does stamp an active org, a different branch — which is why it is measured rather than asserted.
  • Path corrected: current-user-endpoints.ts is in plugin-hono-server, not plugin-auth.

Zone 2 — the reader census

Site Check Disposition
plugin-auth/src/platform-admin-gate.ts:73 positions.includes('platform_admin') re-bound in round 2 — the escalation path
plugin-auth/src/auth-manager.ts (isPlatformAdmin) was positions.includes(...) re-bound in round 2 — reads the rung
objectui packages/auth/src/useWorkspaceAdminStatus.ts:121 positions.some(isAdminRole) ✅ satisfied; its other two legs read user.role / member.role directly
objectui packages/auth/src/AuthGuard.tsx:78 requiredRoles.some(r => positions.includes(r)) app-supplied prop; no in-repo caller
objectui packages/app-shell/src/providers/expressionUser.ts:182 pass-through to CEL ✅ the defect site — repaired
objectui packages/app-shell/src/hooks/sharedUserFeeds.ts:507 role:<position> identities repaired by the new axis
apps/console/src/services/approvalsApi.ts:394 role:<position> identities repaired by the new axis

Authored 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 itroles is the natural one)". Measured, I propose no new array — the one place I depart from the ruling's wording:

  1. Everything the old union contributed beyond the security axis was the sys_user.role scalar's own tokens (org_* and platform_admin are on both axes).
  2. That scalar is already published, unchanged, as user.role (ADR-0068 D2 pins it is never overwritten).
  3. ADR-0090 D3 makes "role" a reserved-forbidden word with a single carve-out for better-auth's own schema — which a key we mint does not qualify for. Zero consumers need it per the census.

If the maintainer wants the array anyway, it is an additive follow-up; nothing here forecloses it.

Reproduction, inverted

session-positions-security-axis.test.ts drives the real pipeline on both sides — a real better-auth instance answering a real getSession(), and the real celEngine from @objectstack/formula over that payload. A demo_reviewer holder 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/formula is a plugin-auth devDependency for that half; it depends only on cel-js + spec, so there is no cycle.

Out of scope, filed separately

examples/app-showcase/.../cascading-select.object.ts:85 gates on 'admin' in current_user.positions while its comment claims the server rejects a non-admin. 'admin' is not a built-in identity name — a membership admin is projected as org_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.

Command Result
pnpm --filter @objectstack/plugin-auth test Test Files 102 passed (102) · Tests 2142 passed (2142) · VERDICT command-exit 0
new-pin mutation MUTATED_PIN_EXIT=1, 4 failed — restore proven (empty git diff HEAD, both blobs at HEAD)
round-1 ablation ABLATION_VITEST_EXIT=1, 4 failed — restore proven
check-adr-0087-registration --self-test / real exit 0 / exit 0 — 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition
check-role-word --self-test / real exit 0 / exit 0 — no new occurrences of the reserved word
check-nul-bytes exit 0 — scanned 7699 text file(s) … no raw ASCII control bytes
spec check:migration-registry exit 0 — registry.ts is current (157 semantic, …)
spec check:generated exit 0 — All 15 generated artifacts are up to date
spec check:authorable-surface (round 1) exit 0 — 1221 default(s) unchanged
spec check:docs (round 1) exit 0 — 230 generated files in sync

Gate family re-derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack after merging origin/main twice 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

…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
…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
@github-actions github-actions Bot added the size/l label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/spec, touching 10 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/plugin-auth/tsconfig.json, packages/plugins/plugin-auth/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via auth.me (sdk, the route ledger binds it to GET /api/v1/auth/get-session))
  • content/docs/deployment/environment-variables.mdx (via PLATFORM_ADMIN (literal, a string literal in buildPluginList), sys_member (literal, a string literal in buildPluginList))
  • content/docs/deployment/tenancy-modes.mdx (via sys_member (literal, a string literal in buildPluginList))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via platform_admin (literal, a string literal in buildPluginList; a string literal in isPlatformAdminUser))
  • content/docs/permissions/authentication.mdx (via platform_admin (literal, a string literal in buildPluginList; a string literal in isPlatformAdminUser), sys_member (literal, a string literal in buildPluginList), auth.me (sdk, the route ledger binds it to GET /api/v1/auth/get-session), /api/v1/auth/get-session (route, a path literal in acceptanceCriteria; a path literal in semantic; a path literal in surface))
  • content/docs/permissions/authorization.mdx (via PLATFORM_ADMIN (literal, a string literal in buildPluginList), platform_admin (literal, a string literal in buildPluginList; a string literal in isPlatformAdminUser))
  • content/docs/permissions/delegated-administration.mdx (via sys_member (literal, a string literal in buildPluginList))
  • content/docs/permissions/permission-metadata.mdx (via platform_admin (literal, a string literal in buildPluginList; a string literal in isPlatformAdminUser))
  • content/docs/permissions/permission-sets.mdx (via PLATFORM_ADMIN (literal, a string literal in buildPluginList), sys_member (literal, a string literal in buildPluginList))
  • content/docs/permissions/positions.mdx (via platform_admin (literal, a string literal in buildPluginList; a string literal in isPlatformAdminUser), sys_member (literal, a string literal in buildPluginList))
  • content/docs/permissions/sharing-rules.mdx (via platform_admin (literal, a string literal in buildPluginList; a string literal in isPlatformAdminUser))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_member (literal, a string literal in buildPluginList))
  • content/docs/releases/v14.mdx (via platform_admin (literal, a string literal in buildPluginList; a string literal in isPlatformAdminUser))
  • content/docs/releases/v15.mdx (via PLATFORM_ADMIN (literal, a string literal in buildPluginList))
  • content/docs/releases/v16.mdx (via PLATFORM_ADMIN (literal, a string literal in buildPluginList), sys_member (literal, a string literal in buildPluginList))
  • content/docs/releases/v17.mdx (via PLATFORM_ADMIN (literal, a string literal in buildPluginList), sys_member (literal, a string literal in buildPluginList))

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/plugins/plugin-auth/tsconfig.json, packages/plugins/plugin-auth/vitest.config.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 133 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 f50c394da55846da8d38f1e1efadbc24faa26ce2packageMentionDocs.

Which tree this was computed on

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

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

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

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review (Clause-②: yes, needs:contract-review) — verdict: FAIL — one blocking finding (item 2), small and named

Tier (override + self-report). CONTRACT_REVIEW_TIER = 'claude-fable-5-1' at scripts/pm/dispatch-gates.mjs:9852. This Agent call carries an explicit model: fable override attested by the PM seat; I self-report as claude-fable-5-1. ⛔ Not an exact-match reading and get_session was not called (it describes the parent session).

Independence. The dev line is claude/issue-15136-positions-security-axis from a separate os-dev subagent of the same PM session — a branch, not the self-review case.

Subject. Head e580aa42991a9b43b46a2900f6a855f33f336cd3, merge-base cee3961759160ed72aacb407f15288cbc018d2eb, 10 files. Own detached worktree /home/user/objectstack-review-15948; pnpm install --frozen-lockfile --offline exit 0; pnpm --filter "@objectstack/plugin-auth..." build exit 0. The pre-fix blob (30ee873…) is identical at the merge-base and at the merge's origin/main parent, so the ablation reference is right. Every exit code below is off a single redirected command.

⛔ I am not re-litigating ruling A. Everything below is whether this PR implements it without collateral damage.


1. positions carries the security axis on the real pipeline — PASS

  • The PR's suite, re-run on the real auth.api.getSession() + real celEngine: NEWSUITE_EXIT=0, 18/18.
  • Own measurements on the same real pipeline (real better-auth over a real AuthManager, memory engine from the imported sibling double), values recorded to disk, not read off assertions:
    • R3 (multi-org: owner of org_a, member of org_b, sys_user_position bound to org_b, session stamped org_a): payload [everyone, org_owner] == resolveUserAuthzGrants(…, {tenantId:'org_a'}); the unscoped resolver answers [b_only_reviewer, everyone, org_member, org_owner]. So the payload is tenant-scoped exactly as /auth/me/permissions scopes it (plugin-hono-server/src/current-user-endpoints.ts:576-598 — note the PR body cites this file under plugin-auth; the path is wrong, the line is right) and as resolveAuthzContext scopes ExecutionContext.positions (resolve-authz-context.ts:427,452).
    • R4 (plugins:{admin:true}, sys_user.role='manager,admin', sys_member.role='admin,custom_lead'): post-fix positions [org_admin, custom_lead, everyone], role 'manager,admin'.
  • Ablation (pre-fix blob restored under trap … EXIT INT TERM, absolute paths): BEFORE=0fae6997… (== HEAD blob, asserted before writing), anchors resolveUserAuthzGrants(dataEngine…)=1/const activeOrgRoles=0 → after swap AFTER=30ee8736…, anchors 0/1. ABLATION_VITEST_EXIT=1Tests 4 failed | 29 passed, headline AssertionError: {"ok":true,"value":false}: expected … to match object { ok: true, value: true } — the silent FALSE, reproduced. Pre-fix R4 positions: [manager, admin, org_admin, custom_lead]. Restore: RESTORED_BLOB=0fae6997…, git diff HEAD empty.
  • ⚠️ Non-blocking pin weakness: PIN 3 compares the payload against the unscoped resolveUserAuthzGrants(engine, holderId). That agrees only because the fixture has one org — R3 shows the two references diverge for a multi-org user. The pin should pass tenantId: session.activeOrganizationId, or a scoping regression would pass it.

2. isPlatformAdmin derived from the array vs the other gate — BLOCKING

PIN 6 is genuinely unmodified (blob 1e1d6fc2… at base, head and disk) and genuinely green (PIN6FILE_EXIT=0, 37/37). But it has no case for the one shape the authority itself warns about. resolve-authz-context.ts:1125-1129, on hasPlatformAdminStanding:

⛔ Read the RUNG — never positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN). The positions list is wider on purpose: an ADR-0057 D4 sys_user_position row may spell that very name, and a platform-RBAC assignment is not the D2 capability grant.

The new auth-manager.ts:3633 is exactly positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN), while isPlatformAdminUserId (:6285, still live at :1867 for /sso/register and :3220 for impersonation) reads the rung. Measured, real pipeline (R1): a plain org member plus sys_user_position {position:'platform_admin', organization_id:null}:

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;
  • isPlatformAdminUser drops its positions.includes('platform_admin') leg (keep isPlatformAdmin === true + the legacy scalar). Its fixture admin-ban-endpoints.test.ts:172-179 pins that leg with positions:['user','platform_admin'] and no isPlatformAdmin — one of the two fixtures the ruling named for migration;
  • a pin for the R1 shape: D4 row spelled platform_admin → payload isPlatformAdmin:false, judgePlatformAdmin refuses, rung false — 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:507 and apps/console/src/services/approvalsApi.ts:394 — both mint role:<p> approver identities from user.positions. Their own docstrings say they want position names (manager, finance_approver); the server matches role:<p> against context.positions from 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, :201 as fixtures to migrate; the file is untouched (ad17e502… at base == head). :176 still pins positions:['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-108 docstring ("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_lead survives 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 when tenantId is undefined (:815). New ⊋ old∖scalar; the premise (old's surplus is only the scalar) still holds, but see item 5.
  • (b) user.role published unchanged: R4 role:'manager,admin' on the real payload; :3651 spreads ...user. Holds.
  • (c) ADR-0090 D3 real, check:role-word carries 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-test exit 0, real run exit 0. Nuance: check:role-word ratchets content/docs and skills/ prose; the identifier ban is packages/lint/src/validate-security-posture.ts:153-159 over authored metadata names/labels (tokens role/roles). A TS payload key roles trips neither mechanically until it is documented — the PR's "revive the exact banned identifier check:role-word ratchets 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:396 already splits user.role itself.

5. Changeset — standing guard: one unqualified claim

  • "a name in sys_member.role is 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-registry exit 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 synceval-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

Copy link
Copy Markdown
Collaborator Author

Addendum to the contract review above — closing one NOT MEASURED row. The full @objectstack/plugin-auth suite finished after posting: pnpm test (single redirected run, scratch suite removed first) → FULL_EXIT=0, Test Files 99 passed (99), Tests 2101 passed (2101) — matching the dev's figures. Nothing in it changes the verdict: the item-2 shape (a D4 sys_user_position row spelled platform_admin) is covered by no existing pin, which is why a green suite and a privilege-shaped regression coexist here. FAIL stands; re-review on the next push.

Worktree /home/user/objectstack-review-15948 re-proven clean after the run (git status --porcelain 0 lines, git diff HEAD 0 lines, HEAD e580aa4).


Generated by Claude Code


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

PM — held. Rework dispatched, and the scope expansion the fix needs is authorized here

The 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 main. Pre-fix, the ablation shows isPlatformAdmin, judgePlatformAdmin and hasPlatformAdminStanding all answer false for the constructed user; post-fix the first two answer true while the rung still says false. A disagreement between two gates over who is a platform admin is not a nit, and the reachability was established rather than assumed — sys_user_position is apiEnabled, tenant admins clear the D12 gate outright, and a delegate clears assertAssignmentWrite's boundSets.every(...) vacuously because the seeded platform_admin position has no bound sets.

This is the finding the whole review round existed for. I weighted isPlatformAdmin as "the real regression risk in the change" when dispatching, and asked for PIN 6 to be checked. PIN 6 is unchanged and green — and it did not catch this.

The lesson worth more than the fix

The implementer reported PIN 6 as "the mechanical proof that the authority's platform_admin derivation agrees with both gates shape for shape." The pin passes; the claim attached to it was broader than its population, because nothing in it constructs a sys_user_position row that spells the built-in name. ⇒ A passing pin proves what it covers, never the sentence written above it. That is the same failure this session has now seen five times in changeset prose — a claim quantified over a set the author had only sampled — arriving this time through a test rather than a sentence.

⚠️ Why a pre-existing line is in scope, stated so it does not read as scope creep

positions.includes('platform_admin') at platform-admin-gate.ts:73 predates this PR and was defensible while positions carried the auth axis, where no tenant can make the platform say that word. This PR moves positions to the security axis, where an ADR-0057 D4 row is a tenant-writable string. ⇒ The PR does not merely coexist with that line — it changes what the line reads. Any pre-existing reader that treats a name in positions as authority is in scope for this card, in the diff or not, and the rework brief says so.

Migrating admin-ban-endpoints.test.ts:176 is likewise not scope creep: the ruling names it and :201 explicitly as "fixtures to migrate, not evidence against the ruling."

Ruled and unchanged by this FAIL

Ruling A on the auth-role array stands. The review measured all four of its premises and they hold: the old union's surplus was exactly manager, admin; user.role is published unchanged; ADR-0090 D3 and the check:role-word ratchet baseline are real. ⇒ No new array. One wording correction the rework carries: check:role-word ratchets docs prose, while the identifier ban lives in packages/lint over authored metadata — the conclusion survives, the PR's conflation of the two does not.

Item 5 is a second instance of the standing guard doing its job: "membership-derived names are unaffected" is false for a session with no active org, which now gains org_* from every membership ([][org_admin, everyone]). Measured. That needs a named set or a carve-out in the changeset before this lands.

What comes back

A rework, not a fix-up — and ⛔ this one gets a second review round, because the change is behavioural and security-shaped. Required: derive the alias from the rung (grants.posture === 'PLATFORM_ADMIN'), drop the positions leg from isPlatformAdminUser, migrate the named fixture, and add a three-way-agreement pin driven with a sys_user_position row spelled platform_admin present — ⛔ mutated, because a pin that cannot go red on the escalation is not a pin.

Recorded with thanks for the two ride-along corrections that cost the review extra work and were not asked for: the census omission of objectui sharedUserFeeds.ts:507 / approvalsApi.ts:394 (both repaired by the new axis, not broken — so no re-binding is owed, but a census should not be silent about them), and the addendum closing the full-suite row (99 files / 2101 tests, exit 0) rather than leaving a stale NOT MEASURED standing.


Generated by Claude Code

…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

Copy link
Copy Markdown
Collaborator Author

Contract review, round 2 (Clause-②: yes, needs:contract-review) — verdict: PASS

Tier (override + self-report). CONTRACT_REVIEW_TIER = 'claude-fable-5-1' at scripts/pm/dispatch-gates.mjs:9852. This Agent call carries an explicit model: fable override attested by the PM seat; I self-report as claude-fable-5-1. Not an exact-match reading; get_session was not called.

Independence. Dev line claude/issue-15136-positions-security-axis from a separate os-dev subagent of the same PM session — a branch, not the self-review case. Round 1 (comment 5553063816 + addendum 5553069316) was a different reviewer; I re-took its blocking leg rather than trusting either report.

Subject. Head 9a26de3a617ec59015b91f112f9bebbb9b0252b3, merge-base 3f89967e35acd69cbd9be7310fdb6ee6d83ca93e, 14 files. Own detached worktree /home/user/objectstack-review-15948-r2 (pnpm install --frozen-lockfile exit 0; pnpm --filter "@objectstack/plugin-auth..." build exit 0). Every exit code below is off a single redirected command. Already verified by the PM and not redone: the constant-based read is gone (2× at e580aa429, 0× at head; one assignment at auth-manager.ts:3640), the gate's positions leg is gone, ADR-0087 registration exit 0.


1. Is the escalation closed on the real pipeline? — PASS, measured

Own scratch suite (real better-auth over a real AuthManager, real auth.api.getSession(), memory engine from the sibling double; values written to disk, not read off assertions). A plain member of org_a, session stamped activeOrganizationId = org_a in every row:

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 literally resolveUserAuthzGrants(ql, userId, { nowMs }).posture === 'PLATFORM_ADMIN'.
  • grants.posture = derivePosture({ isPlatformAdmin: hasPlatformAdminGrant, isTenantAdmin: … }) (:1071); derivePosture (posture-ladder.ts:103-107) reads only those two booleans — no positions read anywhere in posture-ladder.ts.
  • hasPlatformAdminGrant flips true at exactly two sites: :955 (an admin_full_access set whose id is in unscopedUserPsIds, i.e. a sys_user_permission_set row with organization_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 — unscopedUserPsIds is filtered on organization_id === null before tenantId is consulted — and the table above measures it: scoped and unscoped posture agree on all seven shapes, and an org-scoped admin_full_access grant 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|js excluding node_modules/dist: 12 files, all under packages/plugins/plugin-auth/src (control: the gate file itself returns 5). objectui: 0.
  • Test fixtures building a platform admin from positions spelled platform_admin under those consumers: admin-ban-endpoints.test.ts (1 case) and admin-sso-bridge-gate.test.ts (4 cases through one SESSIONS map) — 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 real ADMIN_FULL_ACCESS row (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/isPlatformAdmin outside plugin-auth: none — runtime/src/security/resolve-session-principal.ts only calls getSession; sandbox/body-runner.ts:278 reads a hook-engine session, not the auth payload. objectui expressionUser.ts:176 passes isPlatformAdmin through (rung-derived), not re-derived via createEvalUser; useWorkspaceAdminStatus.ts:121 still admits UI affordances on the name — UI-only, the round-1 row stands.

⚠️ Same species, pre-existing on 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-word attribution corrected (lines 64–68) and it matches the tree: scripts/check-role-word.mjs:67 ROOTS = ['content/docs', 'skills']; the identifier ban is packages/lint/src/validate-security-posture.ts:153-165. No roles key minted.
  • ⚠️ PR body: "spec source is unchanged since [round 1]" is false as written on this head — git diff e580aa429 HEAD -- packages/spec/src names 11 files (automation / i18n), moved by the origin/main merge. The PR's own three spec edits are byte-identical to round 1 (0 diff lines on eval-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

Copy link
Copy Markdown
Collaborator Author

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 (pnpm --filter "@objectstack/plugin-auth..." build exit 0, which includes spec, core, formula dist/), so neither row measured a stale artefact.

Command (head 9a26de3a6) Exit Verdict line
pnpm --filter @objectstack/plugin-auth test (scratch suite removed first) 0 Test Files 102 passed (102) · Tests 2142 passed (2142) · 464s — matches the dev's figures, measured independently
pnpm --filter @objectstack/spec test 0 Test Files 477 passed (477) · Tests 12818 passed (12818) · 687s

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 — git diff --name-status e580aa429 HEAD -- packages/spec/src names 11 files, including one added test file (automation/end-node-outcome.test.ts) and three modified ones, brought in by the origin/main merge. The 476 → 477 files / 12787 → 12818 tests delta is exactly that. The PR's own three spec edits are byte-identical to round 1; the spec tree they sit in is not, and it is green.

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. /home/user/objectstack-review-15948-r2 at 9a26de3a6: git status --porcelain 0 lines, git diff HEAD 0 lines, auth-manager.ts blob 4a097c477a… and platform-admin-gate.ts blob 409d8e1b26… == HEAD:, MUT15948R2 markers 0/0, scratch test absent — provably clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

PM sign-off — round 2 PASS, undrafted and armed

Both ledger rows the reviewer left open are now closed by its addendum, and I held the undraft until they were. ⚠️ Round 1 of this PR would have shipped a privilege escalation; a PASS with two open rows was not going to be enough here.

What was actually established

The escalation is closed against seven constructions, not one. A D4 row spelling platform_admin — global, org-scoped, held by the tenant admin themself, and present in both orgs — plus an org-scoped admin_full_access grant: every one answers isPlatformAdmin=false, isPlatformAdminUser=false, judgePlatformAdmin=false·403, rung false, posture MEMBER, with the name still sitting in positions[]. Both genuine-grant controls answer true across the board. ⭐ That the name is still present while every gate says no is the shape that matters: the fix is not filtering the name out, it is refusing to treat a name as authority.

Alias ≡ rung, verified in source rather than asserted. hasPlatformAdminStanding is resolveUserAuthzGrants(...).posture === 'PLATFORM_ADMIN'; derivePosture reads only a boolean; hasPlatformAdminGrant flips only at resolve-authz-context.ts:955 (an unscoped admin_full_access row) or :1011 (config emails). One nuance the reviewer named rather than glossed: the payload resolves scoped and the rung unscoped, and it showed they cannot diverge.

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 plugin-auth, control fired, objectui 0; the family is the 1 + 4 migrated cases and there is no fifth.

⚠️ One PR-body claim measured false — corrected here, not reworked

The body says "spec source is unchanged since round 1." It is not: 11 packages/spec/src files moved via the main merge (one an added test file). ⭐ The PR's own three spec edits are byte-identical, so the conclusion the sentence was defending holds — but the reviewer did the right thing and ran the spec suite rather than inheriting round 1's green: 477 files / 12818 tests, exit 0.

⇒ Not held for it. The sentence lives only in the PR body, this repo squashes from commit messages (#15913), and the claim it supported is independently true. But it is the sixth instance this session of a sentence quantifying over a set the author had only sampled — and note it survived the greppable guard, because "unchanged since round 1" is a temporal claim rather than one of the three phrases the guard names. Worth carrying: the guard catches the phrasing, not the reasoning error behind it.

⭐ The most valuable thing in this review is not about this PR

Four pre-existing server-side readers derive platform authority from the name in ExecutionContext.positionsplugin-sharing/src/sharing-rule-service.ts:254, plugin-approvals/src/approval-service.ts:976, runtime/src/domains/activation-gate.ts:149, plugin-security/src/explain-engine.ts:117. Same species as round 1's blocked escalation, already reachable on main, with ctx.posture already carried at each site for them to read instead. ⇒ Filed as #15981 (p1, security), with the drive-each-site requirement and the ⛔ do-not-verify-by-string-search warning attached.

Gate ledger on this head: full plugin-auth suite 102 / 2142 exit 0 (independently confirmed, closure built first); spec suite 477 / 12818 exit 0; targeted files 17 / 43 / 19 exit 0; check:authorable-surface, check:docs, check:migration-registry, check-role-word (self-test + real), check-nul-bytes all exit 0. Still NOT MEASURED and correctly declared: the remainder of the 153 derived commands (CI runs the farm), browser/dogfood, objectui suites.

Undrafted and auto-merge armed.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

CI red on 9a26de3a6 — two failures, and they are not the same kind

Three check runs are red. They split cleanly, and only one of them is this PR's.

1. Type Check · workspace — ⛔ this PR's, and reproduced locally

Run 33978981028, job 101340632005. Turbo's own closing lines: Tasks: 133 successful, 135 total · Failed: @objectstack/plugin-auth#typecheck. The TypeScript Type Check rollup (101342046338) is that job's summary and carries nothing of its own.

Reproduced on this exact head in a checkout of 9a26de3a6, one command per exit code:

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 gatepnpm check:merge-drivernode 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. ⚠️ Quoted from #15992, not measured here — measured here is only that the step fails identically on 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

Copy link
Copy Markdown
Collaborator Author

Claim — dev seat, domain:services: taking the Type Check · workspace fix-up

Session session_01XpTx2tbq3pZRYAdoGt6E6Y, branch claude/issue-15136-positions-security-axis, head 9a26de3a617ec59015b91f112f9bebbb9b0252b3. This is the same claim loop that opened this PR (claim comment on #15136: 5552492748) — continuing it, not opening a new one. Re-read the comment timelines on #15136 and this PR rather than trusting the assignee field; the identity is shared.

Scope: exactly one type error, and nothing else. CI Type Check · workspace (run 33978981028, job 101340632005) fails @objectstack/plugin-auth#typecheck on a single ARRIVED problem in src/auth-manager.test.ts — TS2554, a two-argument delegation to a one-parameter fake. ⛔ Not re-opening the round-2 Clause-② design: no edits to auth-manager.ts, platform-admin-gate.ts, the changeset, or this body's argument. ⛔ Nothing added to test-typecheck-debt.json (MAINTAINER-ONLY, ratchets down only). ⛔ No undraft, no merge, no auto-merge.

Lint & Repo Gates is expected to stay red here — it fails on main itself (#15992, dispatched to another seat) and is not mine to chase.


Generated by Claude Code

…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

Copy link
Copy Markdown
Collaborator Author

⛔ Correction to my previous comment — section 2 was wrong

My earlier comment said, of this PR's Lint & Repo Gates failure:

2. Lint & Repo Gates — measured NOT this PR's … Job 101340631783, step 141 Merge-driver wiring gate

Every part of that is false for this PR. I read the merge-driver failure off #15985's job and off main's job, then attributed the same shape to this job without ever fetching its step list. Posting as a new comment rather than an edit, so the wrong claim stays legible next to its correction.

What job 101340631783 actually reports

step 130  Test-source alias gate        completed  FAILURE
step 131–148                            completed  skipped
step 141  Merge-driver wiring gate      completed  SKIPPED   ← never ran on this PR

The job halts at 130. The merge-driver gate — the thing I called this PR's blocker — never executed here. This PR's Lint & Repo Gates red is entirely its own.

The real failure, measured with a base-branch control

node scripts/check-test-source-alias.mjs:

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:

  1. :235 pays import('@objectstack/core') inside a function body — a CLOCKED window. Remedy the gate dictates verbatim: a module-top import '@objectstack/core';.
  2. @objectstack/plugin-auth: NEW unaliased artifact import(s) since this entry was measured: @objectstack/formula. Remedy: one anchored entry in the package's vitest.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

Copy link
Copy Markdown
Collaborator Author

Claim — dev seat, domain:services: taking the check:test-source-alias fix-up

Session session_01XpTx2tbq3pZRYAdoGt6E6Y, branch claude/issue-15136-positions-security-axis, head b65fafc81ac1698934ea8e2092957f8556f8b32a. Same claim loop that opened this PR and shipped the Type Check · workspace fix-up (claim comment on #15136: 5552492748; previous fix-up claim on this PR: 5554028115) — continuing it, not opening a new one. The assignee field cannot answer "whose claim is this" under a shared identity, so this is stated in a comment against the timelines of #15136 and this PR, both re-read first.

Scope: check:test-source-alias only, per the correction in comment 5554191202. Two findings, both in src/session-positions-security-axis.test.ts, both remedied as the gate itself dictates: a module-top side-effect import for the clocked-window finding, and one anchored vitest.config alias entry for the new unaliased artifact import. The dynamic import stays where it is; no timeout is widened; the shrink-only registry entry is not widened.

⛔ Not re-opening the round-2 Clause-② design: no edits to auth-manager.ts, platform-admin-gate.ts, auth-manager.test.ts, the changeset, or this body's argument. ⛔ No new PR, no undraft, no merge, no auto-merge arming.

Working in a dedicated worktree at /home/user/objectstack-15948-alias; the shared checkout is untouched.


Generated by Claude Code

…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
The sibling gate `check:type-source-resolution` is red on the same specifier,
and it is red at the PR head `b65fafc81` as well -- measured with both files of
the alias fix-up reverted in place under a trap, then restored byte-identical.
It was invisible only because `Lint & Repo Gates` halts at step 130
(`Test-source alias gate`) and skips 131-148; this gate is step 132, so
unblocking 130 walks the job straight into it.

Same defect class, same specifier, same round-1 import (`celEngine`), and the
remedy the gate dictates: ONE `paths` rule for the bare name in the package's
`tsconfig.json`, copied in shape from the `@objectstack/service-messaging` rule
already beside it. `@objectstack/formula` publishes no subpath in its `exports`,
so a subpath rule would match nothing on disk and fall back to node resolution.
`tsconfig.test.json` inherits this map rather than declaring its own, which is
what carries the rule to the test layer that reaches the specifier.

Measured rather than assumed, because #12570 records this exact rule billing
another package's source diagnostics into this ledger:

- `check:type-source-resolution` 0 -- "125 tsc program(s) across 78 packages
  scanned; 61 registered as still resolving a workspace dep's types through
  `dist/`".
- `pnpm --filter @objectstack/plugin-auth typecheck` 0, with
  `check:test-typecheck` reporting the ledger UNCHANGED at 10 file(s) /
  94 error(s) / 23 pinned signature(s) -- nothing was added to it.
- `tsc -p tsconfig.test.json --listFiles`: 15 `packages/formula/src/*.ts` files
  now in the program, 0 files under `packages/formula/dist/`, program 1243 ->
  1257 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y

Copy link
Copy Markdown
Collaborator Author

os-dev-report

{
  "issue": 15136,
  "status": "done",
  "branch": "claude/issue-15136-positions-security-axis",
  "pr": "https://github.com/objectstack-ai/objectstack/pull/15948",
  "premise_still_valid": true,
  "summary": "The card's premise held and the PM's quotation of the gate was verbatim-accurate. Two commits on top of b65fafc81. (1) 87040f332 fixes `check:test-source-alias` exactly as the gate dictates: a module-top `import '@objectstack/core';` in `session-positions-security-axis.test.ts` (the dynamic `import()` at :235 is untouched, no timeout widened), and ONE anchored `{ find: /^@objectstack\\/formula$/, replacement: path.resolve(here, '../../formula/src/index.ts') }` in the package's `vitest.config.ts` (the replacement was verified to name an existing file, `packages/formula/src/index.ts`, before being written; `KNOWN_UNALIASED_TEST_IMPORTS` was NOT widened — plugin-auth's row is byte-unchanged). (2) 9fb71ee56 is a DECLARED SCOPE INCREMENT beyond the brief, taken under the bounded in-place exemption and flagged here rather than shipped quietly. ⭐ CORRECTION TO THE SUCCESS CRITERION, measured with controls: `Lint & Repo Gates` green is NOT achievable on this PR. Step 130 was only the FIRST red. Job 101340631783 halts at 130 and skips 131-148, so the whole tail was unmeasured, not green. Step 132 (`Type-source resolution gate`, `pnpm check:type-source-resolution`) is red on the SAME specifier — `@objectstack/formula` resolving its TYPES through `dist/` via `tsconfig.test.json` — and it is red at b65fafc81 too: measured by reverting BOTH files of the alias fix-up in place under a trap, running the gate (exit 1, identical finding), then restoring byte-identical (`git diff HEAD` 0 bytes, blob back to its HEAD hash). It is this PR's, from round 1's import, and 9fb71ee56 remedies it as that gate dictates — ONE `paths` rule for the bare name in `packages/plugins/plugin-auth/tsconfig.json`, shaped from the `@objectstack/service-messaging` rule already beside it. AND step 141 (`Merge-driver wiring gate`) is now REACHABLE: it was skipped only because the job halted at 130. It is green in this container (exit 0, git 2.43.0 / pnpm 10.31.0) but red in CI on `main`'s own job per the PM's measurement, cause recorded on #15992 — which remains open. So this PR's own contribution to the red is fully removed; the job's remaining red is `main`'s, not this diff's. I swept every never-run step 131-148 locally and all of them are green here. ⛔ Nothing was touched in `auth-manager.ts`, `platform-admin-gate.ts`, `auth-manager.test.ts`, `test-typecheck-debt.json`, the changeset, or the PR body. NOTE ON A CONTRACT CONFLICT: my standing clause requires an in-place fix to be named in the PR body with its evidence; the brief forbids touching the body (and a PATCH edit would also normalise the create-only session-URL footer down to bare), so the evidence is carried in 9fb71ee56's commit message and in this comment instead.",
  "tests": "All readings at final head 9fb71ee56 (`git rev-parse --short HEAD`), working tree clean. GATE, BEFORE (at b65fafc81): `node scripts/check-test-source-alias.mjs` EXIT=1, two findings, both quoted in full in this thread — its remedy text matched the PM's quotation word for word, so no correction is owed there. GATE, AFTER: `--self-test` EXIT=0 (`check-test-source-alias --self-test OK`); real run EXIT=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.` — the SAME three counts as the base-branch control on origin/main f7db8f4fd, i.e. the registry population did not move. SIBLING GATE: `pnpm check:type-source-resolution` EXIT=0 — `check-type-source-resolution OK — 125 tsc program(s) across 78 packages scanned; 61 registered as still resolving a workspace dep's types through dist/.` SUITE: `pnpm --filter @objectstack/plugin-auth test` EXIT=0 — `Test Files  102 passed (102)` / `Tests  2142 passed (2142)`, the required 102/2142 unchanged. TYPECHECK: `pnpm --filter @objectstack/plugin-auth typecheck` EXIT=0 over all three legs (`tsc --noEmit`, `tsc --noEmit -p tsconfig.examples.json`, `check:test-typecheck` self-test + real) — `check:test-typecheck: OK — ... 10 file(s) / 94 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json`, the ledger UNCHANGED, which is the mechanical evidence the `paths` rule billed nothing from formula's source into it (the risk #12570 records). ⚠️ NOT MEASURED, then measured: the FIRST typecheck attempt exited 2 on `examples/basic-usage.ts(13,28): error TS2307: Cannot find module '@objectstack/plugin-auth'` — a missing module, not a verdict on my change, and it short-circuited legs 2 and 3. Read as NOT MEASURED, plugin-auth itself was built (`pnpm --filter @objectstack/plugin-auth build`, exit 0, `check-dts-emitted: 2/2`), and the task re-run to the exit 0 above; both readings are reported. Dependency closure built first with `pnpm --filter '@objectstack/plugin-auth^...' build` (exit 0). THREE MUTATION LEGS, one per edit, each under `trap restore EXIT INT TERM` with the restore path built from an absolute `git rev-parse --show-toplevel`, each anchor asserted unique IN THE FORM WRITTEN before mutating, each mutation proved on disk by occurrence counts of the injected text plus a changed `git hash-object` (never an editor exit code), and each restore proved by `git checkout HEAD -- path` (never bare `git checkout --`, which reads from the index) followed by `git hash-object` equal to `git rev-parse HEAD:path` AND `git diff HEAD` at 0 bytes AND an empty `git status --porcelain`. LEG 1 (module-top import removed): gate EXIT=1 with exactly finding 1 — `session-positions-security-axis.test.ts:235: import('@objectstack/core') is paid inside a function body — a CLOCKED window` — finding 2 absent; blob e74a7e2e -> 64f2bf06 -> e74a7e2e. LEG 2 (alias entry removed): gate EXIT=1 with exactly finding 2 — `@objectstack/plugin-auth: NEW unaliased artifact import(s) since this entry was measured: @objectstack/formula.` — finding 1 absent, and a control assertion held that the untouched `plugin-security` entry was still present (mutation removed only its target); blob d846c74a -> db390bdd -> d846c74a. LEG 3 (paths rule removed): `check:type-source-resolution` EXIT=1 with `NEW dist-resolved type import(s) ... @objectstack/formula (via tsconfig.test.json)`, service-messaging control rule still present; blob d3f5b1eb -> d4a11d9c -> d3f5b1eb. Each fix is therefore load-bearing, and the three findings are independent. LOAD-BEARING PROOF FOR THE PATHS RULE, separately: `tsc -p tsconfig.test.json --listFiles` shows 15 `packages/formula/src/*.ts` in the program and 0 files under `packages/formula/dist/` (program 1243 -> 1257 files); the same command proved my edited test file IS in the program (1 exact-path line, with `auth-manager.test.ts` as a firing control and a nonexistent filename as a null control returning 0) — so 'typecheck is clean' really does cover the file I edited. TIMING, measured, shared box: full suite 124.08s Duration with the module-top import vs 128.28s with it removed (same tree otherwise, back-to-back under the lock) — i.e. the import did not cost time, it saved a little. Proximity to the cliff, isolated: the file alone runs 20.30s (with) vs 20.19s (without), and the `resolveUserAuthzGrants` test that pays the dynamic import measures 400ms vs 409ms against a 10_000ms `testTimeout` — nowhere near it. The reason, measured not guessed: `src/auth-manager.ts:35` already carries a module-scope VALUE import of `@objectstack/core` (`hasPlatformAdminStanding, resolveUserAuthzGrants`), and the test file imports `AuthManager` from it, so the transform was already being paid during collection; the gate is a per-file static text reader and cannot see that transitive path. The fix is therefore convention-compliance with no measurable cost — which is the honest reading, not 'the cliff was far so the gate was wrong'. ⚠️ ALL WALL-CLOCK ABSOLUTES ARE SHARED-BOX SECONDS (the lock's own verdict line says so): the SAME tree ran 124.08s once and 194.47s later under contention (+57%), a spread far larger than the 4.2s with/without delta — so the ratio, not the absolute, is the load-bearing part. GATE FAMILY: derived mechanically with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (exit 0, no hand-built path list). It printed a STALE TREE warning — this tree is at least 6 commits behind origin/main and 2 files it derives from changed in that range (`scripts/engine-double-contract.pinned.json`, `scripts/pm/check-half-states.mjs`) — reported rather than silently accepted; I did not merge origin/main into this branch because that is outside a narrow fix-up's remit. It named 40 gates matched via my files, nearly all through broad `packages/**` globs. Run locally at 9fb71ee56, all EXIT=0: check:test-source-alias, check:type-source-resolution, check:nul-bytes (also a manual control-byte scan of both edited files, no hits), check:undeclared-dep-imports, check:cross-package-test-inputs, check:engine-double-contract. DECLARED NARROWING: I did not run all 40, nor `pnpm lint` repo-wide; CI runs the farm regardless. DECLARED WIDENING: I DID sweep every lint step 131-148 locally, because CI has produced ZERO information about them on this PR (all skipped) and the success criterion depends on them — check:refd-timer-probe, check:published-files, check:where-matcher, check:objectql-double-limit, check:kernel-hook-pairs, check-plugin-teardown-shape (self-test + real), check:resume-authority-declared, check:driver-memory-census, check:merge-driver, check:spec-parsed-alias, check:tenant-chokepoint, check:pnpm-filter-targets, check:agent-test-spelling, check:turbo-task-graph, check:workspace-manifest-cycles, check-reference-carrier-shape (self-test + real) — every one EXIT=0 here. Exit codes were captured by redirecting to a file BEFORE reading, never after a pipe, and every verdict quoted above is the gate's own printed line, never a bare $?.",
  "mcp_calls": "5 — issue_read(get_comments #15136), pull_request_read(get_comments #15948), add_issue_comment(claim), add_issue_comment(this report), plus one read-back of this comment. The REST channel was probed first and is unavailable on this seat (repo-scoped GET returned 403, `gh` absent), so MCP was the only channel; no search was spent because nothing needed filing.",
  "open_questions": [
    {
      "question": "The ⭐ success criterion — `Lint & Repo Gates` green on this PR — is unreachable while #15992 is open, because step 141 was skipped only as a consequence of the step-130 halt and is now reachable. How should this PR proceed?",
      "options": [
        "A. Accept the PR as is: its own two reds (steps 130 and 132) are fixed and every other step 131-148 is green in this container; the job stays red on step 141, which is `main`'s red, owned by #15992, and will clear on this PR without another push once that lands.",
        "B. Wait for #15992 to land, then merge origin/main into this branch and re-run — costs a merge into a branch that is otherwise review-complete, and per AGENTS §9/§11 a merge here also owes a rebuild plus a `check:generated --fix` pass on the merge commit.",
        "C. Port #15992's fix here — ⛔ rejected on measurement, not preference: `scripts/check-regen-pending.mjs` is a single-writer path and the `No other open PR may claim the same single-writer path` check is currently green on this PR; carrying the fix would red it and collide with #15992's own PR."
      ],
      "recommendation": "A. It is the only option that does not spend work on a red this diff does not own. The evidence that this PR is now clean of its own reds is local and complete (steps 130 and 132 green, 131 and 133-148 green here), so the remaining red is diagnosable at a glance rather than needing another lap. Re-check the job after #15992 lands and confirm from the run's step list rather than from the rollup — reading a step list rather than assuming one is exactly what the earlier misattribution on this PR cost."
    }
  ],
  "out_of_scope_findings": [
    "NOT filed as a new issue, deliberately, and stated so it is not lost: the one out-of-scope red I found — `check:type-source-resolution` at step 132, red at b65fafc81 with a control — was fixed in place in 9fb71ee56 under the bounded in-place exemption (same defect class, same specifier, same round-1 import, mechanical remedy dictated by the gate itself with a sibling rule in the same file to copy, no other claim on the file, same gate family and same CI job). Its verification surface really did widen — the `paths` rule pulls 15 formula source files into the test program — so it was measured rather than asserted, and the test-typecheck ledger is unchanged at 10/94/23.",
    "NOT filed: the CI redness of step 141 (`Merge-driver wiring gate`) is already owned by the open #15992 (with #15990 and #15994 on the same failure); no duplicate was created and no MCP search was spent."
  ]
}

Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

⛔ Second correction — my success criterion for this PR was wrong

I told the dev taking the alias fix-up:

⭐ Success criterion: Lint & Repo Gates green on this PR — which, uniquely here, is achievable, because the merge-driver gate that reds every other PR on this seat is skipped on this job.

The premise was right and the inference was wrong. Step 141 is skipped — but a skipped step is UNMEASURED, not green. The job halts at 130 and skips 131–148, so the entire tail was simply never run, and I read "skipped" as "would have passed". That is the same class as reading exit 3 as a pass, which this seat writes into every brief.

The dev swept the steps CI had never reached and found step 132 (Type-source resolution gate) independently red — on the same specifier, @objectstack/formula resolving its types through dist/ via tsconfig.test.json. It measured the attribution rather than asserting it: both files of the alias fix-up reverted in place under a trap, gate re-run (exit 1, identical finding), then restored byte-identical (git diff HEAD 0 bytes, blob back to its HEAD hash). So step 132 was red at b65fafc81 too, from round 1's import — invisible only because the job never got there.

⇒ Fixing step 130 alone would have walked the job straight into 132 and produced another red lap. The dev took it in place as a declared scope increment and flagged it rather than shipping it quietly: one paths rule for the bare name in packages/plugins/plugin-auth/tsconfig.json, shaped from the @objectstack/service-messaging rule already beside it. ✅ I accept the increment — same defect class, same specifier, same round-1 import, same gate family, same CI job, remedy dictated by the gate itself.

⭐ Third time today this seat has treated an unmeasured state as a known one. Each time it was a dev or a reviewer that caught it.

Verified independently at head 9fb71ee56

The alias fix-up is 3 files, +33/−0. Against the contract-reviewed head 9a26de3a6:

auth-manager.ts           4a097c477a409e4971e60c3970f55a38168b8c69  SAME
platform-admin-gate.ts    409d8e1b26de2bc8b9688a9434a9efdf1ca0ace2  SAME
test-typecheck-debt.json  11f8f4ea43e6cccb8dad9899faaf80deb21731fd  SAME  ← nothing added to the ledger
auth-manager.test.ts                                               DIFFER ← the firing control

So the round-2 design is byte-for-byte intact across both fix-ups, and the debt ledger was not touched — which matters here, because the new paths rule pulls 15 packages/formula/src/*.ts files into the test program and #12570 records that exact rule billing another package's diagnostics into this ledger. Measured, it billed none.

On the base branch, and on the merge

main is green again at f50c394da#16002 fixed #15992, so step 141 is no longer a standing red anywhere. That resolves the dev's open question (A/B/C) without choosing: there is nothing left to wait for.

I test-merged origin/main into this branch locally and did not push it. The merge is clean, but it auto-merges packages/spec/src/migrations/registry.ts, which both sides changed and which is generated. ⛔ A clean text merge of a generated file is not evidence the file is correct — that trap has bitten this seat in both directions today. CI already builds and tests the merge product of this head with current main on every run, check:generated included, so the authoritative measurement is the one already running rather than a hand-made merge commit from me. Run 33988607054 is in progress on 9fb71ee56; I will read its step list, not its rollup.

⛔ Still not armed for auto-merge, and it will not be until that job is green and I have read it.


Generated by Claude Code

@os-warren
os-warren added this pull request to the merge queue Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Armed. I said this would not be armed until Lint & Repo Gates was green and I had read it, so here is the reading.

Run 33988607054, head 9fb71ee56, job 101366646243 — read by its step list, not its rollup: 153 steps, zero non-success. The three that matter on this PR's history:

step 130  Test-source alias gate        success   ← the fix-up's target
step 132  Type-source resolution gate   success   ← the red hiding behind the halt
step 141  Merge-driver wiring gate      success   ← reachable at last, and green

Step 141 running at all is the evidence for the point that cost this PR a lap: it was never passing, it was never reached. It only becomes readable once 130 stops halting the job, and it is green here because main was fixed independently (#16002 for #15992, landed as f50c394da).

Type Check · workspace, · source gates, · consumer gates, · debt ledger and the TypeScript Type Check rollup are all green on the same head.

Auto-merge is now armed. The bar under the standing maintainer ruling is the contract review, which this PR passed in round 2 — CI green alone would not have been enough, and it is not what I armed on.


Generated by Claude Code

Merged via the queue into main with commit 8e0b297 Sep 5, 2026
35 of 36 checks passed
@os-warren
os-warren deleted the claude/issue-15136-positions-security-axis branch September 5, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation needs:contract-review size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

action.visible 的 current_user.positions 装的是 auth 角色而非安全层岗位,按岗位收敛的按钮对所有人静默消失(17.2.0)

2 participants