fix(analytics)!: AnalyticsServiceConfig.sqlDialect declares its three-name accept set, and an out-of-contract answer is diagnosed once (#16206) - #17496
Conversation
…an out-of-contract answer Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
…t-of-contract answer (#16206) Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 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 7a9f32c5ac401febed83434aacc111d63dcd24ba && git checkout 7a9f32c5ac401febed83434aacc111d63dcd24ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c7af6bd6f9d3303a36b46209917a6f68658084e6 f00bd2e50046ead4250d14ac0b894bd79d13fda4 && git checkout -B drift-repro c7af6bd6f9d3303a36b46209917a6f68658084e6 && git merge --no-ff f00bd2e50046ead4250d14ac0b894bd79d13fda4
node scripts/docs-audit/affected-docs.mjs --json c7af6bd6f9d3303a36b46209917a6f68658084e6
|
Contract review (clause ②) — PASS · head
|
|
ACCEPT — 2026-09-10T18:01Z, head The three landing pre-checks, each measured rather than asserted:
Flipped ready for review and armed. ⛔ Not approved and ⛔ not merged by this seat; the merge queue lands it.
Generated by Claude Code |
Fixes #16206
AnalyticsServiceConfig.sqlDialect— a published host hook — was typed as freestringwhile only three spellings ever did anything. A host that owned a SQLite datasource and answered the spelling its own stack uses (knex's canonicalsqlite3, whichdriver-sqlitself lists inSQLITE_EMIT_CLIENTSalongsidebetter-sqlite3) was read asunknown, and nothing told it. BecausesqlDialectForis tiered "cannot answer, do not block", a wrong answer and no answer were the same answer — the host that tried hardest to help got the residue arm, silently.Ruled option A (decision batch #88): declare the vocabulary, diagnose a non-empty answer outside it, keep
undefinedsilent and legal, and do not widen the set. That is what this does, and no more.Re-derived locations — the card's line numbers had rotted twice
Located by TEXT on
origin/main@e758131b3, because the card saidanalytics-service.ts:606, triage measured:621, and the answer today is a third number:sqlDialect?: (object: string) => string | undefinedanalytics-service.ts:606:621analytics-service.ts:707const KNOWN_DIALECTS = new Set(...)text-match-sql.ts:196:196:196normalizeSqlDialect:205-:206:205-:206:205-:206sqlDialectFor:218:218analytics-service.ts:906SqlDialectName(read-only,domain:engine)driver-sql/src/schema-drift.ts:123SQLITE_EMIT_CLIENTS(read-only)driver-sql/src/sql-driver.ts:4846—sqlite3,sqlite,better-sqlite3packages/specis untouched, and the premise that it need not move held: both type names live in this lane's own packages.What changed
text-match-sql.ts— oneconsttupleACCEPTED_SQL_DIALECTSis now the single source for both the exported typeAcceptedSqlDialectand the runtimeKNOWN_DIALECTSmembership set, so a future widening cannot land in one and miss the other.unknownis deliberately kept out of it: it is the residue arm, never something a host says. Two predicates join it —isUnrecognisedSqlDialectAnswer(non-empty and outside the set) andasAcceptedSqlDialect(the narrowing the bridge needs).analytics-service.ts— the config hook now declaresAcceptedSqlDialect | undefined, with the accept set and both behaviours stated in the docblock. The wiring site wraps the host's answer and emits onewarnnaming the object, the answer and the accepted set. The answer is passed through untouched: the diagnostic informs, it does not correct and it does not accept a wider vocabulary.normalizeSqlDialectis unchanged — the accept set was NOT widened. That was option B, refused by name.The level is
warn, noterror, on AGENTS.md's one question: this is functional degradation (a construct is not enabled and the next person to look finds a slower predicate), not durability degradation — nothing that claims to be persisted fails to land."Diagnosed once" — keyed on the ANSWER
The key is the out-of-contract spelling, held in a
Seton the service instance. That is the ruling's own granularity ("a non-empty answer outside them is diagnosed once") and it is the failure's identity: one misspelling reaching a thousand objects is one defect with one fix. The object is named in the line so the host has a concrete place to look; it is not part of the key.Why it is bounded, in the terms #15166 settled: the key set's cardinality is the number of DISTINCT strings the host's own hook can return — a property of the host's code, fixed before the first query runs — and nothing in it grows with traffic. Pinned by measurement, not by argument: 50 laps over two objects and 200 laps over the same two both emit exactly 1 line. A second, different wrong answer is a second failure and gets its own line. The set is per service instance, not module-global, so a second host's identical misconfiguration is not swallowed by the first's.
The shipped bridge cannot reach this path at all: it answers from
SqlDriver.dialectName, whose declared return IS the accept set plusunknown.Reproduction — RED before, GREEN after
The pins were run against the committed tree with the diagnostic call deleted (a delete-shape ablation), and the mutation was proved on disk before the run was allowed to mean anything:
The seven that stayed green under the mutation are the vocabulary pins and the
undefined-stays-silent control — correctly, since they assert ABSENCE. Restoration was proven BY STATE, never by an exit code:git hash-objectback to4f3350d9c278a6f503050de36d31d13f3ccd6861(the HEAD blob),git diff HEADempty andgit status --porcelainempty. Since the predicted direction was RED and the run went RED, the mutation demonstrably reached the code under test; the subject resolves through a relative source import, so nodist/sits on the resolution path.The measurement the ruling made a precondition of landing
Driven once, on sql.js, through a host answering
sqlite3— with a host answeringsqliteas the discriminating control. The two differ in one character of one string and in nothing else: same cubes, same capabilities, same engine, same sharedFILTER_TEXT_ROWSfixture.Rows 1 and 2 of the fixture are
ACME Corpandacme corp. The mechanism, printed from the same run:Five of the six case-exact cases return the wrong rows — every case that discriminates on ASCII case; the sixth carries no cased letter to fold. Note the direction of the negated case:
$notContainsDROPS row 1 from a set that should contain it, so on the read scope this is not only over-reach in one direction.⭐ This is not "slower", it is "returns the wrong rows" for the population this card is about. It is #15684's fold, live on the arm a
sqlite3-answering host lands on. Per the ruling, the grade is the delivering seat's to revisit before this lands, and this PR reports the finding rather than fixing it — closing #15684'sunknownarm is that card's business, and doing it quietly inside this one was refused.One deviation from the dispatched file surface
src/plugin.ts(same package, outside the declared surface) — forced by the declaration, not chosen.SqlDriver.dialectNameis a FOUR-name vocabulary whose fourth name isunknown, that driver's own "I cannot say" (what it returns for a client it does not model,mariadbamong them per #11756). Passed through verbatim it would arrive at the newly-narrowed hook looking like a considered answer outside the accept set, and every such deployment would carry a warning about a driver behaving correctly. The bridge now translates that residue toundefined, this hook's own spelling for the same thing; the dialect the compilers end up with is unchanged either way. Without it,pnpm --filter @objectstack/service-analytics typecheckis RED (TS2322atplugin.ts:1060).driver-sqlwas read and cited, never edited.src/dimension-labels.tsand the two__tests__files PR #17470 holds were not touched.Verification
Measured at
f00bd2e50.pnpm --filter @objectstack/service-analytics test— 2280 passed / 106 files, exit 0 (12 new; every pre-existing case passes unchanged).pnpm --filter @objectstack/service-analytics typecheck— exit 0. Itstscprogram reaches 104 files undersrc/__tests__/, the new suite among them (--listFiles), so the test layer is covered rather than advertised.pnpm --filter '@objectstack/service-analytics^...' build— exit 0 (the dependency closure).dispatch-gates.mjs --commands, then--ranwith exit codes): 61 derived, 58 run green, 3 NOT MEASURED, 0 unrun.pnpm lint(eslint . --no-inline-config, whole repo) — exit 0.node scripts/check-adr-0087-registration.mjs --base origin/main— exit 0; its own verdict line verifies the disposition's four predicates againstanalytics-service.ts#AnalyticsServiceConfig(interface).NOT MEASURED:
check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt— all three exited 3 (PREREQUISITE NOT MET), one cause: they read built output across the whole workspace and no fulldist/closure exists locally. That is a farm-wide build, and it belongs to CI. Recorded as not measured, never as a pass.Derivation caveat, stated rather than smoothed:
dispatch-gatesreports this tree as STALE against a movingorigin/main(one file it derives from,scripts/measure-reserved-identity-name-census.mjs, changed in that range). The derived family count was 61 both before and after a fresh fetch; CI on the merged generation is the reading of record.Acceptance notes
Out of scope, noted and NOT filed:
DatasetScopedStrategyContext.sqlDialect(src/strategies/types.ts:97) staysstring | undefineddeliberately. That is the internal, runtime-honest seam: by the time a compiler asks, the value is whatever the host actually answered, and declaring it narrowly there would be a lie about runtime. The declared vocabulary belongs on the config hook the ruling names. Handed on to whichever PR next moves that file.normalizeSqlDialectstill answersunknownfor a wrong answer and for no answer alike, and that stays correct at that call site — telling them apart is the contract seam's job, once, not a per-filter-node job. Successor: none needed; the reasoning is in the function's own docblock.Nothing was filed as a new card from this run: the one finding worth a card is the wrong-rows measurement above, which belongs to #15684 (already open) and to this card's grade, both of which are the dispatching seat's to act on.
Docs drift — re-derived on the bot's own tree, and hand-checked past what it can see
Re-derived on
7a9f32c5ac401febed83434aacc111d63dcd24ba(the merge off00bd2e50intoc7af6bd6f9), in a detached worktree at that commit — ⛔ not on a worktree cut from an oldermain, which would be a different tree rather than a wrong row. It reproduces exactly: 12 anchors, 1 hand-written page, 0 release-owned.content/docs/plugins/packages.mdx— NOT falsified. The anchor is correct:AnalyticsServicePluginis genuinely in this diff. But the page names it only twice, at:502and:506, inside one composition example whose options are elided (new AnalyticsServicePlugin({ /* … */ })). The falsifying shape would be a sentence or sample on that page that names anAnalyticsServicePluginOptionsmember, passes asqlDialectvalue, or states the plugin's dialect behaviour; none of the three is present, and this diff moves neither the class, its export, its name, nor its options type.The emitter-blind half, hand-read because no anchor can ever reach it — a page stating this rule by its INPUTS shares no identifier with the emitter. Searched
content/docs/**forsqlDialect,sqlite3/better-sqlite3, and everydialectoccurrence outsidereleases/, then read the analytics-facing and driver-facing pages by hand:sqlDialectappears in zero pages. The hook is documented nowhere incontent/docs/**, and nowhere in this package'sREADME.mdeither. There is no page that tells a host what to answer, so there is none to falsify.content/docs/data-modeling/drivers.mdx:100listssqliteandsqlite3as accepted spellings — of the driver config'sdriverkey, a driver-owned surface this diff does not touch. It stays true, and it is worth reading as CORROBORATION of the card's premise rather than as drift: the platform's own documentation teaches a host thatsqlite3is the spelling, one layer down.drivers.mdx:76,:86,:356-:411andpackages.mdx:158are MySQL dialect caveats and knex client names — driver config again, untouched.content/docs/protocol/objectql/query-syntax.mdx:509,:532,:1025-:1026say "on every dialect" about the$fieldcompiler's backend coverage, not about this hook's accept set. Untouched: the runtime accept/reject behaviour ofnormalizeSqlDialectis byte-identical before and after.content/docs/references/api/analytics.mdx:92uses "dialect" in the retired-shim sense, unrelated.⇒ No page is falsified by this diff. Recorded so nobody re-reads the same set.
Generated by Claude Code
Generated by Claude Code