Repository navigation
Description
Full identity-auth area run of the checklist-test skill — all 14 items (12 runnable + 2 ledger-blocked) driven against a live showcase across 3 opus subagents, each on its own port + file DB. Text-only per RUNNER.md.
Result: 5 PASS · 4 PARTIAL · 3 FAIL · 2 BLOCKED.
Environment — framework 92f26f75/f268b2b2 (branch claude/platform-test-checklist-ocwugl) · console bundle 09987b680 (packages/console/dist/.objectui-sha) · showcase app · isolated port + file DB per batch · personas provisioned as real sign-ups and real in-product role writes, not stubs.
Environment fact that shaped this whole area, stated up front: the seeded dev admin is promoted to the ObjectStack platform admin, but its better-auth
sys_user.rolestays'user'— so the entire better-auth-native admin family (list-users,ban/unban,set-role,remove-user,revoke-user-sessions,impersonate-user) 403s out of the box. Framework-overridden endpoints (create-user,set-user-password,import-users) work regardless. Rather than record sixblocked, the runner provisioned a genuine admin with one direct write to its own throwaway DB — the same system-context write the pinned dogfood harness performs — and judged the clauses on merit.
✅ PASS — 5 items
self-service-password-reset(5/5 + all four negatives) — known and unknown addresses return byte-identical bodies (asserted by string comparison, not by eye) andsys_emailholds 1 artifact for the known address / 0 for the unknown, so nothing is sent for a non-account while the HTTP response stays constant. Token reuse → 400 and the password it tried to set never became valid.self-service-profile-password(6/6 + four negatives) — name and avatar persist across a full sign-out/sign-in into a new browser context; the avatar is a genuine presign/PUT/commit cycle, not adata:URL. The item'sknownGapwas closed by provisioning a passwordless persona (deleting the credentialsys_accountrow), proving theset-initial-passwordbranch works and 409s in both directions.sso-enforced-first-paint(5/5) — no password flash: 64 samples across two independent fresh contexts (24 @100 ms fromdomcontentloaded, 40 @50 ms fromcommit) all showedinput[type=password]= 0, with a CDP screencast confirming the page was genuinely painting. The sampling is proven sensitive because the unenforced boot shows the password field at the very first sample. The hung-IdP timeout was driven by intercepting the handshake and never fulfilling it — button disabled to ~20 s, then re-enabled with the running bundle's own watchdog message. Caveat: exercisesssoEnforcedwith a social (Google) provider;features.ssostays false because nooidcProviders[]lever exists on the dev path.phone-signin-surfaces(4/4 + both negatives) — the fix(showcase): stop passing a non-existent org column when seeding the phone demo user #3408 seed regression has not returned (checked by API before the browser). The create-user dialog is not a client echo — the network shows thephoneNumbergoing out and an independent re-read confirms it persisted. The feat(spec,platform-objects): action param 'visible' predicate; gate create-user phone on features.phoneNumber #2871 gate was proven both ways by cold-booting twice: withfeatures.phoneNumber:falsethe dialog has no phone field at all, and/meta/object/sys_usershows the param carriesvisible:{dialect:'cel', source:'features.phoneNumber == true'}— so the console is evaluating the CEL gate against the served flag.org-membership-team-management(6/6 + four negatives) —{current_org_id}resolves to the active org id (the nav href is the record route, not the raw list); role changes bite in both directions; team membership keys on the(team,user)pair (a duplicate add returns the same row id, count unchanged);remove-memberflips org-scoped reads 200→403 on both the live token and a fresh sign-in. All seven forged management calls refused server-side with a byte-identical before/after state snapshot.
🔴 FAIL — 3 items
1. admin-lifecycle-operations — remove-user is non-atomic and can never succeed
Reproduction rule — as a genuine better-auth admin: POST /auth/admin/create-user {email:…} → 200; create a record owned by that user; then POST /api/v1/auth/admin/remove-user {userId}.
Expected 2xx, sys_user gone, the owned record surviving with owner_id null (engine FK clear).
Actual HTTP 500 with an empty body; sys_user still readable; the record still carries owner_id; and the user's credential sys_account rows have already been deleted — so that email can no longer sign in (401) while still occupying the org roster. Reproduced twice; verified across three victims (accounts=0 for all three vs accounts=1 for untouched users).
Server log gives the real cause: Cannot delete sys_user (<id>): 1 dependent sys_member record(s) reference it via user_id (user_id is required, so it cannot be cleared)… set deleteBehavior:cascade on sys_member.user_id with {code:DELETE_RESTRICTED, status:409} — a 409 leaking to the client as a bodyless 500.
Three compounding problems: (i) every user is auto-bound to the default org at sign-up/create, so remove-user can never succeed on this deployment; (ii) the operator cannot clear the blocker either — DELETE /data/sys_member → 405 with allowed:[get,list,aggregate,history,export]; (iii) the account/session deletions are not rolled back when the sys_user delete is refused, leaving an orphaned, un-authenticatable identity on the roster.
This is not the #3023 owner-anchor regression — the veto comes from the engine's referential restrict on sys_member.user_id, not from the owner-anchor transfer guard.
Also failing in this item — impersonation is invisible in the console. With an impersonation server-confirmed in that very browser context (an in-page get-session returns the target's email and session.impersonatedBy=<admin id>), /_console/home renders "Good morning, Dev Admin." with avatar DA — the admin's identity — and the DOM contains no banner, no "Stop impersonating" control, and the impersonated user's name nowhere. Reproduced twice in fresh contexts with hard reload + networkidle + 3 s settle. The console also offers no Impersonate entry point at all. Support sessions are silent in this build. Not compared against objectui origin/main, so this may already be fixed upstream.
Everything else in this item passes: ban/unban is reversible and enforced at sign-in; set-role is authoritative in both directions (probed as the target, bracketing the write); revoke-user-sessions kills the live session immediately, disproving the "only stops new logins" negative; create-user's explicit password wins over the generated one (#3031/#3033 holds); all 8 forged non-admin operations 403 with state byte-identical.
2. invitation-scope-gates — pending → accepted is unreachable
The delegated-admin scope itself is correct: a real in-product delegated_admin can invite a member (200, exactly one row attributed to the delegate) but cannot invite an admin (403 with the ADR-0090 D12 message) and leaves zero orphan rows; a plain member is refused entirely.
(a) accept-invitation → HTTP 500 with an empty body, and the row stays pending forever. Reproduced twice with fresh emails. Server log: insert into sys_member … UNIQUE constraint failed: sys_member.organization_id, sys_member.user_id — acceptance re-inserts a membership the sign-up reconciler already created. Suspected fix: acceptance should upsert/adopt the existing sys_member row (or skip the insert when the invitee is already a member of the target org). Same bodyless-500 statusCode-leak class as the remove-user failure above.
(b) cancel-invitation writes status: 'canceled', a value absent from the spec enum. InvitationStatus = z.enum(['pending','accepted','rejected','expired']) (organization.zod.ts:105) is what this item's source names as the authority, while the platform object declares a divergent 5-value select including canceled (sys-invitation.object.ts:214). Anything validating an invitation against InvitationSchema rejects a canceled row.
Resend behaves correctly (expiry extended, still exactly one row, a second sys_email artifact). Clause 4 is blocked(fixture): no org-role persona can reach the Setup app at all ("App not available", 0 nav entries) because of the everyone-anchor baseline, so the entitled and unentitled UI halves cannot be discriminated.
3. api-key-ui-lifecycle — API keys cannot be revoked through any product route
Reproduction rule — POST /api/v1/keys {name} → 201, then PATCH /api/v1/data/sys_api_key/<id> {revoked:true} as admin.
Expected 200 and the key stops authenticating. Actual 405 OBJECT_API_METHOD_NOT_ALLOWED, allowed:[get,list,aggregate,history,export]. Same 405 for {revoked:false} (restore). Reproduced 3×, including through the real UI — Setup → API Keys → Open menu → Revoke API Key → Continue produces an error toast, the key keeps authenticating, and the row still reads revoked=false.
Root cause. packages/platform-objects/src/identity/sys-api-key.object.ts declares actions revoke_api_key / restore_api_key as PATCH /api/v1/data/sys_api_key/{id} (lines 41-72) while the same file sets enable.apiMethods = ['get','list'] (line 219) with the comment "#1591 — reads only: writes are refused… HTTP answers 405 before the 403". The two halves of the file contradict each other. No alternative route exists: route-ledger.ts carries only POST /keys; /api/v1/auth/api-key/* and DELETE /api/v1/keys/{id} all 404.
Enforcement of the flag is fine — setting revoked=1 out-of-band makes the very next x-api-key call 401 UNAUTHENTICATED — so the fix is either to allow update scoped to the revoked column, or to give revoke/restore a dedicated auth route.
Nothing pins this today: the unit tests (http-dispatcher.keys.test.ts, resolve-execution-context.test.ts) exercise key resolution with a pre-revoked row and never call the PATCH route the action declares.
The rest of the item is clean: show-once holds (get-by-id, list and a reloaded DOM never carry the raw secret), and a key carries exactly its owner's permissions — an org_member-owned key reproduces its owner's 403, and get-session via x-api-key returns null. Secondary finding: the key column (a SHA-256 hash) does serialize over the data API on both get-by-id and list, despite being declared Field.text({hidden:true, description:'Hashed API key value - never exposed to clients'}). The spec defines hidden as "Hidden from default UI", so this is not a hidden-contract violation — but the item's parenthetical claim is false on this build. Admin-readable only on this app.
🟡 PARTIAL — 4 items
teams-bu-membership — the BU tree is load-bearing, but revocation is lazy
Measured matrix: inSubtree=1, reparentedOut(no touch)=1, reparentedOut(touched)=0, restored(no touch)=0, restored(touched)=1.
The risky direction: after a business unit is moved out of a shared subtree, its members keep read access until the record is next written.
Root cause (located). The sharing-rule rebind triggers are bound to data changes on the rule's object (the boot log binds them for showcase_project, showcase_inquiry, showcase_task) but not to sys_business_unit_member or sys_business_unit changes — so tree and membership edits never enqueue a recompute for rules with unit_and_subordinates recipients.
The item's knownGap can be retired: it anticipated that no showcase geometry might consume BU membership, but share_new_inquiries_with_field_ops does, so the clause ran. Seed note for the next sweep: a fresh boot seeds the BU tree but zero sys_business_unit_member rows, so the rule materializes nothing until a tester places someone — that is the seed-data-thin trap for this item.
auth-method-matrix
Every flag with a boot switch matched the config that booted it; 13 forged disabled-method endpoints all 404 across two boots with no silent 200; the 2FA challenge interrupts before a session is granted (the accompanying token is provably unusable); both .well-known documents are byte-identical and every advertised endpoint was probed and resolves.
Three partials: (i) /auth/config still advertises passkeys:false/magicLink:false — a stale prebuilt plugin-auth/dist/index.js, not a regression (src dropped them in 86f7a20e); (ii) the phone-OTP NOT_SUPPORTED branch is unreachable because isPhoneOtpDeliverable() admits LogSmsTransport outside production — proven not a silent success (the code appears in the transport log, a resend is 429, and the captured code completes a real verify); (iii) change-email and delete-user are booked as mounted in auth-route-ledger.ts (lines 118/120) but plugin-auth never configures better-auth's user.changeEmail/user.deleteUser — 0 grep hits in both src and dist — so they answer 400 CHANGE_EMAIL_DISABLED / 404 with no switch to enable them.
session-list-revoke
Revocation is immediate, token-scoped and server-enforced on both bearer and cookie transports (401 on a protected route, not merely absence from the list); revoke-other-sessions leaves exactly the caller alive and never touches another user; sys_session writes are refused as a method gate (405, same for admin — not a permission accident).
Two gaps: (i) no ordinary persona can read sys_session at all (403 for [org_member, everyone]), so raw-table RLS for a non-admin is unobserved — the admin mine view (12 of 50 rows) was substituted; (ii) sys_session.revoked_at/revoke_reason are declared readonly and "System-managed" but have no writer anywhere in the tree and appear in no listView — better-auth deletes the row rather than tombstoning — so the ADR-0069 D4 revoke-audit trail is inert, an enforce-or-remove candidate. Also: Setup → Sessions lands on mine rather than all_sessions because nav_sessions carries no viewName, so an admin sees only their own sessions until they notice the "All" tab.
identity-import-wizard
Both invite channels proven in one batch (delivery {emailInvite:1, smsInvite:1}, the reset link captured from sys_email and the invite SMS from the transport log); the temporary policy gives every row a distinct one-time password via API and through the real wizard; both credential routes then authenticate; upsert is idempotent on the same ids with no duplicates; non-admin 403 / anonymous 401 with zero users created.
Strongest oracle of the run: a byte-level grep for all four plaintext passwords across data.db, data.db-wal, data.telemetry.db, data.telemetry.db-wal and boot.log returned 0 matches — against a positive control that matched 25 times, proving the search reached the stored bytes.
The one unproven half is the auto policy's temporary-fallback branch, which is structurally unreachable on objectstack dev: both transports always register, and dev.ts hard-sets NODE_ENV='development' so isPhoneOtpDeliverable() cannot be made false. Suggest recording this as a fixture note on the item so the next sweep does not re-derive it.
⛔ BLOCKED — 2 items
oauth-app-consent-loop and linked-accounts-social — both carry a ledgered blocked:{by,ref} and remain unrunnable on stock fixtures (no outbound egress for a real IdP round trip).
Checklist maintenance falling out of this run
org-membership-team-managementclause 1 and its negative name the wrong vocabulary. The enforced builtin set is{owner, admin, delegated_admin, member}per ADR-0108 (BUILTIN_MEMBERSHIP_ROLE_OPTIONS, "nothing widens these at boot any more") — not{owner, admin, member, guest}.guestis actually rejected (400ROLE_NOT_FOUND), anddelegated_adminis legitimate. The spec doc-comment onMemberSchema.role(organization.zod.ts:84-87, "Common roles: owner, admin, member, guest") is itself stale and is probably where the checklist text came from — worth fixing both.invitation-scope-gatesclause 6 conflates two axes.DelegableScopemodels ObjectStack positions / permission sets / business units and has no field that could carry a better-auth org invitation role, so it can never "contain member". The delegate demonstrably can mint a member invitation while the endpoint reports an empty assignable set — it under-reports, never over-reports, which is the safe direction. Needs a revision, not a product fix.admin-lifecycle-operationsclause 4 should name the authed-request oracle, notget-session's status code — better-auth's no-session convention is 200-with-null-body, so the clause's literal 401 expectation misdescribes a correct implementation. The session was provably gone.teams-bu-membership— retire theknownGap(a BU-consuming geometry does ship) and add the zero-seeded-placements note.identity-import-wizard— add the fixture note that theautotemporary-fallback branch cannot occur onobjectstack dev.
Activity
huangyiirene commentedon Aug 11, 2026
Extraction ledger — COMPLETE. Closing.
All 3 FAILs, the 4 PARTIALs' actionable halves and the checklist-maintenance list extracted. Every defect re-verified live on
origin/main— none already-fixed, no duplicates.admin-lifecycle-operationsremove-useris non-atomic and can never succeed — a 409DELETE_RESTRICTEDleaks as a bodyless 500, credentials are already deleted and not rolled back, leaving an un-authenticatable identity on the rosterdomain:identityinvitation-scope-gates(a)accept-invitation→ bodyless 500, the row stayspendingforever — acceptance re-inserts a membership the sign-up reconciler already createddomain:identityinvitation-scope-gates(b)cancel-invitationwritesstatus:'canceled', absent from the spec's 4-valueInvitationStatusenumdomain:specapi-key-ui-lifecyclePATCHwhile the same file setsapiMethods:['get','list'](405)security·domain:metadatasys_api_key.key(a SHA-256 hash) serializes over the data API despite its "never exposed to clients" descriptiondomain:metadatateams-bu-membership(PARTIAL)sys_business_unit(_member), so a BU moved out of a shared subtree keeps read access until the record is next writtensecurity·domain:identitysession-list-revoke(PARTIAL)sys_session.revoked_at/revoke_reason— the ADR-0069 D4 revoke-audit trail is effectively inertdomain:identityauth-method-matrix(PARTIAL)change-email/delete-userare booked as mounted inauth-route-ledger.tsbut plugin-auth never configures better-auth'suser.changeEmail/user.deleteUser— no switch to enable themdomain:identityknownGap, import-wizard fixture notedomain:devxThe 5 PASS items and the 2 ledger-BLOCKED items produced no cards.
One place the extraction is more accurate than the run
#7732 — the run reported
revoked_at/revoke_reasonas having "no writer anywhere in the tree". Measured:auth-manager.tsdoes stamp both on three paths (idle timeout, absolute-max, concurrent-cap;:4606/:4647, present at the served pin too — not a post-pin fix). The defect is narrower and more precise than stated: the admin/interactive revoke path deletes the row rather than tombstoning, and GC removes expired ones, so the audit trail is effectively inert rather than never-written. The card says so, because "no writer" would have sent a dev looking for the wrong thing.Two of these are security-relevant and neither is a bypass
Worth stating so nobody over- or under-escalates:
revoked=1out-of-band 401s the very next call). The fix is to allowupdatescoped torevoked, or to give revoke/restore a real route.inSubtree=1,reparentedOut(no touch)=1,reparentedOut(touched)=0) is in the card.An environment fact that shaped the whole area, worth keeping
The seeded dev admin is promoted to the ObjectStack platform admin but its better-auth
sys_user.rolestays'user'— so the entire better-auth-native admin family 403s out of the box. The runner provisioned a genuine admin with one system-context write to its own throwaway DB rather than record sixblocked, which is why this area has real coverage instead of a wall of skips. Recorded in #7740 so the next sweep does not re-derive it.Provenance: maintainer standing rule that every QA problem becomes an issue; run records close at extraction, same exit as #7514/#7463/#7627.
Generated by Claude Code