Repository navigation
fix(rest): the /meta state route tells a wired-and-failing engine apart from an absent one - #17077
Merged
os-project-manager merged 4 commits intoSep 9, 2026
Conversation
…jectql-provider-second-consumer Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
Contributor
📓 Docs Drift Check
What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-project-manager
marked this pull request as ready for review
September 9, 2026 07:18
os-project-manager
enabled auto-merge
September 9, 2026 07:18
os-project-manager
deleted the
claude/issue-15405-objectql-provider-second-consumer
branch
September 9, 2026 07:47
This was referenced Sep 16, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 17, 2026
…nd its third consumer, driven (objectstack-ai#18556) Part of objectstack-ai#18546 — deliverable 1 is delivered and pinned here; deliverable 2's disposition is the seat's, for the reason in the second section. `objectstack-ai#18546 remains open` on purpose. ## Deliverable 1 — the consumer census, and it disagrees with the record **The engine slot has THREE consumers, not two.** Both prior enumerations counted two, in the same direction, and the third was already on the tree each of them was taken against. | enumeration | said | measured here at `79a046f8c` | |:--|:--|:--| | objectstack-ai#14251 phase-1 census table (comment `5542086636` accepts it) | 2 — `computeExecCtx`, `GET /meta/object/:name/state/:field` | 3 | | the objectstack-ai#15405 repair's own docblock (landed as PR objectstack-ai#17077) — *"`objectQLProvider` has two consumers in `rest-server.ts`"* | 2 | 3 | The third is **`POST /batch`** (the cross-object transactional batch door), which reads the field directly. It was present at the census sha: `git show a56baa2:packages/rest/src/rest-server.ts | grep -n 'this.objectQLProvider'` prints `2362`, `2363`, `7452`, `7453` **and `12251`**. It is not hidden either — the provider's own docblock in `rest-api-plugin.ts` names it in as many words, *"the `transaction` probe behind the batch routes"*. Neither enumeration could fail, because both were prose. ### The pathspec bound of this enumeration, not only the count **Bound:** every read of `RestServer`'s private field `objectQLProvider`, in `packages/rest/src/rest-server.ts`. That is the whole reachable set, and two facts make it so — both now asserted in the new pin rather than argued here: the shipped closure is handed to exactly **one** `new RestServer(...)` construction site in `packages/rest/src/rest-api-plugin.ts`, and the field is read only as `this.objectQLProvider` (no bracket access, no `as any` read) in any non-test source. **Control that can fail, carried per the dispatch's instruction.** The scanner is run against a symbol known to be present — it must find sites, and it does — and against one known to be absent, which must read zero. So a future zero means "gone" rather than "the regex stopped matching". That is the failure mode that produced the two-consumer sentence in the first place: a consumer that cannot be parsed and a consumer that does not exist look identical, and the correct actions are opposite. **What this search cannot see, stated rather than rounded away:** - A consumer reaching the `objectql` service through a **different producer**. Two exist and neither is this slot: the deliberately-absorbing KERNEL branch at the same `computeExecCtx` seam (`kernel.getServiceAsync('objectql')` via `seamOrUndefined`, the RESIDUE `wiredEngineOrLoud` documents and objectstack-ai#14251 leaves in place), and roughly ninety direct `ctx.getService('objectql')` sites in other packages, each its own seam. - A consumer landing in a sibling branch not yet on `origin/main` at `79a046f8c`. - A runtime-only dynamic read by an embedder outside this repo — TypeScript `private` is compile-time only. No such spelling exists in this repo's non-test sources, which is what the pin asserts; it cannot speak for code that is not here. ### The verdict on the decidable test — driven, not inferred The tightened test from comment `5542086636`: a slot is ripe only when **NO** consumer converts a rejection into exactly the value its `undefined` path produces. | consumer | rejection (wired and failed to build) | resolved `undefined` (absence) | re-collapses? | |:--|:--|:--|:--| | `computeExecCtx`, via `wiredEngineOrLoud` | **503** `AuthzStoreUnavailableError` | quiet `undefined` | **no** | | `GET /meta/object/:name/state/:field`, via `wiredEngineOrLoud` since objectstack-ai#17077 | **503** | **404** `NOT_FOUND` | **no** | | `POST /batch`, direct `await` into the handler's own `catch` | **500** `INTERNAL_ERROR` | **501** `NOT_IMPLEMENTED` | **no** | ⇒ **No consumer re-collapses. The slot is ripe.** The card's STOP branch is therefore not triggered: there is no re-collapsing consumer to file.⚠️ The third consumer distinguishes **incidentally**, through `handleRouteError`, not through the seam helper its two siblings use — and it answers 500 where they answer 503 for the identical fact. ⛔ That is recorded, not ruled, and ⛔ nothing here moves it: a public door changing its answer is the per-consumer wire ruling objectstack-ai#14251 reserves. It is handed back as a finding. ## Deliverable 2 — ⛔ the premise is falsified: there is no provider half left to repair The card says deliverable 2 is *"the provider-half repair in `rest-api-plugin.ts`, in the shape objectstack-ai#13904 landed for `objectQLProvider`"*. Read at `origin/main` `79a046f8c`, `objectQLProvider` **already is** that shape — objectstack-ai#13904 landed it on this very closure: the async leg absorbs only `isServiceNotRegisteredError` and re-raises everything else, the sync leg is the documented `KernelBase` leg the census explicitly fences off from any phase-2 roster, and its pin `rest-api-plugin-objectql-provider-three-state.test.ts` is on the tree. The engine slot is the census's **worked example**, not one of the twelve unrepaired slots (those are `auth`, `security`, `email`, `sharing`, `reports`, `approvals`, `sharingRules`, `analytics`, `i18n`, `settings`, `metadata`, `default-project`). Its consumer half landed first (objectstack-ai#13476), its provider half second (objectstack-ai#13904), and its second consumer third (objectstack-ai#15405 via PR objectstack-ai#17077). **Both halves are landed.** So the only half of deliverable 2 with a target left is *"plus the pin"* — which is this PR. ## The two stop-gates — reported, not decided ### 1. `Clause-②`: **no** Measured from this diff (`79a046f8c...HEAD`), 2 files changed, both `*.test.ts`, 384 insertions / 3 deletions: | reading | measurement | |:--|:--| | new exports | **0** — `git diff … \| grep -cE '^\+\s*export '` = 0; the new file exports nothing | | `ERROR_CODE_LEDGER` / `StandardErrorCode` | **0** occurrences anywhere in the diff | | accept-set changes | **0** — no schema, allowlist, `refuseUnknownQueryParams` set or `.strict()` surface is touched; no non-test file is touched at all | `check-clause2-carriers --pair` exit code is recorded in the `os-dev-report` comment on objectstack-ai#18546, run against this PR number once it existed. ⇒ Not a clause-② PR, so it does not park outside the merge queue the way objectstack-ai#18428 / objectstack-ai#18429 do. ### 2. Runtime permission/security behaviour change: **NO** The diff touches **no runtime file** — two test files, nothing else — so there is no before/after to have. The dispatch asked for the wire consequence to be DRIVEN rather than inferred, so it was driven anyway, on a real `RestServer` over a real `ObjectKernel`, and the answer is that a registered-but-FAILING engine is **already** told apart from an absent one at all three doors (the table above). What changed it was objectstack-ai#13904 plus objectstack-ai#17077, both landed; this PR moves none of it. For completeness, the third door's four facts as measured on `POST /api/v1/batch` this run — no provider wired **501**, provider resolving `undefined` **501**, provider rejecting **500**, provider throwing synchronously **500** — beside a served **200** and a declared **400**, so no fault status above comes from an instrument that can only report faults. ## Verification - Ablation, two legs, each proved on disk before the run and restored from `HEAD` after it (blob hash equal to `HEAD:packages/rest/src/rest-server.ts` and `git diff HEAD` empty on both restore legs; the mutation script carries a `trap … EXIT INT TERM`): - a **fourth** consumer injected → the census pin goes red (`exit 1`, naming *"THREE consumers, not two"*); restored → green. - the third consumer **re-collapsed** to the retired `.catch(() =` `> undefined)` spelling → the behaviour pin goes red (`exit 1`, naming the decidable-test assertion); restored → green. - Baseline and both restore legs: `11 passed (11)`. - ⭐ The first ablation attempt asserted the wrong injected-text count and the script **refused to run**, rather than measuring nothing and reading green. The expectation was corrected and the leg re-run; recording this because the guard is the point. - `pnpm --filter @objectstack/rest test` — **193 files, 3229 passed, 1 skipped**. - `pnpm --filter @objectstack/rest typecheck` — clean. `pnpm --filter @objectstack/rest check:test-typecheck` — `0 file(s) / 0 error(s) / 0 pinned signature(s)`. - `pnpm --filter '@objectstack/rest^...' build` — green (dependency closure, so nothing above read a stale `dist`). - Gate families derived in this worktree, cut fresh from `origin/main` — ⛔ not reused from the dispatch, whose checkout the tool reported stale: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → **53 commands**, all 53 run, reconciled with `--ran`: *"53 derived famil(ies) accounted for — 51 run, 2 NOT-MEASURED"*. - `pnpm lint` repo-wide (the lane blind spot `dispatch-gates` does not name) — **exit 0**, whole tree, no narrowing claimed. - NOT MEASURED, both `PREREQUISITE NOT MET` (`exit 3`, a gate refusing its own prerequisite — ⛔ not a finding and ⛔ not a pass): `check:dual-build-cjs-loads` and `check:type-check-debt`, each needing every package's `dist/`. CI builds first and runs both. -⚠️ One recorded `exit 1` is **pre-existing and not this diff's**: `check:cross-package-test-inputs` reds on `packages/cli/test/init-created-files-summary.e2e.test.ts` walking `packages/spec/dist/`. Controlled: a detached worktree at `79a046f8c` with this diff absent exits **0** with no `packages/spec/dist/` on disk and exits **1** with the identical finding once that directory exists. ⇒ the verdict follows local build state, not the change set. Filed back as a finding. `skip-changeset` measured rather than assumed: `packages/rest` ships `files: ["dist", "README.md", "CHANGELOG.md"]`; after `pnpm --filter @objectstack/rest build`, every symbol unique to this diff reads **0 hits** across all three, while the positive control `wiredEngineOrLoud` is found in `dist/index.cjs`, `dist/index.js` and `CHANGELOG.md`. ⇒ nothing published moves. ## Acceptance notes Observed while measuring, ⛔ not filed and ⛔ not fixed here: - `enumerateAbsorbSites()` in `execctx-authz-input-seam-reachability.test.ts` is the same instrument as this PR's §1 scanner but scoped to the body of `computeExecCtx` alone. The two could share one helper; they do not, and this PR does not merge them — a whole-file enumerator is a wider verification surface than this card's. Successor: whichever card next widens that seam census. - objectstack-ai#14251's body still promises *"one bounded edit per slot"*, which its own phase-1 acceptance refuted (*"ruling + consumer edit + provider edit + pin"*). Triage flagged the drift in comment `5706358804` and declined to correct another card's body; this PR is not that card either. Successor: the card's next implementer, who reads the body top-down. --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 28, 2026
…eOrLoud`, so a wired-and-failing engine answers 503 on every wiring (objectstack-ai#18805) Fixes objectstack-ai#18559 Clause-②: no `objectQLProvider`'s THIRD consumer — `POST /api/v1/batch` — now reaches the data-engine seam through `wiredEngineOrLoud`, the same helper its two sibling consumers already use. A wired-and-failing engine answers `503 SERVICE_UNAVAILABLE` instead of the `500 INTERNAL_ERROR` the handler's generic outer `catch` used to produce. ## The card's reading REPRODUCES — and narrows Driven at the real door on a real `RestServer` over a real `ObjectKernel` (⛔ not inferred from the card's prose, ⛔ not from a unit double), the 500 is there. But it is reachable on ONE of the two wirings only, and the card's 503/503/500 table silently mixes wirings: | wiring, engine wired and FAILING | before | after | |:--|:--|:--| | single-kernel — the composition the open core boots | 503 `SERVICE_UNAVAILABLE` | 503 — unchanged | | multi-kernel — a `kernelManager` is wired | **500 `INTERNAL_ERROR`** | **503 `SERVICE_UNAVAILABLE`** | On the single-kernel wiring `computeExecCtx` resolves the engine through its OWN `wiredEngineOrLoud` branch and raises before the batch handler's engine line runs, so this door already answered 503. The 500 was reachable only where that gate's KERNEL branch absorbs by design (`wiredEngineOrLoud`'s RESIDUE note) and hands the engine question down. This is the same narrowing PR objectstack-ai#17077 had to make for the sibling `/meta/object/:name/state/:field` door, and for the same structural reason. ⇒ **the repair does not choose a new wire answer for this door.** It removes a wiring-dependent divergence, leaving the answer this door already gave on the composition the open core boots. §2b of the census test measures both wirings side by side so that sentence is a reading rather than an argument. ## Why this is NOT a re-collapse, and NOT a regression The two facts always differed on the wire (500 against the 501 an absent engine gets), so the decidable test objectstack-ai#14251 tightened — *"NO consumer re-collapses a rejection into the `undefined` path"* — was already satisfied at this consumer. What was wrong is that they differed through a catch-all that knows nothing about this seam. The adjacent `501 NOT_IMPLEMENTED` arm tests `!ql || typeof ql.transaction !== 'function'`, which a rejection never reaches. ## The pin: fails before, passes after — both readings `packages/rest/src/objectql-slot-consumer-census.test.ts` recorded the 500 explicitly as *"RECORDED here, not ruled"*. That is the pin that moved, and the ablation is shown in the PR thread: with the test file at this branch and `packages/rest/src/rest-server.ts` reverted to the merge base, the new assertions FAIL; restored, they pass. ⛔ No test was skipped, disabled or quarantined. Three things are pinned UNCHANGED, because they are what says no accept set moved: - both ABSENCE shapes still answer `501 NOT_IMPLEMENTED`, on BOTH wirings — no provider wired at all, and a provider that RESOLVES `undefined` (the seam contract declaring absence rather than failing); - the fault MESSAGE is still withheld — `Internal server error`, `INTERNAL_ERROR_MESSAGE`, asserted plus a negative assertion that the driver's own sentence never reaches the wire; - a non-`async` provider that throws SYNCHRONOUSLY reaches the same answer as one that rejects (objectstack-ai#13280). ## Controls, including the one that came back dark - §1's window predicate is controlled on BOTH axes: it must read 0 for `emailServiceProvider` + `wiredEngineOrLoud` and still FIRE (1) for `emailServiceProvider` + `seamOrUndefined`, the helper that slot really uses. Without the second half "3 of 3" is the only sentence the instrument can produce. -⚠️ **A dark control, declared rather than hidden.** On the MULTI-KERNEL wiring a batch request carrying a real operation continues past the engine line into `resolveProtocol`/`loadObjectItems`, which the harness's auth-only kernel cannot serve — measured, a HEALTHY engine answers 500 INTERNAL_ERROR there too. A 500 read on that wiring with a real op is therefore ambiguous between "the seam answered" and "the harness ran out of kernel". The healthy control uses `{ operations: [] }`, which returns inside the door after the engine line and before the protocol is touched, so the engine fact is the only variable. §2b adds the control the multi-kernel harness cannot give: on the single-kernel wiring a healthy engine SERVES the same real operation with 200. ## The `/actions` precedent does NOT transfer — measured, not assumed PR objectstack-ai#18760 (card objectstack-ai#18540) was handed over as the precedent for this family. It is a different defect on a different axis, in a different package: - objectstack-ai#18540 is a MESSAGE leak: *"the status is already right here; what leaks is the sentence."* Its remedy withheld prose. Here the message is ALREADY withheld — `INTERNAL_ERROR_MESSAGE` is on the wire today — and what moves is the STATUS. - Its mechanism (`classifyDataError`, `looksLikeInternalErrorLeak`, `errorFromThrown`, `UNCLASSIFIED_FAULT`, `deps.error`) lives in `packages/runtime/src/domains/actions.ts`. None of it is on this door's path: `POST /api/v1/batch` is `packages/rest/src/rest-server.ts` reaching the wire through `handleRouteError` → `resolveErrorResponse`. - The `errorFromThrown` branch that must survive (an error declaring its own HTTP status is served with it) is untouched here: nothing in this diff is in that file, and the branded `AuthzStoreUnavailableError` this repair raises IS an error declaring its own status — it is served with it, which is exactly how the 503 reaches the wire. The precedent that DOES transfer is objectstack-ai#15405 / PR objectstack-ai#17077 — same slot, same file, same helper, the sibling consumer — which the card itself names as the pattern to copy. ## Clause ② — declared `no`, from this diff `Clause-②: no` above is derived from the delivered diff, not inherited: - **No new error code.** `SERVICE_UNAVAILABLE` is an existing `StandardErrorCode`, already in `packages/spec/src/api/error-code-ledger.zod.ts` mapped to 503, and already emitted by the sibling door for this same fact. `AGENTS.md` and the PM protocol are explicit that a NEW code is always `yes`; this is not one. - **No new export, no new payload key, no new envelope.** The 503 wears the same flat `{ error, code }` shape the 500 wore. - **Nothing that was refused becomes served**, and nothing that was served becomes refused: both sides are faults, and both absence shapes are pinned unchanged. - **It is a pull-back to a DECLARED contract** — `wiredEngineOrLoud`'s own table and `AUTHZ_STORE_UNAVAILABLE_STATUS = 503`. The PM protocol states that pulling back to an already-declared contract does not touch clause ②: 「条款②只指已发布契约面,拉回已声明契约不触它」. - The arm slot is deliberately EMPTY. Neither `(widening)` nor `(narrowing)` is truthful here: no accept set widens, and nothing that was accepted is now rejected. `no (widening)` is malformed by construction, and a fabricated `(narrowing)` would carry a false BREAKING declaration against a `patch` changeset. This is the same declaration PR objectstack-ai#17077 landed and its acceptance confirmed for the identical move at the sibling consumer (404 → 503 on a published route), on the same three grounds.⚠️ **Declared conflict, stated rather than quietly resolved.** The dispatch fence for this card said that if the remedy moves a status code it should ship as `minor` with a BREAKING banner and an ADR-0087 disposition. Measured against `AGENTS.md`'s own arms, that spelling has no truthful form here: `minor` + BREAKING is what a declared `(narrowing)` owes, and this diff narrows nothing. The changeset is `patch` per `AGENTS.md`: *"A bug fix in a released package takes a `patch` changeset — never none, and ⛔ never `skip-changeset`"*. `@objectstack/rest` publishes (`files: ["dist", "README.md", "CHANGELOG.md"]`, not private), and the changed door is in the published bundle — so `skip-changeset` is refused on a measurement, not on the file's look. ##⚠️ The one thing this PR does not carry: a maintainer's word on THIS card Stated plainly because it is the half a reviewer should decide, and this PR is a draft for exactly that reason. The card reserves a per-consumer wire ruling: *"A public door changing its wire answer is the per-consumer wire ruling objectstack-ai#14251 reserves … and the ruling half is nobody's to assume."* For the sibling card objectstack-ai#15405 that ruling was obtained explicitly — it sat at `pm:awaiting-maintainer` until the maintainer's batch move, quoted verbatim on that thread with the criterion 「恢复一条既有修复的不变量,属具名不升级类,⛔ 无产品语义分叉」. **No equivalent maintainer act exists on objectstack-ai#18559**: triage moved it straight to `pm:queue`. What this delivery offers in its place is a reading the card's filer did not have, and it is why the work was written rather than handed back: **this door already answers 503 for this fact on the composition the open core boots.** That reframes the remedy from "pick a wire answer for a public door" to "remove a divergence between two compositions of one server". If a reviewer weighs it the other way, the revert is one commit and the PR is a draft. ⛔ Not widened into the undeclared-5xx band at `/meta` or `/mcp` — that is objectstack-ai#5667's recorded decision with objectstack-ai#12281 as the card for the declared limb, and reversing a recorded decision is a decision, not an execution. ## Acceptance notes - *noted, not filed:* on the MULTI-KERNEL wiring, a batch request whose ops survive the engine probe answers `500 INTERNAL_ERROR` when the resolved kernel carries no metadata protocol, because `resolveProtocol`/`loadObjectItems` fault under the generic outer catch. Observed only in this file's harness (an `ObjectKernel` with `auth` and nothing else), which is not a supported deployment shape — a real multi-kernel host registers the protocol. ⛔ Not a reproducible defect against a supported wiring, so it is not one of the three filing classes; recorded because it is what makes a naive 500-based control on that wiring ambiguous, and the next reader of this harness will meet it. Successor who would touch this file: whoever extends `objectql-slot-consumer-census.test.ts` for a FOURTH consumer. - *noted, not filed:* the batch handler calls `this.resolveExecCtx(environmentId, req)` without the `.catch(rethrowAuthzStoreUnavailable)` that many sibling call sites in `rest-server.ts` carry. Measured, it needs none: `computeExecCtx` re-raises the branded outage from its own `catch` and this handler's outer `catch` serves it with its declared status — which is how the single-kernel 503 above reaches the wire. Observation, not a defect, and ⛔ not touched here. --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #15405
Clause-②: no
objectQLProviderhas two consumers inpackages/rest/src/rest-server.ts. The engine-slot repair landed for one of them (#13476, thecomputeExecCtxauthorization-input seam, reached throughwiredEngineOrLoud). The other,GET {basePath}/meta/object/:name/state/:field, reached the same provider through.catch(() => undefined)and converted every rejection straight back into theundefineda never-registered engine produces, three lines before the answer is chosen. So an engine that was wired and failed to build and an engine that was never registered both answered404 NOT_FOUND · "Object not found"— a diagnostic route lying about the cause during exactly the incident it would be consulted in.That line was newly load-bearing rather than long-broken. Before #13904 the shipped provider was
try { ... } catch { return undefined; }and could not reject at all, so the.catchwas dead code and the collapse happened one layer earlier. #13904 made the provider re-raise precisely so a consumer could see the outage. This consumer caught it back.This is not rework on #13904 and touches nothing it pinned.
The change — two call sites in one file
GET {basePath}/meta/object/:name/state/:fieldthis.objectQLProvider(environmentId).catch(() => undefined)wiredEngineOrLoud(Boolean(this.objectQLProvider), () => this.objectQLProvider!(environmentId))POST {basePath}/email/sendthis.emailServiceProvider(environmentId).catch(() => undefined)seamOrUndefined(() => this.emailServiceProvider!(environmentId))⭐
wiredEngineOrLoud, notseamOrUndefined, at the engine seam — deliberately, and against the dispatch's own generic wording.seamOrUndefined's docblock forbids it in as many words: "⛔ Do not route the data-engine seam back through this helper to 'make the seams uniform' — uniformity there IS the defect #13476 repaired."wiredEngineOrLoudis what the sibling consumer already uses, so this route now gives the answer its sibling gives rather than a third one. Both helpers invoke the provider synchronously, which is the other half of the card: a.catchattaches to the promise the call returns, so a host wiring a non-asyncprovider throws before any promise exists and the handler is never reached.The card does not say which wiring reaches this consumer, and the answer narrows its claim. Every
/metaroute is wrapped byregisterMetadataEndpoints's anonymous-deny gate, and that gate resolves the same engine first, throughcomputeExecCtx. Its two branches do not agree:wiredEngineOrLoud, nokernelManagerwired)seamOrUndefined, absorbing on purpose —wiredEngineOrLoud's RESIDUE note)⇒ the collapse was reachable exactly where a resolvable kernel supplies auth and the separately-wired
objectQLProvideris broken: the multi-kernel wiring (rest-api-plugin.tswires akernelManagerwhen the host registers one). It was not reachable in a single-kernel boot such aspnpm dev:crm, where the gate raises first. Measured on a realObjectKernel, not inferred; §0 of the new test file pins the property (not today's exact raise shape, which belongs to another door).What moves and what does not
Driven end to end on a real
RestServerand a realObjectKernel— nothing private is replaced, no fault is simulated by throwing a hand-made branded error into a stub:objectQLProviderwired at allNOT_FOUNDNOT_FOUND— unchanged ⭐ PINundefined(the seam contract declaring absence)NOT_FOUNDNOT_FOUND— unchanged ⭐ PINNOT_FOUNDNOT_FOUND— unchanged ⭐ NEGATIVE CONTROLNOT_FOUNDSERVICE_UNAVAILABLEasynchost, #13280)SERVICE_UNAVAILABLE⭐ The pins assert the DISTINCTION, not a status code. A build answering 503 for a broken engine and for a never-registered one would satisfy every bare
expect(status).toBe(503)while telling a new lie, so the table above is asserted side by side with an explicit inequality ([404, 404, 503],new Set(...).size === 2, and the codes differing). §4 goes further and derives the expected value off the sibling seam's own error object on the same fault, rather than writing a second literal beside it.{ error: 'Internal server error', code }every withheld 5xx on this server wears (handleRouteError, #5437), while the sibling package door emits its own nested{ error: { code } }. The two doors agree on the pair that is the contract — status and machine code — and differ only in which emitter dresses it. That flat shape is pre-existing and pinned elsewhere (rest-server-meta-read-org-scope.test.tsnames it verbatim); not this card's to move.pnpm check:route-envelopeis green.POST /email/sendcarries only the second observation. No 404/503 question, and not reachable from the shipped wiring — both providers inrest-api-plugin.tsare declaredasync. Its answer is deliberately unchanged at501 NOT_IMPLEMENTED; what changes is that the synchronous fault shape now reaches that same 501 instead of escaping past a.catchthat did not exist yet and landing in the handler's own500 EMAIL_SEND_FAILED. Repaired because it is the same retired spelling at an embedder-reachable seam, not on a claim of live impact.Ablation — predicted in writing first, then run
Both legs mutate the committed tree, prove the mutation reached disk (marker counts and blob hash), run the pins, restore, and prove the restore by blob-hash equality plus an empty
git diff HEAD. Restore isgit checkout HEAD -- ABSOLUTE_PATHunder atrap ... EXIT INT TERMwith absolute paths. Nogit stash.Predicted, before running: leg A red on the 3 cases in §2 plus the 1 in §4 (4 failed / 10 passed); leg B red on the §5 sync-throw case only (1 failed / 13 passed).
Observed:
.catch(() => undefined)atobjectQLProviderf384ecf5→c702672dexpected 404 to be 503), §2 sync-throw (expected 500 to be 503), §2 distinction ([404,404,404]vs[404,404,503]), §4 sibling agreement (expected 404 to be 503).catch(() => undefined)atemailServiceProviderf384ecf5→df3a6adeexpected 500 to be 501)Both legs restored to blob
f384ecf5d419b224d6b9932805a6df2347897a32=HEAD,git diff HEADempty. Prediction and observation match exactly, in both direction and cardinality. No dist preflight: the pins import the subject by relative path (./rest-server.js), so no packageexports/distis on the resolution path.Verification
Head at the time of every reading below:
d5248b67b8(origin/mainmerged in ata016f08b8a; the merge is a message-only amend of47a1ecd761, identical tree664f0fe0).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackfrom a tree atorigin/main, reconciled with--ran: 58 derived, 58 run, 0 UNRUN. Exit codes captured by redirect-then-$?, never through a pipe.PREREQUISITE NOT MET—check:dual-build-cjs-loadsandcheck:type-check-debt— both because they read built output for the whole workspace and this container has no fullpnpm build. ⛔ Recorded as NOT MEASURED, not as failures and not as passes; CI builds the closure first and is where they get their reading.pnpm lint(eslint . --no-inline-config, the whole repo — no narrowing) exit 0 atd5248b67b8.pnpm --filter @objectstack/rest test— 185 files / 3073 tests, all pass. The new file contributes 14.pnpm --filter @objectstack/rest typecheck(tsc --noEmit+check:test-typecheck) exit 0, 0 debt entries.pnpm --filter '@objectstack/rest^...' build(the dependency closure, downstream direction not applicable — no exported type moves) exit 0.Docs drift — the run produced NO anchor, so this was swept by hand
⛔ This is not a clean bill of health, and the drift comment says so itself. The bot reports that 1 changed file yielded no anchor —
packages/rest/src/rest-server.ts, which is the only source file in this diff — so "the pages documenting them are NOT COVERED by this run".scripts/docs-audit/check-affected-docs.mjsandcheck-drift-comment.mjsdo exit 0, but a zero from an instrument whose anchor derivation produced nothing for the file under change is not a reading.--jsonconfirms it numerically:anchors: 0,docs: 0,detail: 0,anchorlessChanges: ["packages/rest/src/rest-server.ts"].content/docs/, not by anchor.What was searched, and the positive control. Terms: the route itself (
state/:field,/state/,legalNextStates), the literal it emits (Object not found), the new answer (503,SERVICE_UNAVAILABLE,AuthzStoreUnavailable), the input-side spellings (no data plane,without a data engine,objectql not registered,no engine is wired), and every page carrying both404and/meta.⭐ The control fires, and fires exactly where the tooling is blind. The content sweep found
content/docs/protocol/objectql/state-machine.mdx:127, which documents this very route verbatim — and that page appears in neither the anchor run (0 anchors) nor the 13-row coarse fallback. It also foundcontent/docs/protocol/kernel/index.mdx:298, the only non-release page in the tree that namesAuthzStoreUnavailableErrorat all, likewise in neither list. A search that reaches two pages both automated paths miss is a search that could have returned a falsification.Result: one candidate, reported not edited; nothing else falsified.
content/docs/protocol/kernel/index.mdx:298. It reads, inside a comment describingresolveExecutionContext():Under the scoped reading — a statement about
resolveExecutionContext(), the function named on the next line — it is untouched: this PR does not change that function, which lives inpackages/runtime. Under the global reading — "that error is only ever raised where an authorization input exists" — this PR is the first change that makes it false, becausewiredEngineOrLoudhad exactly one caller until now (computeExecCtx, an authorization-input seam) and this adds a second on a metadata read, where the engine is consulted as the registry and no capability judgement was being sought. Proposed correction, for whoever owns that page: bound the sentence toresolveExecutionContextexplicitly, or add that the REST metadata plane raises the same branded error when a wired engine cannot be resolved. ⛔ Not edited here —content/docs/**is outside this PR's declared 3-file surface.Read and found NOT falsified:
content/docs/protocol/objectql/state-machine.mdx:127— the page that documents this exact route. It describes only the 200 answer body (object, field, from, next, and whennextisnull), which this PR pins unchanged. Not falsified; it is silent on every non-200 answer, which is a pre-existing gap rather than drift.content/docs/deployment/troubleshooting.mdx:174— "Object not found" after defining it, the one page keyed on this route's 404 literal. Its three listed causes stay true, it is scoped toclient.data.find, and it does not claim to be exhaustive. If anything this PR makes it more nearly complete on the/metastate route, by removing a fourth cause it never listed.content/docs/protocol/kernel/http-protocol.mdx— documents/metatype listings and single-item reads; never mentions the state route and states no 404-versus-503 rule for/meta.packageMentionDocsatc17b494dba2cf8220dbf6332eedcfbd92305c499), not dismissed as wide. Eleven contain none of this change's surface terms.content/docs/api/index.mdx:217carries only the generic mapping503 to SERVICE_UNAVAILABLE, which this change conforms to.content/docs/releases/v17.mdxcarries the historical v17 notes on the store-outage repair; their explicit "what did NOT change, and is pinned" list (reachable-but-empty store, genuine denial, unprovisioned tables, anonymous callers) is untouched by this PR, and release notes are never edited from a code PR in any case.What this sweep does NOT cover: pages that restate the rule in wording sharing no token with any of the searched terms. That residue is irreducible for an emitter-only diff — it is the #11430 shape the bot names — and it is stated here rather than papered over.
Changeset — MEASURED, not guessed
skip-changesetis refused on evidence.@objectstack/restwas built andnpm pack --dry-run --jsonrun; the changed route's own literal (State-machine introspection is not available in this runtime) was searched across every packed path the pack reported (10 files), not against thefiles[]field:dist/index.jsanddist/index.cjs⇒ this route ships.⇒
.changeset/meta-state-route-engine-outage-distinguishable.md,@objectstack/rest: patch.Clause ② — re-derived from the delivered diff, not copied
Clause-②: no.POST /email/sendmoves 500 → 501, refusal to refusal.SERVICE_UNAVAILABLEis an existingStandardErrorCodemember, reached through the existingAuthzStoreUnavailableError—authz-store-unavailable.tssays so itself: "Deliberately NOT a new code: the wire vocabulary is closed."@objectstack/core's publishedAUTHZ_STORE_UNAVAILABLE_STATUS = 503andwiredEngineOrLoud's own table (the engine cannot be RESOLVED: 403 → 503).no. A reviewer who reads the boundary the other way should say so; the basis is stated here so it can be overturned on the record rather than re-derived.验收备注
packages/rest/src/rest-api-plugin.tswas read only. The provider closures there belong to [finding] every OTHER shipped provider in rest-api-plugin.ts still absorbs the three-state ctx.getService throw — the #13904 shape, waiting one seam over #14251 phase 1 and are untouched.objectQLProviderconsumer.POST {basePath}/batchreaches the slot with no.catchat all, so a rejection propagates to that handler's ownhandleRouteError. It does not carry this card's defect (it never collapses an outage into "not found"), and it was not in the card's enumeration. Whoever next audits the slot's consumer census is the reader for this. Carrier: [finding] every OTHER shipped provider in rest-api-plugin.ts still absorbs the three-state ctx.getService throw — the #13904 shape, waiting one seam over #14251's census.objectQLProvider, whichrest-api-plugin.tsdocuments as the "single-kernel fallback". That asymmetry is what makes the defect reachable at all; it is a wiring question one layer above this file and is not touched here. Carrier: the same census.Authored by the
domain:cliexecution PM seat, sessionsession_015QE8qk46e5CHJxyQEUjbf8. Measured on this PR, recorded because AGENTS.md says the channel-by-action cells disagree: a raw-REST edit of a PR body appends its own bare attribution block (58 bytes) whatever the payload carries, so a payload carrying the session-URL form ends with TWO footers and only a footerless payload ends with one. The session id therefore lives in this line, which an edit cannot strip.Generated by Claude Code