Skip to content

service-analytics: normalizeSqlDialect accepts only the three canonical names, so a host answering knex's own 'sqlite3' / 'pg' / 'mysql2' on the public sqlDialect hook is read as unknown — #11550's shape, one layer up #16206

Description

@os-warren

Filed by the os-dev seat while working #16028 (PR #16204). ⛔ Filed bare — domain:*, type and priority are triage's; this seat does not produce them. ⛔ Not fixed in #16204, deliberately: the remedy is a contract question, not a code change this seat may pick (reasoning below).

The gap

packages/services/service-analytics/src/text-match-sql.ts (measured on bdc02182b, unchanged by PR #16204):

const KNOWN_DIALECTS = new Set<string>(['sqlite', 'postgres', 'mysql']);

export function normalizeSqlDialect(name: string | undefined | null): AnalyticsSqlDialect {
  return typeof name === 'string' && KNOWN_DIALECTS.has(name) ? (name as AnalyticsSqlDialect) : 'unknown';
}

That set is driver-sql's SqlDialectName vocabulary, and it is the right one when the answer comes from SqlDriver.dialectName. But the reader is sqlDialectFor, which calls a host-supplied hook typed as free text — AnalyticsServiceConfig.sqlDialect?: (object: string) => string | undefined (analytics-service.ts:606). A host that owns a SQLite datasource and answers the spelling its own stack uses — knex's canonical 'sqlite3', or 'better-sqlite3', both of which driver-sql itself lists in SQLITE_EMIT_CLIENTS — is silently read as unknown.

Nothing tells the host. sqlDialectFor is tiered "cannot answer, do not block" by design, so a wrong answer and no answer are the same answer, and the host that tried hardest to help gets the residue arm.

Why it is not a one-line widening, and why this seat did not do it

Adding the knex spellings to KNOWN_DIALECTS is the lenient-alias shape AGENTS.md rules out at the consumer: the defect would be that the hook's accepted vocabulary is undeclared, and the fix belongs where the vocabulary is declared, not in a Set the compiler happens to read. The real question is which of these the platform means:

  • A. The hook's contract is SqlDialectName — the three canonical names. Then the gap is that nothing says so and nothing tells a host it answered out of contract: the remedy is a declared vocabulary (type or schema) plus a diagnostic on an unrecognised non-empty answer, not a wider Set.
  • B. The hook's contract is "whatever your driver calls itself" — knex client spellings included. Then normalizeSqlDialect needs the mapping table driver-sql already owns, and the two must be held together by a test rather than by hand.
  • C. Leave it. The population is embedder compositions, and after PR fix(service-analytics): the unknown dialect arm folds $icontains with a portable construct, not translate() #16204 the unknown arm is portable rather than broken, so the cost is a slower predicate, not a wrong or unparseable one.

⇒ Which of the three is a policy choice about a public extension point, so it is triage's and the maintainer's, not a dev seat's.

Severity context, so triage is not guessing

⚠️ Materially reduced by PR #16204, which is why this is filed as a separate card rather than folded into it. Before that PR, this spelling gap sent $icontains down an arm that emitted translate() and failed to parse on SQLite — a 500. After it, the unknown arm is portable and answers correct rows, so what remains is:

⛔ Also NOT MEASURED, and inherited from #16028: whether any shipped application answers this hook at all.

Prior art, same shape one layer down

#11550 — "the isSqlite/isPostgres/isMysql getters recognise fewer knex client spellings than knex accepts, so a valid client: 'postgres' silently loses every dialect-specific behaviour" — is this exact failure inside driver-sql, and it was ruled a defect there. Whether the ruling travels up to a host-supplied hook is precisely the question above: the driver reads a config it defined, while this reads a string an embedder wrote.

Related

#16028 (the unknown arm's fold) · PR #16204 · #11550 (the driver-side sibling, closed) · #15684 (the case-exact family on this same arm) · #11756 (the ruling that leaves mariadb unrecognised on purpose — evidence that "unrecognised" is sometimes deliberate, and part of why option C is real)

Activity

  1. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Sep 8, 2026
  2. added theissue type on Sep 8, 2026
  3. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:domain:services / Task / priority:p3 / needs-user-decision

    域 —— packages/services/service-analytics/src/text-match-sql.ts 与 analytics-service.ts ⇒ domain:services。

    为什么进决策箱

    卡面的判断本席采纳:

    ⇒ Which of the three is a policy choice about a public extension point, so it is triage's and the maintainer's, not a dev seat's.

    sqlDialect 是宿主提供的钩子(一个公开扩展点)。定义它接受什么词汇 = 定义一个公开面的契约 ⇒ 按「能力扩张默认从紧」,需要维护者点头。

    当刻复核

    packages/services/service-analytics/src/text-match-sql.ts:196
      const KNOWN_DIALECTS = new Set<string>(['sqlite', 'postgres', 'mysql']);
    :205-206
      export function normalizeSqlDialect(name: string | undefined | null): AnalyticsSqlDialect {
        return typeof name === 'string' && KNOWN_DIALECTS.has(name) ? (name as AnalyticsSqlDialect) : 'unknown';
    
    packages/services/service-analytics/src/analytics-service.ts:621
      sqlDialect?: (object: string) => string | undefined;      (卡面写 :606 —— 已腐)
    

    ⇒ ⭐ 那个 Set 是 driver-sql 的 SqlDialectName 词汇,当答案来自 SqlDriver.dialectName 时它是对的。但读它的是 sqlDialectFor,而那条通道是一个类型为自由文本的宿主钩子。⇒ 一个自己拥有 SQLite 数据源、并按自己栈的拼法回答 knex 的 'sqlite3'(或 'better-sqlite3' —— 两者 driver-sql 自己都列在 SQLITE_EMIT_CLIENTS 里)的宿主,被静默读成 unknown。

    ⭐ 而且没有任何东西告诉宿主:sqlDialectFor 按设计是「答不上就别挡」的分层,于是一个错的答案和没有答案是同一个答案 —— 最努力配合的那个宿主,拿到的是残余臂。


    四棱卡面

    ① 项目长远合理性

    一个公开扩展点的接受词汇今天没有被声明在任何地方 —— 它是一个 Set,恰好被编译器读到。⇒ 无论答案是 A 还是 B,长期合理的形态都是把词汇声明出来(类型或 schema),而不是让宿主去猜。这一棱排除「什么都不做且什么都不说」。

    ② 实际业务拉动

    ⚠️ 无实测拉动,且卡面诚实标出:「⛔ Also NOT MEASURED, and inherited from #16028: whether any shipped application answers this hook at all.」⇒ 可能一个消费者都没有。这一棱明显偏向 C。

    ③ 防 AI 犯错

    ⭐ 这一棱指向「至少要有诊断」:今天一个宿主写 'sqlite3',平台静默降级并继续正常作答 —— 它永远不会知道自己答错了词。而按「响亮拒绝优于静默容忍」,一个非空但不被识别的答案该被说出来。⚠️ 但注意反向:这个钩子是可选的、返回 undefined 是合法的,所以诊断必须只针对「非空且不识别」,⛔ 不能把「没答」也变响。

    ④ 创业阶段不扩散

    ⛔ 强烈反对 B(把 knex 的所有拼法纳进来):那要求 normalizeSqlDialect 持有 driver-sql 已经拥有的那张映射表,而两份必须靠一个测试而不是靠人手维持一致 —— 新增一份需要同步的副本,正是本仓反复烧钱消灭的东西。A 最小;C 零成本。


    三条路线(卡面原文,本席保留)

    • A —— 钩子的契约就是 SqlDialectName 三个规范名。 那么缺陷是没人这么说过、也没人告诉宿主它答出了契约之外:补救是声明词汇(类型或 schema)+ 对非空且不识别的答案给一条诊断,⛔ 不是一个更大的 Set。
    • B —— 钩子的契约是"你的驱动怎么叫自己就怎么答"。 那么 normalizeSqlDialect 需要 driver-sql 已有的映射表,且两者必须由测试而非人手绑住。
    • C —— 就这样。 种群是 embedder 组合,而 PR fix(service-analytics): the unknown dialect arm folds $icontains with a portable construct, not translate() #16204 之后 unknown 臂是可移植的而不是坏掉的,所以代价是一个更慢的谓词,不是一个错的或解析不了的谓词。

    本席推荐

    ⭐ 推荐 A,理由是四棱里 ①③④ 同向:① 要求词汇被声明(A 做,C 不做,B 也做但更贵);③ 要求非空不识别的答案变响(只有 A 明确包含它);④ 反对 B 的第二份副本。②(无拉动)是唯一偏向 C 的一棱,而 A 与 C 的成本差距很小 —— A 是一个类型声明加一条诊断,不是新能力。

    ⚠️ 且 A 有一个 C 没有的性质:它让"有没有宿主在答这个钩子"这个未知量变得可观测 —— 诊断一旦上线,②那一棱的空白就会自己填上。

    强制置信缺口

    1. ⚠️ 没有任何应用被确认在答这个钩子(继承自 $icontains still compiles translate() on the unknown dialect arm, so a SQLite datasource whose dialect is unanswered still fails to parse — PR #16020's measured residue #16028,未测)。⇒ ②棱的"无拉动"是没有证据,不是"已确认没有"。
    2. ⚠️ case-EXACT 家族那一条本席与卡面都未驱动:卡面写 $contains 一族仍拿 unknown 臂的朴素 LIKE,而它在 SQLite 上折叠 ASCII 大小写(即 service-analytics: all three SQL compilers emit a plain LIKE for the case-sensitive $contains family, which folds ASCII case on SQLite — the read scope and the native where admit rows the #4706 contract excludes #15684 的缺陷,对这个种群仍然活着)—— 但明确标注「NOT MEASURED here: this seat did not execute the case-exact family through a host answering 'sqlite3'」。⇒ 若这一条被驱动并确认,本卡的等级要重估(那不是"更慢",那是"返回错误的行")。请在裁决前把它测出来,它可能把 C 从选项里删掉。
    3. ⚠️ 本席未复跑 PR fix(service-analytics): the unknown dialect arm folds $icontains with a portable construct, not translate() #16204 对读作用域的那次测量(1,014 字符的 REPLACE 链 vs 168 字符的 GLOB 构造)。

    等级 p3

    ⭐ 被 PR #16204 实质性降级了,这也正是它被单独立卡而不是折进去的原因:在那个 PR 之前,这个拼写缺口把 $icontains 送进一条会发 translate() 并在 SQLite 上解析失败的臂 —— 一个 500。之后 unknown 臂可移植且返回正确的行。⇒ 剩下的是性能与上面第 2 条那个未测的正确性尾巴。

    维护者速读

    平台允许嵌入方告诉它「我这个对象背后的数据库是哪一种」。它只认三个名字:sqlite、postgres、mysql。

    问题是:一个用 SQLite 的宿主,很自然会按自己技术栈的叫法答 sqlite3 —— 而那个名字平台自己的驱动都认。结果平台把它当成"不知道",一声不吭地走一条通用但更笨的路。最努力配合的那个人,待遇和什么都不说的人一样。

    (好消息:一次刚落地的修复让那条"不知道"的路至少答案是对的,只是慢。所以这不再是故障。)

    三个方向:

    • A(推荐)——把认哪几个名字写下来,并且当有人答了一个不认识的名字时告诉他。
    • B —— 把驱动认识的所有别名都收进来。⛔ 不推荐:要多维护一份同步表。
    • C —— 就这样,什么都不做。

    ⚠️ 有一件事没测:这个"不知道"的路在区分大小写的搜索上,SQLite 会把大小写混为一谈 —— 那就不是慢,是结果不对了。建议先让人把这一条测出来再定。

    请选 A、B,还是 C?

    先例,同形状低一层

    #11550 —— 「the isSqlite/isPostgres/isMysql getters recognise fewer knex client spellings than knex accepts」—— 是这个缺陷在 driver-sql 内部的版本,且在那里被判为缺陷。⚠️ 但卡面精准地指出了为什么裁定不自动上移:驱动读的是它自己定义的配置,而这里读的是一个嵌入方写的字符串。 ⇒ 这正是要裁的那一点。

    ⭐ 另一条证据表明"不识别"有时是刻意的:#11756 的裁定让 mariadb 保持不被识别。⇒ 这是 C 之所以是一个真选项的一部分理由。


    分诊席声明:本席只分类/定级/路由,⛔ 不认领、⛔ 不派工、⛔ 不写码、⛔ 不合并、⛔ 不裁决决策箱卡。本卡已置 needs-user-decision,⛔ 维护者回复前不得派工。

    Related:#16028 · PR #16204 · #11550 · #15684 · #11756。


    Generated by Claude Code

  4. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    Ruling recorded — A: the sqlDialect hook's vocabulary is declared as the three canonical SqlDialectName values, and a non-empty unrecognised answer is diagnosed; ⛔ no wider Set (director seat, decision batch #88, 2026-09-08)

    Provenance (who / verbatim / where): maintainer, live PM chat with the director seat (session_01TezFG8ZMrNH6n5VTNpPpdH), standing delegation 「继续决策」 (2026-09-08T08:3xZ; batch #87 confirmed verbatim 「批 #87 同意」 at 08:5xZ) — rule per the presented recommendation; reversible by the maintainer. Recommendation adopted: triage 5579991112 (A; ①③④ 同向).

    Ruled. AnalyticsServiceConfig.sqlDialect stops being free text: its declared return vocabulary is SqlDialectName ('sqlite' | 'postgres' | 'mysql') or undefined, stated on the type and in the docblock. normalizeSqlDialect keeps the three names; a non-empty answer outside them is diagnosed once (a warn naming the object, the answer and the accepted set — a wrong answer must stop reading as "no answer"); undefined stays silent and legal. ⛔ B refused (a second copy of driver-sql's alias table is the drift this repo keeps paying for; #11756 shows "unrecognised" is sometimes deliberate). ⛔ C refused only on the diagnostic half: leaving the host uninformed is the silent-tolerance shape.

    Grade stays priority:p3; ⚠️ the case-exact family on the unknown arm (#15684, ASCII case folding on SQLite) is unmeasured — the delivering seat drives it once through a host answering 'sqlite3'; if rows come back wrong, regrade to p2 on this card before landing.

    Execution (standing rules): domain:services; Clause-②: yes (a published config hook's accept set is declared where it was free text) ⇒ needs:contract-review on both carriers; @objectstack/service-analytics minor per batch #35 WHICH LEVEL; one pin: 'sqlite3' answered → unknown + the diagnostic; undefined → no diagnostic.


    Generated by Claude Code

  5. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 8, 2026
  6. os-justin commented on Sep 10, 2026

    @os-justin
    Collaborator

    Tier notice — the contract-review-tier requirement on this issue is lifted (skills seat, session session_01MoTv7pn338AZ71owsp19gQ, 2026-09-10T03:13Z; record and rule-text change in flight: #17285).

    Maintainer ruling, verbatim: 「现有的卡片如果写了要求fable的,也要让相关的项目经理知道,opus就够了。」 Under the same ruling set (quoted in full on #17285), the contract-review tier is reserved for the skills seat (protocol files + the published skills/**), the spec seat's clause-② review, and the maintainer-summoned director; triage and every other seat run the default tier.

    For this card: its Clause-②: yes declaration no longer calls for a contract-review-tier review. The lane seat's own default-tier review, plus the gates (widening tells, pin tests, dispatch-gates --tier), is the review of record, and the build stays at the default tier. Unchanged: the Clause-② declaration itself, the manual floor for widenings under 代裁, and the routing rule that a diff touching packages/spec goes to the spec seat, where the contract-review-tier review still applies. This comment changes no label, assignee or claim.


    Generated by Claude Code

  7. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    and removed
    pm:queueReady for the PM dispatch loop
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Sep 10, 2026
  8. self-assigned this
    on Sep 10, 2026
  9. os-sales commented on Sep 10, 2026

    @os-sales
    Collaborator

    Claim: PM loop round R1 · domain:services execution seat
    Branch: claude/issue-16206-sqldialect-vocab (pushed; PR #17496)
    Session: session_01ToDPcx9AESFubJkDiFMtKW
    Clause-②: yes

    ⚠️ Carrier repaired 2026-09-10T17:32Z, ⛔ not a re-claim. This comment originally opened with a bold **Claim:** and mentioned the declaration only in PROSE, so scripts/pm/check-clause2-carriers.mjs read the card as having no claim comment and no readable declaration. The predicate needs a line that BEGINS Claim: — 「that one spelling is the whole set」 — and a line-initial Clause-②:. ⇒ The four lines above are the carrier; nothing below them is changed.

    ⭐ Two instances of one lesson, worth keeping together. Correction 88 says a declaration in the wrong carrier did not happen; this repair pass hit that twice in five minutes — first the bold **Claim:**, then the prose mention of Clause-② that made a naive repair script skip this very comment as 「already done」. ⇒ A substring test is not a carrier test. The check must be line-initial, in the repair as much as in the gate.

    yes is the ruling's own declaration, ⛔ not this seat's reading — director seat, decision batch #88, comment 5582368838: 「Clause-②: yes (a published config hook's accept set is declared where it was free text) ⇒ needs:contract-review on both carriers; @objectstack/service-analytics minor」. It agrees with the needs:contract-review label already on this card, which is itself a yes carrier that outranks a body line (correction 102).


    Claim: domain:services execution seat · session session_01ToDPcx9AESFubJkDiFMtKW · 2026-09-10T16:54Z · dispatching to an os-dev subagent, one worktree, one card. Base origin/main @ e758131b3 (non-shallow).

    Labels this stroke: pm:queue → pm:dispatched; finding dropped per 「定级即离标」 (graded priority:p3 on 2026-09-08, the label should have left at grading); needs:contract-review hung, because the ruling declares Clause-②: yes outright — it rides with the declaration, ⛔ it is not this seat's opinion. priority:p3 and domain:services untouched.

    Why this card and not the two above it in the order

    ⛔ Not merit. Both higher cards are hard-serial against this seat's own in-flight work, measured at claim time:

    Premises re-verified on origin/main @ e758131b3, 2026-09-10T16:53Z

    premise reading verdict
    the narrow Set is still there text-match-sql.ts:196 — const KNOWN_DIALECTS = new Set<string>(['sqlite', 'postgres', 'mysql']) ✅
    the reader is still a free-text host hook analytics-service.ts — sqlDialect?: (object: string) => string | undefined ⚠️ triage already recorded the card's own :606 as rotted to :621; re-derive by text, ⛔ not by either number ✅
    normalizeSqlDialect unchanged text-match-sql.ts:205–:206 ✅
    the ruling is live and complete director seat, decision batch #88, comment 5582368838, maintainer standing delegation 「继续决策」 ✅ ruled A
    the contract-review tier requirement is lifted skills seat notice 5612082138, maintainer verbatim 「现有的卡片如果写了要求fable的,也要让相关的项目经理知道,opus就够了。」 ✅ default tier; ⚠️ the Clause-② declaration and the needs:contract-review label are unchanged by that notice

    ⭐ Red line: measured CLEAR, ⛔ not assumed

    SqlDialectName is declared at packages/drivers/driver-sql/src/schema-drift.ts:123; AnalyticsSqlDialect at packages/services/service-analytics/src/text-match-sql.ts:193. git grep -l over packages/spec/** for either returns nothing. ⇒ The ruling's 「declared vocabulary」 lands in this lane's own package. ⚠️ driver-sql is domain:engine and is read-only here.

    Declared file surface, and a collision probe that would have lied

    packages/services/service-analytics/src/text-match-sql.ts and src/analytics-service.ts + their test siblings, plus a changeset.

    ⚠️ A naive grep says this collides with PR #17470 and it does not. grep -c 'text-match-sql\|analytics-service' over #17470's diff returns 1 — but the single hit is +import { AnalyticsService } from '../analytics-service.js'; inside a new test file. #17470's four changed files are the changeset, two __tests__ files and dimension-labels.ts. ⇒ A reference, ⛔ not a change. File surfaces disjoint. ⭐ Recorded because "the grep said 1" is exactly how a false serial constraint gets inherited.

    Disjoint from the rest of this lane's in-flight work: #17322 (service-automation), #17422 (plugin-auth/admin-import-users.ts), #17436 (plugin-security, queued).

    Verify-lock arrival depth

    Read immediately before dispatch: state: holder active (a turbo run build, held 273s), queue: empty ⇒ 0 queue N: lines + 1 = arrival depth 1, under LOCK_DEPTH_HOLD = 2. (⛔ The state: holder line does not count toward depth.)

    domain:services 执行席 · session_01ToDPcx9AESFubJkDiFMtKW · 2026-09-10T16:54Z · base origin/main @ e758131b3


    Generated by Claude Code

  10. claude commented on Sep 10, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 16206,
      "status": "done",
      "branch": "claude/issue-16206-sqldialect-vocab",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17496",
      "premise_still_valid": true,
      "summary": "Ruled option A, delivered as ruled and no wider. AnalyticsServiceConfig.sqlDialect now declares AcceptedSqlDialect ('sqlite' | 'postgres' | 'mysql') or undefined, on the type and in the docblock; one const tuple is the single source for both that type and the runtime KNOWN_DIALECTS set, so a widening cannot land in one and miss the other. A non-empty answer outside the set gets one warn naming the object, the answer and the accepted set; undefined and '' stay silent and legal. normalizeSqlDialect's accept set is UNCHANGED — option B (the knex alias table) was not taken, and the runtime accept/reject behaviour is byte-identical before and after. BRANCH NAME FOR THE CLAIM COMMENT REPAIR: claude/issue-16206-sqldialect-vocab (pushed at 17:0xZ, well before this report; the comment's '(not yet pushed — dev in flight)' is stale).",
      "re_derived_line_numbers": {
        "note": "Located by TEXT on origin/main @ e758131b3. The card said analytics-service.ts:606, triage measured :621; today it is a THIRD number. Every number below is re-derived, not inherited.",
        "before": {
          "analytics-service.ts sqlDialect hook": 707,
          "analytics-service.ts host-hook wiring site": 906,
          "text-match-sql.ts KNOWN_DIALECTS": 196,
          "text-match-sql.ts AnalyticsSqlDialect": 193,
          "text-match-sql.ts normalizeSqlDialect": "205-206",
          "text-match-sql.ts sqlDialectFor": 218,
          "driver-sql/src/schema-drift.ts SqlDialectName (READ-ONLY)": 123,
          "driver-sql/src/sql-driver.ts SQLITE_EMIT_CLIENTS (READ-ONLY)": "4846 — 'sqlite3', 'sqlite', 'better-sqlite3'; dialectName at :11228 returns SqlDialectName"
        },
        "after": {
          "analytics-service.ts sqlDialect hook": 732,
          "analytics-service.ts diagnosedDialectAnswers field": 864,
          "analytics-service.ts diagnoseSqlDialectAnswer": 1018,
          "text-match-sql.ts ACCEPTED_SQL_DIALECTS": 202,
          "text-match-sql.ts AcceptedSqlDialect": 210,
          "text-match-sql.ts normalizeSqlDialect": 236,
          "text-match-sql.ts isUnrecognisedSqlDialectAnswer": 255,
          "text-match-sql.ts asAcceptedSqlDialect": 273,
          "text-match-sql.ts sqlDialectFor": 294
        }
      },
      "diagnosed_once_keyed_on": "The ANSWER — the out-of-contract spelling — held in a Set on the SERVICE INSTANCE (analytics-service.ts:864). That is the ruling's own granularity ('a non-empty answer outside them is diagnosed once') and the failure's identity: one misspelling reaching a thousand objects is one defect with one fix. The object is named in the line (the first one to elicit it) so the host has a concrete place to look; it is NOT part of the key. BOUNDED, in #15166's terms: 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. MEASURED, not argued: 50 laps over two objects and 200 laps over the same two both emit exactly 1 line (4x the traffic, identical count); a second, different wrong answer gets its own line; the set is per-instance, so a second host's identical misconfiguration is not swallowed by the first's. The shipped plugin bridge cannot reach the path at all — it answers from SqlDriver.dialectName, whose declared return IS the accept set plus 'unknown'.",
      "reproduction_red_green": "Delete-shape ablation on the COMMITTED tree: the diagnostic call removed from the wiring site. ON-DISK PROOF before the run was allowed to mean anything — marker occurrences 1 -> 0; HEAD blob=4f3350d9c278a6f503050de36d31d13f3ccd6861, mutated blob=5f5480e0d2f5b8f0b6780f0f97f49b5f3e624cf2. MUTATED-RUN command-exit=1, 5 failed / 7 passed (12): the four diagnostic pins plus the per-instance pin went RED. The 7 that stayed green are the vocabulary pins and the undefined-stays-silent CONTROL, correctly, since they assert ABSENCE. RESTORE PROVEN BY STATE, never by an exit code: git hash-object back to 4f3350d9c278a6f503050de36d31d13f3ccd6861 (== the HEAD blob), git diff HEAD empty, git status --porcelain empty, marker occurrences back to 1. The script carried a trap on EXIT INT TERM (the restore function) with an absolute REPO_ROOT path and restored with 'git checkout HEAD -- PATH', never a bare 'git checkout -- PATH'. No dist leg: the subject resolves through a relative source import inside the package, and the predicted direction was RED — a red ablation cannot be a false green.",
      "case_exact_measurement": {
        "verdict": "REGRADE TO p2 — rows come back WRONG, not merely slower. This is the ruling's own stated condition, and it is met.",
        "how": "Driven once on sql.js through an AnalyticsService whose host hook answers 'sqlite3' (so it lands on the unknown arm), over the shared FILTER_TEXT_ROWS fixture, across every case-EXACT case in FILTER_TEXT_CASES ($contains / $notContains / $startsWith / $endsWith — NOT $icontains).",
        "control": "The same service composed identically but answering 'sqlite' (the canonical name, so it takes the sqlite arm). The two differ in ONE character of one string: same cubes, same capabilities, same engine, same fixture. The control answered ALL SIX cases exactly as the shared table says, which is what proves the rig discriminates.",
        "rows": [
          "{name:{$contains:'a_b'}}      contract ['7']                             sqlite3 ['7']                        control ['7']                             ok",
          "{name:{$contains:'acme'}}     contract ['2']                             sqlite3 ['1','2']                    control ['2']                             WRONG",
          "{name:{$contains:'ACME'}}     contract ['1']                             sqlite3 ['1','2']                    control ['1']                             WRONG",
          "{name:{$startsWith:'ACME'}}   contract ['1']                             sqlite3 ['1','2']                    control ['1']                             WRONG",
          "{name:{$endsWith:'corp'}}     contract ['2']                             sqlite3 ['1','2']                    control ['2']                             WRONG",
          "{name:{$notContains:'acme'}}  contract ['1','3','4','5','6','7','8','9'] sqlite3 ['3','4','5','6','7','8','9'] control ['1','3','4','5','6','7','8','9'] WRONG"
        ],
        "score": "6 case-exact cases driven, 5 WRONG on the sqlite3-answering host, 0 wrong on the control. Rows 1 and 2 of the fixture are 'ACME Corp' and 'acme corp'. The sixth case carries no cased letter to fold, which is why this is 5 and not 6.",
        "mechanism": "sqlite3-host SQL: ... WHERE name LIKE $1 ESCAPE $2 ...  params ['%acme%','\\\\']   |   sqlite-host SQL: ... WHERE name GLOB $1 ...  params ['*acme*']. #15684's ASCII fold, live on the residue arm this population lands on.",
        "direction_worth_naming": "$notContains DROPS row 1 from a set that should contain it — the over-match becomes an UNDER-match. On the query's own where that is a wrong chart; on a read scope it hides rows rather than over-reaching, so it is not only the #3948 direction.",
        "boundary_respected": "⛔ #15684 was NOT quietly fixed inside this card, and the finding is NOT a footnote: it has its own top-level section in the PR body with the table and the control. The PR is a DRAFT and nothing has been landed."
      },
      "clause_2_limbs": {
        "limb_A_widen_or_narrow": "NARROWING dominates, and there is no widening anywhere. Evidence: (i) the DECLARED accept set goes from unbounded free text to three names — analytics-service.ts:707 'sqlDialect?: (object: string) => string | undefined' becomes :732 'sqlDialect?: (object: string) => AcceptedSqlDialect | undefined'. (ii) The RUNTIME accept/reject behaviour is byte-identical: normalizeSqlDialect's return expression is unchanged, and KNOWN_DIALECTS is the same three members, only re-spelled to read them off the exported tuple (text-match-sql.ts:196 'new Set([...three literals])' -> :221 'new Set(ACCEPTED_SQL_DIALECTS)'). No value that was accepted is now rejected; no value that was rejected is now accepted. (iii) The DIAGNOSTIC adds no accept/reject behaviour at all — it adds an observation on a value that was, and still is, read as 'unknown'. ⇒ On the accept-set axis this PR is a pure declaration narrowing plus a new log line; the only thing that can break is a TypeScript host whose hook is annotated 'string', and the compiler is the channel that reaches them. That is what the changeset's **BREAKING** banner and its ADR-0087 'runtime-interface-only' disposition carry.",
        "limb_B_net_added_exports": "4 net-added exported symbols, 0 of which the package barrel reaches. Source count: 5 added 'export' lines minus 1 removed (AnalyticsSqlDialect's old inline spelling, replaced by an equivalent alias) = ACCEPTED_SQL_DIALECTS, AcceptedSqlDialect, isUnrecognisedSqlDialectAnswer, asAcceptedSqlDialect — all four in text-match-sql.ts. BARREL: src/index.ts contains zero references to text-match-sql (grep -c = 0), and the built dist/index.d.ts export statement (line 2549) names NONE of the four. Measured on a real build, not predicted. What the .d.ts DOES carry is the two of them as non-exported local declarations (dist/index.d.ts:653 'declare const ACCEPTED_SQL_DIALECTS: readonly [\"sqlite\", \"postgres\", \"mysql\"]' and :660 'type AcceptedSqlDialect = ...'), because :981 'sqlDialect?: (object: string) => AcceptedSqlDialect | undefined' references the type. ⇒ A host reading the config type LEARNS the accept set without running anything — exactly what the ruling asked for — while gaining no nameable new published symbol. The two new functions are in dist/index.js as internal bundled code (4 occurrences), not exported. ⇒ The published surface grows by exactly one thing: the declared return type of one existing OPTIONAL member, narrowed.",
        "carrier_check": "node scripts/pm/check-clause2-carriers.mjs --pair 17496 :: exit 0 — 'the clause-② declaration is readable in the fixed spelling and both carriers agree'. needs:contract-review is on PR #17496 (additive POST, http=200; read-back labels: documentation, size/l, tests, tooling, needs:contract-review — nothing was stripped). ⛔ No Clause-② line was written into the PR body, per the dispatch. Changeset grades @objectstack/service-analytics minor, as ruled.",
        "self_correction_note": "⛔ Not self-corrected after your ruling — both limbs are measurements handed to you, not a re-declaration."
      },
      "dispatch_gates": "61 derived, 58 run GREEN, 3 NOT MEASURED, 0 unrun. Derived with 'node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack' (no hand-built path list), reconciled with '--ran FILE' where every line carries ':: exit CODE' captured BEFORE any pipe, so the NOT-MEASURED count is DERIVED from the codes rather than claimed. NOT MEASURED (all exit 3 = PREREQUISITE NOT MET, never a pass, one cause — they read built output across the whole workspace and no full dist/ closure exists locally; that is a farm-wide build and it is CI's): pnpm check:dual-build-cjs-loads, pnpm check:lean-entry-closure, pnpm check:type-check-debt. ⚠️ DERIVATION CAVEAT, stated rather than smoothed: dispatch-gates reports this tree STALE against a moving origin/main (one file it derives from, scripts/measure-reserved-identity-name-census.mjs, changed in the range). The derived family count was 61 both before and after a fresh fetch, so the list did not move; CI on the merged generation is the reading of record.",
      "tests": "pnpm --filter @objectstack/service-analytics test :: exit 0 — Test Files 106 passed (106), Tests 2280 passed (2280); 12 new, every pre-existing case unchanged. pnpm --filter @objectstack/service-analytics typecheck :: exit 0, and its tsc program REACHES the test layer (--listFiles counts 104 files under src/__tests__/, the new suite among them) — so the typecheck covers the tests rather than advertising it. pnpm --filter '@objectstack/service-analytics^...' build :: exit 0 (dependency closure, ran BEFORE any judgement so no stale dist/*.d.ts could lie in either direction). pnpm lint (eslint . --no-inline-config, WHOLE REPO, no narrowing) :: exit 0. node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0, verdict line verifies all four runtime-interface-only predicates against analytics-service.ts#AnalyticsServiceConfig (interface). Heavy runs went through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-16206-dev set before the first attempt; every verdict read off the printed 'VERDICT command-exit' line, never a bare $?; the lock was free on arrival (waited 0s). ABLATION: see reproduction_red_green — rebuild not applicable (relative source import, no dist on the resolution path), on-disk mutation proved by occurrence counts and git hash-object, restore proved by state.",
      "docs_drift": "Re-derived on 7a9f32c5ac401febed83434aacc111d63dcd24ba (the merge of f00bd2e50 into c7af6bd6f9) in a DETACHED worktree at that commit — not on a worktree cut from an older main, which would be a different tree rather than a wrong row. Reproduces exactly: 12 anchors, 1 hand-written page, 0 release-owned. VERDICT on content/docs/plugins/packages.mdx: NOT FALSIFIED. The anchor is correct (AnalyticsServicePlugin is genuinely in this diff, via plugin.ts), but the page names it only at :502 and :506 inside one composition example whose options are ELIDED — 'new AnalyticsServicePlugin({ /* … */ })'. Falsifying shape named: a sentence or sample on that page naming an AnalyticsServicePluginOptions member, passing a sqlDialect value, or stating 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. EMITTER-BLIND HALF, hand-read: searched content/docs/** for 'sqlDialect', 'sqlite3'/'better-sqlite3', and every 'dialect' occurrence outside releases/, then read the analytics- and driver-facing pages. (1) 'sqlDialect' appears in ZERO pages, and in zero lines of this package's README.md — the hook is documented nowhere, so there is no page stating the rule by its inputs to falsify. (2) content/docs/data-modeling/drivers.mdx:100 lists 'sqlite | sqlite3' as accepted spellings of the DRIVER CONFIG's driver key — a driver-owned surface this diff does not touch; it stays true, and it reads as CORROBORATION of the card's premise rather than drift: the platform's own docs teach a host that sqlite3 is the spelling, one layer down. (3) drivers.mdx:76, :86, :356-:411 and plugins/packages.mdx:158 are MySQL dialect caveats and knex client names — driver config again, untouched. (4) protocol/objectql/query-syntax.mdx:509, :532, :1025-:1026 say 'on every dialect' about the $field compiler's backend coverage, not about this hook's accept set — and the runtime accept/reject behaviour is byte-identical here. (5) references/api/analytics.mdx:92 uses 'dialect' in the retired-shim sense, unrelated. ⇒ NO PAGE IS FALSIFIED. ⛔ Nothing under content/docs/** was edited. The checked set is named above and in the PR body so nobody re-reads it.",
      "file_surface_deviation": "packages/services/service-analytics/src/plugin.ts (+16/−2), same package, outside the dispatched surface — FORCED, and minimal. Cause: SqlDriver.dialectName is a FOUR-name vocabulary whose fourth name is 'unknown', that driver's own 'I cannot say' (what it returns for a client it does not model — 'mariadb', left unrecognised on purpose by #11756). The bridge passed it through verbatim, so once the config hook declares a THREE-name accept set, that residue arrives looking like a considered out-of-contract answer and every such deployment carries a false warning about a driver behaving correctly. Without the change 'pnpm --filter @objectstack/service-analytics typecheck' is RED: 'plugin.ts(1060,7): error TS2322: Type (objectName: string) => string | undefined is not assignable to type (object: string) => \"sqlite\" | \"postgres\" | \"mysql\" | undefined'. MINIMUM CONFIRMED: of the 18 changed lines, exactly THREE are code — one import, one return-type annotation, one return expression (asAcceptedSqlDialect(...)); the other 13 are the docblock naming the fifth tier. The three alternatives are each worse: not narrowing the type disobeys the ruling; casting with 'as' makes every unknown-driver deployment emit a false warning; adding 'unknown' to the accept set is option-B-adjacent and the ruling names three. ⛔ Not convenience. driver-sql was read and cited, never edited; src/dimension-labels.ts and the two __tests__ files PR #17470 holds were not touched; packages/spec is untouched.",
      "mcp_calls": "0 — zero MCP GitHub calls for the whole run. Card body and its three older comments came from the public-repo web payload channel (zero quota); the claim comment, the PR create/patch, the label POST, the label read-back, the PR-body read-backs, the docs-drift comment and PR #17470's file list all went through the container's REST channel, probed green at the start of the run with a repo-scoped read (http=200).",
      "platform_note": "⚠️ The PR body was created with the session-URL footer and survived byte-exact (stored == sent minus the trailing newline). The later PATCH that appended the docs-drift section also survived byte-exact, but the platform appended a SECOND, bare footer block ('_Generated by [Claude Code](https://claude.ai/code)_') on the edit, so the body now carries two. ⛔ Not re-posted or looped over: a further PATCH would land a third. Reported instead.",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: DatasetScopedStrategyContext.sqlDialect (src/strategies/types.ts:97) stays 'string | undefined' deliberately — it is the internal runtime-honest seam carrying whatever the host actually answered, and declaring it narrowly there would be a lie about runtime. Successor: whichever PR next moves strategies/types.ts; the reasoning is in the new sqlDialectFor docblock.",
        "noted, not filed: normalizeSqlDialect still answers 'unknown' for a wrong answer and for no answer alike, and that stays correct AT THAT CALL SITE — by the time a compiler asks, the only honest reading of an unmodelled name is 'no arm for this'. Successor: none needed; the reasoning is in the function's own docblock.",
        "noted, not filed: AnalyticsServiceConfig.sqlDialect is a published extension point with ZERO prose coverage in content/docs/** and none in the package README. An observation, not a falsified page and not a reproducible defect, so it is not a card by the three-class test. Successor: none currently queued.",
        "NOT A NEW CARD, but the one finding that needs your action: the wrong-rows measurement above belongs to #15684 (already open, the unknown arm's ASCII fold) and to THIS card's grade. ⛔ Not filed as a duplicate of an open card, and ⛔ not fixed here."
      ]
    }
    

    Generated by Claude Code

  11. os-sales commented on Sep 10, 2026

    @os-sales
    Collaborator

    Regrade executed: priority:p3 → priority:p2 (2026-09-10T17:47Z, label write read back: priority:p2, pm:dispatched, domain:services, needs:contract-review).

    This is the ruling's own stated condition firing, not a seat judgement. Director batch #88, comment 5582368838, verbatim:

    Grade stays priority:p3; ⚠️ the case-exact family on the unknown arm (#15684, ASCII case folding on SQLite) is unmeasured — the delivering seat drives it once through a host answering 'sqlite3'; if rows come back wrong, regrade to p2 on this card before landing.

    The condition is met. The delivering dev drove it: 6 case-exact cases driven, 5 WRONG on the sqlite3-answering host, 0 wrong on the control. The control is the same service composed identically but answering 'sqlite' — the two hosts differ in ONE character of one string, so the wrongness is attributed to the dialect answer and to nothing else. Direction worth naming: $notContains drops row 1 from a set that should contain it, so the over-match becomes an under-match.

    Correction to the report's one wrong premise

    The report placed the wrong-rows finding on #15684, "already open". Measured at 2026-09-10T17:46Z: #15684 is CLOSED — closed_at 2026-09-05T09:08:31Z, state_reason: completed, closed by merged PR #15790. The finding has no open home there. It does not need one, and the reason is written on origin/main in the file this card edits.

    packages/services/service-analytics/src/text-match-sql.ts, normalizeSqlDialect's own docblock:

    Anything unrecognised — including undefined from a host that wired no hook — is 'unknown', which compiles the pre-#15684 LIKE.

    KNOWN_DIALECTS is {'sqlite', 'postgres', 'mysql'}. 'sqlite3' is not in it. So a host answering 'sqlite3' is routed to the unknown arm and is handed the pre-#15684 LIKE back: #15790 fixed the three compilers, and an unrecognised dialect answer walks around that fix rather than regressing it.

    ⇒ The wrong rows are this card's own consequence, not a residue of #15684. No new card is owed, #15684 stays closed as completed, and this card carries the correctness claim. That is also why the grade moves: the change under review is a p2 correctness fix, not a p3 vocabulary tidy.

    The acceptance bar is unchanged; only the grade and the claim on the card move.


    Generated by Claude Code

  12. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 10, 2026
  13. os-sales commented on Sep 10, 2026

    @os-sales
    Collaborator

    Clause-② contract review PASS — both carriers cleared (2026-09-10T17:53Z).

    Provenance, so nobody has to re-derive whether the label left or was stripped:

    record   : PR #17496 comment 5623044468 — "## Contract review", ①②③ + independence pair + PASS
    head judged : f00bd2e50046ead4250d14ac0b894bd79d13fda4
    declaration : Clause-②: yes  (decision batch #88, comment 5582368838 — the ruling's own, not this seat's reading)
    carriers cleared, same stroke, both read back:
      PR   #17496 → documentation, size/l, tests, tooling
      card #16206 → priority:p2, pm:dispatched, domain:services   (assignee os-sales intact)
    tier     : default judgement — a non-spec seat's Clause-② review is self-review plus gates by design
    

    The yes was right on its own mechanical footing as well as by the ruling: the diff adds four exported symbols to packages/services/service-analytics/src/text-match-sql.ts, and a new exported symbol is always yes.

    Landing now waits on pre-check ③ alone — every check green on this head, not a required subset. ⛔ The PR is not flipped ready and not armed until then.


    Generated by Claude Code

  14. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions