Repository navigation
fix(core): a rejecting sys_api_key read is the store outage (503), not "no key", so a cookie beside the key never answers for it - #24397
Conversation
…the store outage, not "no key" (WIP) Claude-Session: https://claude.ai/code/session_01BXWZRDo9sKLCG7VehArSHR Co-authored-by: Claude <noreply@anthropic.com>
… and the settings door (WIP) Claude-Session: https://claude.ai/code/session_01BXWZRDo9sKLCG7VehArSHR Co-authored-by: Claude <noreply@anthropic.com>
…the degrade-to-guest pin moves to a session fault (WIP) Claude-Session: https://claude.ai/code/session_01BXWZRDo9sKLCG7VehArSHR Co-authored-by: Claude <noreply@anthropic.com>
…i-key-read-outage
… hold the caller's bound; the REST pin declares its log level (WIP) Claude-Session: https://claude.ai/code/session_01BXWZRDo9sKLCG7VehArSHR Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 36680df96b5d376c23047ab4ac3cf83e58e2687d && git checkout 36680df96b5d376c23047ab4ac3cf83e58e2687d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 584b4225e1fcbaf5e8bed14d9c280d6347a897ff 3d826adf2685d9c2c878a4618d4eaa6ec5c43ae6 && git checkout -B drift-repro 584b4225e1fcbaf5e8bed14d9c280d6347a897ff && git merge --no-ff 3d826adf2685d9c2c878a4618d4eaa6ec5c43ae6
node scripts/docs-audit/affected-docs.mjs --json 584b4225e1fcbaf5e8bed14d9c280d6347a897ff
|
Contract reviewServed-tier: ① Derived judgmentsEvery served-set and accept-set change, named:
② Semver level
③ Boundary flagsDev deviations (report 6254998617), each answered or escalated:
Check-runs on Implemented-by: VERDICT: PASS |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | Draft PR → main. The body opens Fixes #24299. 6 files, +517 / −9. Not governed (check-governed-merges.mjs --pr 24397). |
| Scope | One source file, packages/core/src/security/api-key.ts: the catch around the sys_api_key read throws AuthzStoreUnavailableError('sys_api_key', err), except a never-provisioned table (isMissingTableError(err, 'sys_api_key')), which still answers "no key". Pins on core, the REST data door, the runtime dispatcher (an existing pin flipped) and the settings door, declared to domain:cli (6255106909, 6255110824) and domain:services (6255115822). One changeset for @objectstack/core. resolve-authz-context.ts, fenced for the epic:#15194 lane's PR #24373, is unchanged. |
| Seat's own read | At the head, the catch throws the branded outage and returns none only for the missing-table case, which the comment ties to the 2026-08-30 ruling beside tryFind. git diff over the merge base shows no change to resolve-authz-context.ts. |
| Measured | Before the fix, with the key read rejecting, on the REST data door, the runtime dispatcher and the settings door: a key-only caller got 401. A key beside another user's session cookie was served as the cookie's user (REST 200; the runtime scope resolved to that user). After the fix, both answer 503 SERVICE_UNAVAILABLE on all three doors. Session-only callers and the other "no key" exits are unchanged. |
| Review face | The narrowing. CONTRACT_REVIEW_TIER record on this head is PASS. The seat verified its central claims at the head (the row above). |
| Declaration | Clause-②: no (narrowing), on the claim, the PR body and the changeset alike. The changeset is minor, BREAKING, with the adr-0087 not-required (no-migration-prescription) marker; the record ② finds both right, and names the one other moved shape, resolveApiKeyPrincipal rejecting where it answered undefined. |
| Pins and ablation | Core: the brand, code, status, object and cause; key plus cookie through resolveAuthzContext with the session read 0 times; both missing-table spellings answer "no key"; a phrase naming another relation stays loud; the other "no key" exits unchanged; a healthy control. Door pins: key-only and key plus cookie 503, session-only and a healthy key served as before, an unprovisioned table 401. The ablation turned 8 cases red (core 3, REST 2, settings 2, runtime 1) with every control green; the restore read back as the HEAD blob. |
| Gates | Dev at 3d826adf26: 69 derived, 69 run, 0 NOT MEASURED. Typecheck exit 0 for core, rest, runtime and service-settings. |
| CI | At this read: 40 check-runs on 3d826adf26, 34 success, 6 skipped, 0 failed. The skips are the rostered path and opt-in skips. Test Core 1/6–6/6, every Type Check lane, Lint & Repo Gates, Check Changeset and Governed Surface Queue Guard are green. |
Disposition:
open_questions:- The never-provisioned table: B, it stays "no key", through the shared predicate the 2026-08-30 ruling signed for
tryFind. Throwing there would answer503to every key-bearing request on the supportedABSENT_AUTHZ_TABLESdeployment shape, where no key can exist. - The outage label: A,
'sys_api_key', the table whose read failed, astryFindlabels its reads. No reader branches on it. - The grade: p1, not
security, as re-grade 6255097198 set it and record ③ confirms. The caller holds both credentials, so no access is gained beyond them. The harm is identity confusion with wrong audit attribution on public doors during an outage.
- The never-provisioned table: B, it stays "no key", through the shared predicate the 2026-08-30 ruling signed for
deviations: all accepted, as record ③ judges them. These are: background runs waited on in the same turn; M1 measured through the real route handlers in-process; the missing-table exception; the runtime full suite before the last merge, with the pins and gates re-run on the final head; one changeset for the one published source change; the trailers and footer. The detached shared checkout is noted; no edit was made there.out_of_scope_findings: the storage family's gate reader had the same fall-through. This PR's throw reaches it through the reader's existing relay, so nothing is left open (noted in the PR, not filed). Record ③ notes, as a gap rather than a defect, that no committed door pin drives key plus cookie on the runtime dispatcher or the storage family; the seam pin covers the composition both use.
Landing: ordinary queue (pr_ready + automerge_enable through the relay). Every check on this head is green or a rostered skip. Fixes #24299 then closes the card.
Generated by Claude Code
Fixes #24299
Clause-②: no (narrowing) (a key whose read fails is no longer taken as "no key", so the request no longer falls on to another credential; owes one
CONTRACT_REVIEW_TIERreview before the queue)What changes
resolveApiKeyAdmission(packages/core/src/security/api-key.ts) read the key'ssys_api_keyrow inside a barecatchthat answered{ outcome: 'none' }("this request carries no key") and logged nothing. Thatcatchnow raises the brandedAuthzStoreUnavailableError('sys_api_key', err), the answer the same function already gave when its lifecycle-policy source (api_key_policy) or owner-posture source (auth_gate) rejects. The driver's error is kept ascause.One read failure stays "no key": a
sys_api_keytable that was never provisioned, where no key can exist. That is the boundary the 2026-08-30 ruling (option A) draws forresolveAuthzContext'stryFind, asked through the same sharedisMissingTableError(err, 'sys_api_key'). No second classifier was added.The other
outcome: 'none'exits are unchanged and pinned: no key header, no engine, an engine with nofind, no matching row, a row with nouser_id.No edit to
resolve-authz-context.ts(fenced for PR #24373,epic:#15194) was needed. The throw leavesresolveApiKeyAdmissionbefore the session arm runs, and every door already relays the branded outage as503.Measured first (M1), before the code changed
The same harness was run on three doors that admit keys, on
origin/maind65826fa7cand again after the change. Each case is either the engine'ssys_api_keyread rejecting with every other table healthy (key read rejects), or every read rejecting (all reject). The key belongs tou_keyand the cookie to a different user,u_cookie.GET /api/v1/data/:object(real route, single-kernel wiring)401 UNAUTHENTICATED503 SERVICE_UNAVAILABLE200, served asu_cookie503, data service never called200asu_cookie401/503/503503/503/503resolveRequestScope+GET /api/v1/automation/_status)401, scope guest503, raisessys_api_keyu_cookie(wire501, admitted)503, raisessys_api_keyu_cookieGET /api/settings(bootedSettingsServicePlugin)401 UNAUTHENTICATED503 SERVICE_UNAVAILABLE200(the session read answered)503, session never read200The cookie-beside-a-failed-key reading is now measured. A request that led with a key was admitted as the principal of a cookie it did not lead with, on the REST data door and the runtime dispatcher, and served on the settings door. The triage grade (6249028135) named this reading as the point to re-raise; that call is the lane's.
Hypotheses: H1 held (the throw alone gives every door measured
503, through the handling the lifecycle-policy outage already takes). H2 held (the key-plus-cookie case answers503and the session is never read).Pins
packages/core/src/security/api-key-read-outage.test.ts(new): a rejecting read raises the branded outage (code,status,object,cause). ThroughresolveAuthzContext, a key plus a cookie rejects andgetSessionis called zero times. A never-provisioned table (SQLite and Postgres spellings) is stillnone. A missing-table phrase naming another relation stays loud. The othernoneexits are unchanged, and a control admits the healthy key.packages/rest/src/rest-api-key-read-outage.pin.test.ts(new, door pin): the real REST data route. Key only answers503 SERVICE_UNAVAILABLE. Key plus cookie answers503and the data service is never called. Session only is unchanged. Controls: a healthy key is served as its owner, and an unprovisioned table answers401.packages/services/service-settings/src/settings-routes.api-key-read-outage.pin.test.ts(new, door pin):GET /api/settingson the booted plugin, with the same rows as the REST pin. The session read count is 0 for key plus cookie.packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts(flipped by the sweep): the one existing pin that asserted the old answer. "A plain engine fault under a key degrades to a guest" now asserts the brandedsys_api_keyoutage, with the superseded assertion quoted. Its intent, that every non-branded identity fault degrades to anonymous, moves to a session read that throws, which still resolves a guest.Pin sweep: I searched the error code and message across the repo for pins with the same meaning, and ran the suites of every package whose tests send an API key while a read rejects (list below). Only the runtime pin above asserted the old answer. No existing pin asserted
401on a door for a key whose read fails.authz-store-unavailable.test.ts'sTRANSPORT_LEDGERneeds no row: it enumerates callers ofresolveAuthzContext, and none was added.Verification record
Final head
3d826adf2(merge basec76e84922).Suites (after the change, before the merge of
origin/mainc76e8492):@objectstack/core: 104 files, 2551 tests passed.@objectstack/rest: 295 files, 6051 passed and 326 skipped.@objectstack/service-settings: 50 files, 876 passed.@objectstack/service-storage: 60 files, 1041 passed.@objectstack/service-datasource: 45 files, 838 passed.@objectstack/runtime: 1 failed and 420 passed (421 files). The one red was the predicted pin above; after the flip that file reads 11 passed.cloud-connection(41),plugin-sharing(31),organizations(32) andplugin-auth(11): all passed.Ablation (declared direction RED).
throw new AuthzStoreUnavailableError('sys_api_key', err);was replaced by anoneanswer carrying the markerABLATION-24299, throughscripts/ablation-replace.mjs(marker 1, anchor 0).@objectstack/corewas then rebuilt, andablation-dist-preflightfound the marker in 2 built files. Result: 8 red, every control green.Restore: blob equals HEAD (
13e1cdc2035),git diff HEADis empty, core was rebuilt, and the--absentpreflight exited 0 with the tree clean. A first attempt used a comment marker, which the build strips. Its preflight refused, so that leg's readings were discarded and the leg was re-run with a string marker.Typecheck, after the merge of
origin/main: core, rest, runtime and service-settings all exit 0.--listFilesshows each new test compiled:tsconfig.test.jsonfor core, rest and runtime,tsconfig.jsonfor settings.Gates, on
3d826adf2:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackwith no paths derived 69 commands. All 69 exited 0, and each exit code was captured before any pipe.--ranreads 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.check-rest-log-spy-declared(the REST pin now declaresOS_REST_LOG),check:objectql-double-limit(afinddouble the probe could not drive), andcheck:where-matcher(two matchers that read a combinator as a field; both now refuse).check:dual-build-cjs-loadsfirst exited 3 because some dist was missing. After a whole-tree build (76 of 76 tasks) it exited 0.eslint --no-inline-configover the 5 changed.tsfiles exited 0, andcheck-platform-checklist --changed-from origin/mainexited 0.Acceptance notes
resolveApiKeyPrincipal, the principal-or-nothing collapse, now raises the outage too instead of answeringundefined. Its docblock says so. Its only in-repo users are re-exports. No caller in this repo catches its throw differently from the admission's.resolveApiKeyAdmissiondirectly. It had the same fall-through: withnone, it went on to read the session's posture. The throw now reaches its door as the branded outage, which its comment already says the door relays as503. Its suite is green.@objectstack/core(minor, breaking,no-migration-prescription). The rest, runtime and settings diffs are tests only, which no package publishes.Generated by Claude Code