Repository navigation
finding: the direct /sso/register endpoint's ADR-0024 before-hook admits org owners/admins — wider than the platform-admin posture #9653 landed on the /admin/sso/* bridges #10009
Description
Activity
First-touch grading (triage seat, session
session_014qTKTqjme5Fp5BH9iRmy6t):finding→needs-user-decision+domain:services. Security/permission-boundary policy is the human floor — the card's own framing is correct that aligning the two SSO-registration surfaces is a policy choice with real consequences for multi-org deployments, not a code fix. (Per the #9994 protocol: not assigned; the inbox is digested periodically.)<!-- os-decision-facets -->
- platform long-term coherence: two surfaces reaching the same operation with different admit sets is the "two implementations, one operation" shape — the governed side (finding: the four /admin/sso/* bridges carry no ObjectStack-side admin gate, and their delegated authorization is unprovable on a stock boot #9653's platform-admin bridge, with pins) is the ruled posture; leaving the vendor hook wider makes the bridge tightening labelling, not a boundary.
- measured business pull: none measured — no report of an org admin needing (or abusing) direct
/sso/register; the divergence was found by reading, not by an incident. - AI-agent error-resistance: an admit set that depends on WHICH URL reaches the operation is exactly what an agent will get wrong when writing console/SDK code; one judged posture on both doors is checkable, and the direct surface currently has no pins at all.
- startup scope discipline: option (a) (tighten the hook to platform-admin-only) is the smallest and matches ADR-0068 D4's sole-operator reading; option (b) (org-admin for org-scoped providers, platform-admin for global) preserves a vendor-designed self-serve capability nobody measurably uses today.
Options: a — tighten the ADR-0024 before-hook to platform-admin-only, with pins on the direct surface. b — split by provider scope (org-scoped: org admin; global: platform admin), with pins for both. c — leave the divergence, document it loudly.
Recommendation: a, on the two-implementations meta-rule (the governed, pinned side wins; the wider side re-binds) and ADR-0068 D4 — with the explicit note that if multi-org IdP self-serve is a product goal, b is the deliberate version of it and should be chosen on purpose, not inherited from the vendor default. Confidence gap: this analysis has not measured whether any deployed environment's org admins currently use direct
/sso/register(the sweep sees the repo, not deployments); a live consumer flips the recommendation to b.
Generated by Claude Code
Maintainer ruling recorded (2026-08-20, live decision-inbox session with the triage seat, session
session_01PjAP6vbcsg2yMtvySPv1Qo)Ruled: (a) — tighten the direct
/sso/registerbefore-hook to platform-admin-only, aligning it with the #9653 posture on the/admin/sso/*bridges (ADR-0068 D4: registering an identity provider is a platform-operator action). Provenance: maintainer accepted the batch, verbatim: 「其他接受你的建议。」; the presented rationale: today's deployment shapes carry no org-self-serve IdP need — startup scope discipline says close the wider door now and reopen an org-scoped channel (option b) only when a real multi-org self-serve requirement arrives, as its own ruling.Implementation: change the admit set in the ADR-0024 before-hook (
auth-manager.ts) fromisOrgOrPlatformAdminto the shared platform-admin judge, and land pins on the direct surface (the hook had none before #9653's bridge fixtures). Clause-②: yes (accept-set narrowing on an auth surface) — contract-review tier.State transition in the same stroke:
needs-user-decision→pm:queue.
Generated by Claude Code
Claim: PM seat #6021, session
session_01PnJHU45vPJj5UQrxe946Bx.- Branch:
claude/issue-10009-sso-register-platform-admin-only - Worktree:
/home/user/objectstack-issue-10009(⛔ 禁git stash) - File surface:
packages/plugins/plugin-auth/src/auth-manager.ts(ADR-0024 before-hook)+ 直接面的 pin + changeset - Container & model:
claude-opus-5 - Clause-②: yes —— 裁决文自己写明"accept-set narrowing on an auth surface — contract-review tier"
- Serial constraints:
⚠️ PR fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) #10352(impersonate_userandset_user_rolestill 403 every platform admin — neither route is safely raw-mountable, and each blocks for a different reason #9968)仍开着且改同一文件。我查过scripts/check-single-claim-paths.mjs:它的键是显式路径清单,auth-manager.ts不在其中(0 命中,反向对照reason命中 7 次证明检索是活的),所以 CI 的 single-writer 门不会拦。fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) #10352 已推送、其 worktree 已移除 ⇒ 没有活着的 agent 在该文件上,剩下的只是合并冲突风险。执行位须:从当前main切;把改动限制在 ADR-0024 before-hook 那一段;在回报里点名这处重叠,好让后落地的一方知道要合。
⚠️ 门禁偏离,明示授权维护者 2026-08-20 授权:fable 用尽期间,Clause-② 卡可在 opus 上开发,落地前等分诊审核。⇒ ⛔ PM 不翻 ready、不武装 auto-merge。
Generated by Claude Code
- Branch:
- added a commit that references this issue
on Aug 20, 2026 { "issue": 10009, "status": "done", "branch": "claude/issue-10009-sso-register-platform-admin-only", "pr": "https://github.com/objectstack-ai/objectstack/pull/10390", "premise_still_valid": true, "summary": "Ruling (a) implemented: the ADR-0024 before-hook on the direct POST /sso/register now admits PLATFORM ADMINS ONLY, matching the #9653 posture on the /admin/sso/* bridges. PREMISE re-established first, on one tree in one run, before any edit: the same org-owner principal (sys_member.role='owner', sys_user.role=null, zero permission-set grants) reached 422 reserved-providerId at the DIRECT endpoint (admitted, past both the ObjectStack hook and the vendor's whole authorization prologue) while the BRIDGE answered 403 PERMISSION_DENIED — exactly the divergence the card records. The shared judge in platform-admin-gate.ts did NOT fit: it is session-shaped (isPlatformAdminUser reads isPlatformAdmin/positions[]/role, which customSession contributes), while this call site resolves through resolveActor(ctx), the shared hook-order-independent resolver used by ~5 gates, which returns {userId, activeOrgId} — an id. Feeding the session judge would need a second session resolution (the 'two resolution sites' defect the file's own #8102 comment forbids) and would fall through to the RETIRED role==='admin' scalar, refusing real permission-set platform admins. So the id-shaped question was needed; rather than mint a fourth differently-named spelling (#10348), I used the EXACT name and body #10352 is already landing, isPlatformAdminUserId, so the two collide loudly on one identifier and the merge resolution is 'keep one'. #10348's other spellings were not touched. CONSEQUENCE, forced by the compiler and reported rather than hidden: with /sso/register no longer asking the org question, isOrgOrPlatformAdmin lost its only production caller and TS6133 fired under noUnusedLocals — it was REMOVED rather than left dead, along with its now-unused isOrgAdminGrade import. #5942's substance survives: the one grade ladder keeps direct pins in member-role-canonical.test.ts and its other reader in last-admin-guard.ts. packages/spec was NOT touched — SSO_REGISTER_FORBIDDEN is already registered in error-code-ledger.zod.ts:362, so the existing catalog member was reused and no error-code registration was needed. No ledger entry raised: plugin-auth TEST_DEBT left at 109 as instructed (the gate reports it now measures 108, i.e. down 1, and says lowering is optional).", "tests": "All heavy runs went through scripts/pm/os-verify-lock.sh; exit codes captured before any pipe; verdicts quoted from each gate's own line. PREMISE (pre-change, same tree/run): 'PREMISE member role = \"owner\"' / 'PREMISE legacy sys_user.role = null' / 'PREMISE permission-set grants = []' / 'PREMISE (1) DIRECT /sso/register -> 422 {\"message\":\"This providerId is reserved and cannot be used for an SSO provider\"}' / 'PREMISE (2) BRIDGE /admin/sso/register -> 403 {\"success\":false,\"error\":{\"code\":\"PERMISSION_DENIED\"...}}'. PINS (two-directional, 4 cases, all green): org owner -> 403 AND SSO_REGISTER_FORBIDDEN (ADR-0112 code AND status); platform admin -> still admitted (reaches vendor 422 /reserved/); anonymous -> still 401; and one principal at both doors. The platform admin is granted the ADR-0068 way (org-less sys_user_permission_set -> admin_full_access) and the fixture ASSERTS sys_user.role is NOT 'admin', so the suite cannot pass by riding the retired channel. ABLATION, predicted signature stated BEFORE running, both directions, with git hash-object proof on every leg — pre-ablation da7f6561c3b47e02188ac02b09019afcd1f6201e; leg A (narrowing absent, judge admits all) 7e29fb567de19defff1f920f73ee94df33058c1c predicted 'refusal pins RED, admission pins GREEN' and observed 'Tests 13 failed | 227 passed' with 'expected 422 to be 403' x2 and 'expected true to be false' x11, pins (2) and (3) absent from the FAIL list i.e. GREEN; leg B (gate refuses everyone) 351124571c5f65344b0f80df9a08771e7a4ef062 predicted 'admission pins RED, every refusal pin GREEN' and observed 'Tests 3 failed | 237 passed' with 'expected 403 to be 422' and 'expected false to be true' x2 — every refusal pin green, which is the direct demonstration that a one-directional suite would have been blind here; RESTORE leg da7f6561c3b47e02188ac02b09019afcd1f6201e — BYTE-IDENTICAL to pre-ablation, no ABLATION marker left in the file, and re-run green 'Tests 240 passed (240)'. REBUILD STATEMENT, argued from the files: AuthManager is imported RELATIVELY as './auth-manager.js' (line 62 of the new test) and there is no vitest alias in plugin-auth's vitest.config.ts or the root, so vitest executes the MUTATED SOURCE, not a dist/ artifact — the dist-staleness hazard does not apply to the mutated module and no rebuild could change what these legs measured; the cross-package imports (@objectstack/objectql, /platform-objects, /spec, /core) do resolve through dist and WERE built (closure build 'VERDICT command-exit 0'), but none of them is the mutated module. PACKAGE VERIFICATION on the merged tree: 'pnpm --filter @objectstack/plugin-auth typecheck' clean (no 'error TS'), 'vitest run' -> 'Test Files 61 passed (61)' / 'Tests 1323 passed (1323)'. GATE UNION: node scripts/pm/dispatch-gates.mjs with NO paths passed, run AFTER the final commit on a CLEAN worktree, at be985a623 — EXIT=0 for check:changeset-gate-self-tests, check:objectui-changeset, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs, check-affected-docs.mjs; convention-triggered by the new test files, all EXIT=0: 'query-options-erasure ratchet holds: 67 unswept non-test site(s) ... none new', 'check-engine-double-contract: OK — 338 pinned, 133 in the DEBT ledger, 2 exempt.', 'where-matcher conformance holds: 265 matcher(s) discovered ... 0 silently-wrong', 'check-type-check-coverage: OK — 64/77 workspace packages type-checked', 'check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 232.7s, 1923 raw tsc error(s) total, none above its recorded number.' NOTE ON A PREREQUISITE, not reported as a pass: check:type-check-debt first REFUSED with '--re-measure cannot run: 30 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk' — a precondition, so I satisfied it exactly as lint.yml does (turbo run build --filter='./packages/*' --filter='./packages/*/*' -> 'Tasks: 70 successful, 70 total') and re-ran to the EXIT=0 above. IN-REPO CONSUMER SWEEP, each search validated against a known-present control FIRST: objectstack (control 'admin/sso/register' -> 9 files) 0 direct callers, every hit being prose/ADR text or the BRIDGE target including both sys_sso_provider metadata actions; objectui console (control 'api/v1/auth' present) 0; objectos docs (control present) 0; examples//SDK/apps (control 'signIn' present) 0. BOUNDED STATEMENT: zero in-repo callers of the direct endpoint across the three checked-out repos; deployments are NOT visible from here, so this does not establish that no deployed org admin uses it — it is only the repo-measurable half of the confidence gap triage recorded.", "open_questions": [], "out_of_scope_findings": [ "NOT filed (deliberately, with reasoning): check:type-check-debt reports '@objectstack/plugin-auth: TEST_DEBT records 109, tsc now reports 108 (-1) -- the entry can be lowered', with the standing hazard that while the gap is open 1 new error can land and the gate still reports success. I left the ledger at 109 per the dispatch prohibition and did not file an issue because the gate itself names the tracking issue for this exact class (#6376) and prints this line for every package whose measured count has drifted below its recorded number — a per-package card would be duplicate noise. Flagging here so the PM can decide; closing it is one command, `pnpm check:type-check-debt --lower`.", "No other out-of-scope defects encountered. The #10352 file overlap is a serial-coordination note, not a defect — see the PR body's 'Serial note'." ] }Reviewer orientation (prose, outside the JSON)
- ⛔ PR security(plugin-auth): gate direct /sso/register on platform admin only #10390 is a draft and must NOT be auto-merged — Clause-② accept-set narrowing on an auth surface, developed at
claude-opus-5under the 2026-08-20 authorization while fable is exhausted. It waits on triage review. The accept→reject narrowing and who loses access are the first thing in the PR body, as a table, not a footnote. ⚠️ File overlap with open PR fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) #10352 (impersonate_userandset_user_rolestill 403 every platform admin — neither route is safely raw-mountable, and each blocks for a different reason #9968) onpackages/plugins/plugin-auth/src/auth-manager.ts.auth-manager.tsis not oncheck-single-claim-paths.mjs's explicit list, so the single-writer gate blocks neither side.origin/mainwas merged into this branch before the final commit; fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) #10352 had not landed. Both branches add a privateisPlatformAdminUserIdwith the same body — keep one. fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) #10352 also references{@link isOrgOrPlatformAdmin}in a docblock, which this PR removes, so that line needs updating on whichever side lands second.
Generated by Claude Code
Generated by Claude Code
- ⛔ PR security(plugin-auth): gate direct /sso/register on platform admin only #10390 is a draft and must NOT be auto-merged — Clause-② accept-set narrowing on an auth surface, developed at
os-dev-report
⚠️ Supersedes comment 5359591911: its leading<!-- os-dev-report -->HTML-comment marker was eaten by the GitHub body sanitizer on the way into storage (confirmed by reading the comment back — the stored body began with the code fence). Re-posting with the marker as literal text so the PM's scan can see it. The JSON below is identical.{ "issue": 10009, "status": "done", "branch": "claude/issue-10009-sso-register-platform-admin-only", "pr": "https://github.com/objectstack-ai/objectstack/pull/10390", "premise_still_valid": true, "summary": "Ruling (a) implemented: the ADR-0024 before-hook on the direct POST /sso/register now admits PLATFORM ADMINS ONLY, matching the #9653 posture on the /admin/sso/* bridges. PREMISE re-established first, on one tree in one run, before any edit: the same org-owner principal (sys_member.role='owner', sys_user.role=null, zero permission-set grants) reached 422 reserved-providerId at the DIRECT endpoint (admitted, past both the ObjectStack hook and the vendor's whole authorization prologue) while the BRIDGE answered 403 PERMISSION_DENIED — exactly the divergence the card records. The shared judge in platform-admin-gate.ts did NOT fit: it is session-shaped (isPlatformAdminUser reads isPlatformAdmin/positions[]/role, which customSession contributes), while this call site resolves through resolveActor(ctx), the shared hook-order-independent resolver used by ~5 gates, which returns {userId, activeOrgId} — an id. Feeding the session judge would need a second session resolution (the 'two resolution sites' defect the file's own #8102 comment forbids) and would fall through to the RETIRED role==='admin' scalar, refusing real permission-set platform admins. So the id-shaped question was needed; rather than mint a fourth differently-named spelling (#10348), I used the EXACT name and body #10352 is already landing, isPlatformAdminUserId, so the two collide loudly on one identifier and the merge resolution is 'keep one'. #10348's other spellings were not touched. CONSEQUENCE, forced by the compiler and reported rather than hidden: with /sso/register no longer asking the org question, isOrgOrPlatformAdmin lost its only production caller and TS6133 fired under noUnusedLocals — it was REMOVED rather than left dead, along with its now-unused isOrgAdminGrade import. #5942's substance survives: the one grade ladder keeps direct pins in member-role-canonical.test.ts and its other reader in last-admin-guard.ts. packages/spec was NOT touched — SSO_REGISTER_FORBIDDEN is already registered in error-code-ledger.zod.ts:362, so the existing catalog member was reused and no error-code registration was needed. No ledger entry raised: plugin-auth TEST_DEBT left at 109 as instructed (the gate reports it now measures 108, i.e. down 1, and says lowering is optional).", "tests": "All heavy runs went through scripts/pm/os-verify-lock.sh; exit codes captured before any pipe; verdicts quoted from each gate's own line. PREMISE (pre-change, same tree/run): 'PREMISE member role = owner' / 'PREMISE legacy sys_user.role = null' / 'PREMISE permission-set grants = []' / 'PREMISE (1) DIRECT /sso/register -> 422 This providerId is reserved and cannot be used for an SSO provider' / 'PREMISE (2) BRIDGE /admin/sso/register -> 403 code PERMISSION_DENIED'. PINS (two-directional, 4 cases, all green): org owner -> 403 AND SSO_REGISTER_FORBIDDEN (ADR-0112 code AND status); platform admin -> still admitted (reaches vendor 422 /reserved/); anonymous -> still 401; and one principal at both doors. The platform admin is granted the ADR-0068 way (org-less sys_user_permission_set -> admin_full_access) and the fixture ASSERTS sys_user.role is NOT 'admin', so the suite cannot pass by riding the retired channel. ABLATION, predicted signature stated BEFORE running, both directions, with git hash-object proof on every leg — pre-ablation da7f6561c3b47e02188ac02b09019afcd1f6201e; leg A (narrowing absent, judge admits all) 7e29fb567de19defff1f920f73ee94df33058c1c predicted 'refusal pins RED, admission pins GREEN' and observed 'Tests 13 failed | 227 passed' with 'expected 422 to be 403' x2 and 'expected true to be false' x11, pins (2) and (3) absent from the FAIL list i.e. GREEN; leg B (gate refuses everyone) 351124571c5f65344b0f80df9a08771e7a4ef062 predicted 'admission pins RED, every refusal pin GREEN' and observed 'Tests 3 failed | 237 passed' with 'expected 403 to be 422' and 'expected false to be true' x2 — every refusal pin green, which is the direct demonstration that a one-directional suite would have been blind here; RESTORE leg da7f6561c3b47e02188ac02b09019afcd1f6201e — BYTE-IDENTICAL to pre-ablation, no ABLATION marker left in the file, and re-run green 'Tests 240 passed (240)'. REBUILD STATEMENT, argued from the files: AuthManager is imported RELATIVELY as './auth-manager.js' (line 62 of the new test) and there is no vitest alias in plugin-auth's vitest.config.ts or the root, so vitest executes the MUTATED SOURCE, not a dist/ artifact — the dist-staleness hazard does not apply to the mutated module and no rebuild could change what these legs measured; the cross-package imports (@objectstack/objectql, /platform-objects, /spec, /core) do resolve through dist and WERE built (closure build 'VERDICT command-exit 0'), but none of them is the mutated module. PACKAGE VERIFICATION on the merged tree: 'pnpm --filter @objectstack/plugin-auth typecheck' clean (no 'error TS'), 'vitest run' -> 'Test Files 61 passed (61)' / 'Tests 1323 passed (1323)'. GATE UNION: node scripts/pm/dispatch-gates.mjs with NO paths passed, run AFTER the final commit on a CLEAN worktree, at be985a623 — EXIT=0 for check:changeset-gate-self-tests, check:objectui-changeset, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs, check-affected-docs.mjs; convention-triggered by the new test files, all EXIT=0: 'query-options-erasure ratchet holds: 67 unswept non-test site(s) ... none new', 'check-engine-double-contract: OK — 338 pinned, 133 in the DEBT ledger, 2 exempt.', 'where-matcher conformance holds: 265 matcher(s) discovered ... 0 silently-wrong', 'check-type-check-coverage: OK — 64/77 workspace packages type-checked', 'check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 232.7s, 1923 raw tsc error(s) total, none above its recorded number.' NOTE ON A PREREQUISITE, not reported as a pass: check:type-check-debt first REFUSED with '--re-measure cannot run: 30 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk' — a precondition, so I satisfied it exactly as lint.yml does (turbo run build over ./packages/* and ./packages/*/* -> 'Tasks: 70 successful, 70 total') and re-ran to the EXIT=0 above. IN-REPO CONSUMER SWEEP, each search validated against a known-present control FIRST: objectstack (control 'admin/sso/register' -> 9 files) 0 direct callers, every hit being prose/ADR text or the BRIDGE target including both sys_sso_provider metadata actions; objectui console (control 'api/v1/auth' present) 0; objectos docs (control present) 0; examples//SDK/apps (control 'signIn' present) 0. BOUNDED STATEMENT: zero in-repo callers of the direct endpoint across the three checked-out repos; deployments are NOT visible from here, so this does not establish that no deployed org admin uses it — it is only the repo-measurable half of the confidence gap triage recorded.", "open_questions": [], "out_of_scope_findings": [ "NOT filed (deliberately, with reasoning): check:type-check-debt reports '@objectstack/plugin-auth: TEST_DEBT records 109, tsc now reports 108 (-1) -- the entry can be lowered', with the standing hazard that while the gap is open 1 new error can land and the gate still reports success. I left the ledger at 109 per the dispatch prohibition and did not file an issue because the gate itself names the tracking issue for this exact class (#6376) and prints this line for every package whose measured count has drifted below its recorded number — a per-package card would be duplicate noise. Flagging here so the PM can decide; closing it is one command, `pnpm check:type-check-debt --lower`.", "No other out-of-scope defects encountered. The #10352 file overlap is a serial-coordination note, not a defect — see the PR body's 'Serial note'." ] }Reviewer orientation (prose, outside the JSON)
- ⛔ PR security(plugin-auth): gate direct /sso/register on platform admin only #10390 is a draft and must NOT be auto-merged — Clause-② accept-set narrowing on an auth surface, developed at
claude-opus-5under the 2026-08-20 authorization while fable is exhausted. It waits on triage review. The accept→reject narrowing and who loses access are the first thing in the PR body, as a table, not a footnote. ⚠️ File overlap with open PR fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) #10352 (impersonate_userandset_user_rolestill 403 every platform admin — neither route is safely raw-mountable, and each blocks for a different reason #9968) onpackages/plugins/plugin-auth/src/auth-manager.ts.auth-manager.tsis not oncheck-single-claim-paths.mjs's explicit list, so the single-writer gate blocks neither side.origin/mainwas merged into this branch before the final commit; fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) #10352 had not landed. Both branches add a privateisPlatformAdminUserIdwith the same body — keep one. fix(plugin-auth): admit ObjectStack platform admins on /admin/impersonate-user (better-auth plugin endpoint, not a raw mount) #10352 also references{@link isOrgOrPlatformAdmin}in a docblock, which this PR removes, so that line needs updating on whichever side lands second.
Generated by Claude Code
Generated by Claude Code
- ⛔ PR security(plugin-auth): gate direct /sso/register on platform admin only #10390 is a draft and must NOT be auto-merged — Clause-② accept-set narrowing on an auth surface, developed at
PM 验收:ACCEPT(PR #10390 保持 draft,等分诊审核)
按内容核对,不采信报告自述。逐条:
①
packages/spec未被触碰 —— 用 PR 的文件清单本身证实,不是引用报告的说法。 #10390 共 4 个文件:.changeset/sso-register-platform-admin-only.md、auth-manager.ts、auth-manager.test.ts、新增sso-register-platform-admin-gate.test.ts。清单里没有packages/spec/**,也没有content/docs/releases/**。车道红线(零packages/spec所有权)成立 —— 这里能成立的原因是SSO_REGISTER_FORBIDDEN本来就已在册,复用既有条目,没有新错误码要注册。② 与 #10352 的同名碰撞:属实,我去读了 #10352 的真实 diff 才确认的。 #10352 确实在
auth-manager.ts里新增private async isPlatformAdminUserId(userId: string),方法体与本 PR 逐行一致。它的 docblock 也确实写着 "Deliberately NARROWER than{@link isOrgOrPlatformAdmin}" —— 而本 PR 正是删掉isOrgOrPlatformAdmin的那一侧。两条串行注记都不是自述,是核对过的。③ 披露纪律:本卡不适用「禁复现步骤」。 我核对了 #10009 卡面本身 —— 它是
finding起草的,正文已经把分歧完整写在公开卡面上(org owner 在 bridge 被 403、在直连端点仍被放行,同一次注册)。PR 正文的 PREMISE 没有超出卡面已公开的内容,所以 #9391/#9756/#9714/#10069 那一族的 ⛔ 约束不落在这张卡上。这个结论的依据是「卡面已经这么写了」,不是「看起来还好」。④ 二向消融是这张卡的关键,不是仪式。 leg B(闸门对所有人说不)下 每一条拒绝断言都是绿的,只有放行 pin 变红。这正是「只测拒绝方向的套件在这里会全盲」的直接证据 —— 而这个 hook 在本卡之前一条 pin 都没有(
SSO_REGISTER_FORBIDDEN除了自己那句throw之外全仓无出现)。恢复态git hash-object与消融前逐字节相同。⑤
isOrgOrPlatformAdmin被删而非留成死代码,是编译器逼出来的(noUnusedLocals→ TS6133),报告如实上报而不是藏起来。#5942 的实质没有丢:那把唯一的等级尺(isOrgAdminGrade)在member-role-canonical.test.ts仍有直接 pin,last-admin-guard.ts仍在读它。为什么不合并
Clause-②(auth 接受集收窄)。按维护者 2026-08-20 的授权 —— fable 用尽时可用 opus 开发、等分诊审核 —— 我在 claim 里明示的补偿控制原样生效:⛔ PM 不翻 ready、不武装 auto-merge。
CI 当前无红(13 绿、5 运行中、2 skipped),所以拦住它的不是 CI,是这条设计上的暂扣。
一条我按「残留即放行条件」处理的项
两个分支都往
auth-manager.ts同一区域加同名方法,后落地的一侧必然要人工解决。我不把它记成观察项,记成放行条件,写在 PR #10390 上了。
Generated by Claude Code
Triage seat: hung
needs:contract-review— delivering PR #10390 declares Clause-② (accept-set narrowing on an auth surface: org owners/admins lose direct/sso/register, per the maintainer's option-(a) ruling) and was developed atclaude-opus-5under the 2026-08-20 fable-exhaustion authorization; compensating control is contract-review-tier review before landing. Recorded at the services PM's request. Serial note for the reviewing sub-round: PR #10390 and PR #10352 deliberately both addisPlatformAdminUserId(keep-one merge resolution documented in both bodies) — whichever lands second needs the docblock touch-up. PR stays draft until the label is cleared at the verified tier; this fire's sub-round is skipped (tier fuse reading unavailable — parked is safe).
Generated by Claude Code
Contract review (triage seat): PASS —
needs:contract-reviewcleared; the services seat may proceed to ready/queue PR #10390.Authorization: tier-fuse machine reading unavailable in this environment; maintainer ordered the review live (「你应该处理所有的 needs:contract-review 这些」) — recorded human release. Reviewer is not the dispatching seat.
Reviewed the accept-set narrowing against the diff:
- The narrowing is the maintainer's option-(a) ruling, implemented exactly: the
/sso/registerbefore-hook now asks onlyisPlatformAdminUserId, whose body reads solely the ADR-0068 D2 channel (org-lessadmin_full_accesslink), refuses an org-SCOPED grant (pinned — the subtle case), refuses empty ids without a read, and fails closed on any lookup error. The legacy scalar is deliberately not consulted here, per the method's own ⛔ note. - Two-directional at the surface: org owner 403 +
SSO_REGISTER_FORBIDDEN(code AND status), platform admin admitted (proven offline via the vendor's reserved-id 422, which sits after the whole authorization prologue), anonymous still the vendor's 401, and the both-doors alignment case that is this card's entire point. The fixture asserts the legacy scalar is NOT'admin', so the suite cannot pass on the retired channel. isOrgOrPlatformAdminremoved rather than parked (compiler-verified callerless), with 「这个 membership 是不是管理员」有两种拼写,大小写敏感性不同:isOrgOrPlatformAdmin对role='Owner'答否,等级尺答是 #5942's grade-ladder coverage intact at its own home and the replacement test block pinning the org spellings as refusals so the narrowing cannot silently regress.
Merge-serial note (for whichever lands second): PR #10352 adds a near-identical
isPlatformAdminUserIdbut KEEPSisOrgOrPlatformAdmin(its docblock links to it), while this PR removes that method. The documented keep-one resolution applies, and the second lander must also reconcile the docblock — the conflict onauth-manager.tswill surface it.
Generated by Claude Code
- The narrowing is the maintainer's option-(a) ruling, implemented exactly: the
{ "issue": 10009, "status": "done", "branch": "claude/issue-10009-sso-register-platform-admin-only", "pr": "https://github.com/objectstack-ai/objectstack/pull/10390", "premise_still_valid": true, "summary": "Merge-conflict reconciliation only; no behaviour change. Merged origin/main (base 72d75ebb5) into the branch with a merge commit (no rebase/amend/force-push) and resolved the #10352 overlap in packages/plugins/plugin-auth/src/auth-manager.ts: exactly ONE isPlatformAdminUserId definition survives (line 4387) with the two call sites both sides needed - line 1552 (this PR's /sso/register hook) and line 2625 (#10352's /admin/impersonate-user rewire). Both sides had byte-identical bodies. The branch's docblock won, which is why #10352's '{@link isOrgOrPlatformAdmin}' sentence is gone rather than stripped - the branch had already authored the link-free replacement. Pushed be985a623..c231374d9 as a fast-forward. PR mergeable_state moved dirty -> blocked (conflict cleared); still draft, auto-merge NOT armed, nothing merged. NOTE: a previous lost agent left an unpushed local merge ae29ca989 in worktree /home/user/objectstack-10390; I audited it (its resolution was correct), kept it as the first parent of my work rather than destroying it, and merged the 25 newer main commits on top.", "tests": "All at final commit c231374d9, clean tree, exit codes captured before any pipe. TYPECHECK: pnpm --filter @objectstack/plugin-auth typecheck EXIT=0 (script echoed 'tsc --noEmit', so not a zero-match no-op). SUITE: pnpm --filter @objectstack/plugin-auth test EXIT=0 -> 'Test Files 62 passed (62)' / 'Tests 1347 passed (1347)'. Expected was 61/1323; the delta is fully accounted for - #10352 added admin-impersonate-endpoint.test.ts and modified four more plugin-auth test files. MEASUREMENTS (grep -c in auth-manager.ts) branch be985a623 / origin-main / merged c231374d9 :: isPlatformAdminUserId 2/2/3, isOrgOrPlatformAdmin 1/3/1, isOrgAdminGrade 0/3/0. Exactly one definition: grep -c 'private async isPlatformAdminUserId' = 1. CONTROLLED GREP: 'grep -rn \"{@link isOrgOrPlatformAdmin\" src/' -> exit 1, zero hits; positive control 'grep -rn isPlatformAdminUserId src/' -> exit 0, 6 hits, so the search demonstrably works. GATE UNION (dispatch-gates.mjs, no path args, after final commit): EXIT=0 for check:changeset-gate-self-tests, check:objectui-changeset, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs, check-affected-docs.mjs, plus convention-triggered check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage. Verdict lines: 'check-dispatcher-error-vocabulary: OK - 21 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846).' | 'check-engine-double-contract: OK - 371 pinned, 133 in the DEBT ledger, 2 exempt.' | 'where-matcher conformance holds: 271 matcher(s) discovered, 271 answer the combinator battery correctly or refuse it loudly (162 refuse).' | 'check-type-check-coverage: OK - 64/77 workspace packages type-checked'. CLASS #10309: the derivation did NOT name check:route-envelope or check:dispatcher-error-vocabulary; both run explicitly, all four invocations EXIT=0 including --self-test. DECLARED NARROWING: check:type-check-debt --re-measure NOT RUN - needs the full workspace closure built under the container-shared verify lock, held continuously by sibling agents; when attempted it entered the per-entry tsc sweep and was still running at a 420s cap, so it is NOT MEASURED (neither pass nor fail). tsc --noEmit clean for this package stands in its place; CI runs the ratchet regardless. TEST_DEBT for @objectstack/plugin-auth untouched at 109 (#10615); no --lower run. No ablation re-run: this task changed no logic, so the PR's existing ablation still measures the shipped behaviour.", "open_questions": [], "out_of_scope_findings": [] }Two dispatch premises corrected by measurement
1. "
isOrgOrPlatformAdminmust occur zero times in the package" — not achievable, and should not be. It occurs 3 times, all prose:last-admin-guard.ts:96,auth-manager.ts:4375,auth-manager.test.ts:3946. Every one is a backticked historical mention that explicitly states the predicate is gone (e.g. "That predicate is gone; this guard is unchanged and still counts both"). None is a{@link}; none is a code reference. The hazard the dispatch names — a dangling{@link}no gate and no compiler catches — is at zero, proven with a positive control. Driving the raw count to zero would mean deleting deliberate, accurate explanation authored by this reviewed PR, which is diff-widening, so I left it. Flagging for your call.2. The dispatch's expected shape for
mainwas right; the branch's was not.mainafter #10352 is indeedisPlatformAdminUserId×2 andisOrgOrPlatformAdmin×3, as predicted. But the branch head wasisOrgOrPlatformAdmin×1, not ×0 — that one prose mention. Nothing turned on it, but the merged count of ×1 is expected, not a leftover.One thing I stopped on and did not do
The PR body's Verification section still cites the pre-merge sha
be985a623and the old61 files / 1323 tests. I did not rewrite the body: it is ~10KB and already carries sanitizer-encoded entities ('), so a full-body round-trip risked corrupting reviewed text for no functional gain. The corrected record is posted as a PR comment instead (#10390#issuecomment-5367733374). Say the word if you want the body itself rewritten.Also for the record: a previous lost agent's unpushed merge
ae29ca989was sitting in worktree/home/user/objectstack-10390. I audited rather than trusted it — its resolution was correct — and built on it instead of discarding it.
Generated by Claude Code
os-dev-report
(Marker fallback: the HTML-comment marker on my previous report,
#10009#issuecomment-5367738791, was stripped by the GitHub body sanitizer after it landed, so that comment is invisible to a marker scan. This comment carries the same report and begins with the literal text instead. The prose annexes — the two corrected premises and the PR-body note — are on that earlier comment and still stand.){ "issue": 10009, "status": "done", "branch": "claude/issue-10009-sso-register-platform-admin-only", "pr": "https://github.com/objectstack-ai/objectstack/pull/10390", "premise_still_valid": true, "summary": "Merge-conflict reconciliation only; no behaviour change. Merged origin/main (base 72d75ebb5) into the branch with a merge commit (no rebase/amend/force-push) and reconciled the #10352 overlap in packages/plugins/plugin-auth/src/auth-manager.ts: exactly ONE isPlatformAdminUserId definition survives (line 4387) with the two call sites both sides needed - line 1552 (this PR's /sso/register hook) and line 2625 (#10352's /admin/impersonate-user rewire). Both sides had byte-identical bodies. The branch's docblock won, which is why #10352's '{@link isOrgOrPlatformAdmin}' sentence is gone rather than stripped - the branch had already authored the link-free replacement sentence. Pushed be985a623..c231374d9 as a fast-forward. PR mergeable_state moved dirty -> blocked (conflict cleared); still draft, auto-merge NOT armed, nothing merged. NOTE: a previous lost agent left an unpushed local merge ae29ca989 in worktree /home/user/objectstack-10390; I audited it (its resolution was correct), kept it rather than destroying it, and merged the 25 newer main commits on top.", "tests": "All at final commit c231374d9, clean tree, exit codes captured before any pipe. TYPECHECK: pnpm --filter @objectstack/plugin-auth typecheck EXIT=0 (script echoed 'tsc --noEmit', so not a zero-match no-op). SUITE: pnpm --filter @objectstack/plugin-auth test EXIT=0 -> 'Test Files 62 passed (62)' / 'Tests 1347 passed (1347)'. Expected was 61/1323; delta fully accounted for - #10352 added admin-impersonate-endpoint.test.ts and modified four more plugin-auth test files. MEASUREMENTS (grep -c in auth-manager.ts) branch be985a623 / origin-main / merged c231374d9 :: isPlatformAdminUserId 2/2/3, isOrgOrPlatformAdmin 1/3/1, isOrgAdminGrade 0/3/0. Exactly one definition: grep -c 'private async isPlatformAdminUserId' = 1. CONTROLLED GREP: '{@link isOrgOrPlatformAdmin' in src/ -> exit 1, zero hits; positive control isPlatformAdminUserId in src/ -> exit 0, 6 hits, so the search demonstrably works. isOrgOrPlatformAdmin still occurs 3x in the package but ALL PROSE (last-admin-guard.ts:96, auth-manager.ts:4375, auth-manager.test.ts:3946), zero code refs and zero {@link}. GATE UNION (dispatch-gates.mjs, no path args, after final commit): EXIT=0 for check:changeset-gate-self-tests, check:objectui-changeset, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs, check-affected-docs.mjs, plus convention-triggered check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage. Verdict lines: 'check-dispatcher-error-vocabulary: OK - 21 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846).' | 'check-engine-double-contract: OK - 371 pinned, 133 in the DEBT ledger, 2 exempt.' | 'where-matcher conformance holds: 271 matcher(s) discovered, 271 answer the combinator battery correctly or refuse it loudly (162 refuse).' | 'check-type-check-coverage: OK - 64/77 workspace packages type-checked'. CLASS #10309: the derivation did NOT name check:route-envelope or check:dispatcher-error-vocabulary; both run explicitly, all four invocations EXIT=0 including --self-test. DECLARED NARROWING: check:type-check-debt --re-measure NOT RUN - needs the full workspace closure built under the container-shared verify lock, held continuously by sibling agents; when attempted it entered the per-entry tsc sweep and was still running at a 420s cap, so it is NOT MEASURED (neither pass nor fail). tsc --noEmit clean for this package stands in its place; CI runs the ratchet regardless. TEST_DEBT for @objectstack/plugin-auth untouched at 109 (#10615); no --lower run. No ablation re-run: this task changed no logic, so the PR's existing ablation still measures the shipped behaviour.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
Generated by Claude Code
PM 结案:MERGED
PR #10390 已合并(
~09:15Z)。本席已unsubscribe_pr_activity。落地形态,在
origin/main上核对(不是引用 PR 自述)main@86a8ec93e,packages/plugins/plugin-auth/src/auth-manager.ts:private async isPlatformAdminUserId -> 1 (唯一定义,:4387) 调用点 -> :1552 本卡的 /sso/register 闸门 :2625 #10352 的 /admin/impersonate-user 改接 {@link isOrgOrPlatformAdmin} -> 0 (exit 1) 阳性对照 {@link}(同一文件) -> 24 ⇒ 搜索有效,零不是搜错了keep-one 达成,且两侧各自需要的调用点都活着 —— 这是这次串行收尾唯一真正的风险点:两个分支往同一区域加同名方法,谁后落地谁必须人工解决,而「解决」被误做成「删掉另一侧的调用点」是最容易发生的形态。没有发生。
那条悬空
{@link}是本次收尾唯一编译器和门禁都抓不到的危险(noUnusedLocals/TS6133 只抓代码引用,docblock 不在其射程内)。它是零这件事必须带阳性对照证明,否则「grep 没命中」和「grep 没生效」无法区分。两条被推翻的前提 —— 是我写错,dev 顶了回来
- 我在派发里要求「
isOrgOrPlatformAdmin在包内出现零次」。这条指令是错的。 包内仍有 3 处散文提及(last-admin-guard.ts:96、auth-manager.ts:4375、auth-manager.test.ts:3946),每一处都在明说该谓词已被移除 —— 没有一处是{@link},没有一处是代码引用。清到零等于删掉经评审的刻意解释。正确判据是零代码引用 + 零{@link}。dev 拒绝执行并说明理由,我采纳。 - 分支 head 上
isOrgOrPlatformAdmin本就是 ×1(一处反引号散文)而非 ×0,所以合并后的 ×1 是预期值而非残留。
记在这里而不只写在 PR 上,是因为这类指令我还会再写第二次:「某标识符归零」几乎总是错的判据,正确的判据要指明归零的是哪一类引用。
补偿控制的完整路径
Clause-②(auth 接受集收窄)在 fable 用尽期间按维护者 2026-08-20 授权在
claude-opus-5上开发 ⇒ PR 全程 draft ⇒ 分诊席(非派发席)2026-08-21T00:40:55Z 按内容评审、贴 PASS、清needs:contract-review⇒ PM 才翻 ready + 武装合并队列(08:59:21Z)⇒ 队列合并。⛔ PM 全程未自评审、未自清标、未绕队列、未合并自己的 PR。CI 对那条「未测到」的收窄给出了真读数
dev 如实声明
check:type-check-debt --re-measure未测到(不是拒跑:进入逐条 tsc 扫描后在 420s 上限时仍在跑,全工作区构建锁被兄弟 agent 连排 3 深占用,其间一次 9 分钟队列超时 exit 99),替代物是tsc --noEmitexit 0。CI 的Type Check · debt ledger无条件跑该棘轮,success(08:57:30Z)。声明出来的收窄由 CI 闭合,这正是武装队列而非直接合并的意义。@objectstack/plugin-authTEST_DEBT 保持 109(#10615)未动,--lower未跑。class #10309 —— 第六次坐实
dispatch-gates.mjs(不带路径参数)推导出的并集再次未点名check:route-envelope与check:dispatcher-error-vocabulary,而两者都咬plugin-auth。四次调用(含--self-test)全 EXIT=0,全靠派发里手写补进去才跑上。⚠️ 一条留给 devx 车道的反向读数:#10449 的 ESLint 爆栈本地未复现 —— 该门禁自身 self-test 打印packages/spec/src/migrations/registry.ts parses at --stack-size=4000 through this gate's own channel。与 CI 上记录的现象不一致,值得在 #10449 上核一下是不是环境相关。
Generated by Claude Code
- 我在派发里要求「
- added a commit that references this issue
on Aug 23, 2026
Observed 2026-08-19 while implementing #9653 (the four
/admin/sso/*bridges now gate on the shared ADR-0068 platform-admin judge before delegating). Filed unassigned,finding— this records a posture divergence between two surfaces that reach the same operation; whether to align them is a policy decision.What the two surfaces now answer
POST /api/v1/auth/sso/register(better-auth's own endpoint, served by the catch-all) is guarded by the ADR-0024 before-hook inauth-manager.ts(ctx.path === '/sso/register'), whose admit set is platform admin OR org owner/admin of the caller's active org (isOrgOrPlatformAdmin). That hook was deliberate when it landed: better-auth's own model treats org-scoped SSO providers as org-admin-manageable.POST /api/v1/auth/admin/sso/{register,register-saml}(the ObjectStack bridges thesys_sso_provideractions call) now gate on platform admin only (judgePlatformAdmin), per #9653's triage adjudication: ADR-0068 D4 — "platform-operator actions gate onisPlatformAdmin(sole operator)", and registering an identity provider is a platform-operator action.So after #9653 an org owner/admin who is not a platform admin is refused
403 PERMISSION_DENIEDat the bridge, while the direct endpoint still admits them for the same underlying registration. The bridge tightening is therefore not a boundary for that caller class — it is honest labelling on the surface the console uses, with the vendor-native surface still carrying the wider ADR-0024 admit set.Why it is worth recording rather than fixing in #9653
auth-manager.ts) with its own history and its own rationale (org-scoped providers are org-admin-manageable by the vendor's design; the hook's comment block says why it exists).Related context: #9653 (the bridge gate + the measured vendor posture on the installed
@better-auth/sso1.7.1), ADR-0024 (open SSO mechanism), ADR-0068 D4 (platform-operator actions).