Skip to content

Commit 0b44840

Browse files
feat(spec): name the error codes for plugin-auth's eight code-less hook refusals, registering the four no standard code fits (#24294)
Fixes #24254 Clause-②: yes ## What `plugin-auth` throws eight hook refusals as a better-auth `APIError` with a `message` and no `code`, so a client such as the console cannot branch on them. This PR names one ADR-0112 code for each of them. - **Four get a standard code**, so they need no ledger row. - **Four get a new `@objectstack/plugin-auth` row** in `packages/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-auth` is #24243's work. #24243 remains open, and this PR does not touch `packages/plugins/plugin-auth/**`. ## The table #24243 stamps from Line numbers are `packages/plugins/plugin-auth/src/auth-manager.ts` on this PR's base, `origin/main` `93b4acb57`. They are the same as the card's. | refusal (line, door) | status | code | standard or new | why | |:--|:--|:--|:--|:--| | `:1343`, phone OTP send: the deployment-wide SMS quota (`smsQuotaExceededApiError`) | 429 | `RATE_LIMIT_EXCEEDED` | standard | It must equal `:5761`. The header above `:1343` records that the two walls stay indistinguishable from outside, so a caller cannot learn which budget it hit. `RATE_LIMIT_EXCEEDED` is the 429 member for request pacing platform-wide: the runtime's inbound limiter answers with it, and the retired-code prescription in `retired-error-codes.ts` points pacing clients at it. | | `:5761`, phone OTP send: the per-number cooldown and hourly cap (`assertPhoneOtpSendAllowed`) | 429 | `RATE_LIMIT_EXCEEDED` | standard | The same code as `:1343`, by the constraint above. | | `:1358`, update-organization: the slug guard's read could not answer (`slugGuardReadFaultApiError`) | 503 | `SERVICE_UNAVAILABLE` | standard | The #23945 precedent applies unchanged. `03ac4eca2` stamped the same condition (a store read that reached no verdict) on the invitation role cap as `INVITATION_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) | 400 | `VALIDATION_ERROR` | standard | The refusal rejects an input value, which is the generic validation member's condition. No client branches on it. The console cannot send `__global__`, because its create dialog folds `_` into `-` when it derives a slug (objectui `CreateWorkspaceDialog.tsx:53`). | | `:3472`, create-organization hook: creating more organizations is disabled on this deployment | 403 | `ORGANIZATION_CREATION_DISABLED` | **new** | The judge is the deployment's effective tenancy posture (`multiOrgPostureEffective()`, ADR-0105), which `/auth/config` publishes as `features.multiOrgEnabled`. The console already holds its own sentence for this posture (`workspace.multiOrgDisabled`, objectui `CreateWorkspaceDialog.tsx:190`), but the server's refusal gives it no code to map. The name is a sibling of `DOMAIN_VERIFICATION_DISABLED` in the same owner row. | | `:3540`, invitation placement hook: no delegated-administration runtime on this deployment | 403 | `INVITE_PLACEMENT_UNSUPPORTED` | **new** | This is a fact about the composition. It is not the actor's permission and not a transient outage. Retrying the same request cannot succeed, while the same invitation without a placement can. `UNSUPPORTED` follows `RAW_SQL_UNSUPPORTED` and `MONGODB_MULTI_TENANT_UNSUPPORTED`. | | `:3558`, invitation placement hook: the placement is outside the issuer's delegated scope | 403 | `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE` | **new** | It is named in the shape of `INVITE_ROLE_ABOVE_ISSUER`: subject, relation, issuer. The console's Invite User dialog sends a placement and maps each refusal by code (objectui `InviteMemberDialog.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 failed | 403 | `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS` | **new** | Renamed in patch round 1 from `ORGANIZATION_SLUG_REFERENCED_BY_ENVIRONMENTS`, because the guard establishes no reference: it reads no hostname, only whether a `sys_environment` row with this `organization_id` has a `status` other than `archived` or `failed` (`auth-manager.ts:3654`–`:3661`). The new name states that cause and what it blocks, and it matches the console's `slugLocked` state, which reads the same predicate (objectui `orgEnvironments.ts:85`). It still cannot be confused with better-auth's `ORGANIZATION_SLUG_ALREADY_TAKEN` on 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 (objectui `SettingsPage.tsx:154`). | objectui is cited at `32b492b67`. ## Naming rationale: the alternatives considered and not taken - **`:1343` / `:5761`: not `QUOTA_EXCEEDED`.** One code has to describe both walls, and the per-number wall is a cooldown, not a quota. `QUOTA_EXCEEDED` also carries the console chatbot's AI-usage reading (`details.resetAt`). - **`:1343` / `:5761`: not `TOO_MANY_REQUESTS`.** That string is the SMS service's internal prefix and better-call's status name. `standardSynonymOf('TOO_MANY_REQUESTS')` returns `RATE_LIMIT_EXCEEDED`, so it cannot be registered without a synonym waiver. - **`:3464`: not `INVALID_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`: not `PERMISSION_DENIED`.** No grant changes the answer, because the refusal applies to every caller. - **`:3472`: not better-auth's `YOU_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", objectui `orgErrorMessage.ts`). Reusing it would also make a future per-user gate indistinguishable from the posture. - **`:3540`: not `PERMISSION_DENIED`, `SERVICE_UNAVAILABLE`, `NOT_IMPLEMENTED` or `LICENSE_REQUIRED`.** Each names a different condition: a grant, a temporary outage, unbuilt code (501), or a licence. - **`:3558`: not `PERMISSION_DENIED`.** It is the one real standard alternative, because it is the code the delegated-administration gate's own `PermissionDeniedError` carries (plugin-security `errors.ts:42`). It was not chosen because #24082 named this door's issuer-authority refusals for the condition, not the status, and the card asks for siblings of those. - **`:3668`: not `PERMISSION_DENIED`.** An owner is refused too. - **`:3668`: not `RESOURCE_CONFLICT`.** That code is a 409, and the refusal stays a 403. - **`:3668`: not `ORGANIZATION_SLUG_IN_USE`.** It reads as "taken by another organization". - **`:3668`: not `ORGANIZATION_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 a `code:`. better-call 1.4.0's `APIError` stores `body` verbatim and derives no code. Measured with `better-auth/api`: `new APIError('TOO_MANY_REQUESTS', { message })` has `body` `{ message }` only. - **The read fault (A2, held).** `:1358` takes the standard `SERVICE_UNAVAILABLE` and needs no row, as `03ac4eca2` did for the role cap's read fault. - **The OTP walls (A3, held).** The walls take the standard `RATE_LIMIT_EXCEEDED`. `standardSynonymOf` was asked on the BUILT dist (`@objectstack/spec/api`, resolved to `packages/spec/dist/api/index.mjs` after `pnpm --filter @objectstack/spec build`, exit 0): | name | StandardErrorCode | ErrorCode | standardSynonymOf | |:--|:--|:--|:--| | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED`, `SERVICE_UNAVAILABLE`, `VALIDATION_ERROR`, `INVALID_FIELD`, `PERMISSION_DENIED` | member | parses | `undefined` | | `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`, `INVITE_PLACEMENT_UNSUPPORTED`, `ORGANIZATION_CREATION_DISABLED`, `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS` | no | parses (new rows) | `undefined` | | `TOO_MANY_REQUESTS` | no | refused | `RATE_LIMIT_EXCEEDED` | | `FORBIDDEN` | no | parses (waived row) | `PERMISSION_DENIED` | | `YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION` | no | refused | `undefined` | `standardSynonymViolations()` 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. So `check:error-code-provenance` has 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_CODES` or `ErrorCode` as a whole set. None of them goes red because a row is added: - Inside `packages/spec`: the ledger admission test (casing, duplicates, shadowing, synonyms, the sorted union) and `check:error-code-provenance`, which reds only on a stamp site with no row. - At the repo root: `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. - Outside `packages/spec`, the one test that iterates every owner row is `packages/rest/src/rest-field-visibility-fault-envelope.test.ts`. It asserts that no row lists `RECORD_NOT_ACCESSIBLE`. Run after building `@objectstack/types`: 1 file, 4 tests passed. The first attempt stopped at module resolution because `@objectstack/types` was not built. That was a prerequisite, not a reading. ## Notes for #24243's implementer - **`:1343`.** The first argument of `new APIError(SMS_QUOTA_EXCEEDED_CODE, { message })` is better-call's status name. `SMS_QUOTA_EXCEEDED_CODE` is `'TOO_MANY_REQUESTS'`, so it happens to work as one. The body `code` is `RATE_LIMIT_EXCEEDED`. `TOO_MANY_REQUESTS` is not an admissible body code (see the table above). - **`:3558`.** The `catch` maps every throw out of `assertIssuable` to a 403 and passes on its message. `assertIssuable` also reads the store (`resolveUserAuthzGrants`, plugin-security `invitation-placement.ts:153`), so a store fault there would come back as a scope refusal, carrying the driver's message. `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE` means the gate's refusal (`PermissionDeniedError`). A throw that is not the gate's refusal reached no verdict, and #23945 answered that case `SERVICE_UNAVAILABLE` for the role cap. Whether to split it here is #24243's call. - **`:3540`.** The service lookup at `:3537` ends in a `.catch` that answers `undefined`, and the resolver in `auth-plugin.ts` has its own `catch`. 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 under `plugin-auth`, and has no synonym. Same shape as #24082's. - `content/docs/references/api/contract.mdx`, `content/docs/references/api/error-code-ledger.mdx`: regenerated by `pnpm --filter @objectstack/spec check:generated --fix`. The only stale artifact it found was `check:docs`. The enum tail moved from `+335 more` to `+339 more`. - `.changeset/24254-plugin-auth-hook-codes.md`: `minor` on `@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` (the `local` project): 693 files passed; 21,003 tests passed and 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0. It includes `check:test-typecheck` over `tsconfig.test.json`. - **Ablation of the renamed row, done once.** The rows were committed first. Then `node scripts/ablation-replace.mjs` turned the `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS` row into a comment: - The anchor went from 1 to 0, and the blob changed from `c1463e2555f9` to `d761b61aca77`. - The ledger test then reported 1 failed and 22 passed. The failure was exactly the new case, `accepts the organization and placement hook refusal codes…`. - Restore: the blob equals HEAD (`c1463e2555f9`), `git diff HEAD` is empty, and `git status` is clean. - Round 0 had ablated the `INVITE_PLACEMENT_UNSUPPORTED` row the same way, with the same result. The test imports the ledger by relative path, so it reads source and needs no `dist` rebuild. - `pnpm --filter @objectstack/spec check:generated`: exit 1 before regenerating (`check:docs` stale), exit 0 after `--fix`. - The intermediate head `070661f71` carried the rename without the regenerated pages. That is the red `Type Check · source gates` on that head; `0ddc5f00b` carries the pages. - `standardSynonymOf('ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS')` on the built dist: `undefined`. - The new name has 0 hits in this repo outside this diff and 0 in objectui. The old name has 0 hits anywhere in the tree. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 113 commands, the same list as round 0, and all of them were run in one pass after a full workspace build. - All 113 exit 0, among them `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-loads` and `check:lean-entry-closure`. - The `--ran` reconciliation 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-prereqs` and `check:skill-frame-freshness` (local-only): exit 0 both. - ESLint (`--no-inline-config`) over the two changed `.ts` files: exit 0. - **Not run locally.** `pnpm --filter @objectstack/spec test:repo` (the `repo` project) belongs to CI's `Test Core`. The repo-wide `pnpm lint` belongs to `Lint & Repo Gates`. - **Not merged with main.** The branch is on `93b4acb57`. The `origin/main` commits 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. - **A console map row nothing on this tree emits.** objectui `orgErrorMessage.ts` calls `YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION` "the multi-org-disabled refusal". No producer in `packages/**` emits that code: nothing sets `allowUserToCreateOrganization`, so better-auth's default allows creation. Once #24243 stamps `ORGANIZATION_CREATION_DISABLED`, the console needs a row mapping it to its existing `workspace.multiOrgDisabled`. Carrier: none. - **The slug guard's remedy may not clear the guard.** The refusal tells the caller to "rename their hostnames first". The guard counts environments only by `organization_id` and `status` (`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's `sys_environment`. Carrier: none. - **The reservation is checked on create only.** `beforeCreateOrganization` reserves `__global__`, but `beforeUpdateOrganization` does not refuse a slug change to it. This is an inference from the source. Carrier: none. - **A comment calls a non-member an ADR-0112 code.** The comment on `SMS_QUOTA_EXCEEDED_CODE` calls `'TOO_MANY_REQUESTS'` "a closed-vocabulary ADR-0112 error code". It is neither a `StandardErrorCode` member nor a ledger row: `ErrorCode.safeParse` refuses it, measured above. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30da04f commit 0b44840

5 files changed

Lines changed: 53 additions & 1 deletion

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): register four organization and invitation-placement refusal codes under `@objectstack/plugin-auth`
6+
7+
Clause-②: yes (widening)
8+
9+
The error-code ledger (`ERROR_CODE_LEDGER`, and so `ErrorCode`) gains four `@objectstack/plugin-auth` codes. Each names a hook refusal that today answers a bare `403 FORBIDDEN` with a message and no `code`, so a client such as the console cannot tell it apart from any other refusal on the same endpoint:
10+
11+
- `ORGANIZATION_CREATION_DISABLED`: creating an organization is refused for every caller, because the deployment's effective tenancy posture enforces no organization wall (`/auth/config` reports `features.multiOrgEnabled: false`).
12+
- `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`: an organization's slug cannot change while the organization has an environment whose status is neither archived nor failed.
13+
- `INVITE_PLACEMENT_UNSUPPORTED`: fail-closed. The invitation carries a placement (a business unit and positions), and this deployment has no delegated-administration runtime to authorize it.
14+
- `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`: the delegated-administration gate refused the invitation's placement, because the business unit or a position lies outside the issuer's delegated scope.
15+
16+
Registration only: `@objectstack/plugin-auth` still throws these refusals without a code until #24243 stamps them. The same change names a standard code, which needs no ledger row, for the plugin's other four code-less refusals: `RATE_LIMIT_EXCEEDED` for both phone-OTP send walls, which stay indistinguishable; `SERVICE_UNAVAILABLE` for the slug guard's read fault; and `VALIDATION_ERROR` for the reserved `__global__` organization id or slug. No existing code changes, and no standard-catalog member is shadowed (none needs a synonym waiver).

‎content/docs/references/api/contract.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +335 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +339 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -221,6 +221,8 @@ const result = ApiErrorSchema.parse(data);
221221
* `INVITE_EMAIL_FAILED`
222222
* `INVITE_ISSUER_MEMBER_ONLY`
223223
* `INVITE_ISSUER_UNVERIFIED`
224+
* `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`
225+
* `INVITE_PLACEMENT_UNSUPPORTED`
224226
* `INVITE_REQUIRES_EMAIL`
225227
* `INVITE_ROLE_ABOVE_ISSUER`
226228
* `INVITE_SMS_FAILED`
@@ -268,7 +270,9 @@ const result = ApiErrorSchema.parse(data);
268270
* `OBJECT_OWNERSHIP_CONFLICT`
269271
* `OBJECT_PACKAGE_DISABLED`
270272
* `OPENAPI_UNAVAILABLE`
273+
* `ORGANIZATION_CREATION_DISABLED`
271274
* `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`
275+
* `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`
272276
* `OS_PROTOCOL_INCOMPATIBLE`
273277
* `PACKAGE_DELETE_FAILED`
274278
* `PACKAGE_DELETE_PARTIAL`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,8 @@ const result = ErrorCode.parse(data);
388388
* `INVITE_EMAIL_FAILED`
389389
* `INVITE_ISSUER_MEMBER_ONLY`
390390
* `INVITE_ISSUER_UNVERIFIED`
391+
* `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`
392+
* `INVITE_PLACEMENT_UNSUPPORTED`
391393
* `INVITE_REQUIRES_EMAIL`
392394
* `INVITE_ROLE_ABOVE_ISSUER`
393395
* `INVITE_SMS_FAILED`
@@ -435,7 +437,9 @@ const result = ErrorCode.parse(data);
435437
* `OBJECT_OWNERSHIP_CONFLICT`
436438
* `OBJECT_PACKAGE_DISABLED`
437439
* `OPENAPI_UNAVAILABLE`
440+
* `ORGANIZATION_CREATION_DISABLED`
438441
* `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`
442+
* `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`
439443
* `OS_PROTOCOL_INCOMPATIBLE`
440444
* `PACKAGE_DELETE_FAILED`
441445
* `PACKAGE_DELETE_PARTIAL`

‎packages/spec/src/api/error-code-ledger.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,24 @@ describe('ErrorCode (standard ∪ registered)', () => {
243243
}
244244
});
245245

246+
it('accepts the organization and placement hook refusal codes, registered ahead of their plugin-auth producer', () => {
247+
// #24254 is the spec half of #24243's split: four of plugin-auth's eight
248+
// code-less hook refusals get a new row; the other four answer a standard
249+
// member and need none. #24243 stamps them and asserts these exact strings
250+
// by value — pinned here too.
251+
for (const code of [
252+
'INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE',
253+
'INVITE_PLACEMENT_UNSUPPORTED',
254+
'ORGANIZATION_CREATION_DISABLED',
255+
'ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS',
256+
]) {
257+
expect(ErrorCode.parse(code)).toBe(code);
258+
expect(ERROR_CODE_LEDGER['@objectstack/plugin-auth'], `${code} registered under plugin-auth`).toContain(code);
259+
// INVITE and ORGANIZATION are tokens no standard member carries — no waiver needed.
260+
expect(standardSynonymOf(code), `${code} needs no waiver`).toBeUndefined();
261+
}
262+
});
263+
246264
it('accepts the nine-code batch — every code that ships in dist, door or no door (ruled: the ledger is the published face)', () => {
247265
// The #16404 ruling (director seat, decision batch #62, 2026-09-07, option
248266
// D): the published face is this ledger, so a code that ships in `dist` is

‎packages/spec/src/api/error-code-ledger.zod.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,11 @@ export const ERROR_CODE_LEDGER = {
574574
// each thrown 403 from `beforeCreateInvitation` with its code (#23945).
575575
'INVITE_ISSUER_MEMBER_ONLY', // [#24082] 403: an issuer below admin grade may invite only as a plain `member`; the requested role does not outrank theirs but may carry capability of its own
576576
'INVITE_ISSUER_UNVERIFIED', // [#24082] 403, fail-closed: the issuer's `sys_member` role was not established (the read answered with no usable row, or the composition has no data engine to read), so the invitation may confer nothing beyond `member`; a read that throws answers `SERVICE_UNAVAILABLE` (503) instead
577+
// [#24254] The invitation placement gate's two refusals (ADR-0105 D8), thrown
578+
// 403 from `beforeCreateInvitation` after the role cap, registered AHEAD of
579+
// their producer: today both are a bare `FORBIDDEN`, and #24243 stamps these.
580+
'INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE', // [#24254] 403: the delegated-administration gate refused the invitation's placement intent (its business unit, or a position's permission sets, lies outside the issuer's `adminScope`, ADR-0090 D12), so no invitation is created; the gate's own verdict, not a failure to reach one
581+
'INVITE_PLACEMENT_UNSUPPORTED', // [#24254] 403, fail-closed: the invitation carries placement intent (a business unit and positions) and this deployment has no delegated-administration runtime (plugin-security's `invitation-placement` service) to authorize it, so no invitation is created; the same invitation without placement is unaffected
577582
'INVITE_REQUIRES_EMAIL',
578583
'INVITE_ROLE_ABOVE_ISSUER', // [#24082] 403: the requested role grades above the issuer's own (an invitation never confers authority above its issuer)
579584
'INVALID_REQUEST',
@@ -582,6 +587,11 @@ export const ERROR_CODE_LEDGER = {
582587
'LAST_LOCAL_CREDENTIAL', // refusing to remove the user's only local credential
583588
'NO_IDENTITY', // import row has neither email nor phone
584589
'NO_PENDING_VERIFICATION', // pass-through from better-auth
590+
// [#24254] The organization hooks' two refusals no standard member names
591+
// (`beforeCreateOrganization`, `beforeUpdateOrganization`), registered AHEAD
592+
// of their producer: today both are a bare `FORBIDDEN`, and #24243 stamps these.
593+
'ORGANIZATION_CREATION_DISABLED', // [#24254] 403: the deployment's effective tenancy posture enforces no organization wall (ADR-0105), so creating an organization is refused for every caller; the same derivation `/auth/config` publishes as `features.multiOrgEnabled: false`, a deployment setting and not a grant the caller lacks
594+
'ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS', // [#24254] 403: a change to a new slug is refused while the organization has an active environment, meaning a `sys_environment` row with its `organization_id` whose `status` is neither `archived` nor `failed` (a row with no status counts), and no hostname is read; a guard read that could not answer is `SERVICE_UNAVAILABLE` (503) instead
585595
'PASSWORD_ALREADY_SET', // pass-through from better-auth
586596
'PASSWORD_EXPIRED',
587597
'PASSWORD_POLICY_VIOLATION',

0 commit comments

Comments
 (0)