Skip to content

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 into
mainfrom
claude/issue-15405-objectql-provider-second-consumer
Sep 9, 2026
Merged

os-project-manager merged 4 commits into
mainfrom
claude/issue-15405-objectql-provider-second-consumer

Conversation

@claude

@claude claude Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #15405

Clause-②: no

objectQLProvider has two consumers in packages/rest/src/rest-server.ts. The engine-slot repair landed for one of them (#13476, the computeExecCtx authorization-input seam, reached through wiredEngineOrLoud). The other, GET {basePath}/meta/object/:name/state/:field, reached the same provider through .catch(() => undefined) and converted every rejection straight back into the undefined a 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 answered 404 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 .catch was 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

site before after
GET {basePath}/meta/object/:name/state/:field this.objectQLProvider(environmentId).catch(() => undefined) wiredEngineOrLoud(Boolean(this.objectQLProvider), () => this.objectQLProvider!(environmentId))
POST {basePath}/email/send this.emailServiceProvider(environmentId).catch(() => undefined) seamOrUndefined(() => this.emailServiceProvider!(environmentId))

⭐ wiredEngineOrLoud, not seamOrUndefined, 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." wiredEngineOrLoud is 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 .catch attaches to the promise the call returns, so a host wiring a non-async provider throws before any promise exists and the handler is never reached.

⚠️ Reachability — a correction to the card, measured rather than reasoned

The card does not say which wiring reaches this consumer, and the answer narrows its claim. Every /meta route is wrapped by registerMetadataEndpoints's anonymous-deny gate, and that gate resolves the same engine first, through computeExecCtx. Its two branches do not agree:

gate's engine branch on a broken engine
PROVIDER branch (wiredEngineOrLoud, no kernelManager wired) raises — this route's own engine line is never reached
KERNEL branch (seamOrUndefined, absorbing on purpose — wiredEngineOrLoud's RESIDUE note) absorbs — this route decides ⇐ the defect

⇒ the collapse was reachable exactly where a resolvable kernel supplies auth and the separately-wired objectQLProvider is broken: the multi-kernel wiring (rest-api-plugin.ts wires a kernelManager when the host registers one). It was not reachable in a single-kernel boot such as pnpm dev:crm, where the gate raises first. Measured on a real ObjectKernel, 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 RestServer and a real ObjectKernel — nothing private is replaced, no fault is simulated by throwing a hand-made branded error into a stub:

engine fact before after
no objectQLProvider wired at all 404 NOT_FOUND 404 NOT_FOUND — unchanged ⭐ PIN
provider RESOLVES undefined (the seam contract declaring absence) 404 NOT_FOUND 404 NOT_FOUND — unchanged ⭐ PIN
healthy engine, object that genuinely does not exist 404 NOT_FOUND 404 NOT_FOUND — unchanged ⭐ NEGATIVE CONTROL
healthy engine, object that exists 200 200 — unchanged
provider REJECTS (wired, failed to build) 404 NOT_FOUND 503 SERVICE_UNAVAILABLE
provider THROWS SYNCHRONOUSLY (non-async host, #13280) 500 503 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.

⚠️ Envelope, so nobody reads it as a second defect. The 503 arrives as the flat { 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.ts names it verbatim); not this card's to move. pnpm check:route-envelope is green.

⚠️ POST /email/send carries only the second observation. No 404/503 question, and not reachable from the shipped wiring — both providers in rest-api-plugin.ts are declared async. Its answer is deliberately unchanged at 501 NOT_IMPLEMENTED; what changes is that the synchronous fault shape now reaches that same 501 instead of escaping past a .catch that did not exist yet and landing in the handler's own 500 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 is git checkout HEAD -- ABSOLUTE_PATH under a trap ... EXIT INT TERM with absolute paths. No git 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:

leg mutation reached disk result the cases that went red
A — restore .catch(() => undefined) at objectQLProvider injected marker 0→1, deleted marker 1→0; blob f384ecf5 → c702672d 4 failed / 10 passed §2 rejecting (expected 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)
B — restore .catch(() => undefined) at emailServiceProvider injected marker 0→1, deleted marker 1→0; blob f384ecf5 → df3a6ade 1 failed / 13 passed §5 sync-throw (expected 500 to be 501)

Both legs restored to blob f384ecf5d419b224d6b9932805a6df2347897a32 = HEAD, git diff HEAD empty. 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 package exports/dist is on the resolution path.

Verification

Head at the time of every reading below: d5248b67b8 (origin/main merged in at a016f08b8a; the merge is a message-only amend of 47a1ecd761, identical tree 664f0fe0).

  • Gate families — derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack from a tree at origin/main, reconciled with --ran: 58 derived, 58 run, 0 UNRUN. Exit codes captured by redirect-then-$?, never through a pipe.
  • 56 of 58 green. Two returned exit 3 PREREQUISITE NOT MET — check:dual-build-cjs-loads and check:type-check-debt — both because they read built output for the whole workspace and this container has no full pnpm 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 at d5248b67b8.
  • 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 automated run covered none of this diff; see the dedicated section below for what was hand-swept instead.

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.mjs and check-drift-comment.mjs do exit 0, but a zero from an instrument whose anchor derivation produced nothing for the file under change is not a reading. --json confirms it numerically: anchors: 0, docs: 0, detail: 0, anchorlessChanges: ["packages/rest/src/rest-server.ts"].

⚠️ The input-vs-emitter blind spot the bot discloses is live here: this diff is emitter-only, and a page that states a rule by its inputs shares no token with the emitter, so it cannot be listed on this run or any run. The sweep below was therefore done by CONTENT, over the whole of 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 both 404 and /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 found content/docs/protocol/kernel/index.mdx:298, the only non-release page in the tree that names AuthzStoreUnavailableError at 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.

⚠️ CANDIDATE — content/docs/protocol/kernel/index.mdx:298. It reads, inside a comment describing resolveExecutionContext():

It rejects with AuthzStoreUnavailableError (503) only when an authorization input exists and could not be read — a failed permission-store read, or a tenancy service that is registered and failed to build.

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 in packages/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, because wiredEngineOrLoud had 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 to resolveExecutionContext explicitly, 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 when next is null), 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 to client.data.find, and it does not claim to be exhaustive. If anything this PR makes it more nearly complete on the /meta state route, by removing a fourth cause it never listed.
  • content/docs/protocol/kernel/http-protocol.mdx — documents /meta type listings and single-item reads; never mentions the state route and states no 404-versus-503 rule for /meta.
  • All 13 coarse-fallback rows read out (packageMentionDocs at c17b494dba2cf8220dbf6332eedcfbd92305c499), not dismissed as wide. Eleven contain none of this change's surface terms. content/docs/api/index.mdx:217 carries only the generic mapping 503 to SERVICE_UNAVAILABLE, which this change conforms to. content/docs/releases/v17.mdx carries 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-changeset is refused on evidence. @objectstack/rest was built and npm pack --dry-run --json run; 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 the files[] field:

  • POSITIVE CONTROL fires in dist/index.js and dist/index.cjs ⇒ this route ships.
  • NEGATIVE CONTROL (a nonsense symbol) fires nowhere ⇒ the search discriminates.

⇒ .changeset/meta-state-route-engine-outage-distinguishable.md, @objectstack/rest: patch.

Clause ② — re-derived from the delivered diff, not copied

Clause-②: no.

  • No accept set widens. Nothing that was refused becomes served: the two absence rows keep their 404, the healthy-engine rows are untouched, and the only movement is which refusal is named for a fault that was already being refused. POST /email/send moves 500 → 501, refusal to refusal.
  • No published surface grows. No new route, no new export, and no new wire code: SERVICE_UNAVAILABLE is an existing StandardErrorCode member, reached through the existing AuthzStoreUnavailableError — authz-store-unavailable.ts says so itself: "Deliberately NOT a new code: the wire vocabulary is closed."
  • It is a pull-back to a declared contract, which the criterion says does not touch clause ②. The contract is @objectstack/core's published AUTHZ_STORE_UNAVAILABLE_STATUS = 503 and wiredEngineOrLoud's own table (the engine cannot be RESOLVED: 403 → 503).

⚠️ The second look the dispatch asked for, recorded rather than waved past: an operator-visible status code does move on a published route (404 → 503, multi-kernel wiring). Under the criterion's own negative boundary that is a runtime refusal-path behaviour change, not an accept-set widening — hence 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.

验收备注

Authored by the domain:cli execution PM seat, session session_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

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c17b494dba2cf8220dbf6332eedcfbd92305c499 → packageMentionDocs.

@os-project-manager
os-project-manager marked this pull request as ready for review September 9, 2026 07:18
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit dfb42c5 Sep 9, 2026
53 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-15405-objectql-provider-second-consumer branch September 9, 2026 07:47
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants