Skip to content

Commit 0f05907

Browse files
feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account (#23287) (#23440)
Fixes #23287 Clause-②: yes (narrowing) — the published contract widens (a closed posture vocabulary with a declared scope, the new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served). ## What changed This is the code half of ruling C′ (director record `6159450584`, the maintainer's 「同意」). The ADR-0069 D3 sentence (ruling item 5) is a separate Tier H PR. The key-lifecycle policy (ruling item 6) is #23367. Nothing here touches the cloud repository. **1. Posture codes are a closed vocabulary, and each code declares its scope (`@objectstack/spec/kernel`, ruling items 1 and 2).** - `AuthGateCodeSchema` = `PASSWORD_EXPIRED` | `PASSWORD_RESET_REQUIRED` | `MFA_REQUIRED`. `ExecutionContext.authGate.code` is now typed against it. - `AUTH_GATE_SCOPES` declares each code's scope once, beside the codes: `MFA_REQUIRED` → `account`; `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` → `credential`. The table is typed total over the enum, so a new code without a scope does not compile. - `PASSWORD_RESET_REQUIRED` is registered under `@objectstack/plugin-auth` in the error-code ledger. **2. One per-user posture computation (`@objectstack/plugin-auth`).** `AuthManager.resolveUserAuthGates(userId, { organizationId })` answers every posture the user holds, most urgent first: forced reset, then expiry, then MFA. It is declared on the contract as `IAuthService.resolveUserAuthGates`. The session gate (`computeAuthGate`) takes its first entry, so a session sees what it saw before. The key seam reads the whole list, so a credential-scope entry at the head cannot hide an MFA requirement behind it. It is keyed by user id, which is the shape seat 1's waiter note asks for: #23335's impersonation leg can call the same oracle. **3. One judgment at the shared key-admission seam (`@objectstack/core`, ruling item 3).** - `resolveApiKeyAdmission` takes an optional `userAuthGates` source. It asks it for the key's owner, in the key's own organization, AFTER the tenancy refusal. An account-scope posture yields a new outcome, `held`, carrying the principal and the posture. - `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. A held key does not fall through to the session path. The session arm now carries `credential: 'session'` and the session's own posture, so a door reads one field for either credential. - `userAuthGateSourceOf(authService)` binds the source from an auth service. A posture computation that rejects is the branded store outage (`503`), never an admission. - The refusal is not a revocation (ruling item 4): nothing is written, and the same key is admitted again once the posture clears. **4. REST and the dispatcher drop the cookie re-read after a key has admitted a user.** `RestServer.computeExecCtx` and `HttpDispatcher.enforceAuthGate` now re-read the session for the gate only when a session admitted the caller. For a key, they use the key's answer. The dispatcher learns the admitting credential through a new `onAdmission` option on `resolveExecutionContext`; the envelope's `authGate` stays withheld on that face, as before. The declared-endpoint fallback (`dispatcher-plugin.ts`, gate step ②) does the same. **5. The five raw-host families, measured on `main` `e4ec6ec1a`.** | Family | Admits an API key? | Here | |---|---|---| | datasource admin (`requireDatasourceAdmin`) | yes, through `resolveAuthzContext` | the door asks `evaluateAuthGate` about the resolver's posture, and wires `userAuthGates` | | share links (`SharingServicePlugin` resolver) | yes | the resolver wires `userAuthGates`; the posture rides the spread envelope to the door | | settings (`SettingsServicePlugin` resolver) | yes | the resolver wires `userAuthGates` and puts `authz.authGate` on the context | | storage (`registerStorageFamily` door) | yes, on the parent-governed download | the door's gate reader asks `resolveApiKeyAdmission` for a key, and reads the session only when no key was recognised | | webhook redeliver (`serveRedeliver`) | **no**: the door authenticates by session alone | no code change; a pin holds that a key alone is `401` on both faces | So the claim's assumption that the families needed only test changes does not hold: four of the five read the posture off the session user, which a key-admitted request never has. Each now reads the shared resolver's answer. **6. The forced reset (ruling item 2).** `sys_user.must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. `runAdminSetUserPassword` (the `set_user_password` action) carries `revokeSessionsAndKeys`, on by default. When on, after the new password is set, it revokes every live session (ADR-0069 D4's shape: `expires_at` past, `revoked_at`, `revoke_reason: admin_password_reset`) and every unrevoked API key (`sys_api_key.revoked`) of the user. The response and the audit row report what landed: `revoked: { sessions, apiKeys, failed, complete }`. A refused write is counted and logged, and the reset still succeeds. The act records no reason, so the switch defaults on, which is the suspected-compromise default (see Open questions). The action gains the switch as a param, translated in the four platform-objects bundles. ## Seven faces, each with its pin Every face pins three facts: a key whose owner holds `MFA_REQUIRED` gets `403 MFA_REQUIRED` with the session's message; a key whose owner holds only a credential-scope posture is served; and the same key is served again once the posture clears. | Face | Pin | |---|---| | core seam | `packages/core/src/security/api-key-account-posture.test.ts` | | REST | `packages/rest/src/rest-api-key-account-posture.pin.test.ts` (also: a held cookie beside the key is never re-read) | | dispatcher | `packages/runtime/src/http-dispatcher.api-key-account-posture.pin.test.ts`, plus three key cases in `endpoint-policy.auth-gate.pin.test.ts` for the declared-endpoint fallback | | datasource admin | `service-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts` (every mounted route) | | share links | `plugin-sharing/src/share-link-routes.api-key-account-posture.pin.test.ts` (booted plugin) | | settings | `service-settings/src/settings-routes.api-key-account-posture.pin.test.ts` (booted plugin, every route) | | storage | `service-storage/src/storage-routes.api-key-account-posture.pin.test.ts` (composed mount, every route) | | webhook redeliver | `plugin-webhooks/src/webhook-redeliver-api-key.pin.test.ts` (non-admission, both faces) | The forced-reset switch is pinned in `admin-user-endpoints.test.ts`: the default on position, the off position, a malformed value keeping the default, an incomplete revocation, and an engine with no read surface. The posture list is pinned in `auth-manager.test.ts`. ### Negative control: the seam's judgment removed The `held` return in `resolveApiKeyAdmission` was replaced through `scripts/ablation-replace.mjs` in WRAP mode, on the committed tree `2d2dd3fa4`. Anchor 1 → 0, blob `8f6d0a1daa71` → `aaabc628a1e4`. Core was rebuilt, and `ablation-dist-preflight.mjs --absent` read the marker absent from all 14 built files. The face pins import core through its `exports`, so this dist leg was required. | Face | Ablated | |---|---| | core seam | `4 failed / 13 passed` | | REST | `2 failed / 3 passed` | | dispatcher + declared endpoint | `3 failed / 21 passed` | | datasource admin | `12 failed / 12 passed` | | share links | `2 failed / 1 passed` | | settings | `2 failed / 1 passed` | | storage | `11 failed / 20 passed` | | webhook redeliver | `2 passed`: green by construction, because the door never asks the seam | Only the held cases went red. The credential-scope, cookie-beside-key and session controls stayed green. Restore: the blob after restore equals HEAD (`8f6d0a1daa71`) and `git diff HEAD` is empty. Core was then rebuilt, and the preflight read the marker present in `dist/index.js` and `dist/index.cjs` with a clean tree. ## Verification (head `56004af60`) Tests and type checks, per package, all passing: - Full suites on the pre-merge tree, every touched package: - spec 673 files (1 stale-artifact case, re-run green after regeneration); - core 98 / 2439; plugin-auth 140 / 2815; rest 282 / 5850; runtime 374 / 5460; - plugin-sharing 45 / 1058; plugin-webhooks 19 / 201; service-datasource 45 / 838; - service-settings 46 / 826; service-storage 56 / 987; platform-objects 70 / 1091. - After `origin/main` (`b5b1bf727`) was merged in, rebuilt and re-run: runtime 374 / 5463; spec 672 of 673 files. The one red case was the ADR-0122 isomorphic-pin count, raised 785 → 787 for the two new enums (`93db5c5f0`) and re-run green. - `pnpm --filter … run typecheck` exits 0 for all eleven packages, including their `check:test-typecheck` ledgers. - ESLint, narrowed, on `56004af60`: `eslint --no-inline-config --format json` over the 45 changed `.ts` files reports 45 files linted, 0 errors, 0 warnings, none ignored. `eslint.config.mjs` never enables type-aware linting, so this diff cannot move a verdict on an untouched file. **Gates, on head `56004af60`.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 125 commands from that tree. - All 125 ran, each recording its exit code before any pipe, and **all 125 exited 0**. - `--ran` reconcile: `125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN`. - Earlier passes caught and fixed these on the way: - the tenant-audit census and its prose figures (two new write sites, regenerated, and again after the merge); - the posture seam's fake engine: read-only, `limit`-bound, refusing an unimplemented combinator; - ADR-0122 bare-alias naming for the two enums, with their isomorphic pins (785 → 787); - a typed `auth` slot lookup in the settings plugin; - six spec gates that refused (exit 3) on a stale dist until spec was rebuilt. - A full workspace build (75 tasks) preceded the final pass, so `check:dual-build-cjs-loads`, `check:i18n` and `check:skill-examples` measured rather than refused. The i18n bundles, which carry the new action param, are in sync with the generator. - `pnpm --filter @objectstack/spec check:generated`: every artifact up to date. The regenerated `skills/**` index lines are forced by `check:skill-refs`, which measured stale on this diff before regeneration. - **Size:** 61 files, +2678 / −204 = **2882** changed lines against `origin/main`, under the 3,000-line threshold. ## Acceptance notes - **The Console does not recognise `PASSWORD_RESET_REQUIRED` yet.** objectui's `detectAuthGate` (`packages/auth/src/auth-gate-events.ts` at `origin/main` `b434f5f4`, and at the pinned `.objectui-sha`) raises the remediation overlay for `MFA_REQUIRED` and `PASSWORD_EXPIRED` only, and `RemediationOverlay` routes `PASSWORD_EXPIRED` to the change-password form. Once this lands, a force-reset user gets `403 PASSWORD_RESET_REQUIRED` and no overlay. See Open questions; this PR should not land ahead of the objectui change. - **Faces outside the seven that admit keys and do not ask the owner yet:** the marketplace install-local doors in `@objectstack/cloud-connection`, which read the gate off the session user only, and the MCP stdio face (`resolveStdioExecutionContext`), which carries no gate at all. Both are reported for the seat to route. The cloud-connection binding doors, the platform-admin judge and the webhook redeliver door authenticate by session alone. - **Declared endpoints:** the fallback's `authRequired` floor keys on the session principal (`resolveSessionPrincipalId`), so a key alone meets `401` on a default endpoint whatever its owner's standing. The posture bites on `authRequired: false` endpoints, which run as the key's principal. Pre-existing, not changed here. - **Storage corner:** the storage door asks the admission seam, not the full resolver, so a key whose membership ended AND whose owner is held answers `403 MFA_REQUIRED` there, while REST answers the membership refusal's `401`. Both refuse. - **MFA grace clock:** the posture list evaluates every policy, so a user under a forced reset has their MFA grace clock started on first sight instead of after the reset clears. - **Session provenance:** a `credential`-scope posture still holds every session, an SSO-opened one included, as before. The ruling's scope decides what holds a key; it does not change the session gate. - **Stale two-code comments** at the doors (`share-link-routes.ts`, `settings-routes.ts`, `storage-routes.ts`, the two REST sub-route families, `platform-admin-gate.ts`) now name the closed vocabulary. - **`skills/**`:** the only paths are `gen:skill-refs` outputs (`skills/objectstack-{api,data,ui}/references/_index.md`, one line each), generator-owned under the #11705 carve-out. Line readings: each file 46 → 46, 72 → 72 and 66 → 66; all `SKILL.md` together 4408 → 4408. ## Open questions 1. **Landing order with objectui (the Console) — a sequencing decision for the seat.** objectui recognises only `MFA_REQUIRED` and `PASSWORD_EXPIRED` as remediation codes, so once this lands a force-reset user gets `403 PASSWORD_RESET_REQUIRED` and no change-password overlay. - A. objectui first: one small objectui change makes `detectAuthGate` accept `PASSWORD_RESET_REQUIRED` and routes it to the change-password form. It is harmless before the backend emits the code. This PR waits on it and on the `.objectui-sha` bump. - B. One landing: this PR carries the pin bump and manifest regeneration, the way a sibling-breaking removal ships. - C. Land this first and accept a window where force-reset users are stuck on raw 403s. - Recommendation: **A.** It is backward-compatible, keeps each repo's diff in its own repo, and leaves no window. C strands exactly the users an administrator just reset. 2. **The forced reset records no reason, so "on by default when the reason is a suspected compromise" has no reason to read.** I measured the act (`runAdminSetUserPassword`, the `set_user_password` action): its body is `userId`, `newPassword` / `generatePassword` and `mustChangePassword`. - A. What is built: the switch is ON for every forced reset, which treats every forced reset as a possible compromise. Turning it off is the administrator's explicit act. - B. Add a `reason` field (`suspected_compromise` | `routine`) whose value sets the switch's default. That is a new API and action field nobody pulls today. - Recommendation: **A**, the narrowest honest default. B adds a surface whose only reader would be the default it replaces. 3. **The Clause-② grammar.** The closed form takes both facts: `Clause-②: yes (narrowing)` reads as "widens one surface and narrows another" (`scripts/pm/clause2-line.mjs`: value `yes`, arm `narrowing`). Nothing is picked silently; noted here only because the dispatch asked. ## 维护者速读(草稿) - **改了什么:** API key 现在和它主人的登录会话同答:主人被「强制两步验证」扣住时,key 在所有接口上返回同样的 403 MFA_REQUIRED;主人只是密码过期或被管理员强制改密时,key 照常工作。管理员强制改密改用自己的码 PASSWORD_RESET_REQUIRED,并带一个「退出所有会话并吊销 API key」的开关,默认开。 - **为什么改:** 裁决 C′:扣账号的姿态扣住账号的每种凭证,扣密码的姿态只扣密码登录;同一个请求在不同接口上要同一个答案。 - **风险与代价(含回滚):** 开了强制两步验证的部署里,没绑定的员工手里的 key 在宽限期满后会被拒,直到本人绑定(不需要重新签发)。管理员重置密码默认会让该用户在所有设备上退出、key 被吊销。Console 尚不认识新码,需先合 objectui 的配套改动。回滚:回退本 PR 即恢复旧行为;已吊销的 key 需在 sys_api_key 上逐个恢复。 - **席位意见:** - **你要做的:** 确认 objectui 配套改动的先后顺序(见 Open questions);ADR-0069 D3 那句另走 Tier H PR。 --- _Generated by [Claude Code](https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 73c7dc4 commit 0f05907

61 files changed

Lines changed: 2678 additions & 204 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/rest': minor
5+
'@objectstack/runtime': minor
6+
'@objectstack/plugin-auth': minor
7+
'@objectstack/plugin-sharing': minor
8+
'@objectstack/service-datasource': minor
9+
'@objectstack/service-settings': minor
10+
'@objectstack/service-storage': minor
11+
'@objectstack/platform-objects': minor
12+
---
13+
14+
feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account, the forced reset gets its own code and an explicit revocation switch
15+
16+
Clause-②: yes (narrowing) — the published contract widens (the posture codes become a closed vocabulary with a declared scope, a new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served).
17+
18+
<!-- adr-0087: not-required (no-migration-prescription) An authentication-policy change at the API-key admission seam plus one new error code, not a metadata change. No authorable spec key, stored shape or metadata surface is removed or renamed, so `objectstack migrate meta` has nothing to rewrite. `ExecutionContext.authGate.code` narrows from a bare string to the closed posture vocabulary, but that field is server-constructed only and never authored; its one producer already wrote only members of it. What narrows is which API keys are served (a key whose owner owes MFA enrolment is refused with 403 MFA_REQUIRED), and the administrator-forced password change answers its own code. The packages publish (not unpublished), no ADR-0087 id covers this and none is added (not registered or already-registered). -->
19+
20+
**BREAKING** (an accept-set narrowing at the API-key door, and a changed code for the administrator-forced password change), shipped as `minor` under the launch-window convention for breaking changes.
21+
22+
Ruling C′ on #23287: posture codes are a closed vocabulary, and each code declares what it holds.
23+
24+
- **The vocabulary and its scope (`@objectstack/spec/kernel`).** `AuthGateCodeSchema` is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES` declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). `ExecutionContext.authGate.code` is typed against the vocabulary, and `PASSWORD_RESET_REQUIRED` is registered for `@objectstack/plugin-auth` in the error-code ledger.
25+
- **One per-user posture computation (`@objectstack/plugin-auth`, `IAuthService.resolveUserAuthGates`).** It answers every posture a user holds, most urgent first, keyed by user id and the organization the credential operates in. The session gate shows its first entry, as before.
26+
- **One judgment at the shared key-admission seam (`@objectstack/core`).** `resolveApiKeyAdmission` asks that computation for the key's owner and holds the key on an account-scope posture (a new `held` outcome). `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. Both new inputs are optional (`userAuthGates`), and `userAuthGateSourceOf(authService)` binds one from an auth service.
27+
- **REST and the runtime dispatcher** judge a key-admitted request by the key's answer, and re-read the session cookie for the gate only when a session admitted the caller. The declared-endpoint fallback does the same.
28+
- **The raw-host families** take the same answer through the shared seam: datasource admin, share links, settings, and the storage family's door. The webhook-redeliver door admits no API key at all, and still does not.
29+
- **The forced reset (`POST /api/v1/auth/admin/set-user-password`, the `set_user_password` action).** `must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. The act carries `revokeSessionsAndKeys`, **on by default**: it ends every live session of the user and revokes every API key they own, and the response reports what landed (`revoked: { sessions, apiKeys, failed, complete }`).
30+
31+
What changes for you:
32+
33+
- **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED`, with the message the session gets, on every face that admits keys. It works again the moment the owner enrols, with nothing reissued: the refusal is not a revocation.
34+
- **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working. A session sent beside an API key no longer answers for the key.
35+
- **A forced password change answers `PASSWORD_RESET_REQUIRED`.** A client that recognises `PASSWORD_EXPIRED` to show its change-password flow should recognise `PASSWORD_RESET_REQUIRED` the same way.
36+
- **An administrator's password reset now signs the user out everywhere and revokes their API keys by default.** For a routine reset, pass `revokeSessionsAndKeys: false` (or clear the switch in the Set Password dialog). Revoked keys stay revoked after the password change; an administrator can restore a key a routine reset revoked with the Restore API Key action, or the owner mints a replacement.
37+
- **Hosts calling `resolveAuthzContext` or `resolveApiKeyAdmission` directly** pass `userAuthGates: userAuthGateSourceOf(authService)` to have the key's owner asked. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `held` outcome.

‎content/docs/permissions/authentication.mdx‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -602,8 +602,17 @@ often before the account-level lock engages.
602602
> HTTP endpoint), and every route family that a plugin or service mounts
603603
> directly on the host server and that resolves its own caller, at the family's
604604
> own door. A gated session gets `403` with the gate's own code
605-
> (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) at whichever of them serves the
606-
> request. The auth endpoints (sign-in, change-password, two-factor enrollment
605+
> (`PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` for an administrator-forced
606+
> change, or `MFA_REQUIRED`) at whichever of them serves the request.
607+
>
608+
> **API keys answer for the account, not for the password.** Each code has a
609+
> scope. `MFA_REQUIRED` holds the *account*: an API key whose owner owes MFA
610+
> enrollment is refused with the same `403 MFA_REQUIRED` and message on every
611+
> face that admits keys. `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold
612+
> *password sign-in* only, so the owner's API keys keep working. The refusal is
613+
> not a revocation: the same key works again the moment its owner enrolls. A
614+
> request is judged by the credential that admitted it, so a session cookie
615+
> sent beside an API key never answers for the key. The auth endpoints (sign-in, change-password, two-factor enrollment
607616
> and sign-out among them) and a short allow-list kept with that decision
608617
> (health and discovery among them) still pass, so the user can remediate. Platform administration is not part of that exemption: the
609618
> platform-admin actions refuse a gated session with the same `403`, a platform
@@ -961,8 +970,10 @@ const response = await fetch('http://localhost:3000/api/v1/auth/admin/create-use
961970
A generated temporary password is returned **once** in the response (the
962971
Users-list action reveals it in a one-shot dialog) and is never persisted or
963972
logged. While `mustChangePassword` is set, the user is blocked with
964-
`403 PASSWORD_EXPIRED` on every protected route until they complete a
965-
password change — the same gate and Console redirect as password expiry.
973+
`403 PASSWORD_RESET_REQUIRED` on every protected route until they complete a
974+
password change. It is the same gate as password expiry, under its own code,
975+
and like expiry it holds password sign-in only: the user's API keys are not
976+
held by it.
966977

967978
#### Set / Reset a Password
968979

@@ -971,6 +982,13 @@ password change — the same gate and Console redirect as password expiry.
971982
same `mustChangePassword` flag. It also provisions a credential account for
972983
SSO-onboarded users who never had a local password.
973984

985+
The forced reset also carries `revokeSessionsAndKeys`, **on by default**: it
986+
ends every session of the user and revokes every API key they own, so a
987+
suspected compromise leaves no live credential behind. Pass
988+
`revokeSessionsAndKeys: false` for a routine reset. The response reports what
989+
was revoked (`revoked: { sessions, apiKeys, failed, complete }`), and revoked
990+
keys stay revoked after the password change.
991+
974992
#### Bulk Import Users
975993

976994
`POST /api/v1/auth/admin/import-users` accepts the same payload shapes as the

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122122

123123
The same holds twice over for the context. An options argument spelled as a
124124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125-
forwarding shim cannot, and **53 of the 225 sites are spelled that way**. A
125+
forwarding shim cannot, and **53 of the 227 sites are spelled that way**. A
126126
context resolved from an inline literal or a local `const` can be tested for
127127
`isSystem`; one arriving from a helper call cannot.
128128

@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187187

188188
| carried figure | where it survives | this census |
189189
| :--- | :--- | ---: |
190-
| 175 write call sites | quoted in the merged changeset | **225** |
190+
| 175 write call sites | quoted in the merged changeset | **227** |
191191
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable |
192-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 225** decidable, **71** undecidable |
193-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 120 decidably elevated, 0 decidably not, 97 undecidable |
192+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 227** decidable, **73** undecidable |
193+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 122 decidably elevated, 0 decidably not, 97 undecidable |
194194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195195

196196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -207,11 +207,11 @@ would report a smaller number and would not say so.
207207

208208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209209
figure has no surviving corroboration anywhere in the tree.** This census reads
210-
120 of 225 (53%) as decidably elevated, with 97 more whose elevation is a
210+
122 of 227 (54%) as decidably elevated, with 97 more whose elevation is a
211211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212212
answer is that a static reading cannot settle it.
213213

214-
⇒ **Cite `2 / 225`, and say what it is**: the sites whose options argument was
214+
⇒ **Cite `2 / 227`, and say what it is**: the sites whose options argument was
215215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217217
without tenant context" — **32 further sites** have an options argument this
@@ -223,31 +223,31 @@ cannot read, and they are neither in nor out.
223223

224224
| what | count |
225225
| :--- | ---: |
226-
| write call sites on the application surface | **225** |
226+
| write call sites on the application surface | **227** |
227227
| …whose object name is statically decidable | 154 |
228-
| …whose object name is chosen at run time | 71 |
228+
| …whose object name is chosen at run time | 73 |
229229
| …against an object with tenancy ENABLED | 153 |
230230
| …against an object that declares tenancy off | 1 |
231-
| threading a tenant context | 164 |
231+
| threading a tenant context | 166 |
232232
| PROVABLY carrying none (options read, no context key) | **8** |
233233
| …of those, against a decidably tenancy-enabled object | **2** |
234234
| options argument UNREADABLE — may or may not carry one | 53 |
235235
| …of those, against a decidably tenancy-enabled object | 32 |
236-
| threading a decidably ELEVATED (`isSystem`) context | 120 |
236+
| threading a decidably ELEVATED (`isSystem`) context | 122 |
237237
| threading a context that is decidably NOT elevated | 0 |
238238
| threading a context whose elevation is a run-time fact | 97 |
239239

240240
| how the instrument reached the site | count |
241241
| :--- | ---: |
242-
| receiver carried a readable engine type | 187 |
242+
| receiver carried a readable engine type | 189 |
243243
| receiver erased, placed by the object NAME | 28 |
244244
| receiver erased, placed by an `object: string` PARAMETER | 5 |
245245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246246

247247
| object name spelled inline | 104 |
248248
| object name spelled through a `const` | 50 |
249249
| object name is an `object: string` parameter | 7 |
250-
| object name is some other run-time expression | 64 |
250+
| object name is some other run-time expression | 66 |
251251

252252
### Subtractions the census could NOT defend — enforced
253253

@@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true;
297297
their values are not compared. The reasoning, and the measurement behind it,
298298
are in `scripts/check-tenant-audit-census.mjs`.
299299

300-
Measured on 2026-10-14 at `ddcdb46bd`.
300+
Measured on 2026-10-14 at `f98ac45c5`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304-
| tracked non-test sources scanned | 649 |
305-
| engine-shaped types recognised | 75 |
304+
| tracked non-test sources scanned | 650 |
305+
| engine-shaped types recognised | 76 |
306306
| declared objects in the registry | 116 |
307307
| same-named calls subtracted as non-engine | 164 |
308308

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

Lines changed: 2 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' \| … +326 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' \| … +327 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`. |
@@ -269,6 +269,7 @@ const result = ApiErrorSchema.parse(data);
269269
* `PASSWORD_ALREADY_SET`
270270
* `PASSWORD_EXPIRED`
271271
* `PASSWORD_POLICY_VIOLATION`
272+
* `PASSWORD_RESET_REQUIRED`
272273
* `PASSWORD_REUSE`
273274
* `PAYLOAD_TOO_LARGE`
274275
* `PERMISSION_NOT_ALLOWED`

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ const result = ErrorCode.parse(data);
436436
* `PASSWORD_ALREADY_SET`
437437
* `PASSWORD_EXPIRED`
438438
* `PASSWORD_POLICY_VIOLATION`
439+
* `PASSWORD_RESET_REQUIRED`
439440
* `PASSWORD_REUSE`
440441
* `PAYLOAD_TOO_LARGE`
441442
* `PERMISSION_NOT_ALLOWED`

0 commit comments

Comments
 (0)