Repository navigation
feat(spec): name the error codes for plugin-auth's eight code-less hook refusals, registering the four no standard code fits - #24294
Conversation
…-code ledger ORGANIZATION_CREATION_DISABLED, ORGANIZATION_SLUG_REFERENCED_BY_ENVIRONMENTS, INVITE_PLACEMENT_UNSUPPORTED and INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE, each with its meaning comment, registered ahead of their plugin-auth producer, plus one ledger test case pinning them by value and a minor spec changeset. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
…w plugin-auth codes Produced by `pnpm --filter @objectstack/spec check:generated --fix`, which found `check:docs` the only stale artifact. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin caa8d226c304b4ae410fb10585db9c0ec546a7b2 && git checkout caa8d226c304b4ae410fb10585db9c0ec546a7b2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d537a318b3116a2bcb4f70cf85ffd2a05c13d002 0ddc5f00bc1cd5b58991530914f01a0cd9891266 && git checkout -B drift-repro d537a318b3116a2bcb4f70cf85ffd2a05c13d002 && git merge --no-ff 0ddc5f00bc1cd5b58991530914f01a0cd9891266
node scripts/docs-audit/affected-docs.mjs --json d537a318b3116a2bcb4f70cf85ffd2a05c13d002
|
席位核对 (seat check) · PR #24294 ·
|
ORGANIZATION_SLUG_REFERENCED_BY_ENVIRONMENTS becomes ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS. The guard reads only whether the organization has a sys_environment row whose status is neither archived nor failed; it reads no hostname, so neither the name nor the meaning comment nor the changeset bullet claims a reference it never establishes. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
… slug-guard code Produced by `pnpm --filter @objectstack/spec check:generated --fix`, which found `check:docs` the only stale artifact. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
席位核对 (seat check) · PR #24294 · patch round 1 ·
|
| item | reading |
|---|---|
| The REWORK item | The slug-guard code is renamed ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS. The ledger comment now states the measured condition: an organization sys_environment row whose status is neither archived nor failed, a row with no status counted, no hostname read. That matches beforeUpdateOrganization's filter on origin/main (e?.status !== 'archived' && e?.status !== 'failed' over where: { organization_id: orgId }). The changeset bullet says the same. |
| The rename is consistent | The old spelling has 0 hits at this head (git grep exit 1). The new spelling is in all 5 diff files: ledger row, test, changeset, and both regenerated pages. |
| PR body | Rewritten by the seat in this act from the dev's five replacement pairs (each matched exactly once). The :3668 row, the naming rationale, the A3 table and the Verification block now read head 0ddc5f00b. Read back byte-identical, with one footer. |
| Gates (check-runs on this head) | 41 runs: 36 success, 5 skipped, 0 failure. The superseded head 070661f71 was red on the stale reference pages; this head carries them regenerated. |
Held from round 0 (6248288979) |
Shape, the four standard-code answers, the PR #24092 pin shape, both open_questions read as A, and the out-of-scope notes as Acceptance notes (the runtime message's "rename their hostnames" goes to #24243, which stamps that throw site). |
| Docs Drift Check | Its four hand-written pages name ERROR_CODE_LEDGER only. None lists plugin-auth codes one by one (INVITE_ROLE_ABOVE_ISSUER: 0 hits in each), so none is falsified by new rows. |
| Landing | After the PASS on this head and a fully green head: pr_ready then automerge_enable, through the relay. |
Generated by Claude Code
Contract reviewServed-tier: Inputs: card #24254 (body and all six comments), PR #24294 (body, five-file list, net diff against merge base Gate verdicts on this head, read and not re-run: 52 check-runs, 41 ① Derived judgmentsPublic-surface and accept-set changes the diff implies, each judged:
Each new code, three questions: is the meaning comment true of its producer site; would a standard member fit better (ADR-0112 standard-first, and the ledger header's "if the condition is generic, use the standard catalog"); does the name read as a sibling of its family.
The four standard answers, which register nothing:
Group comments: both true. The placement refusals sit after the role cap inside ② Semver level
③ Boundary flagsEvery dev flag and both
No FAIL item. Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Contract review adopted ·
Generated by Claude Code |
Fixes #24254
Clause-②: yes
What
plugin-auththrows eight hook refusals as a better-authAPIErrorwith amessageand nocode, so a client such as the console cannot branch on them. This PR names one ADR-0112 code for each of them.@objectstack/plugin-authrow inpackages/spec/src/api/error-code-ledger.zod.ts, each with its meaning comment.This is registration only, ahead of the producer. Stamping the codes in
plugin-authis #24243's work. #24243 remains open, and this PR does not touchpackages/plugins/plugin-auth/**.The table #24243 stamps from
Line numbers are
packages/plugins/plugin-auth/src/auth-manager.tson this PR's base,origin/main93b4acb57. They are the same as the card's.:1343, phone OTP send: the deployment-wide SMS quota (smsQuotaExceededApiError)RATE_LIMIT_EXCEEDED:5761. The header above:1343records that the two walls stay indistinguishable from outside, so a caller cannot learn which budget it hit.RATE_LIMIT_EXCEEDEDis the 429 member for request pacing platform-wide: the runtime's inbound limiter answers with it, and the retired-code prescription inretired-error-codes.tspoints pacing clients at it.:5761, phone OTP send: the per-number cooldown and hourly cap (assertPhoneOtpSendAllowed)RATE_LIMIT_EXCEEDED:1343, by the constraint above.:1358, update-organization: the slug guard's read could not answer (slugGuardReadFaultApiError)SERVICE_UNAVAILABLE03ac4eca2stamped the same condition (a store read that reached no verdict) on the invitation role cap asINVITATION_ROLE_CAP_READ_FAULT_CODE: StandardErrorCode = 'SERVICE_UNAVAILABLE'(auth-manager.ts:1369). There is no reason for the slug guard to differ.:3464, create-organization hook: the reserved__global__id or slug (ADR-0120 D3)VALIDATION_ERROR__global__, because its create dialog folds_into-when it derives a slug (objectuiCreateWorkspaceDialog.tsx:53).:3472, create-organization hook: creating more organizations is disabled on this deploymentORGANIZATION_CREATION_DISABLEDmultiOrgPostureEffective(), ADR-0105), which/auth/configpublishes asfeatures.multiOrgEnabled. The console already holds its own sentence for this posture (workspace.multiOrgDisabled, objectuiCreateWorkspaceDialog.tsx:190), but the server's refusal gives it no code to map. The name is a sibling ofDOMAIN_VERIFICATION_DISABLEDin the same owner row.:3540, invitation placement hook: no delegated-administration runtime on this deploymentINVITE_PLACEMENT_UNSUPPORTEDUNSUPPORTEDfollowsRAW_SQL_UNSUPPORTEDandMONGODB_MULTI_TENANT_UNSUPPORTED.:3558, invitation placement hook: the placement is outside the issuer's delegated scopeINVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPEINVITE_ROLE_ABOVE_ISSUER: subject, relation, issuer. The console's Invite User dialog sends a placement and maps each refusal by code (objectuiInviteMemberDialog.tsx:133), so it can tell "this placement" apart from "this invitation".:3668, update-organization slug guard: the organization still has an environment whose status is neither archived nor failedORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTSORGANIZATION_SLUG_REFERENCED_BY_ENVIRONMENTS, because the guard establishes no reference: it reads no hostname, only whether asys_environmentrow with thisorganization_idhas astatusother thanarchivedorfailed(auth-manager.ts:3654–:3661). The new name states that cause and what it blocks, and it matches the console'sslugLockedstate, which reads the same predicate (objectuiorgEnvironments.ts:85). It still cannot be confused with better-auth'sORGANIZATION_SLUG_ALREADY_TAKENon the same door. The console's settings page holds a translated sentence for this state, but it shows the server's English when a save is refused (objectuiSettingsPage.tsx:154).objectui is cited at
32b492b67.Naming rationale: the alternatives considered and not taken
:1343/:5761: notQUOTA_EXCEEDED. One code has to describe both walls, and the per-number wall is a cooldown, not a quota.QUOTA_EXCEEDEDalso carries the console chatbot's AI-usage reading (details.resetAt).:1343/:5761: notTOO_MANY_REQUESTS. That string is the SMS service's internal prefix and better-call's status name.standardSynonymOf('TOO_MANY_REQUESTS')returnsRATE_LIMIT_EXCEEDED, so it cannot be registered without a synonym waiver.:3464: notINVALID_FIELD. In this repo that code refuses the field a request names, not the value it carries: an unknown field (metadata-protocol), a mapping target the object does not declare (core's import mapping), and a column a door will not filter on (mcp).:3464: not a new code. A specific code can be registered when a client needs to branch on it. Today none does.:3472: notPERMISSION_DENIED. No grant changes the answer, because the refusal applies to every caller.:3472: not better-auth'sYOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION. That is a per-user verdict (allowUserToCreateOrganization(user)). The console maps it to a different sentence ("You are not allowed to create a new organization", objectuiorgErrorMessage.ts). Reusing it would also make a future per-user gate indistinguishable from the posture.:3540: notPERMISSION_DENIED,SERVICE_UNAVAILABLE,NOT_IMPLEMENTEDorLICENSE_REQUIRED. Each names a different condition: a grant, a temporary outage, unbuilt code (501), or a licence.:3558: notPERMISSION_DENIED. It is the one real standard alternative, because it is the code the delegated-administration gate's ownPermissionDeniedErrorcarries (plugin-securityerrors.ts:42). It was not chosen because error-code ledger: register the invitation role-cap refusal codes plugin-auth throws (grade above the issuer, a non-admin inviting a non-member role, membership unverifiable), so a client can tell them apart #24082 named this door's issuer-authority refusals for the condition, not the status, and the card asks for siblings of those.:3668: notPERMISSION_DENIED. An owner is refused too.:3668: notRESOURCE_CONFLICT. That code is a 409, and the refusal stays a 403.:3668: notORGANIZATION_SLUG_IN_USE. It reads as "taken by another organization".:3668: notORGANIZATION_SLUG_REFERENCED_BY_ENVIRONMENTS, this PR's first spelling. It claimed a reference that the guard never checks, because no hostname is read.Measurements (head
0ddc5f00b)The eight throw sites (PM assumption A1, held). On
93b4acb57, all eight sit at the card's line numbers. None passes acode:. better-call 1.4.0'sAPIErrorstoresbodyverbatim and derives no code. Measured withbetter-auth/api:new APIError('TOO_MANY_REQUESTS', { message })hasbody{ message }only.The read fault (A2, held).
:1358takes the standardSERVICE_UNAVAILABLEand needs no row, as03ac4eca2did for the role cap's read fault.The OTP walls (A3, held). The walls take the standard
RATE_LIMIT_EXCEEDED.standardSynonymOfwas asked on the BUILT dist (@objectstack/spec/api, resolved topackages/spec/dist/api/index.mjsafterpnpm --filter @objectstack/spec build, exit 0):RATE_LIMIT_EXCEEDED,QUOTA_EXCEEDED,SERVICE_UNAVAILABLE,VALIDATION_ERROR,INVALID_FIELD,PERMISSION_DENIEDundefinedINVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE,INVITE_PLACEMENT_UNSUPPORTED,ORGANIZATION_CREATION_DISABLED,ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTSundefinedTOO_MANY_REQUESTSRATE_LIMIT_EXCEEDEDFORBIDDENPERMISSION_DENIEDYOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATIONundefinedstandardSynonymViolations()on the same dist returns[], so no waiver is needed.Name collisions. Each of the four new names has 0 hits in this repo outside this diff and 0 in objectui. A control name,
INVITE_ROLE_ABOVE_ISSUER, does hit. Socheck:error-code-provenancehas no existing stamp site to red on.Gates that enumerate the ledger (A4). I searched for tests and gates that iterate the ledger,
REGISTERED_ERROR_CODESorErrorCodeas a whole set. None of them goes red because a row is added:packages/spec: the ledger admission test (casing, duplicates, shadowing, synonyms, the sorted union) andcheck:error-code-provenance, which reds only on a stamp site with no row.check:error-status-conformance, whose header says registering a ledger code "moves no literal here";check:dispatcher-error-vocabulary;check:error-code-casing; and the two regenerated reference pages.packages/spec, the one test that iterates every owner row ispackages/rest/src/rest-field-visibility-fault-envelope.test.ts. It asserts that no row listsRECORD_NOT_ACCESSIBLE. Run after building@objectstack/types: 1 file, 4 tests passed. The first attempt stopped at module resolution because@objectstack/typeswas not built. That was a prerequisite, not a reading.Notes for #24243's implementer
:1343. The first argument ofnew APIError(SMS_QUOTA_EXCEEDED_CODE, { message })is better-call's status name.SMS_QUOTA_EXCEEDED_CODEis'TOO_MANY_REQUESTS', so it happens to work as one. The bodycodeisRATE_LIMIT_EXCEEDED.TOO_MANY_REQUESTSis not an admissible body code (see the table above).:3558. Thecatchmaps every throw out ofassertIssuableto a 403 and passes on its message.assertIssuablealso reads the store (resolveUserAuthzGrants, plugin-securityinvitation-placement.ts:153), so a store fault there would come back as a scope refusal, carrying the driver's message.INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPEmeans the gate's refusal (PermissionDeniedError). A throw that is not the gate's refusal reached no verdict, and plugin-auth: the invitation role-cap refusals (invite as admin / delegated_admin by a delegated issuer) answer 403 with a message and nocode— the sibling vendor refusal on the same endpoint carries one #23945 answered that caseSERVICE_UNAVAILABLEfor the role cap. Whether to split it here is 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's call.:3540. The service lookup at:3537ends in a.catchthat answersundefined, and the resolver inauth-plugin.tshas its owncatch. Both treat a lookup that throws the same as an absent service.Files
packages/spec/src/api/error-code-ledger.zod.ts: four rows under@objectstack/plugin-auth, each with a meaning comment, in two commented groups.packages/spec/src/api/error-code-ledger.test.ts: one case that pins the four rows by value: each parses, is underplugin-auth, and has no synonym. Same shape as error-code ledger: register the invitation role-cap refusal codes plugin-auth throws (grade above the issuer, a non-admin inviting a non-member role, membership unverifiable), so a client can tell them apart #24082's.content/docs/references/api/contract.mdx,content/docs/references/api/error-code-ledger.mdx: regenerated bypnpm --filter @objectstack/spec check:generated --fix. The only stale artifact it found wascheck:docs. The enum tail moved from+335 moreto+339 more..changeset/24254-plugin-auth-hook-codes.md:minoron@objectstack/spec,Clause-②: yes (widening).Verification
Every reading below is on head
0ddc5f00b, the final commit after patch round 1. Exit codes were captured before any pipe.pnpm --filter @objectstack/spec build: exit 0.pnpm --filter @objectstack/spec exec vitest run src/api/error-code-ledger.test.ts src/api/api-error-code-type.test.ts: 2 files, 25 tests passed.pnpm --filter @objectstack/spec test(thelocalproject): 693 files passed; 21,003 tests passed and 1 todo.pnpm --filter @objectstack/spec typecheck: exit 0. It includescheck:test-typecheckovertsconfig.test.json.node scripts/ablation-replace.mjsturned theORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTSrow into a comment:c1463e2555f9tod761b61aca77.accepts the organization and placement hook refusal codes….c1463e2555f9),git diff HEADis empty, andgit statusis clean.INVITE_PLACEMENT_UNSUPPORTEDrow the same way, with the same result. The test imports the ledger by relative path, so it reads source and needs nodistrebuild.pnpm --filter @objectstack/spec check:generated: exit 1 before regenerating (check:docsstale), exit 0 after--fix.070661f71carried the rename without the regenerated pages. That is the redType Check · source gateson that head;0ddc5f00bcarries the pages.standardSynonymOf('ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS')on the built dist:undefined.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 113 commands, the same list as round 0, and all of them were run in one pass after a full workspace build.check:error-code-casing,check:error-status-conformance,check:error-code-provenance,check:dispatcher-error-vocabulary,check:api-surface,check:docs,check:type-check-debt,check:dual-build-cjs-loadsandcheck:lean-entry-closure.--ranreconciliation exits 0:113 derived famil(ies) accounted for — 113 run, 0 NOT-MEASURED (a DERIVED zero — all 113 recorded an exit code and none of them is 3).check:dev-prereqsandcheck:skill-frame-freshness(local-only): exit 0 both.--no-inline-config) over the two changed.tsfiles: exit 0.pnpm --filter @objectstack/spec test:repo(therepoproject) belongs to CI'sTest Core. The repo-widepnpm lintbelongs toLint & Repo Gates.93b4acb57. Theorigin/maincommits since then share 0 files with this diff, and none touches the ledger or the two regenerated pages. So the PR's CI on the merge ref is the joint check.Acceptance notes
None of these was filed. Each is a reading or an inference, not a reproduced defect.
orgErrorMessage.tscallsYOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION"the multi-org-disabled refusal". No producer inpackages/**emits that code: nothing setsallowUserToCreateOrganization, so better-auth's default allows creation. Once 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 stampsORGANIZATION_CREATION_DISABLED, the console needs a row mapping it to its existingworkspace.multiOrgDisabled. Carrier: none.organization_idandstatus(auth-manager.ts:3660), so renaming a hostname alone would not clear it. This is an inference from the source. It was not reproduced, because that needs cloud'ssys_environment. Carrier: none.beforeCreateOrganizationreserves__global__, butbeforeUpdateOrganizationdoes not refuse a slug change to it. This is an inference from the source. Carrier: none.SMS_QUOTA_EXCEEDED_CODEcalls'TOO_MANY_REQUESTS'"a closed-vocabulary ADR-0112 error code". It is neither aStandardErrorCodemember nor a ledger row:ErrorCode.safeParserefuses it, measured above. Carrier: none.Generated by Claude Code