Repository navigation
fix(objectql,service-automation,runtime): the card's named warnings and endpoint hints state each decision in words instead of a tracker number - #20738
Conversation
… words instead of a tracker number The objectql data-event warnings, the service-automation resumeAuthority warning and its three refusal siblings, and the runtime endpoint-step and mapping hints now say what the cited decision was. The pins that asserted a number now assert the sentence that carries it. Text only: no code, field or status changes. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…n warnings and hints Regenerated with `node scripts/check-doc-authoring.mjs --census-ledger` (the growth check ran against the checked-in baseline). Fourteen id occurrences leave the ledger, all from the messages the previous commit rewrote. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…ecision Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…f the tracker number Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…ntime-strings-no-tracker-numbers
📓 Docs Drift CheckThis PR changes 3 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 37 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e7061f462fd95192fb5a1fd9f5d4793193245eeb && git checkout e7061f462fd95192fb5a1fd9f5d4793193245eeb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f927864ea056f79d04ad8d62f1a7c13afed31d07 f636206f33cb9ac0dbc6719b1fc748ea2d451ddd && git checkout -B drift-repro f927864ea056f79d04ad8d62f1a7c13afed31d07 && git merge --no-ff f636206f33cb9ac0dbc6719b1fc748ea2d451ddd
node scripts/docs-audit/affected-docs.mjs --json f927864ea056f79d04ad8d62f1a7c13afed31d07
|
Contract reviewServed-tier: Inputs read: card #20513 (body and all 5 comments: triage 5879525283, folded runtime producers 5880028473, claim 5900600330, round-1 os-dev-report 5900801368, round-2 os-dev-report 5901412858); PR #20738 body, file list (10 files) and the net diff against merge base ① Derived judgmentsAccept-set and public surface: no change, judged right. Every changed line in the four source files is a string-literal fragment inside an existing template or The 11 rewritten messages, each judged against the cited card's ruling and today's code:
Re-pinned assertions (6, judged each): all pin the decision, none an incidental phrase. Pins correctly left alone: Ledger: Shipped changeset prose: carries no tracker number; each package paragraph states the same decisions as the strings above and nothing more. Non-blocking observation, not this PR's: the per-record warning interpolates ② Semver level
③ Boundary flagsDev flags (round-2
Scope held: no Check-runs on Implemented-by: VERDICT: PASS Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36651223609 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…ongs is refused INVALID_FILTER / 400 on every driver (objectstack-ai#20546) (objectstack-ai#20744) Fixes objectstack-ai#20546 Clause-②: no (narrowing) ## What this changes A plain object with no `$`-operator key where a scalar field's value belongs, for example `where: { amount: { a: 1 } }` on a `number` field, is now refused with `INVALID_FILTER` / 400. The refusal names the field, its declared type, the object's keys (never its values) and the path. It runs before any driver is resolved, on every driver, at the three positions the engine judges: `where` (object form and `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete` and the judge-only `judgeFilter`), `aggregations[i].filter`, and `having`. **Landing site: the number-comparand door's walk, as a second arm. It adds no second traversal.** Triage said: "If the same walk is the natural site, it lands serially after that PR, in the same walk. ⛔ No second traversal of the filter." PR objectstack-ai#20545's walk (`walkCondition` in `number-comparand-declared-type-door.ts`) is the only filter walk the engine runs at all three positions with each column's declaration in hand. It already stood on the exact branch: a field spec with no `$` key, which it stepped past (`return kept(spec)`). It now asks one question per field key before the number arm runs: - `packages/objectql/src/no-operator-object-door.ts` (new) holds the arm's classification (`holdsScalarValues`), its structure test (`isNoOperatorObject`) and its words. ⛔ Nothing in it walks a filter. - `number-comparand-declared-type-door.ts`: the walk's per-key resolver now supplies two facts, the number arm's meta and the column's scalar-valued type. The first refusal the walk meets is either arm's. - `having-filter.ts`: `aggregatedRowColumnTypes` reads each aggregated column's type off the query. `aggregatedRowColumnClasses` is now derived from it, so the class and the type are one reading of the query. The `having` arm needs the type because the `text` class lumps a `json` or `lookup` groupBy in with a real text column. - `engine.ts`: the `having` call passes the types; the other hunks are comments. PR objectstack-ai#20738's warning-text region is untouched. **Which columns are judged (H3): a closed definition from spec's classes.** `SCALAR_FILTER_HEAD_TYPES` (spec's published "stores one scalar value" set, derived from the ADR-0104 value classes; the objectstack-ai#8371 dotted-head verdict reads the same set) with or without `multiple: true`, plus `MULTI_OPTION_TYPES`. The accepted side is never judged: relation types (`lookup`, `master_detail`, `user`, `tree`, single or multiple), structured-JSON types, file and media types (the objectstack-ai#8371 carve-out: a legacy stored value is an inline object), `formula` (refused one door earlier, `INVALID_FIELD`), undeclared keys, and unknown types. ## Before, measured on `origin/main` `fbec216e2d` Through `engine.find` / `engine.aggregate` and `POST /api/v1/data/:object/query` (both doors answered alike). Three rows (`amount` 5 / 12 / 30; `owner` u1 / u2 / u1 with u1 in region NA; `meta` `{a:1}` / `{a:2}` / `{b:1}`). InMemoryDriver, SqlDriver on SQLite (better-sqlite3), SqlDriver on a live PostgreSQL 16.13: | position · filter | InMemoryDriver | SQLite | PostgreSQL 16 | |:--|:--|:--|:--| | `where` `{ amount: { a: 1 } }` (number, the card) | 200, no rows | 400 `INVALID_FILTER`, the driver's words ("cannot be bound") | same as SQLite | | `where` `{ title: { a: 1 } }` (text) | 200, no rows | 400, the driver's words | 400, the driver's words | | `where` single select, boolean, date, autonumber, `multiple: true` select, `multiselect`, `tags` | 200, no rows | 400, the driver's words | 400, the driver's words | | `where` `{ $not: { amount: { a: 1 } } }` | 200, **every row** | 400 ("not one this driver evaluates") | same | | `where` `{ $or: [{ amount: { a: 1 } }, { amount: 30 }] }` | 200, 1 row | 400 | 400 | | `where` sugar `[['amount', '=', { a: 1 }]]` | 200, no rows | 400 | 400 | | `where` `{ amount: {} }` | 400, the objectstack-ai#5240 words | 400, the objectstack-ai#5240 words | same | | `aggregations[1].filter` `{ amount: { a: 1 } }`, `{ title: { a: 1 } }`, `{ amount: {} }` | 200, count 0 | 200, count 0 | 200, count 0 | | `having` `{ total: { a: 1 } }` (a `sum`), `{ title: { a: 1 } }` (a groupBy), `{ total: {} }` | 200, no group | 200, no group | 200, no group | | control: `where` `{ owner: { region: 'NA' } }` (lookup; `master_detail` and a multiple lookup alike) | 200, no rows | 400, the driver's words | same | | control: `where` `{ meta: { a: 1 } }` (json) | 200, 1 row (deep equality) | 400, the driver's words | same | | control: `where` `{ amount: { $gt: { $field: 'cap' } } }` | 200 | 200, 2 rows | 200, 2 rows | ## After, the same run on this branch Every non-control row above answers `400 INVALID_FILTER` in the engine's words on all three drivers, at the path the object sits at (`where.amount`, `where.$not.amount`, `where.$or[0].amount`, `aggregations[1].filter.amount`, `having.total`). No read of the object runs. Every control answers exactly as before: the lookup, master-detail, multiple-lookup and JSON filters reach the driver as written, and so do the file field, the `$field` reference, the undeclared key and the `id` key. Example of the words: ```text find('rp_ledger_20546'): filter on 'amount' puts an object with no operator key (keys "a") at where.amount, where a value of the declared number field 'amount' belongs. An object with no "$" operator is filter structure, not a value: beneath a field it is a nested-relation condition, which only a relation field (lookup, master-detail, user, tree) can carry, or a whole-value match, which only a JSON-bearing field can hold. A number column holds scalar values — one, or a list of them — so no record can match an object there, and an empty answer would read exactly like a real one. The filter was NOT applied. Compare 'amount' with a value ({ "amount": VALUE }) or an operator ({ "amount": { "$eq": VALUE } }). ``` ## Hypotheses (zone 2), which held - **H1: held, with one refinement.** `lowerWhereFilterArray` is the seam, and `narrowNumberComparands` is called there on both branches (the object branch and the lowered array branch). The number door's walk was number-specific only at its per-field gate (`numberComparandFieldVerdict(meta) !== 'judged'`), and its `where` resolver already returned every declared field's type. The text door and the temporal door each walk too, but neither runs at `having` with a column declaration, so neither covers every position. The number door's walk is the one walk that does. The arm rides it, and no traversal was added. - **H2: held, and all three positions are reached.** Measured above: `where` answered per driver, and `aggregations[i].filter` and `having` answered a silent empty on every driver. Each is pinned. - **H3: refined.** The closed definition is above. Multi-value fields were measured on their own: a `multiple: true` select, `multiselect` and `tags` split exactly as a scalar field does (memory 200 no rows, SQL 400). That includes `{ tags: { 0: 'x' } }`, the spelling the objectstack-ai#8371 multi-value carve-out exists for: the nested-object form does not reach an array member on InMemoryDriver. So they are judged. A multiple lookup stays on the relation side. A `{ $field }` reference carries a `$` key, so it is never this arm's (measured: served 2 rows on SQL, as before). - **H4: held.** No driver file changes (`git diff fbec216 HEAD -- packages/drivers` is empty). The SQL driver's own `INVALID_FILTER` stays as defence in depth for driver-direct callers and for the columns this arm does not judge (the lookup and JSON controls above still meet it). ## Tests All from `b50627aca9` or from a commit whose non-test source is byte-identical to it (the last two commits touch only the changeset). - `pnpm --filter @objectstack/objectql test`: **338 files / 6704 tests passed**. `test:repo`: 1 file / 5 passed. - `pnpm --filter @objectstack/objectql typecheck`: exit 0 (`check:test-typecheck` OK, the debt ledger held). - `pnpm --filter @objectstack/rest typecheck && pnpm --filter @objectstack/rest test`: **229 files / 4391 passed / 63 skipped** (the live-dialect cells, no URL set). - New pin `packages/objectql/src/engine-no-operator-object-door.test.ts` (17 tests). It uses a recording driver, which is InMemoryDriver's cell by construction because the arm answers before a driver is resolved. It covers every scalar class, `{}`, every verb and the judge, `$and` / `$or` / `$not` paths, sugar, the three REST doors into `findData`, the per-aggregation filter, `having` (sum, groupBy, max of a date, a month bucket), the accepted side at all three positions, a `Map` and the classification GUARD over every `FieldType`. - New pin `packages/rest/src/data-no-operator-object-door.test.ts`: SQLite always, PostgreSQL and MySQL where `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are set. `where` refusals, the per-aggregation filter, `having`, and the **two controls** (a lookup nested-relation filter and a JSON object comparand: the driver is asked, and the answer is never the arm's). Local run with a live PostgreSQL 16.13: **8 passed (sqlite 4, live postgres 4) / 4 skipped (mysql, no URL)**.⚠️ As with the sibling door suites, no CI job sets these URLs for `@objectstack/rest`, so the live cells run only locally. - Consumer suites (downstream of `@objectstack/objectql`): `service-analytics` 137 files / 3216 passed; `plugin-security` 147 files / 3202 passed / 23 skipped. The other downstream consumers are declared to CI. **Reverse verification (ablation), from the committed fix.** It ran through `scripts/ablation-replace.mjs` (WRAP mode, trap-restored). The anchor `if (facts.scalarType !== null && isNoOperatorObject(value)) {` was replaced by `if (facts.scalarType === '__ablated_20546__' && …) {`. On disk the anchor went 1 → 0 and the marker 0 → 1, with blob `16151b29f6c1` → `0e8acf882100`. Then objectql was rebuilt and `ablation-dist-preflight` reported the marker present in 4 built files. Predicted direction: red. Observed: red. The objectql pin went **10 failed / 7 passed**: every refusal case failed, and every control and GUARD stayed green. The rest pin went **4 failed / 4 passed / 4 skipped**: the `where` and aggregate refusals failed on SQLite and live PostgreSQL, and the controls stayed green. Restore leg: blob equals HEAD (`16151b29f6c1`), `git diff HEAD` empty, the whole-tree `git status --porcelain` empty, rebuilt, `--absent` preflight (marker absent from all 14 built files), then both pins green again (17 / 17; 8 passed + 4 skipped). ## Gates `node scripts/pm/dispatch-gates.mjs --commands` at `b50627aca9` derived 65 commands. All were run on `b50627aca9`, and `--ran` reconciles them: **65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN**. 63 exit 0, including `check:adr-0087-registration --base origin/main` (`not-required (no-migration-prescription)` accepted), `check:changeset-no-major`, `check:empty-changeset`, `check:doc-authoring`, `check:nul-bytes`, `check:engine-double-contract`, `check:where-matcher`, `check:driver-memory-census`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage` and `check:query-options-erasure`. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Reason: each exits 3 (PREREQUISITE NOT MET) because it reads the built closure of every package, and this box built only the objectql/rest closure. CI's `Lint & Repo Gates` builds that closure. - Driver-related families read before (on `fbec216e2d`, a detached comparison worktree) and after (on `b50627aca9`): - `check:where-matcher`: 440 matchers, 440 correct or loudly refusing, before and after. - `check:driver-memory-census`: 12 bindings / 2 ruled consumers, before and after. - `check:engine-double-contract`: pinned rows 825 → 825 and discovered files 953 → 953. Test files went 4231 → 4233 and production files 2997 → 2998, which are the two new tests and the new module. No new fake engine. - Lint, narrowed and proven: `pnpm exec eslint --no-inline-config --format json` over the 7 changed `.ts` files, at `b50627aca9`, found **7 files, 0 errors, 0 warnings**. The checked population comes from eslint's own config: `calculateConfigForFile` answers `isPathIgnored=false` for all 7. The file count comes from the JSON output (7 results). Untouched files cannot change verdict: `parserOptions.project` and `projectService` are `null` for every file, so type-aware linting is not enabled and this diff cannot move any untouched file's lint result. ## Changeset `.changeset/20546-no-operator-object-on-scalar.md`: `@objectstack/objectql` `minor`, a BREAKING banner, `Clause-②: no (narrowing)` and the ADR-0087 marker `not-required (no-migration-prescription)`, following the objectstack-ai#20501 / objectstack-ai#20545 precedent. Its "Who is affected" section names a caller that sends the shape to the in-memory driver: a test suite, a local or embedded deployment on `InMemoryDriver`, or a flow or hook calling the engine in-process. No export or published type changes: the door modules are internal, and `@objectstack/objectql`'s root and `./core` exports are unchanged. ## Acceptance notes - **Out of scope, reported to the PM, not filed:** the two controls still answer two ways, because this card's direction keeps them accepted. `{ owner: { region: 'NA' } }` on a `lookup` gives memory 200 with no rows and SQL 400. `{ meta: { a: 1 } }` on a `json` field gives memory 200 with 1 row and SQL 400. So does the undeclared `id` key (`{ id: { a: 1 } }`: memory 200 no rows, SQL 400), because the registry's declared map carries no `id`. Spec's `FilterCondition` declares the nested-relation form, but no data-path driver serves it. objectstack-ai#20546 is not the card for that. - File and media fields keep the objectstack-ai#8371 carve-out and stay unjudged. `{ photo: { url: 'x' } }` answered memory 200 with no rows (on fresh rows) and SQL 400. A legacy inline value could still match on memory. - At `where`, a `{}` under a judged column is now answered in the engine's words instead of each driver's objectstack-ai#5240 words, with the same `INVALID_FILTER` / 400 envelope. Under a column this arm does not judge, `{}` keeps the drivers' refusal. - `findNonNumericComparand` (internal, tests only) still answers the number arm alone. When the walk's first refusal is the new arm's, it answers `null`, and its docblock says so. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #20513
Clause-②: no
The card's named producers are 11 author- and operator-shown messages in 4 files. Each one pointed the reader at a tracker number for the reason behind it. Each now says what the cited decision was, in the sentence being read (form D, as the migration-entry rewrite applied it). Text only: no error
code, field name, HTTP status or behaviour changes. Every changed line in the four source files is a string-literal line.This PR covers the card's first step only. The rest of the family is the maintainer's burn-down decision, and the census below is the input to it, so 20513 stays open.
What each sentence now says
engine.tsdata.record.* warning (7077)recordId. A predicate (multi: true) write publishes its owndata.records.*event carrying the affected-row count, so reaching this line is a driver defect.engine.tsdata.records.* warning (7171)engine.tsresumeAuthority warning (3276)resumeAuthorityresolves to'service', fail-closed, because guessing'any'is how a raw resume once walked past an approval decision no service had recorded.resumeAuthorityresolves to'service', fail-closed.resumeAuthorityresolves to'service', which the generic route refuses.api-endpoint-step.tsno-policy-context hint (245)NOT_IMPLEMENTEDmessage (346)api-mapping.tspath hint (233)transformrefusal (265)transformis rejected at publish rather than parsed and ignored; only the citation goes.The integration pin in
dispatcher-plugin.endpoint-fallback.integration.test.tsassertsnot.toContain('without a policy context')andnot.toContain('no wiring'). Both phrases are kept verbatim, so that negative pin still means what it meant.Pins re-pinned: 6, one more than the order counted
Each of these now asserts the words that carry the decision instead of the number:
resume-authority-declaration.test.ts84-85engine-data-events.test.ts374api-endpoint-step.test.ts143, 232api-mapping.test.ts230: amustMentionfragment list. The matcher-line grep behind the order's count of 5 cannot see it. It pins thetransformrefusal, one of the 11 named messages, so it had to move. The first full runtime run showed it red; after the re-pin it is green.Ledger burn-down
scripts/doc-authoring-prose-id.baseline.jsonwas regenerated withnode scripts/check-doc-authoring.mjs --census-ledger. It was generated into a scratch file first, so the growth refusal ran against the checked-in baseline, and then copied into place. 14 id occurrences leave in 7 (file, id) pairs, and 2 files leave the ledger entirely. Pairs go 618 to 611, occurrences 945 to 931, files 229 to 227, and the gate's printed "pinned site(s)" (id-bearing string literals) 808 to 794. Nothing else in the file moved.pnpm check:doc-authoringis green on it.Census: the input to the maintainer's burn-down decision
Instrument. A TypeScript-compiler AST walk over every non-test
src/file of every public package underpackages/(69 manifests, 2,582 files). It works per message:+, a template literal, parentheses, and a string array whose parent is.join(sep). A string nested inside a span, or inside a call that is an operand of the chain, is folded into the outermost message.src/(testkits, bench, contract-suite case labels), metadata text (description, help or label, and generated translations), and one excluded false positive (CSS colours in the CLI's inline HTML).Controls.
engine.ts7077-7080 gives one logger message citing 4639 and 4626.+chain over 8 lines and is read as ONE message citing 3801, 5561 and 3823.//comment interleaved inside a+chain in lint'svalidate-action-body-writes.ts(426). Across the whole tree, 1,125 hit lines were checked and 0 fall on a comment line.The doc-authoring ledger finding. The card says
check:doc-authoringdoes not read these positions. It does. Its ledgered sibling-package leg, added in 3f54efd, holds every one of them in a shrink-only baseline. It exits 0 because they are baselined, not because they are unread. So the gate's reach does not need to move: any burn-down is monotone, and each burn-down PR regenerates that one shared file. That makes parallel stages serialise on it.Totals (tree 36d043b, before this PR):
This PR removes 11 messages. On the merged head the census reads 892 messages with an id where it read 903, with 0 new.
Per package (bold = this lane; "id occurrences" counts the ids the population messages cite):
The full per-hit list (file, line and cited ids for all 903 messages) is in the os-dev-report comment on the card (5900801368).
Verification
Final head
f636206f33: this branch plus a merge ofmainat 1940afd. That merge touched none of this PR's files.turbo run buildover the closure of objectql, service-automation and runtime: 31/31 tasks. Then the whole workspace except docs: 72/72 tasks.@objectstack/service-automation: 155 files, 1,942 tests passed.@objectstack/runtime: 289 of 290 files passed. The red one was the api-mapping pin above; after its re-pin,api-mapping.test.tsandapi-endpoint-step.test.tspass 59/59.@objectstack/objectql: 336 files, 6,679 tests passed.engine-data-events.test.tspassed 50/50. That is AGENTS.md's scoped re-check: the incoming commits touch spec and metadata-protocol, not these packages or this behaviour.typecheckfor the three packages exits 0 each; each includescheck:test-typecheck.node scripts/pm/dispatch-gates.mjs --commandsonf636206f33names 75 commands, and all 75 exit 0.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET on the partial build. It was re-run after the full build: exit 0, 104 entries measured.--ranreconciliation: 75 derived, 75 run, 0 NOT-MEASURED, 0 UNRUN.eslint --no-inline-config --format jsonover the 8 touched TypeScript files: 8 files reported, 0 errors, 0 warnings, onf636206f33. The narrowing is safe becauseeslint.config.mjsenables no type-aware linting (noparserOptions.projectorprojectServiceanywhere), so this diff cannot move any untouched file's verdict. The repo-widepnpm lintis CI's.Acceptance notes
service-knowledge'sevent-sync-data-events.test.tspins service-knowledge's OWN warning (knowledge-service-plugin.ts, ids 4639 and 4672), not the objectql string. It is untouched here. Runtime also has 5040 citations beyond this order's five sites:endpoint-executor.ts255 (pinned byendpoint-executor.test.ts212) androute-ledger.ts574. Both are census hits left for the burn-down decision.Generated by Claude Code