ktx/spider2-specs/specs/08-per-dialect-sql-syntax-notes.md

396 lines
23 KiB
Markdown
Raw Normal View History

feat: ktx batch — scan resilience, analytics SQL craft, connector hardening (#312) * docs: add spider2-specs handoff directory for benchmark-driven feature specs * feat(cli): connection-scoped wiki pages Add an optional `connections` frontmatter field so database-specific wiki knowledge can be scoped to a connection without polluting searches about other databases, while page keys stay a flat, globally-unique namespace. - connections: single string or list; absent/empty ⇒ unscoped (applies to all) - wiki_search (MCP) and `ktx wiki --connection` return unscoped ∪ matching pages, filtered at the disk-load seam so all three search lanes draw their candidate pool from the already-scoped set (not a post-filter) - wiki_write accepts connections with REPLACE semantics and rejects a connection-scoped write whose key collides with a disjoint-connection page (data-loss guard; hard error, no silent clobber) - explicit connection-id args (wiki_search, memory_ingest, ktx wiki) are validated against ktx.yaml via a shared assertConfiguredConnectionId, which also closes the prior gap where memory_ingest's connectionId was unvalidated; persisted ids absent from config warn (not fail) in `ktx status` - prompt guidance in the wiki_capture skill and external-ingest prompt; the session connectionId is surfaced to the memory agent and ingest work units Implements spider2-specs/specs/01-connection-scoped-wiki.md; intake draft moved to spider2-specs/done/. * docs(spider2-specs): add specs/ refinement stage and composite-key join spec Describe the todo/ → specs/ → done/ pipeline in the README (refined specs are the durable artifact; intake drafts move to done/ on ship) and add a MEDIUM-priority spec for multi-column composite-key join detection found during the first sqlite smoke test. * feat(cli): add --verbatim ingest mode for authoritative documents Store each --text/--file document body unchanged as a GLOBAL wiki page instead of routing it through the memory agent, which may rewrite, condense, or re-title it. The LLM derives only metadata (summary, tags, sl_refs) and only for frontmatter fields the document does not already set; the stored body is written by code and never edited. - Deterministic page key: files derive it from the filename, inline text from its leading Markdown heading (headless inline text is rejected — pass it as --file instead). - Idempotent: re-running the same body is a no-op; a different body at the same key fails loudly rather than overwriting. - Works with llm.provider.backend: none, deriving a degraded summary from the heading or first sentence. - Existing frontmatter (including unmodeled fields like effective_date) passes through untouched; --connection-id scopes the page. * feat(cli): SQL-authoring craft and per-dialect notes tool for the analytics skill Spec 07: add a dialect-agnostic <sql_craft> block to the ktx-analytics skill (schema discovery, composition, window-function correctness, numeric precision, answer completeness) with one worked window-then-filter example. Workflow steps gain pointers into it; existing guidance is unchanged. Spec 08: add a read-only sql_dialect_notes MCP tool returning a connection's engine SQL conventions (FQTN form, identifier quoting/case, date/time, top-N idiom, JSON access), resolved through the existing sqlAnalysisDialectForDriver path. Notes are per-dialect markdown files under context/sql-analysis/dialects, served by the tool and copied to dist (package-internal, never installed). Non-SQL connections return a clear KtxExpectedError. The flat skill gains a one-line pointer to the tool. Both spider2-specs intake drafts move to done/ with implementation notes. * feat(cli): tolerate objects that fail introspection during scan Isolate per-object introspection failures so one broken or inaccessible object no longer zeroes out a connection's whole semantic layer: the sqlite and bigquery connectors introspect each object defensively (tryIntrospectObject), the live-database adapter records a scan outcome and fetch report, and enabled_tables accepts catalog.db.name, db.name, or bare names with a clear no-match error. Includes matching ktx-daemon introspection changes, docs, and tests. * docs(spider2-specs): add 06-scan-tolerate-broken-objects spec * feat(cli): generalize analytics fan-out rule to multi-hop join chains The ktx-analytics skill's fan-out rule only reliably caught single-hop inflation; agents still silently fanned out on multi-hop chains where the offending one-to-many join sits several hops below the SUM/COUNT and is easy to miss. Rewrite the Composition rule so the danger reads as cumulative across the whole chain (pre-aggregate per measure-owning table), add an affirmative grain-verification habit (default: pre-aggregate to grain; escape hatch: COUNT(DISTINCT key) for pure counts only; SUM/AVG of a fanned-out measure must pre-aggregate), and add one generic wrong-vs-right worked example. Content-only and dialect-agnostic; no new tool, flag, or config. Implements spider2-specs/specs/09 and annotates spec 07's one-example constraint as superseded. * feat(cli): add panel-completeness, time-series window, and text-encoded numeric SQL craft Extend the analytics skill's <sql_craft> with three correctness habits and route the dialect-specific halves through sql_dialect_notes: - Panel completeness (spec 10): full-domain spine -> LEFT JOIN -> COALESCE for "each/every/all/per" questions, defaulted by measure additivity. - Time-series windows (spec 11): explicit cumulative frames, calendar-range rolling windows with minimum-periods guards, and period-over-period via LAG. - Text-encoded numerics (spec 12): sample distinct values, strip/scale/cast in one early CTE, and confirm coverage with a failure-detecting cast. Add per-dialect Series, Rolling window, and Safe cast notes to all seven dialect files so the skill stays dialect-agnostic while the engine-specific syntax lives in sql_dialect_notes. Tests updated and passing (19). * docs(spider2-specs): add specs 10-12 for analytics SQL-craft additions Refined specs and completion records for the panel-completeness spine (10), time-series window recipes (11), and text-encoded numeric parsing (12) implemented in the preceding commit. * docs(spider2-specs): add backlog intake drafts 13-14 - 13: canonical authoritative-source measures - 14: output-completeness final check * skill(analytics): spec 14 output-completeness + iter1 (active column planning) Bundles two changes (entangled in SKILL.md; future spider2 iterations land as separate commits): - spec 14 (output-completeness): multi-part "answer every requested output" rule + a "Final completeness check" in workflow Step 6 and <sql_craft>; analytics skill-content test updated; intake draft -> done/, refined spec added. - iter1 experiment: spec 14's passive end-check did not change behavior on the benchmark's output-completeness failures, so (a) the Plan step now writes the exact output-column list UP FRONT as a contract the final SELECT must match, and (b) "expose identity" -> "project BOTH the entity id and its name" (covers both omission directions). All generic craft. Driven by the Spider 2.0-Lite failure analysis (incomplete output was the largest failure bucket); benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): iter2 — deterministic order in string/array aggregation GROUP_CONCAT/string_agg/array_agg element order is undefined without an explicit ORDER BY; also note SQLite's default text sort is binary/case-sensitive (uppercase before lowercase) vs case-insensitive (COLLATE NOCASE). Generic SQLite craft. Spider 2.0-Lite motivation: an ordered-ingredient-list question failed only on the within-string element order (right elements, wrong order); benchmark as motivation only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mcp): structured, leveled logging for the MCP server Add one synchronous pino logger per MCP server process, written through the io.stderr sink: plain JSON when stderr is not a TTY, colorized pino-pretty (sync, in-process) when it is. Every tool call logs tool.start with its raw params BEFORE the handler runs and tool.end after (info / warn past KTX_MCP_SLOW_TOOL_MS / error), correlated by callId plus sessionId, so a runaway sql_execution leaves a recoverable start line with its exact SQL and no matching end. HTTP logs session.open/close and wires the previously-dead transport.onerror to transport.error; stdio routes its transport error through the logger. Level via KTX_MCP_LOG_LEVEL (default info). Existing mcp_request_completed telemetry and registerParsedTool are unchanged; no worker/async transport and no redaction in v1 (logs are local-only). Implements spider2-specs/specs/15-mcp-server-structured-logging.md and moves the intake draft to done/. * feat(mcp): report uptimeMs in MCP server /health The /health endpoint now includes uptimeMs (monotonic elapsed time since the server started), mirroring the Python daemon's uptime_ms telemetry field. * feat(cli): bound read-query execution with a per-connection deadline Enforce one shared query deadline (default 30s, overridable per connection via query_timeout_ms) on every executeReadOnly path, so an accidentally-expensive LLM-authored query returns a fast "query exceeded Ns" KtxQueryError instead of hanging the MCP server. - New shared contract context/connections/query-deadline.ts (resolveQueryDeadlineMs, queryDeadlineExceededError); query_timeout_ms added to the shared warehouse schema; BigQuery's job_timeout_ms removed. - SQLite runs the read query in a short-lived forked child process and enforces the deadline with SIGKILL. worker_threads + terminate() was tried first but cannot interrupt a synchronous better-sqlite3 scan (the native loop never yields); SIGKILL reclaims the process in ~2ms and keeps the event loop free. - Remote connectors apply a real server-side statement timeout and re-wrap their own timeout signal as KtxQueryError: Postgres statement_timeout/57014, MySQL max_execution_time/3024, Snowflake STATEMENT_TIMEOUT_IN_SECONDS/604, ClickHouse max_execution_time + aligned request_timeout/159, SQL Server requestTimeout/ ETIMEOUT, BigQuery jobTimeoutMs. - Relationship validation skips a candidate to review on a deadline timeout instead of aborting the pass; the deadline surfaces through the existing MCP pino logger as a matched tool.start/tool.end(error) pair (no new logging code). Also fixes a pre-existing, unrelated invalid cast in mcp-server-factory.test.ts that was breaking tsc -p tsconfig.test.json. * docs(spider2-specs): mark spec 16 (bounded query execution) done Append Implementation notes to the refined spec (what shipped, where, and the worker-thread -> child-process+SIGKILL deviation with its evidence) and move the intake draft from todo/ to done/. * skill(analytics): iter3 — measure-as-amount, inter-event gap, top-per-metric career Three generic interpretation rules: a named business measure (sales/revenue/spend) means its amount not a row count; "inter-event duration/gap" is LAG/LEAD time-between events not a magnitude column; "highest across several achievements" aggregates per metric over the whole history. All three demonstrably FIRE (verified on local008/003/152 SQL). local008 flips to correct (mechanism-aligned). 003/152 still fail on a different axis (source-column / grouping). Generic craft; benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): spine-for-extreme-selection + aggregate-over-selected-set Two generic answer-completeness refinements: - Selecting the extreme group (lowest/highest count over a period/category domain) must rank over the COMPLETE spine, not only groups with fact rows — an empty period is a genuine 0 and often the true minimum. - An aggregate scoped to a per-entity selected set ('avg revenue per actor in those top-3 films') is computed ACROSS that set, distinct from the per-item value; project both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter2 — sharpen extreme-selection spine + top-N ranking-measure - spine-for-extreme: concrete cue that a zero-row period never appears in a GROUP BY of the facts; generate the full calendar, LEFT JOIN, COALESCE, then rank. - aggregate-over-selected-set: top-N selection ranks by the named ranking measure (the item's own revenue), independent of the per-item share that feeds the aggregate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter3 — comparison-between-two-extremes is one wide row Distinguishes a cross-item comparison ('the difference between the highest and lowest month' -> single wide row, both extremes side by side + the comparison column) from 'report a metric for each group' (-> stays long). Generic, question- derived; targets the wide-vs-long shape gap without affecting per-group long output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter4 — anchor a period bucket to the named lifecycle event When a record carries multiple lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a named *completed state* by period ("delivered orders by month", "shipped items per week"), bucket the period by that named event's own timestamp, not the record-creation timestamp; the state value is the qualifying filter, the matching timestamp is the time anchor. Wording priority is explicit — purchased/placed/ created/submitted/ordered keep the start-event timestamp — and a non-temporal state filter (counts by customer/city/seller with no period) introduces no anchor. Generic analytics craft: counting completed-state records by their creation date silently answers "records that later reached that state, grouped by when they started" instead of the question asked. Surfaced via the spider2-autofix loop; FAIR_PRODUCT (adversary-screened, restatable from question wording + schema/ semantic-layer lifecycle descriptions, no gold dependency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter5 — canonicalize observed URL-path variants before page-level analysis When a question groups/filters/sequences web pages by a path/url column, sample its distinct values; if the data itself shows /route and /route/ variants for the same page context, canonicalize in an early CTE (preserve / as root, strip trailing slashes from non-root paths, map an observed empty path to / only when the column is a URL path with blank root-page events) and use the canonical path everywhere above. Explicitly forbids inventing aliases the data doesn't show: no merging different route names, no stripping query/fragment/host/scheme, no lowercasing, and no canonicalization when the question asks for raw URL/path or slash-vs-no-slash diffs. Generic web-analytics craft: raw request logs routinely store the same user-visible page with and without a trailing slash, so grouping raw labels silently splits one page into several. Surfaced via the spider2-autofix loop (Codex runner, round r2); FAIR_PRODUCT (adversary-screened, restatable from URL-path semantics + page-grain question wording + solver-observed distinct values, no gold dependency). The rule fired mechanism-aligned on both targets; flipped local330 (landing/exit page counts), local331 residual is a separate sequence-semantics axis beyond canonicalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter6 — coverage over a selected group is a set-membership aggregate When a question first selects a group of entities ("the top 5 actors", "these products") and then asks what count/share/percentage of a DIFFERENT subject domain relates to *these* selected entities ("what % of customers rented films featuring these actors"), the subject set is the UNION across the whole group: count DISTINCT subject ids once across the selected entities and return one collective value at the subject-domain grain — not one row per selected entity (which double-counts subjects related to more than one entity and answers a different question). Narrowly guarded: emit one row per entity only when the wording says "for each / per / by / list" or asks for each entity's own metric ("top 5 players and their batting averages"). The collective-coverage cousin of the existing per-entity selected-set rule. Generic analytics craft (per-entity metric vs set-level coverage). Surfaced via the spider2-autofix loop (Codex runner, round r3); FAIR_PRODUCT (adversary-screened, restatable from wording alone, no gold dependency). Flipped local195 mechanism-aligned (union COUNT(DISTINCT customer)/total, one scalar); 0 regression across 5 passing per-entity top-N guards (local023/024/029/212/221 stayed long). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): label-only joins must LEFT JOIN — incomplete dims silently drop fact rows Mirror of the existing fan-out rule for the DROP direction: an inner JOIN to a dimension table used only to attach a display attribute silently discards every fact row whose key has no parent when the dimension is incomplete (trimmed catalogs, late-arriving / SCD-gap rows), shrinking counts/sums and the universe over which shares/averages/medians are computed. Guidance: LEFT JOIN pure enrichment; inner-join a dimension only when intended as a filter; key the aggregate/GROUP BY on the fact column, not the dimension column. Spider2 autofix round 'joindim': flips complex_oracle local050 (FAIL->PASS, official scorer) — solver dropped the gratuitous products inner-join and recovered the exact gold. local060/063 also adopt LEFT JOIN (rule fires) but remain gold-convention-blocked. Guards local061/067 held. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spider2-specs): add todo/17 — lifecycle-event metrics (semantic-layer) Draft intake spec surfaced by the spider2-autofix loop (round r1): the model-layer form of the shipped iter4 lifecycle-date-anchoring skill rule — infer per-state lifecycle-event metrics (e.g. delivered_orders with defaultTimeDimension = the delivery timestamp) during enrichment so the correct time anchor is the default for any consumer, not only an agent that loaded the skill. Generic; FAIR_PRODUCT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(connectors): accept leading underscore in connection/identifier ids The safe-identifier validator regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ allowed an underscore everywhere except the first character, so a connection id / database name that legitimately starts with '_' (valid in Snowflake, e.g. _1000_GENOMES) could never be ingested or queried. Allow a leading underscore across all 16 duplicated validators (connection ids, source ids, page/wiki keys, warehouse- verification tool schemas). Path-safety is unaffected — '.' and '/' remain excluded, and assertSafePathToken still blocks traversal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): generic geospatial query guidance Add a Snowflake ST_* dialect note (ST_MAKEPOINT lon-first, ST_DWITHIN/ST_CONTAINS/ ST_WITHIN/ST_INTERSECTS, bbox->polygon via ST_MAKEPOLYGON/ST_MAKELINE) and a dialect-agnostic 'Spatial predicates' recipe in the analytics skill (resolve the entity geometry, build an area-of-interest polygon, test with the engine's containment/proximity/overlap predicate; mind lon/lat argument order). Steers the solver off hand-rolled lat/lon BETWEEN boxes toward correct, index-assisted geospatial predicates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): parse code/dependency text by language grammar Add two generic <sql_craft> rules: (1) parse imported/required/loaded packages by the language or manifest format (Java import keep-package-path allowing underscores/ mixed-case; Python import/from + alias stripping; R library/require; .ipynb parse JSON cell source before language rules; JSON manifests flatten the dependency object keys), stripping comments/prose and splitting multi-import lines; (2) on a de-duplicated table with a documented copy/occurrence count, choose COUNT(*) vs the weight column from the population the question names, not silently. Steers off one broad regex that drops valid identifiers and matches prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): source filters/dates/measures from the owning fact grain Add a <sql_craft> rule for joined fact tables at different grains (parent order vs child line item): read each predicate, calendar bucket, and measure from the table whose grain the question names, not whichever is in scope post-join. An order-grain filter ("orders that are Complete", "the order's creation date") must come from the parent even though the child carries its own status/created_at; line price/cost come from the child. Mirror at metric grain: don't combine a parent-grain count with child rows (num_of_item * SUM(line_price) per line) — aggregate each measure at its own grain before combining. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): collapse multi-valued classes to one representative per entity before counting/concentration When an entity carries a multi-valued classification array (IPC/CPC codes, tags) and the methodology counts entities-per-class or a concentration/diversity metric (HHI, originality, share), pick ONE representative per entity first (the array's main/primary/first flag, else a defined fallback like most-frequent), then aggregate; and use COUNT(DISTINCT entity) when the denominator is defined as a count of entities. Unnesting the array otherwise multiplies an entity's weight by its code count, inflating per-class frequencies and skewing the ranking/score. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connectors): introspect BigQuery datasets hosted in foreign projects A dataset_ids/dataset_id entry may now be written `project.dataset` to introspect a dataset hosted in another project while query jobs still bill to credentials.project_id. Entries are parsed once at the config boundary into canonical {project, dataset} pairs; introspection, primary-key discovery, testConnection, getTableRowCount, and listTables (grouped per project) all resolve in the dataset's own project, and scanned tables are labeled with that project so sampling, distinct-value, and read queries resolve. Bare entries are unchanged. Implements spider2-specs/specs/18-bigquery-cross-project-datasets.md. * feat(scan): durable, resumable, bounded relationship detection during enrichment Move the enrichment persistence boundary to the cost boundary and bound the open-ended relationship stage (spec 19). - Checkpoint descriptions + embeddings into the queryable `_schema` manifest (and the raw enrichment artifacts) before relationship detection runs, via a new `onCheckpoint` hook + `writeLocalScanEnrichmentCheckpoint`. An interrupted, budget-truncated, or failed relationship stage now degrades to "no joins", never "no descriptions". - Resume the enrichment cache by content identity: re-key the SQLite stage store on `(connection_id, stage, input_hash)` so a re-run with a fresh runId resumes finished descriptions/embeddings instead of re-paying for LLM work. The disposable cache recreates its table if the on-disk key shape differs. - Make the relationship stage observable and bounded: a sticky wall-clock budget (`scan.relationships.detectionBudgetMs`, default 600000 ms) + per-unit progress + honored `ctx.signal`, threaded through profiling, validation, and composite detection. On exhaustion/abort it stops scheduling, finalizes, and returns a partial result instead of throwing or hanging. - Mark a budget/abort-truncated result partial (diagnostics `partial`/`partialReason` + recoverable `relationship_detection_partial` warning). A graceful partial saves as a completed stage and resumes cheaply; raising the budget changes inputHash and forces a fresh, fuller run. A process killed mid-stage saves nothing. Document `detectionBudgetMs` in the ktx.yaml reference. Append implementation notes to specs/19 and move the intake draft to done/. Also carries the in-tree per-table enrichment LLM timeout work it builds on (`description-generation.ts` + the `enrichment_timeout` warning code), which is intertwined in `local-enrichment.ts`/`types.ts` and cannot be split into a separately-building commit. * feat(scan): bound + retry the per-table enrichment LLM call The batched table-description call had no retry (sampleTable retried 3x, this did not), so a single transient backend error (e.g. an overloaded/burst rejection when many tables enrich concurrently) silently nulled a whole table's descriptions — observed dropping ~70% of a db's tables during a bad window despite ample quota. - Wrap generateObject in retryAsync (3 attempts + backoff; KTX_ENRICH_LLM_ATTEMPTS). - Fresh per-attempt timeout (KTX_ENRICH_LLM_TIMEOUT_MS, default 120s) still bounds a wedged wide table; a timeout is surfaced as KtxAbortedError so it is NOT retried (one wedge stays one timeout, not 3x). - Granular per-table progress + start/done/retry/timeout logging. Composes with spec 19 (its non-goal #1): spec 19 makes completed descriptions durable; this makes more of them complete. * feat(scan): survive a hung LLM enrichment backend and resume descriptions Two compounding failure modes on the per-table description-enrichment path (spec 20): Enforced per-table timeout for subprocess backends. The runtime declares whether it owns an SDK subprocess (subprocessForkSpec on KtxLlmRuntimePort); codex/claude-code calls run behind a ktx-owned detached child that is tree-killed (SIGKILL of the process group on POSIX, taskkill /T on Windows) on the deadline or ctx.signal, reaping the wedged model grandchild. HTTP backends keep native fetch abort. Default stays 120s, one-wedge-one-timeout. Incremental, resumable descriptions persistence. generateDescriptions flushes enriched tables per batch to an inputHash-tagged durable record (at a stable, non-syncId path) plus only the changed manifest shards, skips already-enriched tables on resume, and never lets one table's failure discard the stage (a skipped table costs one missing description, not the whole stage's output). Spec 20 refined + intake draft moved to done/. * feat(scan): selective enrichment stages (--stages) + per-stage cache keys Split the single coarse enrichment cache key into per-stage hashes (descriptions <- snapshot + LLM identity; embeddings <- snapshot + embedding identity + description digest; relationships <- snapshot + relationship settings + LLM identity), so changing one stage's inputs invalidates only that stage and never throws away the expensive per-table descriptions on an unrelated edit. Add `ktx ingest --stages <list>` to force-re-run a chosen subset on an already-ingested connection: a named stage bypasses the completed-stage short-circuit while the per-table descriptions resume record still skips already-enriched tables, and unselected stages are left untouched on disk. Feed embeddings + relationships their description context from the on-disk _schema when descriptions do not run this invocation, and carry descriptions into the llmProposals evidence packet (closing a latent gap on the full-run path too). Surface an enrichment_stage_stale warning when an unselected stage's inputs have drifted, rather than silently cascading the work. Implements spider2-specs/specs/21-selective-enrichment-stages.md. * test(analytics): realign SKILL.md acceptance test with the evolved skill Three assertions in analytics-skill-content.test.ts drifted from the analytics SKILL.md as later iterations edited the skill without updating the test: - the sub-heading was renamed Window functions -> Ordering & aggregation determinism (iter2), so follow the source name; - the rule "Expose identity, not just the label" was renamed to "Project BOTH identity and label" (spec 14), so match the new wording; - the dialect-FQTN guard false-positived on the Java package example com.planet_ink.coffee_mud, whose backticks made a 3-segment package path read as a BigQuery/Snowflake `a.b.c` table reference. Drop the backticks so the guard stays at full strength without weakening it. * fix(scan): --stages subset must not delete unselected stages' on-disk artifacts A --stages subset that omitted descriptions wiped all on-disk ai/db descriptions from the written _schema. runLocalScan writes the structural manifest shard from the bare snapshot BEFORE enrichment runs, and the shard merge treats ai/db as scan-managed and overwrites them with whatever the run emits — none, on a subset that skips descriptions. Enrichment then read the already-wiped shard via loadPriorDescriptions and had nothing to restore. runLocalScanEnrichment now returns the best-available descriptions (fresh-this-run if descriptions ran, else loaded from the on-disk _schema) instead of [], and runLocalScan captures the prior descriptions before the structural write and feeds them to both the structural write and enrichment, so an unselected stage's artifacts survive. Joins were already preserved for --stages descriptions via the manual/inferred preservedJoins path. Tests: a full runLocalScan --stages relationships path test (RED without the fix, GREEN with it — the earlier unit test missed the structural-pre-write ordering), plus enrichment-layer contract tests for both directions. Validated live on northwind: --stages relationships keeps all 110 descriptions + 22 joins (was wiping to 0); --stages descriptions restores descriptions from the spec-20 resume record (no LLM calls) while keeping joins. * feat(dialects): bigquery nested-data (ARRAY/STRUCT/UNNEST), geospatial (GEOGRAPHY), SAFE_DIVIDE bigquery.md lacked the two sections that define BigQuery analytics (present in snowflake.md): - Nested & repeated data: UNNEST to flatten arrays of STRUCTs (GA360 hits, GA4 event_params), dot-notation field access, key-value param scalar-subquery extraction, fan-out/COUNT(DISTINCT) guard. - Geospatial (GEOGRAPHY): ST_GEOGPOINT (lon-first), containment/proximity/distance/intersection predicates, areal allocation via ST_AREA(ST_INTERSECTION()). - SAFE_DIVIDE for zero-denominator-safe rates; sharded-table shard-presence note. Generic BigQuery craft surfaced by sql_dialect_notes; product-completeness (any BQ analyst benefits). * feat(dialects): sqlite ROUND half-up FP-underflow note (+1e-9 before ROUND) SQLite ROUND(x,n) rounds half-away-from-zero, but binary FP stores an exact half-way value just below it, so ROUND(6.475,2) returns 6.47 not 6.48. Add a dialect note: nudge by a tiny epsilon (1e-9) below display precision before rounding for deterministic half-up, leaving non-boundary values unchanged. Generic SQLite craft surfaced by sql_dialect_notes (any analyst rounding a displayed average/rate/price benefits). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(analytics): list-as-delimited-string, answer-literally, drop free-text columns Add SKILL.md guidance to emit list-valued answer cells as delimited STRING (not ARRAY/repeated column), answer the literal ask without unrequested transformations (HAVING for aggregate bounds), and avoid projecting unrequested free-text columns that corrupt row-delimited output. * fix(scan,mcp): gitignore runtime logs, budget-guard LLM proposal, validate enrich timeout - gitignore `.ktx/logs/` in both scaffold + setup-merge lists: the managed MCP daemon writes raw tool params (SQL, memory_ingest content) to mcp.log under a version-controlled `.ktx/`, and snowflake.log already sat there unprotected. - gate the LLM relationship proposal on the detection budget/abort signal so an exhausted or aborted stage cannot start a fresh LLM call; document the boundary. - validate KTX_ENRICH_LLM_TIMEOUT_MS (NaN/0 → 120s default) like enrichAttempts, so a bad value no longer times out every table immediately. - daemon introspection now warns on malformed column/FK rows instead of dropping them silently, matching the table-row path and the "surface broken objects" goal. - docs: document `ktx wiki -c/--connection`; fix the SQLite query-deadline schema doc (forked-subprocess SIGKILL, not worker-thread termination). * fix(scan,wiki,mcp): address PR #312 review findings - scan: key the description pipeline (resume map, enriched-schema and embedding-text lookups, manifest write/read) by full table identity via tableRefKey/buildTableRef, so two same-named tables in different schemas no longer cross-assign descriptions or skip a sibling on resume - scan: re-throw a genuine context cancel during the batched description LLM call so Ctrl-C resumes the stage instead of nulling tables and recording it completed; per-table timeouts still degrade (context.signal not aborted) - scan: report statisticalValidation 'skipped' (not 'completed') when a budget/abort stop leaves relationship profiling partial - wiki: sync the full page corpus into the sqlite index and filter only the candidate/result set, so a connection-scoped search no longer prunes other connections' pages and cached embeddings from the shared index - wiki: route verbatim ingest through the canonical writePageAndSync so contentHash is set and later syncs can short-circuit - mcp: drop the as-unknown-as cast in serializeMcpError - dialects/analytics: document the integer-division trap on postgres/sqlite/tsql Adds regression tests for each behavior change. * fix(wiki): scope connection filter before SQLite lane limit Connection-scoped wiki search applied the connectionId allowlist after the lexical/semantic lanes had already truncated to laneCandidatePoolLimit over the full (connection-agnostic) corpus. When the requested connection was a minority of a large corpus, its pages were crowded out of the candidate pool before filtering, so a semantic-only match could be missed outright and lexical hits under-ranked. Push the path allowlist into searchLexicalCandidates/searchSemanticCandidates so LIMIT applies to in-scope rows, matching what the token lane already did, and drop the now-redundant post-limit JS filters. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:35:57 +02:00
# Per-dialect SQL syntax notes, served on demand and scoped to the connection
> Refined spec. Intake draft: `todo/08-per-dialect-sql-syntax-notes.md`. Companion
> to `specs/07-analytics-skill-sql-craft.md`, which kept the analytics SQL craft
> dialect-agnostic and explicitly deferred per-dialect syntax to this spec.
## Problem
Spec 07 added universal, **dialect-agnostic** SQL-authoring craft to the
`ktx-analytics` skill (`packages/cli/src/skills/analytics/SKILL.md`). That craft
deliberately excludes anything that reads correctly on only one engine — no
`QUALIFY`, no `strftime`/`julianday`, no backtick or `DB.SCHEMA.TABLE` FQTNs —
because the flat skill is installed verbatim and an agent querying sqlite must
never see Snowflake syntax.
But a large share of *real* correctness depends on exactly that excluded,
engine-specific syntax:
- **Snowflake:** `DATABASE.SCHEMA.TABLE` FQTNs, double-quoted case-sensitive
identifiers (unquoted folds to upper-case), VARIANT colon-paths
(`col:field.sub::type`), `QUALIFY`.
- **BigQuery:** backtick FQTNs (`` `project.dataset.table` ``), `_TABLE_SUFFIX`
for sharded/wildcard tables, `QUALIFY`, `JSON_VALUE`/`JSON_EXTRACT`.
- **sqlite:** `strftime`/`julianday`/`date()` for dates, no `QUALIFY`,
`json_extract`.
- and the remaining supported engines (`postgres`, `mysql`, `clickhouse`,
`sqlserver`/`tsql`), each with its own FQTN, quoting, date, top-N, and
JSON conventions.
This guidance is genuinely useful to an agent writing SQL against a live
database, but it must **not** pollute the flat dialect-agnostic skill. It belongs
in a **dialect-aware** channel, surfaced only for the dialect the active
connection actually uses, and selected from the project's own configured state —
not guessed, not shown all at once.
## Generic use case
Any **ktx** project whose connections span more than one warehouse engine — a
Snowflake warehouse plus a BigQuery export plus a local sqlite extract, say. When
the agent (or a human analyst the agent assists) writes SQL for a given
connection, it should receive *that engine's* syntax conventions — FQTN form,
identifier quoting, date functions, top-N idiom, semi-structured access — and
nothing for the engines it is not querying. The need is independent of any
benchmark: it is what "write correct SQL against this specific warehouse" requires
on every multi-engine stack.
## Model
The change adds a **dialect-aware channel** alongside spec 07's flat skill. The
following decisions are committed by this refinement; the implementer owns the
exact prose and code.
### Delivery: a dynamic MCP tool (decision committed)
The draft posed two delivery mechanisms and asked the refinement to "weigh them
before committing." This spec commits to **dynamic MCP delivery**: a new
read-only MCP tool returns the syntax notes for a given `connectionId`, with the
dialect resolved server-side from the connection's configured `driver`. The flat
skill gains a one-line pointer to that tool. **No install-mechanism change is
required.**
The alternative — **multi-file skill delivery** (bundle `reference/<dialect>.md`
files and point the skill at the matching one) — is **rejected** for **ktx**, for
reasons that hold regardless of how the skill is otherwise authored:
1. **It cannot scope on two of the six install targets.** Cursor
(`.cursor/rules/ktx-analytics.mdc`) and OpenCode
(`.opencode/commands/ktx-analytics.md`) are physically **single-file**;
`setup-agents.ts` flattens the skill to one file there. A bundled `reference/`
directory degenerates to "concatenate every dialect into one file," so a
sqlite agent would see Snowflake VARIANT syntax — **failing this spec's core
no-leak criterion on those targets**, and defeating progressive disclosure
(everything is in context at once). The MCP tool behaves **identically on all
six targets** because it is a tool call, not an installed file.
2. **Selecting the dialect is a deterministic operation, so it belongs in code,
not model judgment.** Anthropic's skill-authoring guidance explicitly says to
*"prefer scripts [tools] for deterministic operations."* With bundled files the
**model** must infer that connection X is Snowflake and open the right file —
and on a multi-connection project it can open the wrong one. With the tool, the
**server** resolves `driver → dialect` from `ktx.yaml` state and returns
exactly the right notes.
3. **It needs a delivery subsystem that the tool does not.** Multi-file delivery
requires reworking `readAnalyticsSkillContent`, `installTarget`,
`plannedKtxAgentFiles`, the install manifest (a directory variant),
`removeKtxAgentInstall`, and `writeClaudeDesktopSkillBundle`, plus a
concatenation transform for the single-file targets. The MCP tool requires one
read-only handler and one skill pointer.
4. **The dependency is free.** The `ktx-analytics` skill already hard-depends on
the **ktx** MCP server — its entire workflow is calling `discover_data`,
`entity_details`, `sql_execution`, and so on. Wherever the server is down, the
skill is already non-functional; the tool adds **no new dependency**.
5. **Dropping Cursor/OpenCode does not change this.** Removing those targets would
make multi-file delivery *possible*, but it would not make it better: reasons
24 stand, and the drop is a disproportionate cost (Cursor is a major target)
to neutralize a constraint the tool handles for free. Whether **ktx** supports
those targets is a separate product decision and is out of scope here.
This is consistent with Anthropic's progressive-disclosure goal — load the
relevant material on demand, at zero context cost until needed — which the tool
satisfies (its output costs context only when called) while resolving *which*
dialect from state rather than from a model guess. Reference:
[Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices).
### Scope derived from state, through the one existing resolver
Which dialect's notes the agent sees is **derived** from the connection's
configured `driver`, via the resolver the rest of the system already uses —
`sqlAnalysisDialectForDriver(driver)` in
`packages/cli/src/context/sql-analysis/dialect.ts`. The same function already
selects the dialect for `sql_execution`, `sl_query`, and the Python SQL-analysis
daemon. This spec **must not** introduce a second driver→dialect map. The notes
are **keyed by the resolved `SqlAnalysisDialect`** (so the SQL Server entry is
keyed `tsql`, not `sqlserver`), tying the note key-space to the resolver's
codomain so the two cannot drift.
### Authored per-engine notes are sanctioned static content
Enumerating syntax notes per engine is **not** a rotting denylist of bad
specifics; FQTN form and identifier quoting are genuine, stable invariants of each
engine — the kind of universal fact **ktx**'s design rules explicitly permit as
static content. What must stay derived-from-state is note *selection* (the active
dialect) and note *coverage* (every configured driver must resolve to notes that
exist), both of which this spec ties to the connector registry.
### The flat skill stays dialect-agnostic (spec 07 invariant preserved)
This work adds a *separate* channel. It does **not** amend spec 07's `<sql_craft>`
block or inline any dialect syntax into `SKILL.md`. Spec 07's acceptance criterion
— no `QUALIFY`/`strftime`/`julianday`/backtick-FQTN/etc. in the flat skill — stays
green. The only `SKILL.md` change is the pointer in requirement 3, which names the
tool and contains no dialect syntax.
## Requirements
### 1. A read-only `sql_dialect_notes` MCP tool
Register a new tool beside the existing context tools
(`packages/cli/src/context/mcp/context-tools.ts`). The tool name is the
implementer's to finalize but should follow the existing snake_case convention
(`entity_details`, `sql_execution`); `sql_dialect_notes` is the suggested name.
- **Input:** `{ connectionId }`, **required** — matching its siblings
`entity_details`/`sql_execution`, which always take an explicit connection.
- **Output:** `{ connectionId, dialect, notes }` where `dialect` is the resolved
`SqlAnalysisDialect` and `notes` is the markdown guidance for that dialect.
- **Resolution:** `connectionId → connection.driver →
sqlAnalysisDialectForDriver(driver) → notes[dialect]`, reusing the existing
resolver. Do not duplicate the driver→dialect map.
- **Guards:**
- A **non-SQL context-source** connection (driver `metabase`, `looker`,
`lookml`, `notion`, `dbt`, `metricflow`) returns a **clear "not a SQL
warehouse connection" error**, not postgres notes. Gate on the existing
`isDatabaseDriver()` (`packages/cli/src/connection-drivers.ts`).
- For any **SQL warehouse** connection the resolver always yields a dialect with
notes (all seven warehouse drivers are covered — requirement 2); its built-in
`postgres` default is a safety floor, so the tool never errors for a SQL
connection and never emits a single-engine dialect (e.g. Snowflake) by
accident.
- **Annotations:** read-only and idempotent, consistent with the other read
tools.
- **Description (docs-grade, third person, states what and when):** e.g.
*"Returns the SQL syntax conventions for a connection's dialect — FQTN form,
identifier quoting and case-folding, date/time functions, top-N idiom, and
semi-structured access. Use before authoring raw SQL against a connection so the
SQL matches that engine."* The description drives the agent's decision to call
the tool, so it must be specific.
### 2. Per-dialect note content
Author concise notes for each supported dialect against a **fixed rubric**, so
every dialect answers the same questions. Each facet is a line or two of timeless,
engine-true convention (no version-dated "as of vX" content), phrased as
guidance with the engine reason where it helps — inheriting spec 07's
heuristics-with-a-why tone. The rubric facets:
1. **FQTN form** — how to fully-qualify a table on this engine.
2. **Identifier quoting & case-folding** — quote character and how unquoted
identifiers fold.
3. **Date/time** — the engine's date functions and common date-encoding idioms.
4. **Top-N / window-filtering idiom**`QUALIFY` where supported; a CTE +
outer-filter form where it is not; `TOP` for `tsql`.
5. **Semi-structured / JSON access** — VARIANT colon-paths, `JSON_VALUE`/
`JSON_EXTRACT`, `->`/`->>`, `json_extract`, as applicable.
6. **Sharded / partition idiom** where the engine has one (e.g. BigQuery
`_TABLE_SUFFIX`).
Constraints on the content:
- **Coverage = the reachable dialect set.** Every driver in the connector registry
must resolve to a dialect that has non-empty notes. The reachable set is
`postgres`, `mysql`, `snowflake`, `bigquery`, `sqlite`, `clickhouse`, and
`tsql` (from `sqlserver`). Do **not** author notes for `duckdb`/`databricks`:
they appear in the resolver map but no connector can produce them, so they are
unreachable — matching the draft's "don't author for nonexistent drivers."
- **Keyed by `SqlAnalysisDialect`** (see Model).
- **Storage is the implementer's choice.** The notes MAY live as per-dialect
markdown files inside the package (e.g. under the skill's directory) served by
the tool, or as a typed map. If files are used they are **package-internal**
served by the tool, never installed onto an agent target — and already ship via
the recursive `src/skills → dist/skills` copy
(`packages/cli/scripts/copy-runtime-assets.mjs`); no `setup-agents.ts` change.
- **No benchmark, gold-answer, grader, or scoring references** anywhere in the
notes.
The implementer must verify each engine's specifics against current official
documentation (the well-known anchors above are starting points, not a
substitute for checking the engine's docs).
### 3. The `SKILL.md` pointer (completes spec 07's deferral)
Add a **single one-line pointer** to the SQL-authoring step (step 4 "Plan" / step
5 "Query") of `packages/cli/src/skills/analytics/SKILL.md`, directing the agent to
call the tool before writing raw SQL against a connection — e.g. *"Before writing
raw `sql_execution` SQL, call `sql_dialect_notes` with the connection's id to get
that engine's syntax conventions."* This is the pointer spec 07 deliberately did
not add because the tool did not yet exist.
- The pointer **names the tool only**; it contains **no dialect syntax**, so the
flat skill stays dialect-agnostic.
- Follow the skill's existing tool-reference convention. The skill currently names
MCP tools by **bare** name (`discover_data`, `sql_execution`). Anthropic's
guidance recommends **fully-qualified** `ServerName:tool` names to avoid
"tool not found" when multiple MCP servers are present. Whether to fully-qualify
the new pointer (and optionally retrofit the existing bare references) is a
small, separable decision flagged for the maintainer — **not** a rename sweep
this spec mandates.
### 4. Coverage is enforced from state, not by hand
A test must **derive** the required coverage from the connector registry rather
than hardcoding a dialect list: enumerate the configured warehouse drivers
(`warehouseDrivers` in `driver-schemas.ts` / `KTX_DATABASE_DRIVER_IDS` in
`connection-drivers.ts`), resolve each through `sqlAnalysisDialectForDriver`, and
assert each result has non-empty notes. Adding a connector later then **fails this
test** until its dialect gets notes — the allowlist-from-state discipline, not a
hand-maintained list.
### 5. No dialect syntax leaks into the flat skill
Spec 07's content assertion over `analytics/SKILL.md` stays green: the flat skill
(and its worked example) still contain no `QUALIFY`, `strftime`, `julianday`,
backtick/`DB.SCHEMA.TABLE` FQTN, or other single-engine construct. This spec adds
a tool and a tool-pointer; it does not move dialect syntax into the skill.
### 6. Delivery is unchanged
`setup-agents.ts` (`readAnalyticsSkillContent`, `installTarget`,
`writeClaudeDesktopSkillBundle`, `plannedKtxAgentFiles`) needs **no change**. The
skill still installs as a single `SKILL.md` per target. Confirm the channel works
on all six targets — Claude Code, Claude Desktop (zip), Codex, universal
`.agents`, Cursor (`.mdc`), OpenCode (`.md`) — by virtue of being a tool call,
including the single-file targets where multi-file delivery could not scope.
### 7. Coordination with specs 07 and 03
- **Spec 07** owns the dialect-agnostic `<sql_craft>` block. This spec must not
amend it; it adds the tool, the pointer, and the notes.
- **Spec 03** (`03-multi-connection-routing-in-analytics-skill`) threads
`connectionId` through the skill's tool calls. The `sql_dialect_notes` pointer
is `connectionId`-scoped and fits that routing; keep the pointer consistent with
spec 03's `connectionId` rules and do not rewrite the routing it owns.
## Acceptance criteria
- An agent querying a **sqlite** connection gets sqlite date idioms and **never**
sees Snowflake/BigQuery-only syntax; an agent querying **Snowflake** gets
FQTN / identifier / VARIANT guidance.
- The dialect shown is **derived from the connection's configured `driver`** via
the existing `sqlAnalysisDialectForDriver`, not hardcoded per project and not
guessed. No second driver→dialect map is introduced.
- **Every configured warehouse driver** (`postgres`, `mysql`, `snowflake`,
`bigquery`, `sqlite`, `clickhouse`, `sqlserver`) resolves to a dialect with
non-empty notes, and the coverage test derives this from the registry.
- A **non-SQL context-source** connection (e.g. `metabase`, `notion`) yields a
clear "not a SQL warehouse" response, **not** postgres notes.
- `analytics/SKILL.md` remains dialect-agnostic — spec 07's criteria are
unaffected. The new pointer references the tool only and adds no dialect syntax.
- The channel installs/serves correctly across **all six** agent targets,
including the single-file Cursor/OpenCode shape, with **no `setup-agents.ts`
change**.
- The notes contain **no** benchmark/gold/grader/scoring references and **no**
time-sensitive ("as of version X") content.
## Implementation orientation
Line numbers drift; treat these as anchors, not addresses. The implementer owns
the design.
- **Dialect resolver (reuse, do not duplicate):**
`packages/cli/src/context/sql-analysis/dialect.ts`
`sqlAnalysisDialectForDriver(driver)`, returning `SqlAnalysisDialect`
(`./ports.ts`), default `postgres`.
- **Connector registry (drives coverage):**
`packages/cli/src/connection-drivers.ts` (`KTX_DATABASE_DRIVER_IDS`,
`isDatabaseDriver`) and `packages/cli/src/context/project/driver-schemas.ts`
(`warehouseDrivers`, the per-driver `connectionConfigSchema`).
- **MCP tool registration:** `packages/cli/src/context/mcp/context-tools.ts`
(register beside `connection_list`, `entity_details`, `sql_execution`); the
`connectionId → driver → dialect` resolution already exists for `sql_execution`
in `packages/cli/src/context/mcp/local-project-ports.ts` — route the new tool
through the same path.
- **The skill (one-line pointer only):**
`packages/cli/src/skills/analytics/SKILL.md` — add the tool pointer in step 4/5;
leave `<workflow>`/`<rules>`/`<sql_craft>`/`<examples>` otherwise intact.
- **Note storage (if files):** under the skill directory, shipped by
`packages/cli/scripts/copy-runtime-assets.mjs`'s recursive copy; served by the
tool, never installed.
- **Delivery (confirm unchanged):** `packages/cli/src/setup-agents.ts`.
- **Tests:** unit tests for resolution (including `sqlserver → tsql`, unknown →
`postgres`, and non-warehouse rejection); a registry-derived coverage test
(requirement 4); a content test that each dialect's notes cover the rubric
facets and contain no banned tokens; and an extension of spec 07's
`analytics/SKILL.md` content test asserting the new pointer is present and the
flat skill is still dialect-clean. Rebuild and re-link the dev binary so the
playground picks up the change: `pnpm run build && pnpm run link:dev`.
## Benchmark context (motivation only)
The Spider 2.0-Lite v9 harnesses' only per-dialect content was Snowflake
(`DB.SCHEMA.TABLE` FQTNs, double-quoted lower-case columns, VARIANT colon-paths),
BigQuery (backtick FQTNs, `_TABLE_SUFFIX` for sharded tables), and sqlite
(`strftime`/`julianday`). That content is real and useful but engine-specific;
spec 07 kept it out of the flat skill and deferred it here so the dialect-agnostic
rules stay clean. Delivering it through a dialect-scoped **ktx** tool generalizes
the same correctness benefit to every multi-engine **ktx** project — improving the
benchmark score is a side effect, not the goal, and the shipped skill contains no
trace of the benchmark.
## Implementation notes
Implemented on branch `write-feature-spec-wiki`, alongside spec 07. The committed
decision (dynamic MCP delivery, not multi-file skill bundling) was implemented as
specified — no `setup-agents.ts` change.
**What was built**
- Per-dialect notes are markdown files under
`packages/cli/src/context/sql-analysis/dialects/<dialect>.md` (one each for
`postgres`, `mysql`, `snowflake`, `bigquery`, `sqlite`, `clickhouse`, `tsql`),
served by `sqlDialectNotes(dialect)` in `sql-analysis/dialect-notes.ts` (lazy
read + cache, `postgres` fallback floor; the authored set is the
`DIALECTS_WITH_NOTES` const). `duckdb`/`databricks` are intentionally unauthored
(unreachable from any connector). Each note answers the fixed rubric — FQTN,
identifier quoting/case-folding, date/time, top-N/window idiom,
JSON/semi-structured, plus a sharded-table line for BigQuery. Engine specifics
were verified against current docs via Context7 (Snowflake VARIANT colon-paths
and unquoted→UPPER case-folding; BigQuery `_TABLE_SUFFIX`, `QUALIFY`,
`JSON_VALUE`; ClickHouse `LIMIT n BY` and `JSONExtract*`, with no `QUALIFY`). The
files are package-internal — `copy-runtime-assets.mjs` ships them to `dist`; they
are never installed onto an agent target.
- New read-only MCP tool `sql_dialect_notes` (`context-tools.ts`): input
`{ connectionId }` (required), output `{ connectionId, dialect, notes }`, read-only
+ idempotent annotations. It resolves through the **existing**
`connectionId → connection.driver → sqlAnalysisDialectForDriver` path (no second
driver→dialect map), implemented as the unconditional `dialectNotes` port in
`local-project-ports.ts` via an extracted `resolveDialectNotesForConnection`. A
non-SQL context source (gated by `isDatabaseDriver`) throws `KtxExpectedError`
("not a SQL warehouse"), not postgres notes — so the expected agent mistake stays
out of Error Tracking.
- `connection-drivers.ts`: `KTX_DATABASE_DRIVER_IDS` is now an exported (`@internal`)
readonly tuple so the coverage test derives required coverage from the registry;
`isDatabaseDriver` behavior is unchanged.
- `skills/analytics/SKILL.md`: a single dialect-agnostic pointer in step 5 ("call
`sql_dialect_notes` … to get that engine's FQTN, identifier-quoting, date, top-N,
and JSON conventions"). It names the tool only; spec 07's `<sql_craft>` block and
its dialect-clean content test are untouched.
**Tests**
- `test/context/mcp/dialect-notes.test.ts`: registry-derived coverage (a future
connector fails the test until its dialect has notes), the full rubric per dialect,
leak isolation (sqlite shows `strftime` and never `VARIANT`/`_TABLE_SUFFIX`;
`QUALIFY` only on snowflake/bigquery; engine-exclusive markers stay put), no
benchmark/grader or version-dated content, the postgres fallback, and
`resolveDialectNotesForConnection` resolving sqlite / snowflake / `sqlserver→tsql`
and rejecting a non-SQL source / unknown connection with `KtxExpectedError`; plus a
guard that the `DIALECTS_WITH_NOTES` const and the `dialects/*.md` files stay in sync.
- `test/context/mcp/server.test.ts`: `sql_dialect_notes` added to the retained tool
set + annotations assertion + a handler-routing test, and the regenerated
`__snapshots__/mcp-tools-list.json`.
- `test/skills/analytics-skill-content.test.ts`: asserts the new pointer is present
and the flat skill stays dialect-clean.
**Verification** — `tsc -p tsconfig.json` (src) clean; full default suite 393 files /
3001 passing; slow suite green (incl. `local-project-ports.test.ts`); all three
`dead-code` checks clean; the `dialects/*.md` files copy into `dist`. Rebuilt and
re-linked `ktx-dev`.
**Deviations / notes**
- Notes are stored as per-dialect markdown files (not a typed map, and not bundled
`reference/*.md` skill files) — all sanctioned by the spec; plain markdown is the
most maintainable to edit. They are served by the tool and ship via a
`copy-runtime-assets.mjs` entry (`src/context/sql-analysis/dialects → dist/…`); no
`setup-agents.ts` change.
- `pnpm run type-check` still reports one pre-existing, unrelated error in
`test/mcp-server-factory.test.ts` (committed in-flight MCP work on this branch);
this change adds zero new type errors and does not touch that file.