Commit de75e40
fix(runtime): the /keys mint and install-wide activation gates classify a tenancy resolution failure instead of reading it as "no wall" (#16385)
* fix(runtime): read the tenancy posture loudly at the /keys mint and activation-write gates
Both gates derived the effective tenancy posture through
`DomainHandlerDeps.resolveService`, the dispatcher's capability PROBE, whose
fallback chain absorbs every rejection at every step and answers `undefined`.
So a `tenancy` service that was registered and FAILED to build arrived at both
gates as the same value a deployment that never registered one produces, and
both gates read that as "there is no wall": `POST /keys` minted an
organization-less key it would otherwise refuse, and an organization
administrator's install-wide activation write was served instead of refused.
Measured on the pre-fix tree with a real `ObjectKernel` whose `tenancy` is
registered through a throwing factory: the mint door answered 201 with one row
written and the raw secret echoed once, and the activation door answered 200
with `setActionActive` called.
The identity step already reads this fact through the classified lookup
(`resolveServiceOrLoud`, #13906 decision 1 option A). That made one failure
answer 503 at the identity step and admit at these two gates in the same
deployment, so "what is this deployment's state on the wall question" had two
answers at once. The gates now read the same classification: never registered
stays quiet and behaves exactly as before, every other resolution failure is
re-raised as `AuthzStoreUnavailableError` (503 `SERVICE_UNAVAILABLE`).
`resolveService` keeps its probe contract for every other name and every other
domain; the classified read is a second, opted-into deps facility, so no gate
that was not named here changes behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
* chore(changeset): the two domain gates classify a tenancy resolution failure
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
* test(runtime): trim the new doubles to the verbs their doors reach, and honour the caller's bound
`check:engine-double-contract` and `check:objectql-double-limit` both read the new
fixture. The `update`/`delete` verbs were copied from a sibling and no door under test
reaches either, so they were coverage nobody was getting and would have owed the
retained ledger two rows for pins that can never fire; they are removed rather than
pinned, and the ledger is untouched (744 rows held, none added). The `find` double now
applies the caller's `limit` by presence and after the filter, so it cannot answer a
page the producer would not have returned.
`check:system-context-census` line rot from the gate edit is repaired by the gate's own
`--fix`: two anchors in the elevation-read page move with the lines they cite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
* fix(runtime): the activation gate reads the tenancy posture in the request's own scope
The classified read this branch gave `refuseUngrantedActivationWrite` dropped
the scope id: `deps.resolveServiceOrLoud(context, 'tenancy')`, where the mint
gate in `./keys.ts` and the identity step in `../http-dispatcher.ts` both pass
`context.environmentId`. A `tenancy` registered `ServiceLifecycle.SCOPED` and
resolved without a scope id rejects UNBRANDED — `Scope ID required for scoped
service 'tenancy'` — which the classified lookup then re-raises, correctly. So
the gate answered 503 on a service that was never unwell, and it was the gate's
own omission that produced the fault it reported.
Driven at the previous head with a HEALTHY scoped `tenancy` reporting
`isolated` and `environmentId = 'platform'`, entering at each door body:
actions/_activation tenant org admin threw 503 (cause "Scope ID required…")
actions/_activation PLATFORM_ADMIN threw 503
automation/:name/toggle both callers threw 503
keys mint org-less answered 400 (correct: keys passed the scope)
So the branch converted one wrong admit AND one correct admit into outages: on
the merge base the tenant org admin was wrongly served 200 with the row written
(the collapse this card is about) and the platform OPERATOR was correctly served
200 — and the operator is the one authority ADR-0126 §5 says this install-wide
switch belongs to. It also re-created the split this branch exists to remove:
the identity step, which resolves with the scope, read that same deployment as
healthy while the gate called it an outage.
With `context.environmentId` passed, the same probe answers 403 PERMISSION_DENIED
for the tenant org admin (no row written) and 200 for the operator (row written)
on all three doors, and every throwing-factory leg still answers 503 with nothing
written. Predicted in writing before the run; all fifteen probe legs matched.
The pins grow a third wiring — `scoped-healthy`, a real `ServiceLifecycle.SCOPED`
factory that SUCCEEDS — because a pin file about outages that never registers a
HEALTHY service cannot tell "loud on a broken service" from "loud on everything".
The operator leg is the load-bearing one: every other caller here is one a refusal
is a correct answer for, so only the caller whose correct answer is 200 can catch
a gate that manufactured an outage.
The automation toggle door is pinned here too — see the following commit, which
corrects what the earlier message said about it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
* docs(runtime): name both doors this activation gate serves, and correct two sentences in 8d189af
Commit 8d189af on this branch says, of the classified read:
"That made one failure answer 503 at the identity step and admit at these two
gates in the same deployment, so 'what is this deployment's state on the wall
question' had two answers at once."
That is overstated for a WIRE caller and it would land in `main` verbatim,
because this repository squashes. Driven at this head, `dispatch('POST',
'/keys', …)` and `dispatch('POST', '/actions/_activation/…')` with a `tenancy`
factory that throws both answer 503 SERVICE_UNAVAILABLE with
`context.executionContext` never set, nothing minted and no activation row
written: the identity step raises first and the request never reaches either
gate. Since PR #15909 the collapse at these two gates is a DOOR-BODY fact — an
embedder calling `handleKeys` / `handleActions` directly, which is exactly where
this branch's pins enter — not a second answer a wire caller could observe for
that class. The pin section of the PR body already said this correctly; the
commit message did not. The severity argument is unchanged and does not rest on
the overstatement: one deployment still holds two different readings of its own
wall question, and the door-body reading is the permissive one.
The same commit also says:
"the classified read is a second, opted-into deps facility, so no gate that
was not named here changes behaviour."
True of `resolveService`'s other callers, false about the gate it edited:
`refuseUngrantedActivationWrite` is ONE body with TWO doors — `./actions.ts`
(`POST /actions/_activation/:object/:action`) and `./automation.ts` (`POST
/automation/:name/toggle`, through `refuseUngrantedFlowActivationWrite`) — so
the toggle door inherited the new throw exit while going unnamed and unpinned.
Both `await` the call, so nothing was ever unhandled; what was missing was the
statement and the coverage. Both doors are now named in the gate's own doc block
and pinned together in `tenancy-posture-outage-gates.test.ts`, which is where a
claim about "the gate" can be checked against both halves of the surface.
`DomainHandlerDeps.resolveServiceOrLoud` also gains the caller rule the scope-id
defect earned: pass the scope you hold, because a rejection out of the
classified read must describe the SERVICE and never the call site's own
omission. Under the plain probe that omission was invisible; under this one it
is a 503 for every caller of the door.
The census docs line moves with the anchors this edit shifted, by the gate's own
`--fix`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
* chore(changeset): grade the runtime change minor — it grows a published interface
The changeset graded `@objectstack/runtime` `patch` and argued it in one line:
"Patch rather than minor: no accept set widens, and a declared guard returns to
enforced." That reasoning answers a different question than the level asks.
`DomainHandlerDeps` is a PUBLISHED type — `packages/runtime/src/index.ts`
re-exports it and the package's `exports["."]` resolves to the emitted
`dist/index.d.ts`, which carries the interface and the new REQUIRED member
`resolveServiceOrLoud(…)`. A required member added to an exported interface is a
new symbol on the published surface: every external `const deps:
DomainHandlerDeps = {…}` stops compiling. This repository's own history grades
that exact shape as minor — three entries under 17.0.0 / Minor Changes in
packages/runtime/CHANGELOG.md say `DomainHandlerDeps` grows two lazily-read
members, grows the shared facilities the body needs, and grows
`getRequestKernelService` and `routeNotFound`. Nothing distinguishes this growth
from those three.
The earlier `patch` rested on an in-repo grep — one constructor, no implementer
outside `packages/runtime` — which is evidence of presence and never evidence of
absence. The mechanical floor exists so that likelihood is not the thing being
argued.
So: level `minor`, and the clause-② declaration in the PR body moves to `yes`
with it — the LEVEL AXIS check makes `yes` plus `patch` a self-contradiction, and
the two belong to one judgement. The justification sentence for the level no
longer used is removed rather than left standing.
The changeset text also picks up what round 2 measured: the activation gate is
one body behind TWO routes, so three routes change, not two, and every gate now
reads the posture in the request's own environment scope.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d83d079 commit de75e40
7 files changed
Lines changed: 739 additions & 15 deletions
File tree
- .changeset
- content/docs/permissions
- packages/runtime/src
- domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
164 | 164 | | |
165 | 165 | | |
166 | 166 | | |
167 | | - | |
| 167 | + | |
168 | 168 | | |
169 | 169 | | |
170 | 170 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
124 | 124 | | |
125 | 125 | | |
126 | 126 | | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
127 | 173 | | |
128 | 174 | | |
129 | 175 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
82 | | - | |
| 82 | + | |
83 | 83 | | |
84 | 84 | | |
85 | 85 | | |
| |||
147 | 147 | | |
148 | 148 | | |
149 | 149 | | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
150 | 170 | | |
151 | 171 | | |
152 | 172 | | |
| |||
156 | 176 | | |
157 | 177 | | |
158 | 178 | | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
159 | 214 | | |
160 | 215 | | |
161 | | - | |
162 | | - | |
163 | | - | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
164 | 221 | | |
165 | 222 | | |
166 | 223 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
34 | | - | |
| 34 | + | |
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
| |||
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
107 | | - | |
108 | | - | |
109 | | - | |
110 | | - | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
111 | 132 | | |
112 | 133 | | |
113 | 134 | | |
114 | | - | |
| 135 | + | |
115 | 136 | | |
116 | | - | |
117 | | - | |
| 137 | + | |
| 138 | + | |
118 | 139 | | |
119 | 140 | | |
120 | 141 | | |
| |||
0 commit comments