Skip to content

Commit 44072fc

Browse files
fix(service-analytics): runtime strings state each decision in words instead of a tracker number (stage 8) (#21569)
Part of #20751 Clause-②: no **Stage 8 of the `domain:services` lane under the maintainer's A / A ruling (5902360492): `service-analytics`, part 2 of 2, the last stage (`read-scope-sql.ts`, `strategies/native-sql-strategy.ts`, `strategies/objectql-strategy.ts`).** The card stays open until the seat acts on it after this lands, so this PR carries no closing keyword. Text only: no status, error `code`, field, route, export or control flow moves (the AST skeleton reads SAME for 3 of 3 changed `.ts` files, below). After this PR the `check:doc-authoring` prose-id ledger is empty: `{}`. ⚠️ **One question for the seat before landing.** The gate's own header asks for an explicit seen-floor in the PR that empties the ledger. This PR does not touch the gate (the dispatch scope). See "For the seat" below. ## What this does Five messages in these three files (nine string literals) sent the reader to a tracker number for the reason behind them. In form D, as stages 1 to 7 applied it, the number goes. Where the sentence already said what was decided, only the citation goes. Where it leaned on the number, it now says the decision in words, in the words stage 7 used wherever the decision is the same one. One decision, one wording. All 13 ledgered occurrences in this stage's surface (claim `5967239052`), re-derived from the ledger on `origin/main` at `1ca1eb09` (where the branch was cut): `read-scope-sql.ts` 5 (5 pairs), `strategies/native-sql-strategy.ts` 2 (2 pairs), `strategies/objectql-strategy.ts` 6 (6 pairs), in 9 string literals across 5 messages. That matches the seat's reading (5, 2 and 6 ids) and is the whole remaining ledger. ### Rewritten in words Refusals an operator or caller reads first; the backstop that is unreachable by construction last. Line numbers are at the head `0edca886`. | Where | Cited | The text now says | Decision read from | |---|---|---|---| | `read-scope-sql.ts:1676-1688`, `undefinedComparandError` (`READ_SCOPE_COMPILE_FAILED` / 500, fail-closed) | 6050, 6125 | "An undefined comparand is refused rather than read as null, on the SQL drivers and on this door alike." This is stage 7's sentence for the same decision at the `where` door, word for word. The sentence before it already says the producer to fix is whoever built the read scope, never the caller of the query, and the prefix still says fail-closed | 6050's ruling (2026-08-07, option B): an `undefined` comparand is refused loudly, never read as null, because the spec declares no such comparand and an undefined key cannot be told from an absent one. 6125's PM ruling (its option 2): that refusal is pushed down to this compiler, in this module's own `READ_SCOPE_COMPILE_FAILED` / 500 envelope, because a read scope is compiled by the platform and is not the caller's input | | `read-scope-sql.ts:1864-1875`, `nonBooleanFlagComparandError` (same envelope, fail-closed) | 5347, 5369, 6387 | "A non-boolean comparand for any of the three is refused rather than coerced, on every driver and on this door alike." "The three" are the `$null`, `$exists` and `$empty` flags the message names two sentences earlier | 5347's PM ruling (option A): a non-boolean `$null` comparand is refused, not coerced, because the spec declares `z.boolean()` and the backends' two default readings point in opposite directions. 5369: the same for `$exists`, applied through the 5298 ruling. 6387: the same refusal pushed down to this compiler in this module's envelope (the disposition is inherited, not the 400). "Every driver" is measured on this tree: `driver-sql` (and `driver-sqlite-wasm`, which extends it), `driver-memory`, `driver-mongodb` and `driver-turso`'s remote transport each refuse a non-boolean `$null`, `$exists` and `$empty` ("requires a boolean comparand") | | `objectql-strategy.ts:461-473`, the `/analytics/sql` echo's refusal of a `{ $field }` comparison (`INVALID_FILTER` / 400) | 5222, 7598, 3601, 3602, 3650 | "enforces the cross-field rules (declared same-table columns only, never the tenant-isolation column, one comparison class) with metadata it owns, so those rules are enforced in one place, next to the metadata they read" (stage 7's sentence); "an echo that contradicts execution is worse than no echo, so the echo renders every predicate the query runs with, or refuses" | 5222's maintainer rulings (2026-08-06, restated 2026-08-11): same-table columns only, declared-only enumeration, the tenant-isolation column forbidden on both sides, plus the comparison class the implementation added. 7598's ruling (2026-08-12, Q1 = B): native SQL declines a `$field` query so it routes to the engine path, where the driver enforces those rules with metadata it owns, in one place; the echo declines too ("one consistent loud answer, no half-rendering", which the sentence already said). 3601 / 3602 / 3650: the echoed statement carried no read-scope `WHERE` and dropped `dateRange`, so it described a different query from the one that ran; each fix made the echo render what execution applies | | `objectql-strategy.ts:1479-1484`, the echo's unmapped-operator refusal (a bare `Error`, deliberately not 400) | 5333 | "an echo without it describes a WIDER query than the one that ran, and the echo renders every predicate the query runs with, or refuses" | 5333's landed change: throw rather than drop an operator the renderer has no arm for, because the normalizer's vocabulary is closed and an arrival is drift between two of our own tables. The sentence already said so; it drops its citation and gains the echo family's one wording | | `native-sql-strategy.ts:1034-1042`, the cross-field backstop (a bare `Error`, unreachable by construction) | 7598, 5222 | "answer a wrong row set silently" drops its citation (the sentence already names the silent bind 7598 measured); "enforces the cross-field rules (declared same-table columns only, never the tenant-isolation column, one comparison class) with metadata it owns, so those rules are enforced in one place, next to the metadata they read" | as the third row | Every cited card (11: 3601, 3602, 3650, 5222, 5333, 5347, 5369, 6050, 6125, 6387, 7598) was read through REST, body and every comment, before its string was rewritten. All eleven answer 200. ### Sibling sentences - `fieldReferenceComparandMessage` and `fieldReferenceBetweenBoundMessage` reach `read-scope-sql.ts` through a bare `[read-scope-sql]` prefix (lines 2010 and 2017), so they already carry stage 7's wording and need nothing here. - The cross-field rules sentence is now word-for-word the same in `comparand-shape.ts`, `analytics-service.ts` (both stage 7), `native-sql-strategy.ts` and `objectql-strategy.ts`; the undefined-comparand sentence is the same at both analytics doors. ### Published contract check None of these strings is a spec-declared message or an i18n key. They are refusal and diagnostic text built inside `service-analytics`. A search for each old fragment across `packages/` finds no test assertion and no doc quoting it; the hits are code comments, test comments and `CHANGELOG.md`. No test asserted any of the five messages by an id or by a fragment this PR rewrites. The assertions that do read these messages ("comparand at ... is undefined", "is not a boolean", "never the caller of this query", "sharing rule", "getReadScope", `The string "false" is TRUTHY`, the one-wording skeleton checks, "$field", "/analytics/query", "read-scope-sql", "cannot render") all still hold, and every `code` / `status` assertion is untouched. ## Ledger (`scripts/doc-authoring-prose-id.baseline.json`) Regenerated with `node scripts/check-doc-authoring.mjs --census-ledger > scripts/doc-authoring-prose-id.baseline.json` (exit 0, no growth refusal). The file is now `{}`: 21 lines deleted and 1 added, because the empty object collapses its opening and closing braces onto one line. Every pinned pair goes to absent and none is added. | | before (`1ca1eb09`) | after | |---|---|---| | `read-scope-sql.ts` | 5 occurrences, 5 pairs | 0 | | `strategies/native-sql-strategy.ts` | 2 occurrences, 2 pairs | 0 | | `strategies/objectql-strategy.ts` | 6 occurrences, 6 pairs | 0 | | whole ledger | 13 occurrences, 13 pairs, 3 files | 0, 0, 0 | Nothing else remains in the ledger. `pnpm check:doc-authoring` at the head: "sibling-package prose ids hold the baseline — 0 pinned site(s) across 0 file(s), 86276 string(s) read in 1252 parsed source(s), no growth, no burn-down unrecorded". The empty ledger still bites. Reverse check at the committed head through `scripts/ablation-replace.mjs` (WRAP mode, plus an outer trap restoring by `git checkout HEAD` on the absolute path): putting the citation back into the unmapped-operator refusal (anchor hit 1 to 0, blob `eedaeae5` to `9d6ea181`) turned `check:doc-authoring` red with exactly one growth pair (`objectql-strategy.ts`, id 5333, 0 pinned, 1 measured), as predicted. Restored blob equals `HEAD` (`eedaeae5`), and `git diff HEAD` is empty. No gate is added or loosened; `scripts/check-doc-authoring.mjs` is untouched. ## For the seat: the gate header's seen-floor note `scripts/check-doc-authoring.mjs` (lines 700-705, the cross-package leg's ratchet notes) says: "While the baseline is non-empty, the ratchet IS this leg's blindness floor: a walker or prefilter that goes blind reads 0 sites against 632 pinned pairs and reds as stale. ... If the baseline is ever burned to empty, add an explicit seen-floor here in the same PR — at that point the stale arm can no longer catch a dormant walker." This PR empties the baseline, and the dispatch says no gate is added or loosened and no other file is touched. So the two disagree, and this PR does not pick a side. What is measured: - With `{}`, the leg has no floor on the real tree: a walker that stopped seeing the real sources would read 0 sites against 0 pinned pairs and print green. - What already guards it: the leg's `--self-test` asserts on a fixture tree that strings are seen at all, that the prefilter is a superset of the id regex, and that an empty root is a hard error; `collectPackageProseFiles` throws on zero files. The part no check covers is the real tree's population shrinking toward zero while the fixtures still pass. The options and a recommendation are in the dev report on the card. This PR is draft either way. ## Changeset `.changeset/20751-services-strings-stage8-state-the-decision.md`: `patch` for `@objectstack/service-analytics`, `Clause-②: no`. Measured after the full build: every new sentence is in `dist/index.js` and `dist/index.cjs`, and none of the old citation fragments is (`pushed down to this compiler`, `ruling B`, `#5347 / #5369`, `maintainer ruling 2026-08-12`, `enforces the #5222`, `silently (#7598)`, `one that ran (#5333)`: 0 hits in each). A TypeScript scan of every string literal and template text in both built files finds 0 tracker ids (1507 and 1511 strings read). The same scan reads 5, 2 and 6 ids in the three branch-point sources, which is its positive control. ## Text-only proof A TypeScript-AST skeleton of each changed `.ts` file, where every string literal and template text is a placeholder, a run of adjacent string operands of a `+` chain is one string (only its embedded expressions are kept), identifiers and numbers keep their text, and comments are never read. `1ca1eb09` against the head: 3 of 3 SAME. Controls on scratch copies of `objectql-strategy.ts` from the branch point, each mutation's marker counted once on disk first: a one-identifier rename reads DIFF; a text-only change reads SAME; a re-split of one string into two concatenated pieces reads SAME. ## Tests All heavy runs went through `scripts/pm/os-verify-lock.sh`, every verdict `VERDICT command-exit 0`, at the head `0edca886`: - Build: `turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*` (71/71). - `@objectstack/service-analytics`, `vitest run --maxWorkers=2` in two shards (`--shard=1/2`, `--shard=2/2`): 88 + 87 = 175 files, 2237 + 1915 = 4152 tests passed, 113 + 140 = 253 skipped. - `@objectstack/service-analytics` `typecheck` (`tsc --noEmit`): exit 0. `--listFiles` counts 175 test files in that program, all 175 on disk. ## Gates - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths) at `0edca886` (5 paths vs merge base `1ca1eb09`, 62 changed lines): 66 commands, run one at a time from the worktree after the full build, each exit code recorded before any pipe; 66 exit 0. `--ran`: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero — all 66 recorded an exit code and none of them is 3)". - Among them: `check:doc-authoring` (above); `check:issue-citations` and `check-issue-citations.mjs`; `check:nul-bytes`; `check:dts-closure`; `check:dual-build-cjs-loads`; `check:published-files`; `check:sourcemap-no-sources-content`; `check-adr-0087-registration` and `check-empty-changeset` against `origin/main`. - Outside the derived set, all exit 0 at `0edca886`: the eleven declared wide-population families (`check:init-service-contract`, `check:live-db-isolation`, `check:meta-type-normalized`, `check:optional-error-sink`, `check:resume-authority-declared`, `check:route-envelope`, `check:runner-env-posture`, `check:settings-bind-window`, `check:startup-registry-verdict`, `check:verify-stand-in`, `check:wildcard-fallthrough`), plus `check:durability-log-level` and `check:error-code-casing` (refusal and diagnostic text moved; no level or code did). - Not measured locally: the six workflow-valued families `dispatch-gates` names (`check-issue-citations.mjs --census` with `GITHUB_TOKEN`, the shard-attestation emits and the test-completeness reads); they need CI values. - Lint, narrowed as a measurement: `eslint --no-inline-config --format json` over the 3 changed `.ts` files at `0edca886`: 3 files linted (none ignored), 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules; its own comment at lines 327-328 says so), so this diff cannot move any untouched file's verdict. Repo-wide `pnpm lint` is CI's. - `origin/main` moved to `bd707067` (one commit, PR 21563, `packages/runtime` only) after the branch point. It touches neither `service-analytics` nor the ledger, and adds no id-bearing string line in a non-test package source (its only id-bearing changes are deleted comment lines), so the empty ledger stands on that tree too; the branch is not merged. No open PR touches the ledger or these three files (8 open PRs read by REST at 09:04Z). ## Acceptance notes Noted, not filed: - Code comments beside the rewritten strings still carry ids (for example the `[#7598]` docblock on the backstop, the `[#5333]` comment above the operator refusal, and `comparand-shape.ts:206`'s table cell naming the read-scope refusal by its two cards). Comments are outside the ledger and outside the rule. Carrier: none. - Test titles and comments that quote card numbers are outside the ledger too. Carrier: none. ## Round 2: the seen floor (stage 8 claim revision 1, `5967600765`) The seat answered the open question with A. Commit `3628b4e8c` adds the explicit seen floor that the header of `scripts/check-doc-authoring.mjs` prescribes for the PR that empties the baseline. It touches the cross-package leg only. This **supersedes** the line above that says "This PR does not touch the gate". - `PACKAGES_PROSE_SEEN_FLOOR` is 600 parsed sources and 40000 strings, about half of the reading when the floor was pinned (1252 / 86276 at `0edca886c`). The leg reds below either number. Each measure is judged on its own, and a missing measure is a breach. Lowering the floor is marked ⛔ MAINTAINER-ONLY. - A new self-test battery, `cross-package seen floor`, has 8 cases. The registry floor goes from 16 to 17. - The header's ratchet note now says the baseline is empty and that the floor does the stale arm's old blindness-floor job. - Ablations ran at the committed head, each predicted first. With the floor raised to 1300 / 90000, the leg showed exactly the two predicted breach lines. With the predicate disabled, exactly 6 self-test cases went red. Both restores are byte-identical to `HEAD`. - There is no changeset change: the script ships in no package (the root package is private). - Gates were re-derived at `3628b4e8c`: 77 commands, all exit 0. - Cross-lane: `scripts/check-doc-authoring.mjs` is `domain:spec`. The notice is on #6017 (`5967607594`). --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6dd99b8 commit 44072fc

6 files changed

Lines changed: 129 additions & 39 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
The read-scope comparand refusals, the native-SQL cross-field backstop and the two display-SQL echo refusals no longer cite tracker numbers; each one states the decision behind it in words
6+
7+
Clause-②: no
8+
9+
Some strings the analytics service shows to operators and callers pointed at an issue-tracker number for the reason behind them. The number goes; where the sentence did not already say what was decided, it now does.
10+
11+
- The read-scope compiler's undefined-comparand refusal says an undefined comparand is refused rather than read as null, on the SQL drivers and on this door alike. Its refusal of a non-boolean `$null`, `$exists` or `$empty` comparand says a non-boolean comparand for any of the three is refused rather than coerced, on every driver and on this door alike. Both still say they fail closed, and that the producer to fix is whoever built the read scope, never the caller of the query.
12+
- The native-SQL strategy's cross-field backstop and the `/analytics/sql` echo's refusal of a field-reference comparison say the engine path's driver enforces the cross-field rules (declared same-table columns only, never the tenant-isolation column, one comparison class) with metadata it owns, so those rules are enforced in one place, next to the metadata they read.
13+
- That echo refusal and the echo's unmapped-operator refusal say the echo renders every predicate the query runs with, or refuses.
14+
15+
Text only: no status, error code, field, route or control flow moves. A client or log filter that matches the old text (for example a tracker-number suffix) needs the new spelling.

‎packages/services/service-analytics/src/read-scope-sql.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,8 +1682,9 @@ function undefinedComparandError(field: string, path: string): Error {
16821682
`predicate was meant ({ "${field}": null } or { "${field}": { "$null": true } }), or omit the key ` +
16831683
`when the value is genuinely absent. The producer to fix is whoever BUILT this read scope — an ` +
16841684
`admin-authored sharing rule / permission set, its CEL lowering, or the in-process code that ` +
1685-
`assembled the FilterCondition — never the caller of this query, who cannot author it (#6050 ` +
1686-
`ruling B, pushed down to this compiler by #6125).`,
1685+
`assembled the FilterCondition — never the caller of this query, who cannot author it. An ` +
1686+
`undefined comparand is refused rather than read as null, on the SQL drivers and on this door ` +
1687+
`alike.`,
16871688
);
16881689
}
16891690

@@ -1870,7 +1871,8 @@ function nonBooleanFlagComparandError(op: string, field: string, path: string):
18701871
`not a string, a number, null or undefined. The producer to fix is whoever BUILT this read ` +
18711872
`scope — an admin-authored sharing rule / permission set, its CEL lowering, or the in-process ` +
18721873
`code (a getReadScope option) that assembled the FilterCondition — never the caller of this ` +
1873-
`query, who cannot author it (#5347 / #5369, pushed down to this compiler by #6387).`,
1874+
`query, who cannot author it. A non-boolean comparand for any of the three is refused rather ` +
1875+
`than coerced, on every driver and on this door alike.`,
18741876
);
18751877
}
18761878

‎packages/services/service-analytics/src/strategies/native-sql-strategy.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,9 +1034,11 @@ export class NativeSQLStrategy implements AnalyticsStrategy {
10341034
`[native-sql-strategy] ${hit.source} carries a field reference ` +
10351035
`{ "$field": "${hit.ref}" } under "${hit.op}" on "${hit.field}", which this strategy does not ` +
10361036
`compile into a column-to-column comparison — it would BIND the reference object as the ` +
1037-
`comparison's value and answer a wrong row set silently (#7598). \`canHandle\` declines such a ` +
1037+
`comparison's value and answer a wrong row set silently. \`canHandle\` declines such a ` +
10381038
`query so it routes to the ObjectQL/engine path, whose driver compiles it and enforces the ` +
1039-
`#5222 rulings with metadata it owns; reaching this throw means the decline and this emitter ` +
1039+
`cross-field rules (declared same-table columns only, never the tenant-isolation column, one ` +
1040+
`comparison class) with metadata it owns, so those rules are enforced in one place, next to ` +
1041+
`the metadata they read; reaching this throw means the decline and this emitter ` +
10401042
`stopped agreeing, which is our bug and must never degrade to a silent answer.`,
10411043
);
10421044
}

‎packages/services/service-analytics/src/strategies/objectql-strategy.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -462,11 +462,14 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
462462
`{ "$field": "${crossField.ref}" } under "${crossField.op}" on "${crossField.field}". ` +
463463
`The query itself is SERVED — \`NativeSQLStrategy.canHandle\` declines a cross-field ` +
464464
`comparison so it routes to the ObjectQL engine path, where driver-sql compiles it into a ` +
465-
`column-to-column predicate written TOTAL across NULLs and enforces the #5222 rulings ` +
466-
`(#7598, maintainer ruling 2026-08-12). This renderer has no faithful rendering of that ` +
465+
`column-to-column predicate written TOTAL across NULLs and enforces the cross-field rules ` +
466+
`(declared same-table columns only, never the tenant-isolation column, one comparison class) ` +
467+
`with metadata it owns, so those rules are enforced in one place, next to the metadata they ` +
468+
`read. This renderer has no faithful rendering of that ` +
467469
`predicate: what it can emit is a comparison against the reference object as a bound VALUE, ` +
468470
`which reproduces none of the rows the query returns. Refusing rather than half-rendering — ` +
469-
`an echo that contradicts execution is worse than no echo (#3601 / #3602 / #3650). Run the ` +
471+
`an echo that contradicts execution is worse than no echo, so the echo renders every ` +
472+
`predicate the query runs with, or refuses. Run the ` +
470473
`query itself (/analytics/query) to get its rows.`,
471474
);
472475
}
@@ -1478,7 +1481,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
14781481
`filter-normalizer.ts refuses anything it cannot map — so this means a new ` +
14791482
`operator reached the normalizer without an arm here. Add one rather than ` +
14801483
`dropping the predicate: an echo without it describes a WIDER query than the ` +
1481-
`one that ran (#5333).`,
1484+
`one that ran, and the echo renders every predicate the query runs with, or refuses.`,
14821485
);
14831486
}
14841487
params.push(values[0]);

0 commit comments

Comments
 (0)