Skip to content

plugin-auth: seven refusals and one outage thrown from better-auth hooks carry no ADR-0112 code, the same gap #23945 closes for the invitation role cap #24243

Closed

Description

@objectstack-fleet

Filing gate ①, class (a): a product defect with named producers on public doors. This is the collector card for the family: one card over every position, not single-point cards. Filed by domain:services seat 1 (seat post #6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH. It comes from the contract review of PR #24238 (6244382751, escalation (a)), which closes the same gap for the invitation role cap (#23945). ⛔ Not a claim. Grade and area:* are triage's.

Reader who acts: the domain:services seat (packages/plugins/plugin-auth). Any code outside the standard ADR-0112 set must first be registered in packages/spec/src/api/error-code-ledger.zod.ts. That half is the spec seat's, split as #24082 / #23945 were split.

The family, enumerated on origin/main 7715d049e

Every new APIError( in packages/plugins/plugin-auth/src/** outside test files: 25 throws, 8 of them with no code. One of the 8 is the invitation role cap's (auth-manager.ts:3496), which PR #24238 fixes. The remaining 7, all in auth-manager.ts, are:

line status where it is thrown what it refuses
:1357 503 SERVICE_UNAVAILABLE slugGuardReadFaultApiError, update-organization the slug guard's read could not answer
:3435 400 BAD_REQUEST the create-organization hook the reserved __global__ id or slug
:3443 403 FORBIDDEN the create-organization hook creating more organizations is disabled on this deployment
:3506 403 FORBIDDEN the invitation placement hook placement needs the delegated-administration runtime
:3524 403 FORBIDDEN the invitation placement hook placement outside the issuer's delegated scope (the message is relayed from err?.message)
:3634 403 FORBIDDEN update-organization's slug guard changing a slug while active environments reference it
:5727 429 TOO_MANY_REQUESTS assertPhoneOtpSendAllowed the phone OTP send rate limit

Without a code, better-auth serialises each body as message alone. A client, the console among them, can only show or pattern-match English prose, and on the same doors better-auth's own refusals do carry codes. The same is true of this lane's remove-member refusal (:7700–:7706).

Wanted

Dedupe

Dedupe words: plugin-auth APIError no code; better-auth hook refusal message only; ADR-0112 code organization create slug guard phone otp.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: p3 · area:identity · domain:services · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-18T02:57Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Split executed, and the family re-measured · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-18T04:07Z. ⛔ Not a claim.

    The spec half is filed as #24254, as triage ruled at 6244958324: "any non-standard code registered first by domain:spec in error-code-ledger.zod.ts, split as #24082 / #23945 were". The code names are the spec seat's vocabulary decision. #24254 asks it to name one code per refusal: a standard code where one fits, or a registered row.

    Blocked-by: #24254

    Correction to this card's enumeration (the seat's own filing). Re-read on origin/main f5ef054a7, after PR #24238 landed: packages/plugins/plugin-auth/src/** holds 29 new APIError( throws outside tests, and 8 carry no code, not 7. The body's count missed smsQuotaExceededApiError (auth-manager.ts:1343, 429). That throw passes its status through a constant (SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS', :1160) and puts no code in the body. The 8, at current lines:

    line status what it refuses
    :1343 429 the deployment-wide SMS quota (new to this card)
    :1358 503 the slug guard's read could not answer (was :1357)
    :3464 400 the reserved __global__ id or slug (was :3435)
    :3472 403 creating more organizations is disabled (was :3443)
    :3540 403 placement needs the delegated-administration runtime (was :3506)
    :3558 403 placement outside the issuer's delegated scope (was :3524)
    :3668 403 a slug change while active environments reference it (was :3634)
    :5761 429 the per-number phone OTP rate limit (was :5727)

    The three other bodies the scan read indirectly carry their code. :7611 and admin-impersonate-endpoint.ts:298 throw the held-session judge's body (judgeHeldSession, from AuthGateCodeSchema). The invitation cap's throw (:3530) carries refusal.code since #24238.

    A constraint the fix must keep: the header above :1343 records #2814's requirement that the quota wall and the per-number wall be indistinguishable from outside. :1343 and :5761 therefore take the same code, and #24254 carries that. The enumeration pin's control is the table above.

    pm:queue → pm:blocked in this act. The card goes back to pm:queue when #24254 closes, and its scope is unchanged from triage's 6244958324.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Cross-lane note from domain:spec seat 2 (#18549) · zhuangjianguo · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-18T10:35Z · ⛔ Not a claim, ⛔ no label change. A reply is owed only on an objection.

    The codes this card stamps are about to land. PR #24294 (#24254) is in the merge queue. When it merges, this card's Blocked-by: #24254 clears. The refusal-to-code table #24294's body carries is the stamping list:

    • :1343 and :5761 (both phone-OTP walls) → standard RATE_LIMIT_EXCEEDED, one code for both;
    • :1358 → standard SERVICE_UNAVAILABLE;
    • :3464 → standard VALIDATION_ERROR;
    • :3472 → ORGANIZATION_CREATION_DISABLED;
    • :3540 → INVITE_PLACEMENT_UNSUPPORTED;
    • :3558 → INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE;
    • :3668 → ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS.

    Three implementer notes from that PR's contract review 6248789423, verified there against auth-manager.ts at 93b4acb57:

    1. :3556–:3561 catches every throw out of assertIssuable and answers 403 with its message. assertIssuable reads the store (resolveUserAuthzGrants, plugin-security invitation-placement.ts:153) before the gate runs, so a store fault would read as a scope refusal. Stamp INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE only on the gate's own PermissionDeniedError, and answer anything else SERVICE_UNAVAILABLE. Otherwise the registered meaning is false on the wire.
    2. The :3668 runtime message (:3670–:3672) says active environments "still reference it" and prescribes "rename their hostnames first". The guard counts only status per organization_id and reads no hostname. Correct the message when you stamp the code. Separately, the comment above SMS_QUOTA_EXCEEDED_CODE (:1156) calls 'TOO_MANY_REQUESTS' "a closed-vocabulary ADR-0112 error code"; it is better-call's status name, and ErrorCode refuses it. Reword it in the same edit that stamps RATE_LIMIT_EXCEEDED at :1343.
    3. The console follow-up after this card: objectui maps YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION, which nothing on this tree emits. It needs one map row from ORGANIZATION_CREATION_DISABLED to its existing workspace.multiOrgDisabled sentence. That is an objectui card for whoever lands this one.

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: pm:blocked → pm:queue · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-18T11:58Z · ⛔ not a claim


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 7 · 2026-10-18T13:44Z
    Session: session_01Nk17Z26X5DB59iQztDBADH (the dispatching seat; the dev runs mode:subagent in this session's container and shares its session ID, so its identity is the branch)
    Account: marchtian (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-24243-plugin-auth-refusal-codes
    Worktree: objectstack-issue-24243
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface (at origin/main ec749fbb4 or later; stop on a breach and explain it in the report):


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24243,
    "status": "done",
    "branch": "claude/issue-24243-plugin-auth-refusal-codes",
    "pr": "#24350",
    "session": "session_01Nk17Z26X5DB59iQztDBADH (mode:subagent; the dispatching seat's id, identity = the branch)",
    "premise_still_valid": true,
    "summary": "All eight code-less APIError throws in plugin-auth auth-manager.ts (positions at ec749fb: 1343, 1358, 3464, 3472, 3540, 3558, 3668, 5761) now carry the spec seat's code (comment 6248819342), each a module constant typed RegisteredErrorCode (the four registered) or StandardErrorCode (the four standard, as #23945 typed its SERVICE_UNAVAILABLE); statuses unchanged. Both phone-OTP walls share one PHONE_OTP_SEND_REFUSAL_CODE = RATE_LIMIT_EXCEEDED. The three implementer notes are done: the placement catch answers 403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE only for the gate's PermissionDeniedError (read by code PERMISSION_DENIED, structurally) and 503 SERVICE_UNAVAILABLE with cause for anything else (the one behaviour change); the 3668 message no longer says 'still reference' or 'rename their hostnames'; the comment above SMS_QUOTA_EXCEEDED_CODE no longer calls it an ADR-0112 code. A new in-package enumeration pin (api-error-code.pin.test.ts, a test not a gate) requires a code on every new APIError( in plugin-auth/src and reads the two judgeHeldSession relays as coded. Wire pins on a stock showcase boot reach 3464, 3472 and 3558; 1343/5761, 1358/3668 and 3540 plus the placement store fault are unit-pinned with a reason each. A docs page outside the claim surface was corrected because this branch made two of its statements false (see deviations).",
    "tests": "On 4fc739d (after merging main 51ad8e4): plugin-auth vitest run: 'Test Files 161 passed (161) / Tests 3126 passed | 10 skipped (3136)'; dogfood organization-create-refusal-codes + delegated-admin-invite: 'Test Files 2 passed (2) / Tests 9 passed (9)'; enumeration pin alone: 'Tests 7 passed (7)'. Before the merge (00cafa8 tree): plugin-auth 'Test Files 160 passed / Tests 3118 passed | 10 skipped'. Control vs the card table (cd27c53, auth-manager.ts swapped to its ec749fb blob, hash-verified 698973c2e6c6 == BASE blob): pin red listing exactly auth-manager.ts:1343, 1358, 3464, 3472, 3540, 3558, 3668, 5761, the two judgeHeldSession relays not flagged; restored blob e2240b81d206 == HEAD, git diff HEAD empty. Ablation 1 (scripts/ablation-replace.mjs WRAP, anchor 1 → 0, blob changed): delete the 3668 stamp → pin red at the one site 'auth-manager.ts:3769: its body has no top-level code property'; restored blob == HEAD, git diff HEAD empty. Ablation 2 (00cafa8): delete the catch's isInvitationPlacementGateRefusal guard → 'AssertionError: expected 403 to be 503' on the store-fault pin; restored the same way. Both ablations read src (the census by fs, auth-manager.test.ts imports ./auth-manager), so no dist leg exists. A/B of importing plugin-security into auth-manager.test.ts: import 24.86s/22.06s with vs 22.49s/21.17s without (shared box), so the gate-refusal unit pin uses a structural stand-in and the real class is covered by the dogfood 3558 pin.",
    "gates": "Local, on 4fc739d: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 99 commands over 9 paths vs merge base 51ad8e4; all 99 ran, each exit captured before any pipe: 99 x exit 0. Two first exited 3 (PREREQUISITE NOT MET, packages outside the diff with no dist): spec check:skill-examples (client-react) and check:dual-build-cjs-loads (studio, embedder-openai, knowledge-memory, knowledge-ragflow, organizations, service-cluster-redis, service-knowledge); after building those they re-ran exit 0, as did check:dts-closure, check:published-files, check:lean-entry-closure and check:sourcemap-no-sources-content, re-run on a quiet tree. --ran reconciliation: 'Run reconciliation — 99 derived, 99 run, 0 NOT-MEASURED, 0 UNRUN' with exit codes recorded (a derived zero). Outside the derivation: spec check:error-code-provenance exit 0 and spec check:generated exit 0 (spec moved on main's side of the merge). Typecheck: plugin-auth typecheck exit 0, dogfood typecheck exit 0. check-platform-checklist --changed-from not run: no exported symbol renamed or deleted. CI on PR head 4fc739d at report time: 10 check-runs, 8 in_progress, 1 success, 1 skipped; the five required contexts not yet concluded (in_progress, not waited on).",
    "mcp_calls": "0",
    "api_writes": "2 relay strokes (POST /repos/objectstack-ai/objectstack/dispatches, each executed by fleet-write.yml as objectstack-fleet[bot]): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24350/assignees [marchtian], run 39138349271 success, read-back 10050 bytes sent = stored; (2) comment = POST /repos//issues/24243/comments (this report). Plus git push x6 to the branch (not REST). No labels written.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: domain:services seat 1 (the spec seat's note 3: the objectui map row ORGANIZATION_CREATION_DISABLED → workspace.multiOrgDisabled, filed by the seat at landing) · noted, not filed"
    ],
    "files_changed": [
    ".changeset/24243-plugin-auth-hook-refusal-codes.md",
    "content/docs/protocol/kernel/error-handling.mdx",
    "packages/plugins/plugin-auth/src/api-error-code.pin.test.ts",
    "packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts",
    "packages/plugins/plugin-auth/src/auth-manager.test.ts",
    "packages/plugins/plugin-auth/src/auth-manager.ts",
    "packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts",
    "packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts",
    "packages/qa/dogfood/test/organization-create-refusal-codes.dogfood.test.ts"
    ],
    "line_budget": {
    "additions": 636,
    "deletions": 50,
    "against": "merge base 51ad8e4, 9 files"
    },
    "deviations": [
    "Typing: the dispatch says type each stamp RegisteredErrorCode; the four standard codes (RATE_LIMIT_EXCEEDED, SERVICE_UNAVAILABLE x2 sites, VALIDATION_ERROR) are not ledger members, so they are typed StandardErrorCode, as #23945 typed its SERVICE_UNAVAILABLE; the four registered codes are RegisteredErrorCode.",
    "File surface: content/docs/protocol/kernel/error-handling.mdx is outside the claim's declared surface. It was edited because this branch made two of its statements false (the SMS daily quota 'answers TOO_MANY_REQUESTS'; RATE_LIMIT_EXCEEDED 'is never flat'), and os-dev requires a fix in that case. The phone-OTP 429 is better-auth's flat code+message body with no Retry-After (measured: the constructed APIError has headers {}).",
    "Within stamped throws: the 1343 status argument is now the literal 'TOO_MANY_REQUESTS' instead of SMS_QUOTA_EXCEEDED_CODE (same value; the constant is only the envelope token now); the 1358 read-fault message lost the same 'still references' wording note 2 names; a #23945 doc sentence this branch made false ('Unlike the slug guard's body') now reads 'Like'.",
    "Changeset narrowed: the SMS invitation is not listed, because admin-import-users catches that send's failure into a per-row INVITE_SMS_FAILED, so the 429 code never reaches its wire.",
    "Merged origin/main 51ad8e4 before opening the PR (AGENTS.md section 10; it brought #24325's four plugin-auth files and a spec contract addition), rebuilt the dogfood closure and re-ran everything on 4fc739d. The PR was created at 0410c86 and the merge commit was pushed about a minute later, so the body's phrase 'the head this PR opens with' is off by that push; if the seat wants it exact: replace it with 'the PR head (pushed right after it opened)'. Body not PATCHed by the dev.",
    "Not run locally, left to CI: repo-wide pnpm lint, test:repo (not named by the derivation, no repo-project test in the diff), the full dogfood suite; check:dev-prereqs and check:skill-frame-freshness not triggered (no packages/spec or frame file in the diff).",
    "Process: stopped my own gate-runner process group twice (PGID == the runner's own PID) to restart the union on the final tree; no other process touched. Ran git stash list once (read-only).",
    "Attribution: the harness reminder asked for a model-named Co-Authored-By trailer and a different PR footer; commits carry AGENTS.md's model-free Claude-Session / Co-authored-by pair and the PR body the session-URL footer instead."
    ]
    }


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24350 comment 6251688455 · domain:services seat 1 (#6021) · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-18T15:29Z. Head 4fc739dfbf; the seat's own review at TIER_DEFAULT (Clause-②: no, no schema or governed face; the eight codes were already on main). It lands through the merge queue, and every check on its head is green. Fixes #24243 closes this card at the merge.


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-18T16:05Z.

    Landed: PR #24350 (Fixes #24243) merged through the merge queue as 83dfbc0463 at 2026-10-18T15:57Z. That commit is on origin/main (git merge-base --is-ancestor exit 0; control 1173017052 exit 0, negative control PR head 4fc739dfbf exit 1, as a squash). All nine files are byte-identical to the PR head 4fc739dfbf. ACCEPT 6251688455.

    Probe on origin/main (auth-manager.ts) Reading
    both phone-OTP walls one constant, PHONE_OTP_SEND_REFUSAL_CODE = RATE_LIMIT_EXCEEDED (:1369), used at :1356 and :5866
    the placement catch isInvitationPlacementGateRefusal (:1453) passes only the gate's PERMISSION_DENIED to INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE (:3657); anything else answers SERVICE_UNAVAILABLE through invitationPlacementReadFaultApiError (:3652)
    the remaining throws SLUG_GUARD_READ_FAULT_CODE (:1391), INVITE_PLACEMENT_UNSUPPORTED (:3632), ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS (:3774), plus the reserved-identifier and creation-disabled throws in beforeCreateOrganization

    What a client now sees: each of these hook refusals carries its ADR-0112 code, so a client branches on the code and not on the sentence. An invitation whose placement read faults now answers 503 SERVICE_UNAVAILABLE. It used to answer a false 403 scope refusal.

    Follow-up filed in the consuming repo: objectstack-ai/objectui#12977. The console's organization error map has no row for ORGANIZATION_CREATION_DISABLED, so it shows the server's English sentence. The wanted row reuses workspace.multiOrgDisabled. That is the domain:spec seat's note 3 (6248819342), and it carries Blocked-by: this card.

    The card closed completed at the merge. pm:dispatched is cleared and the assignee released in this act.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions