Repository navigation
fix(service-analytics,rest): analytics dimension 的源字段闸门 —— 不存在的 dimension 答 400 INVALID_FIELD,dataset 500 不再回显 SQL (#5520) - #5667
Merged
Conversation
…s fields, and stop the dataset 500 echoing SQL (#5520) #4437 gave a measure over a non-existent field a `400 INVALID_FIELD` naming the field — a driver error class must never be the caller's `error.code` for a caller-shaped mistake (ADR-0112). It covered the measure half only, so the identical typo one request key over still reached the driver as a `GROUP BY` column and came back as `500 SQLITE_ERROR`, while the measure control group on the same route answered a clean 400. `ensureCube` now runs `assertDimensionFields` alongside `assertMeasureFields` on all three of its paths, so a dimension whose source column the backing object does not have is refused before any SQL exists, with the same envelope (`INVALID_FIELD`/400 + `field`/`object`/`param`) and a message naming the field, the valid dimensions and the object's known fields. `query`, `generateSql` and `queryDataset` are all covered; a rejected query leaves the cube registry as it found it. `timeDimensions` are covered too — same `cube.dimensions` bag, same `lookupMember`, same 500 — with `param` naming the key that carried it. Grouping by a REAL field the cube never declared keeps working: the question is "does the object have this field", never "did the cube declare this dimension". Expression cubes, dotted relation dimensions and probe-less hosts stand down exactly as the measure gate stands down. `POST /analytics/dataset/query` composed its own 5xx body and echoed the message verbatim, so a knex `<sql> - <cause>` error handed the caller the generated statement with its physical table and column names. The sibling face never did: `/analytics/query` exits through `dispatcher-plugin.errorResponseBase`, which has applied the shared `looksLikeInternalErrorLeak` predicate to every >= 500 message since #3867. That predicate now guards this route's 500 body too — status, code, the ADR-0112 envelope branch and the transitional message list all unchanged, and the full text still reaches `logError`. Fixes #5520 Co-Authored-By: Claude <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 5, 2026 23:43
This was referenced Aug 5, 2026
fix(service-analytics): 带 measure-scoped filter / derived 度量的 dataset 查询,fields 也描述维度列 (#5537)
#5691
Merged
Merged
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Aug 6, 2026
…由的 message 正则名单完全退休 (objectstack-ai#5808) * fix(analytics,rest)!: read-scope lowering failures are READ_SCOPE_COMPILE_FAILED/500 and the route's message list is fully retired (objectstack-ai#5367) `read-scope-sql.ts`'s ten fail-closed refusals were the last error family `/analytics/dataset/query` classified by matching their message text. That verdict (400 DATASET_INVALID) was wrong twice: the inputs are an admin-authored RLS policy and a compiler-generated join alias, never the caller's, and the 400 echoed the refusal message, handing a tenant the field names and comparands of the policy governing them. Per the maintainer's 2026-08-06 ruling (option B on objectstack-ai#5367's decision card): - read-scope-sql.ts gains a module-local `readScopeCompileError` -- the twin of filter-normalizer's `invalidFilterError`, and likewise the only way the module refuses. All ten sites declare READ_SCOPE_COMPILE_FAILED / 500. The :104 alias-vs-field split (option C) collapses under B and is pinned as collapsed. - rest-server.ts loses branch (2) entirely. Nothing in that catch reads prose any more; Prime Directive objectstack-ai#12's retirement schedule is paid off in full. - The 5xx branch withholds the message of any producer that DECLARES a server fault. Needed rather than inherited: `looksLikeInternalErrorLeak` is a heuristic over SQL/driver phrasing and, measured, returns false for all ten read-scope messages, so retiring the list alone would have moved the policy content from a 400 body into a 500 body. Widening that heuristic would have been more message sniffing; the rule keys on the ADR-0112 envelope instead. Undeclared 5xx keeps objectstack-ai#5667's tiering and stays readable. - READ_SCOPE_COMPILE_FAILED registered in ERROR_CODE_LEDGER under @objectstack/service-analytics, typed as RegisteredErrorCode at the site. Visible behaviour change: this family answers 500 instead of 400, with its message withheld from the body and intact in the log. * chore(spec): regenerate content/docs/references for the new READ_SCOPE_COMPILE_FAILED ledger code (objectstack-ai#5367) * test(rest): the read-scope boundary double spells `routes`, not the retired `endpoints` (objectstack-ai#5674) `analytics-read-scope-refusal-envelope.test.ts` was written from a sibling analytics test before objectstack-ai#5674 landed, so its `getDiscovery` double carried `endpoints: {}` -- the dispatcher-only copy of `routes` that objectstack-ai#4828 retired under ADR-0049 and that no producer ever emitted. objectstack-ai#5674 corrected the 26 existing doubles and added `discovery-double-retired-key.test.ts` to pin the key out of the fixture layer; this file arrived after that sweep and reintroduced it. Corrected to the producer's real shape -- `routes: { data: '', metadata: '' }`, `ApiRoutesSchema` as `DiscoverySchema` requires it -- matching the three sibling analytics doubles verbatim. The key was inert here (nothing in this file drives discovery), which is exactly the reason it kept surviving retirement, so this is the double being made faithful to its producer rather than a guard being satisfied. --------- Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Aug 12, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 1, 2026
objectstack-ai#12280) `POST /api/v1/analytics/dataset/query` collapsed every producer-declared 5xx onto a hand-built `500 ANALYTICS_QUERY_FAILED`, where `POST /data/:object` relays the declared status and ADR-0112 code and withholds only the prose (objectstack-ai#5582). Measured door-to-door on one error object, a declared `{ status: 503, code: 'SERVICE_UNAVAILABLE' }` answered `503 SERVICE_UNAVAILABLE` on `/data` and `500 ANALYTICS_QUERY_FAILED` here. `/data` is the reference and does not move. Its 5xx arm is lifted into `declaredServerFaultAnswer` and read by both doors, the way the 4xx arm already imports `classifiedRefusalAnswer` — a third local opinion at this boundary is how the two faces came to disagree. The sibling analytics face `/analytics/query` already relayed both halves, so this door was the only one of three overwriting a producer's declaration. The prose withhold is untouched (objectstack-ai#5352/objectstack-ai#5367/objectstack-ai#5811) and `logError` still runs before the relay branch, so a declared status cannot buy a producer past the operator's log. An undeclared fault keeps `500 ANALYTICS_QUERY_FAILED` and objectstack-ai#5667's tiering. Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 1, 2026
…tcher exit (objectstack-ai#12281) (objectstack-ai#13240) `errorResponseBase` gated its 5xx message withhold on `declaresServerFault` (`status >= 500` AND a non-empty string `code`), while `/data` gates on `declaredHttpStatus` (`status ?? statusCode`, `code` not consulted). Two bands of declared 5xx were withheld one door over and shipped their prose here: one carrying no `code` at all, and one merely spelling `statusCode` despite being fully ADR-0112-compliant. Maintainer ruling 2026-08-27 on objectstack-ai#12509 (option D): adopt the structural withhold for every declared 5xx message, aligning to `/data`. The judgement is inherited rather than re-derived -- the door now reads `serverFaultProvenance` from `@objectstack/types`, the same function `demotedDeclaredCode` already reads for the code channel. The gate is the DECLARED status, never the resolved `httpStatus`, so objectstack-ai#5667's undeclared-5xx tiering is preserved: a bare `Error` stays legible and still goes through the `looksLikeInternalErrorLeak` heuristic alone. Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 17, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 28, 2026
…s its native error message verbatim (objectstack-ai#18760) Fixes objectstack-ai#18540 Clause-②: no A non-sandboxed crash at `/api/v1/actions` shipped its native error message verbatim. The identical crash through the `/data` door was already sanitised. **The status was already right; what leaked was the sentence** — so only `error.message` moves here. No status code, no `error.code`, no envelope key. ## Premise 1 — reproduced first, on today's `origin/main` The card's own status note says the filing seat did not verify the measurement at the tree, and that the first act is to reproduce it. Driven through the real `HttpDispatcher.handleActions` door at base `e77a23f02`, with `mapDataError` driven on the same throw as the control: | | `/actions` (before) | `/data` | |---|---|---| | `new TypeError("Cannot read properties of undefined (reading 'id')")` | `500` `INTERNAL_ERROR` — message `Cannot read properties of undefined (reading 'id')` | `500` `INTERNAL_ERROR` — message `Internal server error` | Byte-for-byte the envelope the card recorded. `premise_still_valid: true`. ## Premise 2 — why objectstack-ai#17273's terminal does not catch it Confirmed at the tree, not copied from the card. The terminal reads: ```ts const sandboxCrash = typeof inner === 'string' && inner.length > 0 && isNativeErrorName(inner.trim()); ``` `inner` is `err.innerMessage`, which only the QuickJS runner populates. An in-process registered handler never crosses the VM boundary, so `innerMessage` is absent, `sandboxCrash` is `false`, and the throw falls through to `unexpectedFault`. ⭐ The shape, carried into the code comment and the test docblock because it generalises: **a predicate that classifies by HOW a crash arrived is structurally blind to crashes that did not arrive that way — while looking exhaustive.** ## Premise 3 — the width, with controls in both directions Both doors driven on the same ten throws. Seven shapes leaked at `/actions` and every one of them was **already** sanitised at `/data`: | throw | `/actions` before | `/data` | |---|---|---| | `TypeError` | leaked the native sentence | `Internal server error` | | `ReferenceError` | `x is not defined` | `Internal server error` | | `RangeError` | `Maximum call stack size exceeded` | `Internal server error` | | `SyntaxError` | `Unexpected token }` | `Internal server error` | | driver class, prose the heuristic does NOT recognise | `database disk image is malformed at /srv/data/tenant_42.db` — a server **filesystem path** | `Internal server error` | | sandbox timeout | `action timed out after 5000ms` | `Internal server error` | | sandbox capability denial | `... capability 'api.read' not granted` | `Internal server error` | Controls in the other direction, unchanged by this PR: | throw | `/actions` | note | |---|---|---| | driver class, leaky prose | already `Internal server error` | the 5xx heuristic already fired | | plain `Error` — a deliberate rejection | `400` + its own sentence | the refusal channel, untouched | | `TypeError` carrying `status: 403` | `403 FORBIDDEN` + `Not allowed` | a declared answer, untouched | ⇒ the leaking population is **exactly** the `unexpectedFault` branch's population, at one site. The other dispatcher doors (`/meta`, `/mcp`) never make this classification at all, so their undeclared-5xx prose is objectstack-ai#5667's recorded decision rather than this defect — noted below, not filed. ## The fix, and why it is not the heuristic The two doors differ **structurally**, not by one missing phrasing: - `/data` is **default-DENY**: `classifyDataError` ends in an unconditional `UNCLASSIFIED_FAULT()`. Its `looksLikeInternalErrorLeak` limb only chooses `DATABASE_ERROR` over `INTERNAL_ERROR` — that limb is not what sanitises. - the actions door was **default-ALLOW**: `unexpectedFault` relayed `err.message` through a 5xx withhold gated on `looksLikeInternalErrorLeak`, a driver-dump heuristic that reads FALSE for stack-shaped prose. `unexpectedFault` **is** this door's unclassified-fault terminal, so it now answers the terminal's envelope — `INTERNAL_ERROR_MESSAGE`, through the same `deps.error` seam objectstack-ai#17273's terminal one branch above already uses. Both fences held: - ⛔ `looksLikeInternalErrorLeak` is **not** relaxed or re-pointed at stack-shaped prose. It guards a different question at every other boundary. - ⛔ `DomainHandlerDeps.error`'s seam is **not** widened. A fault's `userMessage` and its non-string `details.code` do not ride this exit, exactly as they do not ride `/data`'s — the same accepted difference objectstack-ai#17273's terminal already carries. **The status provably does not move.** Reaching this branch already proves `.status`/`.statusCode` are absent (the branch above serves them) and that this is not a `ValidationError`. So in `resolveThrownHttpError` the `declaredStatus` chain is empty, `status` is the `500` fallback, and `code` is `standardErrorCodeForHttpStatus(500)` = `INTERNAL_ERROR` — the code this exit emits. The STOP-AND-REPORT condition did not trigger: no declared status code or envelope shape changes. ## Tests `packages/runtime/src/domains/actions-fault-vs-rejection.test.ts` — the file that already drove this exact door: - the pre-existing `a TypeError from a buggy handler` pin used the card's exact message and asserted status, `success` and `data` but **nothing about `message`** — so the live leak sat green underneath it. "Still 500" is exactly what the defect looked like. That pin now asserts the sentence too. - a new section adds both halves: two crashes whose prose the heuristic does not recognise, and the two controls one property away on either side (a rejection keeps its `400` and its words; a crash that declared its own status keeps both).⚠️ **Tier measured first, in both directions, before and after.** `packages/runtime` has two vitest projects and `vitest.repo-tests.json` names exactly two files. The repo's own filter preflight answers: before, `local` ran the file (17 passed) and `repo` reported `FILTER SELECTED NOTHING ... lives in the local project`; after, `local` runs 21 and `repo` still selects nothing. The file did not move, and its 17 existing pins did not move with it. The `/data` parity is therefore asserted in **prose** rather than by importing `@objectstack/rest` into this file — a cross-package import is what would have moved it. Measured (`scripts/pm/os-verify-lock.sh`, shared box): | run | result | |---|---| | `pnpm --filter @objectstack/runtime test` (project `local`) | 265 files, **3665 passed**, 1 skipped — exit 0 | | `pnpm --filter @objectstack/runtime test:repo` | 2 files, **69 passed** — exit 0 (holds `error-envelope.conformance.test.ts`) | | `pnpm --filter @objectstack/runtime typecheck` | exit 0 | | `pnpm --filter '@objectstack/runtime^...' build` | exit 0 | | `pnpm lint` — repo-wide `eslint . --no-inline-config`, full population, no narrowing | exit 0 | ### Ablation — the pin is shown to go red when the leak is put back Fix committed first, so a restore point existed. Mutation **proven on disk before the run**, not from an editor's exit code: the target blob moved `5aa9ba6dd…` → `a0178be61…`, the leak anchor went 1 → 2 and the fixed anchor 2 → 1, at a printed offset.⚠️ Recorded because it is the point of the proof: the **first** attempt was a silent no-op — a `perl -0777` one-liner died on a quoting error, changed nothing, and the anchor/blob check refused the run rather than reporting a green. The reading below is from the second, verified mutation. With the leak restored: **3 failed | 18 passed**, the three failures being exactly the disclosure assertions, carrying the real leaked strings — ``` AssertionError: expected 'Cannot read properties of undefined (…' to be 'Internal server error' AssertionError: expected 'database disk image is malformed at /…' to be 'Internal server error' ``` The four negative controls stayed green, which is what distinguishes this fix from a blanket sweep that would have eaten the refusal channel. Restore verified **by blob hash** — back to `5aa9ba6dd…`, `git diff HEAD` empty — ⛔ not by an exit code, under a `trap ... EXIT INT TERM` on an absolute path. ### Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`. The first derivation reported a **STALE TREE** (3 commits behind, and 2 files it derives from had changed); `origin/main` was merged and the list re-derived from the merged tree — byte-identical, 59 commands. **57 of 59 exit 0.** Reconciled with every exit code recorded. Also run because their `silent` verdict is a fact about a roster, not about these paths — 5 of them keep that roster in a directory one of these paths is in: `check:route-ledger-census`, `check:error-code-casing`, `check:authz-resolver`, `check:filter-alias-parity`, `check-changeset-fixed`, plus `check:error-status-conformance` and `check:dispatcher-error-vocabulary` as the two adjacent to this change. All exit 0 — the first confirms no status row moved, the second that the `error.code` vocabulary is intact.⚠️ `pnpm lint` is run although `dispatch-gates.mjs` does not name the family — this lane's standing blind spot. **NOT MEASURED, declared rather than counted as a pass:** - `pnpm check:dual-build-cjs-loads` — `exit 3`, `PREREQUISITE NOT MET ... some package has no dist/`, naming ~36 unbuilt packages and `Run pnpm build first`. A whole-farm build; CI's `Build Core` + `Lint & Repo Gates` is where it is measured. ⛔ Not a pass and not a red. - `pnpm check:type-check-debt` first answered `exit 3` for the same class, naming one unbuilt dependency at a time. Both were built (`@objectstack/runtime`, then `@objectstack/driver-mongodb`) and it now reports `--re-measure: OK — 4 ledger entr(ies) re-measured, 53 raw tsc error(s) total, none above its recorded number` — so this one **is** measured. ### Docs-drift rider Predicate stated **before** reading: *a document states, about the `/api/v1/actions` door, that an unexpected fault's client-facing message is or contains the thrown error's own text.* Swept by symbol (`unexpectedFault`, `errorFromThrown`, `INTERNAL_ERROR`, crash) **and** by input shape (the wire body, and the leaked sentences themselves). **NOT FALSIFIED.** `content/docs/api/error-catalog.mdx`'s Action Errors row declares the status and the envelope — `Handler crashed (TypeError, driver error, sandbox timeout)` → `500` → `{ success: false, error: { message, code } }` — never that `message` carries the thrown text; and its `INTERNAL_ERROR` entry already reads *"An unexpected server-side error occurred. Report the issue with the requestId and traceId"*, which is what a withheld sentence means. Controls both ways: the same table's rejection rows **are** statements about the message channel, were found by the sweep, and describe branches this PR does not touch; the three `content/docs/**` hits for the native sentences are authoring-time validator prose, correctly not implicated. `content/docs/protocol/kernel/http-protocol.mdx`, which `main` moved during this change, was re-swept and says nothing about this surface. One in-repo statement **was** falsified and is corrected here: `packages/runtime/src/sandbox/capability-denial-is-a-fault.test.ts` said of a capability denial that *"what the client sees is this text, minus the prefix"*. Measured — that denial reaches `unexpectedFault`, so after this change the client sees `Internal server error`. Its assertions are all about the **thrown** error and are unchanged; only the justification is corrected to name the log as their subject. ## Changeset `patch` on `@objectstack/runtime`. ⛔ Not `skip-changeset`: the package publishes (`files: ["dist", ...]`) and the wire answer of a published HTTP surface moves. `Clause-②: no` — the diff adds or removes no schema key, closed-set member, published export or registry row, and an unexpected fault's message text is not a contract. Verified with the repo's own `readClause2Line`, ⛔ not a hand-written regex: `{"kind":"declared","value":"no","arm":null,"line":"Clause-②: no"}`, re-checked against the newer copy of that reader after the merge. ## Acceptance notes Out of scope for this card, ⛔ not filed and ⛔ not fixed here: - **The two doors still disagree on STATUS for a missing-relation throw.** A `SqliteError('no such table: crm_invoice')` answers `500 INTERNAL_ERROR` at `/actions` and `404 OBJECT_NOT_FOUND` at `/data`, whose classifier has a missing-relation attribution arm the dispatcher has no equivalent of. Measured here as a by-product. This is a **status** difference, and this card is explicitly fenced from moving a status. Successor: none of the open cards named in the dispatch faces it. - **The undeclared-5xx band at the other dispatcher doors.** `/meta` and `/mcp` hand everything to `errorFromThrown(e, 500)` without ever classifying a crash, so a `TypeError` there also ships its prose. That is not this defect: it is objectstack-ai#5667's recorded decision to keep undeclared 5xx legible, guarded only by `looksLikeInternalErrorLeak`, and `resolveThrownHttpError`'s own docblock records objectstack-ai#12281 as the separate card for the *declared* limb with its own measurement-first step. Changing it would reverse a recorded decision. Successor: objectstack-ai#12281 for the declared half; the undeclared half has no card and is a decision, not an execution. - **A phantom-risk note, not a defect.** `deps.error`'s seam carries no `extra`, so this exit cannot pass `userMessage` even if a fault declared one. Reachability is near-nil (a fault that also declares user-facing text), the card fences the widening that would close it, and `/data` has the same gap — recorded so the next reader does not mistake it for an oversight. --- _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 #5520
前提重验(对 origin/main,含 #5567/PR #5587、#5526/PR #5634 之后的最新落点)
单子的前提成立,且三条复现都在测试双上原样重现:
origin/main上assertDimensionFields仍是零命中;ensureCube()的三处校验全部只调assertMeasureFields。dimensions: ["bogus_dim"]→ 生成SELECT bogus_dim AS "bogus_dim", COUNT(*) … GROUP BY bogus_dim,驱动答no such column,错误对象上code/status皆为undefined(所以在 REST 面落到 5xx 兜底)。SELECT bogus_dim AS "bogus_dim", COUNT(*) AS "account_count" FROM "crm_account" GROUP BY bogus_dim - no such column: bogus_dim。dimensions: ["phone"]在两条路上都是 200,按phone分组。闸门必须不误杀它。改了什么
1.
assertDimensionFields—— 对称 #4437 的 measure 闸门ensureCube()的三条出口(自动推断、augmented、已声明)现在都在注册 cube 之前紧跟assertMeasureFields调用新闸门。拒收信封与 measure 侧完全相同:INVALID_FIELD/ 400,带field/object/param,外加dimension字段(对称 measure 侧的measure);message 指名字段、列出可用 dimension、附上对象已知字段清单。query/generateSql/queryDataset三个入口都被覆盖,被拒的查询不会在 cube registry 里留下痕迹(#3867 / #4437 定的同一条规矩)。取舍(逐条给代码证据)
NativeSQLStrategy.resolveDimensionSql的回退是dim ? dim.sql : member,ObjectQLStrategy.resolveFieldName同形。所以phone这类未声明真实字段照常放行(有守卫用例,两条路各一条)。timeDimensions一并覆盖。它和dimensions落进同一个cube.dimensions袋子(inferCubeFromQuery两者都写进去)、被同一个lookupMember解析、并且实测同样把date_trunc('month', bogus_at)送到驱动 —— 是同一个缺陷的同一个 param 变体,不是新面。param报告是哪一个请求键出错。这一点比单子正文的字面范围略宽,在此显著申报:若 PM 认为该拆单,删掉members数组里timeDimensions那一行 + 两条用例即可。lookupMember,包括它故意的最后一档:点号成员若匹配不到任何声明键,就是交给 JOIN 机器的关系穿越,闸门不判(source: null)。否则owner.region会被诬告成crm_account缺列。cube.sql不是裸对象名(表达式 cube)、getObjectFieldNames不作答(无 registry 的宿主 / 外部数据源)、源不是裸列。id/created_at/updated_at无条件放行,与数据面resolveQueryFields一致。cube.dimensions就是从这条查询铸出来的,照抄会把调用方的错别字当成「可用 dimension」推荐回去 —— 与 measure 闸门两趟扫描的理由相同。2. dataset 面回显生成 SQL —— 扩面申报
回显字符串的构造点不在本仓:knex 的错误 message 格式就是「语句 - 成因」,所以 SQL 是驱动 message 自带的。收窄只能在对外信封处做,于是本 PR 动了
packages/rest/src/rest-server.ts一行 —— 按派发口径显著申报:这不是新规则,而是补上缺失的那次应用:同一个 analytics 的兄弟面
/analytics/query走dispatcher-plugin.errorResponseBase,自 #3867 起就对任何 5xx message 应用共享谓词looksLikeInternalErrorLeak(packages/types/src/error-leak.ts)—— 这正是单子里复现 ① 读到"Internal server error"、复现 ③ 却吐出整条语句的原因。本 PR 只补这一处应用:ANALYTICS_QUERY_FAILED;analytics 的 filter 拒收到不了调用方:service 侧多数拒收没有 ADR-0112 信封,REST 面又用 message 正则嗅探,一律答 500 #5352 的 ① 信封分支、② 过渡期 message 正则名单(analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 的地盘)都没碰,并有用例回归钉住。logError在响应之前,改动后日志是唯一一份完整拷贝 —— 用例直接断言语句仍进日志。sendError通路,本路由自建 body,保持 analytics /query 未做 cube 存在性校验,未注册名直达驱动当表名;且错误路径原样回显驱动 SQL(#3770 同类,另一子系统) #3867 的分档。statusCode与body.code(analytics-filter-refusal-envelope.test.ts三例),两包全量绿。测试
新增两套,共 30 例:
packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts(22 例)—— 结构与深度对称measure-source-field-gate.test.ts:三条 describe 分别是闸门本体、dataset 面、「闸门不许做什么」。含未声明真实字段仍可分组的守卫用例(裸 cube 路 + dataset 路各一)、registry 不被污染、generateSql同样受闸、表达式 cube / 点号关系 / 无探针 / 探针答不出四档 stand-down、以及「两样都错时先答 measure」。packages/rest/src/analytics-dataset-dimension-gate.test.ts(8 例)—— 调用方视角:bogus dimension 端到端答 400INVALID_FIELD且响应体里没有 SQL;正向对照(声明维度 200 + 未声明真实字段 200);500 面收窄的三例(带语句的 message 被 withhold 且仍进日志、方言错误码同样 withhold、普通内部故障保留原文);以及 4xx 两条分支未被扰动。反向验证(方向先预测再跑)
方向 A —— 拿掉
ensureCube里三处assertDimensionFields:预测 service 套件 10 红 / 12 绿(前两个 describe 里断言「拒收」的用例转红;第三个 describe 全绿,因为它们钉的是本改动保留的旧行为;dataset describe 里的「pre-fix 驱动错误确实带着语句」那一例是对照,前后都绿)。实测:Tests 10 failed | 12 passed (22),方向与条数完全吻合。同一方向在 REST 套件上出现一次预测偏差,如实记录:预测 3 红,首轮只有 2 红 —— 「响应体里没有 SQL」那一例在闸门被拿掉时仍然绿,因为另一半修复(信封收窄)把驱动 message withhold 掉了,body 于是「无泄漏」却并非因为闸门。这正是「绿得不是因为逻辑对」的那类假绿:该用例已改为同时断言 400
INVALID_FIELD+ 无 SQL,重跑得到预测的 3 红。偏差与修法都写进了测试文件头注,连同「本文件吃的是 service-analytics 的构建产物,不重新 build 就改 service 什么也证明不了」这条坑。方向 B —— 把 500 分支还原成
error: msg.slice(0, 500):预测 REST 套件 2 红(两条 withhold 用例)/ 6 绿,service 套件不受影响。实测:Tests 2 failed | 6 passed (8),吻合。门与全量
pnpm --filter @objectstack/service-analytics test52 files / 927 tests passed(基线 51/905,新增 1 文件 22 例)pnpm --filter @objectstack/rest test50 files / 745 tests passedpnpm --filter @objectstack/runtime test(另一条 analytics 面)97 files / 1428 tests passedpnpm --filter @objectstack/dogfood test(真实 plugin.ts 探针接线)85 passed / 1 skipped, 514 teststsc --noEmit(两包).catch的 4 条 TS2339 债,改用带类型的rejection()/settle()辅助)check:type-check-coverage/check:nul-bytes/check:route-envelope/check:error-code-casing/check:adr-anchors/check:doc-authoring/check:wildcard-fallthrougheslint(改动文件)已合入
origin/main(aa25a81d3/214f67c76,均在 cli/client,与本 diff 无重叠),合并后两包全量重跑仍绿。消费半径
闸门只在
getObjectFieldNames接线时生效;全仓该 hook 的接线点只有生产桥service-analytics/src/plugin.ts与三个测试文件(measure 闸门、本 PR 两套)。生产桥那条路由 dogfood 套件覆盖(真 kernel + 真引擎),全绿 —— 说明闸门在 showcase / CRM 的真实 dashboard、report 元数据上不误杀。compiled dataset 的cube.sql就是dataset.object(裸对象名),而关系维度的dim.sql是点号路径(dataset-compiler里sql: d.field原样透传),前者让闸门对 dataset 生效、后者被点号档跳过 —— 两者都有用例。Generated by Claude Code