Skip to content

feat(spec): name the error codes for plugin-auth's eight code-less hook refusals, registering the four no standard code fits - #24294

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24254-plugin-auth-hook-codes
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24254-plugin-auth-hook-codes

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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 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: 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

Files

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 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 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

…-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
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 5 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-1.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-4.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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): node scripts/docs-audit/affected-docs.mjs --json d537a318b3116a2bcb4f70cf85ffd2a05c13d002 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from caa8d226c304b4ae410fb10585db9c0ec546a7b2 — the merge of head 0ddc5f00bc1cd5b58991530914f01a0cd9891266 into base d537a318b3116a2bcb4f70cf85ffd2a05c13d002, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d537a318b3116a2bcb4f70cf85ffd2a05c13d002 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 (seat check) · PR #24294 · domain:spec seat 2 · zhuangjianguo · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-18T09:33Z

REWORK (patch round 1, same claim 6247302746, same dev) at head ff61cc22ddd4715b77eab285e226324c40d685eb. One item. Everything else below holds and is not re-asked.

The item: one meaning states a condition the guard does not check, in two shipped texts.

  • The ORGANIZATION_SLUG_REFERENCED_BY_ENVIRONMENTS row comment in error-code-ledger.zod.ts (src/**/*.zod.ts ships in the tarball) and its changeset bullet (the CHANGELOG) both say the slug change is refused while an environment "still carries the slug in its hostname".
  • The guard does not look at a hostname. beforeUpdateOrganization (auth-manager.ts:3651–:3661 on origin/main) reads sys_environment where: { organization_id: orgId } and counts the rows whose status is neither archived nor failed. The PR body's own note ("The slug guard's remedy may not clear the guard") reads it the same way.
  • Correct both texts to the measured condition: the organization still has an environment that is neither archived nor failed. Keep the runtime message's "reference" wording out of the meaning unless the code defines it.
  • Re-judge the code's name against that condition and keep it or rename it, with one line of reasoning in the PR body table. If it is renamed, the test, the table, the changeset and the regenerated pages move with it.

Held, read by the seat in the diff (not re-asked):

After the push: the contract review at CONTRACT_REVIEW_TIER runs on the new head, then landing when every check is green.


Generated by Claude Code

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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 (seat check) · PR #24294 · patch round 1 · domain:spec seat 2 · zhuangjianguo · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-18T10:18Z

ACCEPT at head 0ddc5f00bc1cd5b58991530914f01a0cd9891266 (claim 6247302746, REWORK 6248288979, round-1 report on #24254). It lands only after a CONTRACT_REVIEW_TIER PASS on this head (Clause-②: yes plus the schema face), and that review starts now as an isolated subagent. needs:contract-review goes on this PR in the same act.

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0ddc5f00bc1cd5b58991530914f01a0cd9891266
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts + packages/plugins/plugin-auth/src/auth-manager.ts (the eight refusal sites :1343 :1358 :3464 :3472 :3540 :3558 :3668 :5761, multiOrgPostureEffective at :7156 and getPublicConfig at :7265, read on the merge base 93b4acb57), packages/plugins/plugin-security/src/invitation-placement.ts, packages/plugins/plugin-security/src/delegated-admin-gate.ts, packages/plugins/plugin-security/src/errors.ts, packages/spec/src/api/errors.zod.ts (the standard catalog), packages/spec/src/api/retired-error-codes.ts, packages/spec/scripts/check-error-code-provenance.ts, scripts/check-error-status-conformance.mjs, docs/adr/0105 D8, docs/adr/0090 D12, docs/adr/0120 D3; the console consumers the PR body cites were read in objectui at 32b492b67. The remainder of the diff (error-code-ledger.test.ts, the two regenerated content/docs/references/api/*.mdx pages, .changeset/24254-plugin-auth-hook-codes.md) is the lane seat's ACCEPT at TIER_DEFAULT (record 6248678312) and is read here only where ② and ③ need it.

Inputs: card #24254 (body and all six comments), PR #24294 (body, five-file list, net diff against merge base 93b4acb57: 5 files, +53 −1), and the check-runs on this head. The dispatching seat's REWORK and ACCEPT comments were not weighed; the diff and the cited code were.

Gate verdicts on this head, read and not re-run: 52 check-runs, 41 success, 10 skipped, 0 failure, 1 in_progress. The five required contexts all concluded success: Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards), Build Core, Governed Surface Queue Guard. The one in_progress run is a Check Changeset re-run started 10:19:39Z by the label event; the same context concluded success twice on this head (09:43Z and 10:20Z). It is not a required context; the seat's own landing rule already waits for every check to conclude.

① Derived judgments

Public-surface and accept-set changes the diff implies, each judged:

change judgment
ERROR_CODE_LEDGER['@objectstack/plugin-auth'] gains four strings, so ErrorCode and ApiErrorSchema.code widen by four (the generated enum tail moves +335 more to +339 more). At every door a producer throwing one of these four now reaches error.code verbatim instead of being demoted to declaredCode. right. Additive; no existing member touched; no waiver needed. Each new name carries a token no standard member has (INVITE, PLACEMENT, ORGANIZATION, CREATION, LOCKED, ENVIRONMENTS), so the token prong of standardSynonymOf cannot match, and none is an HTTP reason phrase, so the alias prong cannot either; read off the function body in the ledger, not run.
Four rows are registered AHEAD of their producer: no packages/** site stamps any of them on this head. right by the card's own design (#24243 is Blocked-by: this card and open), with ledger precedent for the split shape (the FLOW_INPUT_SCHEMA_INVALID row, the #10413 to #10576 row, the @objectstack/runtime waiver that says "registered ahead of its producer by design", and #24082 before #23945 on this very owner row). The provenance gate reds a stamp site without a row, never a row without a site; the status-conformance gate's header says registering a ledger code moves no literal there; no gate reds a producer-less row. Bounded: if #24243 does not land, these four rows become the ledger header's own "registered-but-unemittable" class and come out under its Retiring a code rule.
error-code-ledger.test.ts gains one pin for the four: parse, owner, no synonym. right; the same shape as the #24082 pin beside it; the dev's ablation showed it reds on one row's removal.
content/docs/references/api/contract.mdx and error-code-ledger.mdx gain the four list entries. right; regenerated, never hand-edited (AUTO-GEN tree); check:docs is green inside TypeScript Type Check.

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.

  • ORGANIZATION_CREATION_DISABLED (:3472, a bare 403 FORBIDDEN today). Meaning true: the gate is if (!this.multiOrgPostureEffective()), which is postureEnforcesWall(this.effectiveTenancyPosture()) with no caller input, and getPublicConfig() derives features.multiOrgEnabled from the same effectiveTenancyPosture() call at :7265, so "every caller" and "the same derivation /auth/config publishes" both hold. Standard-first: PERMISSION_DENIED is false (no grant changes the answer); LICENSE_REQUIRED is false on the single-tenant posture, where no entitlement is missing; NOT_IMPLEMENTED is a 501. New code right. Sibling: DOMAIN_VERIFICATION_DISABLED in the same owner row names the same shape, a feature off on this deployment.
  • INVITE_PLACEMENT_UNSUPPORTED (:3540, a bare 403 FORBIDDEN today). Meaning true: if (!intent) return; at :3535 precedes it, so an invitation without placement is unaffected; the refusal fires when this.config.invitationPlacement?.() resolves no service, and plugin-security's invitation-placement.ts header registers that service and says plugin-auth refuses placement intent when it is absent, fail closed. Standard-first: SERVICE_UNAVAILABLE names a transient outage and this is composition; NOT_IMPLEMENTED is a 501 and the code exists; PRECONDITION_REQUIRED names a missing request precondition, not a missing runtime. New code right. Sibling: INVITE_ prefix, and the _UNSUPPORTED suffix of RAW_SQL_UNSUPPORTED, MAPPING_FORMAT_UNSUPPORTED and the two *_MULTI_TENANT_UNSUPPORTED rows. The same owner row also spells ASYNC_NOT_SUPPORTED; the ledger-wide majority is _UNSUPPORTED, so this is the better sibling. Noted, not a defect.
  • INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE (:3558, a bare 403 FORBIDDEN today). Meaning: the comment defines the code as the gate's own verdict over the issuer's adminScope (ADR-0090 D12: subtree plus assignable-set allowlist; the placement service's header says the same), "not a failure to reach one". That is the right contract and it is true of the gate: every throw on DelegatedAdminGate.assert's path is PermissionDeniedError (errors.ts:42, code PERMISSION_DENIED, 403). It is NOT yet true of the catch at :3556 to :3561, which maps every throw out of assertIssuable to 403 with the thrown message, and assertIssuable reads the store (resolveUserAuthzGrants, invitation-placement.ts:153) before the gate runs. The stamp owes a discrimination; see ③ item 1. Standard-first: PERMISSION_DENIED is the one real alternative, answered in ③. Sibling: the shape of INVITE_ROLE_ABOVE_ISSUER (subject, relation, issuer).
  • ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS (:3668, a bare 403 FORBIDDEN today). Meaning true: beforeUpdateOrganization returns unless the slug changes (:3652), reads sys_environment where: { organization_id: orgId } and counts the rows whose status is neither archived nor failed (:3656 to :3661, so a row without a status counts), reads no hostname, and both of its reads answer SERVICE_UNAVAILABLE on a fault through slugGuardReadFaultApiError. Standard-first: RESOURCE_CONFLICT and LOCK_CONFLICT are 409 members and the site answers 403; DELETE_RESTRICTED is a delete; PERMISSION_DENIED is false, an owner is refused too. New code right. Sibling: ACCOUNT_LOCKED in the same row, SETTINGS_LOCKED, RECORD_LOCKED, ITEM_LOCKED elsewhere; the console's slugLocked state mirrors the same predicate (orgEnvironments.ts, retired statuses archived and failed); it cannot be confused with better-auth's ORGANIZATION_SLUG_ALREADY_TAKEN on the same door. The round-1 rename away from _REFERENCED_BY_ENVIRONMENTS was owed: the guard establishes no reference.

The four standard answers, which register nothing:

  • RATE_LIMIT_EXCEEDED for :1343 and :5761 (429): right. One code honours the feat(sms): 短信全局/每租户日发送配额(成本总量闸) #2814 constraint the header above :1343 records; the catalog's own comment on the member is "Too many requests"; the retired-code prescription in retired-error-codes.ts points request pacing at it. QUOTA_EXCEEDED would fit :1343 alone and not the per-number cooldown, and the constraint forbids two codes. TOO_MANY_REQUESTS is better-call's status name and a reason-phrase alias of RATE_LIMIT_EXCEEDED in the explicit map, inadmissible without a waiver.
  • SERVICE_UNAVAILABLE for :1358 (503): right. The site already spells it as better-call's status, and INVITATION_ROLE_CAP_READ_FAULT_CODE at :1369 is the same condition with the code in the body.
  • VALIDATION_ERROR for :3464 (400, BAD_REQUEST today): right. The generic 400 member, and the one standardErrorCodeForHttpStatus(400) derives. INVALID_FIELD in this repo refuses an unknown field NAME (AGENTS.md, route ownership rule 5), not a reserved value. No client branches on the reserved token, and the console's create dialog folds _ into - when it derives a slug, so it cannot send one.

Group comments: both true. The placement refusals sit after the role cap inside beforeCreateInvitation (:3524 to :3562); the organization refusals are beforeCreateOrganization and beforeUpdateOrganization; all four are a bare FORBIDDEN today. ADR-0105 D8, ADR-0090 D12 and ADR-0120 D3 say what the comments cite them for.

② Semver level

.changeset/24254-plugin-auth-hook-codes.md: "@objectstack/spec": minor, Clause-②: yes (widening). PR body: Clause-②: yes. The diff publishes exactly one released package's surface, @objectstack/spec: four new accept-set members of ErrorCode and ApiErrorSchema.code; nothing removed, renamed or narrowed; no waiver; no standard member shadowed. Additive on a published schema is minor; yes takes at least minor; (widening) is the true arm, and the bare yes on the body is the same declaration (the ADR-0087 registration gate's own self-test reads a bare yes as a widening declared through the value, and Check Changeset is green on this head). No migration text is owed and no ADR-0087 marker is owed, because nothing breaks. The changeset's four bullets state the four meanings consistently with the ledger comments; the :3668 bullet carries the round-1 condition, not the hostname claim. plugin-auth is not published by this diff, so no second changeset is owed. Matches.

③ Boundary flags

Every dev flag and both open_questions from the round-0 report (6248260854), plus the round-1 report (6248656559):

  1. :3558: the new code, or the standard PERMISSION_DENIED the gate's own error carries? Answered: the new INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE, as the PR has it. Grounds: ADR-0112 D3 places a door-specific refusal that a consumer branches on in the registered tier (its own example, ATTACHMENT_DOWNLOAD_DENIED, is a permission-flavoured extension code); the ledger's admission rule has been mechanical since [finding] @objectstack/rest registers four generic synonyms the standard catalog already covers (CONFLICT, NOT_FOUND, FORBIDDEN, INTERNAL) — contract call, not a cleanup #8211 and this name shares no token with PERMISSION_DENIED; the invite door's existing issuer-authority refusals (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) chose condition names over PERMISSION_DENIED, and the card binds siblings of those; the console's Invite User dialog sends placement and maps this door by code (resolveOrgErrorMessage in InviteMemberDialog.tsx), so it needs placement told apart from role. The direct sys_user_position write door keeps the gate's PERMISSION_DENIED: two requests, not one condition in two dialects. Escalated to 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, the carrier the PR body's implementer notes already name: the catch at :3556 is a catch-all, so the stamp must answer this code only for the gate's PermissionDeniedError and answer SERVICE_UNAVAILABLE for any other throw out of assertIssuable (a store fault in resolveUserAuthzGrants or in the gate's own reads), by the plugin-auth: the invitation role-cap refusals (invite as admin / delegated_admin by a delegated issuer) answer 403 with a message and no code — the sibling vendor refusal on the same endpoint carries one #23945 pattern at :1369. Without that the registered meaning, "the gate's own verdict, not a failure to reach one", is false on the wire and a driver message rides a 403.
  2. :3472: the new code, or better-auth's YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION, which objectui already maps? Answered: the new ORGANIZATION_CREATION_DISABLED. The vendor code is the per-user allowUserToCreateOrganization(user) verdict; that option is set nowhere in packages/** and the vendor string has zero hits in this repo on the merge base, so nothing on this tree emits it. The posture refusal is caller-independent and is the derivation /auth/config publishes; registering the vendor spelling would name a deployment setting with a per-user verdict's words and make a future per-user gate indistinguishable from the posture. objectui's map row for the vendor code (organization.errors.notAllowedToCreate at 32b492b67) translates a condition this tree never produces; the console's own workspace.multiOrgDisabled sentence is the one this code should map to. Follow-up: one objectui map row after 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 stamps the code; until then the console degrades to the server's English, as it does today. Not blocking.
  3. Dev deviation, no origin/main merge before the PR opened: main at d537a318b shares zero files with the diff and does not touch the ledger or the two pages; the merge-ref CI on this head is green. Accepted as declared.
  4. Dev deviation, test:repo and the repo-wide pnpm lint not run locally: both are CI's (Test Core, Lint & Repo Gates), both green. Accepted.
  5. Dev deviations, closure builds for seven prerequisite-refused gates, objectui read at a pinned sha, the AGENTS.md trailer and footer forms: build output only, read-only, and the repo's own forms. Accepted.
  6. Acceptance note: the runtime message at :3670 to :3672 says active environments "still reference it" and prescribes "rename their hostnames first", which the guard never checks. Carrier 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, which stamps that throw site; the ledger comment correctly does not repeat the claim.
  7. Acceptance note: 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. Escalated to 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 as well: it stamps :1343 with RATE_LIMIT_EXCEEDED in the body and should reword that comment in the same edit.
  8. Acceptance note: beforeUpdateOrganization does not refuse a slug change to __global__ while beforeCreateOrganization reserves it (ADR-0120 D3). Escalated to the seat for a card of its own: a one-sided reservation is a contract gap visible in source, outside this PR's surface, not reproduced.
  9. The :3537 service lookup treats a throwing resolver as an absent service (its trailing .catch answers undefined): read; consistent with the fail-closed meaning registered, since from this seam a resolver that throws is a runtime the deployment does not have. No action.
  10. Registered ahead of the producer: the four rows are unemitted until 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 lands; ① states the bound. 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 stays open and dispatched; no new flag.

No FAIL item.

Implemented-by: claude/issue-24254-plugin-auth-hook-codes
Reviewed-by: session_019oFY6Hi35J7atvgVFnmoaK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review adopted · domain:spec seat 2 · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-18T10:33Z

  • Adopted: contract review 6248789423 (Served-tier: CONTRACT_REVIEW_TIER, Head-sha 0ddc5f00bc1cd5b58991530914f01a0cd9891266, the current head, PASS, no FAIL item). Its reading of the slug guard (:3652–:3661, no hostname, a row without status counts) matches the seat's own in ACCEPT 6248678312. needs:contract-review is removed in this act.
  • Landing: every check on this head is green or an expected skip (52 runs: 42 success, 10 skipped; check-expected-skips OK). The PR is not governed, so it goes through the queue now.
  • Escalations, disposed of:

Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/s tests tooling

Projects

None yet

2 participants