Skip to content

Commit 33033a3

Browse files
bakeyclaude
andauthored
feat(sources): Slack source pack (milestone 5.2) + built-in packs as embedded YAML (#172)
* feat(sources): GitHub source pack for Open Connector (milestone 5.1) The first real provider pack: repositories, issues, issue comments, pull requests, reviews, commits, workflow runs, and releases as stable SQL tables (packs/github.rs, page-number pagination at GitHub's 100-row maximum), queryable through YAML bindings, open_connector_query, and federated joins. Engine additions the pack required, each sanctioned by the design spec: - Per-mapping filter Fidelity (filters.rs): Exact mappings stay fully provider-side; Inexact mappings narrow the fetch and DataFusion reapplies the predicate. issues.updated_at >= maps to GitHub's `since` as Inexact (documented "updated at or after" — a guaranteed superset); commits' strictly-after `since` is deliberately NOT mapped, because a boundary row the provider drops is unrecoverable — the mock pack's `>=` rule applied for real. Timestamp filter literals render as RFC 3339 UTC strings at every DataFusion granularity. - Utf8ListFromObjectKey (json_to_arrow.rs): the design's `$.labels[*].name` / `$.assignees[*].login` flattening — an array of objects becomes List<Utf8> of one declared key. - A JSON-null *parent* on a nested path is absence, not a structural failure: nullable leaves under `commit.author: null` / `issue.user: null` become SQL NULL (required ones still fail with a targeted error). Extends the previous PR's JSON-null-to-SQL-NULL rule to nested paths. - SourcePackTable::fixed_inputs: issues/pull_requests pin `state=all` so SELECT * reads the complete collection (GitHub defaults to open items only); a pushed state predicate overrides the pin, keeping SELECT * and WHERE state='closed' consistent. Relational caveats encoded rather than papered over: GitHub's issues endpoint returns pull requests, so the stable schema exposes the pull_request marker as nullable opaque JSON (IS NULL selects pure issues). Nullability is conservative — identity fields only. Fingerprint pins stay None like the mock pack until validated against a live gateway's discovered contracts; the redacted per-table fixtures under packs/fixtures/github/ are the build-time conversion contract. Tests: 180 open_connector total (was 158 on main). Fixture contract suites for all 8 tables (null-bearing, null-parent, empty-list, nested, extra-field rows; empty pages keep the stable schema), bind-time validation of every contract, and end to end through the mock gateway: a 150-row two-page scan carrying state=all and the resource inputs on every request, Exact state override, Inexact since narrowing plus local re-filter (gateway ignores since entirely — the harshest legal Inexact provider — and the boundary row is kept), pull_request IS NULL / IS NOT NULL, LIMIT stopping after one page, and open_connector_query parity with the YAML-bound table. Docs: docs/open-connector-github.md (per-table filter/limit behavior, authorization/visibility incl. the issues-returns-PRs caveat, rate limits, freshness, compatibility), open-connector.md status, README row, milestone 5.1 ticked in the tasks spec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): runnable GitHub-pack demo in the db-source demo style docs/open-connector/ mirrors the postgres/dynamodb demo shape: a README quick start whose every command was executed against the real server before being written down, a committed ctx, sample CSV, and four pipelines (stable table with state pushdown + pull_request IS NULL, open_connector_query, open_connector_scan, federated CSV join). The remote service is played by a bundled ~200-line stdlib-Python stub gateway — the same role DynamoDB Local plays in the DynamoDB demo: it speaks the /v1 contract (health, discovery with read_only metadata and an output schema rich enough for raw-scan type derivation, paginated execution with GitHub-style state/since filtering), so the entire Skardi side runs unmodified and offline, credential-free, in CI-able form. A closing section documents the real-gateway path (deploy Open Connector, GitHub connection there, runtime token, one connection_string change) and honestly flags it as pending live validation — the same caveat as the pack's fingerprint pins. Linked from the general guide, the GitHub guide, and the README supported-sources row; tasks spec 5.1 note extended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): honor the nullable-item contract in list conversion Both list variants failed conversion on any JSON null in item position — a null element in a Utf8List, or a pluck key holding null in Utf8ListFromObjectKey — even though the Arrow item field is declared nullable. An explicit provider null is data and now converts to an Arrow null item. The line drawn deliberately stops there: a *missing* pluck key still fails. Unlike columns, items carry no pack-declared nullability that authorizes reading absence as null, so a dropped upstream key (shape drift) fails loudly instead of silently yielding all-null lists. The shape-mismatch errors now name the specific kind found ("array element whose 'name' is number", "string array element", "array element without key 'name'") instead of one blanket message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): translate Utf8View string literals in filter pushdown DataFusion 52 can carry string literals as Utf8View scalars after coercion; scalar_to_json missed the variant, silently demoting Exact predicates like state = 'open' from a provider-side pushdown to a local re-filter over the full (state=all-pinned) fetch. Results stayed correct — Unsupported classification means DataFusion re-evaluates — but the narrowing was lost. Note the review's suggested LargeUtf8View arm does not exist: Arrow view types have no Large variants. Test pins LargeUtf8 and Utf8View both translating Exact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): fold cast-wrapped literals in filter pushdown translate_one only matched bare column-vs-literal comparisons, so a literal that type coercion wrapped in CAST/TRY_CAST (the planner's shape for `updated_at >= '2026-01-01'` against a timestamp column) silently stayed local instead of narrowing the fetch. Evaluate the cast rather than strip it, per the review's intent but not its letter: resolve_literal folds literal-only cast chains with ScalarValue::cast_to — the same Arrow kernel the engine would run — so Exact semantics stay exact (CAST('10' AS DOUBLE) pushes the number 10, never the string "10", which a strip would have sent). A failing cast or non-literal operand classifies Unsupported and is evaluated locally, and a cast around the COLUMN side (`CAST(updated_at AS DATE) >= …`, which changes predicate semantics) is never matched. Tests: coercion-shaped timestamp cast pushes `since` as Inexact, numeric TryCast pushes a JSON number as Exact, unparseable casts and column-side casts both stay Unsupported. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): reject ambiguous short table names in pack lookup Table lookup matched a binding's short name against the ID's last segment and returned the first hit. The comparison is whole-segment equality, so the review's example (issues vs closed_issues) never collided — but the underlying concern is real for multi-segment IDs (github.issue.comments vs github.pr.comments both end in "comments"), where first-match would silently bind the wrong relational contract. Lookup now tries an exact full-ID match first, then the short-name convention, and a short name matching several tables is a targeted SourcePackTableAmbiguous error listing the candidates instead of a silent first-wins. A new invariant test pins every built-in pack to namespaced `<pack>.<table>` IDs with unique last segments, so the ambiguity path is reachable only by future multi-segment or user-authored packs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): reject '$.'-prefixed column paths at converter build RowConverter::from_columns prepends '$.' to every column path, so a path already carrying the marker became '$.$.foo' — which, contrary to first appearance, PARSES: RowPath strips one '$.' and keeps a literal '"$"' first segment, silently reading the wrong key (all-NULL nullable columns, confusing "missing key" failures otherwise). No current caller can produce this (pack paths are reviewed, raw-scan columns reject dotted names), but ColumnSpec is public API. Reject rather than strip: column paths are row-relative by contract, and the error names the corrected spelling ("write 'user.login', not '$.user.login'"). Test pins the rejection and the message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): keep YAML resource-value types instead of stringifying OpenConnectorBinding.resource was BTreeMap<String, String>, so a YAML binding's `issue_number: 42` reached the gateway as the string "42" while the UDTFs' resource JSON sent the number 42 — inconsistent action inputs for the same logical scan, numeric resources (issue_comments, reviews) forced through strings, and — the unflagged consequence — a binding and an identical UDTF invocation computed different scan-cache keys, so the shared cache could never match across the two interfaces. resource is now BTreeMap<String, serde_json::Value>: YAML scalars keep their types and flow into the action input (and the cache key) exactly as the UDTF path sends them. Null values are rejected at validation (NullResourceValue) — a null would satisfy the required-key presence check while sending `null` to the gateway. The GitHub guide's "forwarded as strings" caveat is replaced by the new guarantee. Tests: YAML type preservation and null rejection at config level, and an end-to-end pin that a bound issue_comments scan sends "issue_number":42 (never "42") in every gateway request body. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(sources): use the imported Value alias for the resource map Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(sources): typed fixed_inputs via a const-friendly FixedValue fixed_inputs could only carry &'static str values, so a future numeric or boolean pinned default would have been forced through a string — the same class of stringification the resource map just shed. The review's literal suggestion (&'static [(&str, serde_json::Value)]) cannot compile: Value's String and Number variants are not constructible in static initializers. Introduce FixedValue (Str/Int/Float/Bool, Copy, const-friendly) converting to the JSON scalar at input-assembly time; the github pack's state=all pins move to FixedValue::Str. Behavior unchanged for existing packs; a test pins the scalar conversions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): reconcile the cache rules with their key-scoped reality Two review findings, both intentional behavior whose written rules had drifted: 1. The design doc still stated the original milestone-2-era rule "a LIMIT-stopped scan is never cached", while milestone 3 deliberately refined it: LIMIT is part of the cache key, so a LIMIT-satisfied scan is complete for its key and is stored (cache.rs docs and the tasks spec already said so). Update the design doc to the key-scoped rule, note the refinement explicitly, and name LIMIT's key membership as the load-bearing invariant. A new regression test pins it harder than prose can: a full scan issued after a cached LIMIT scan must fetch live and return all rows — if limit ever falls out of the key, that test replays the truncated entry and fails. 2. The cache key omits fixed_inputs, which the design's key list includes. Safe today because pack-pinned inputs are functionally determined by (action_id, source_pack_version), both already keyed — now stated at the ScanKeyParts construction so the transitive argument survives a future in which fixed inputs become binding-configurable or vary within a pack version. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): run pull_requests' state pin/override end to end pull_requests is the only table besides issues declaring the state=all fixed input plus an Exact state filter, but its pin/override path never ran through the real scan engine — the end-to-end coverage was issues-only. Add the pull_requests variant: a plain scan carries "state":"all" on every request and exposes closed PRs (the pin's whole point — GitHub's endpoint defaults to open only), and a state = 'open' predicate replaces the pin in the action input, verified in the recorded request bodies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): cover pull_number in the numeric-resource assertion Numeric-resource forwarding was pinned for issue_comments (issue_number) only; reviews consumes the same path through pull_number. The test now registers both bindings and asserts, per action, that the value reaches the gateway as a JSON number ("issue_number":42 / "pull_number":7) and never stringified — covering both declared consumers of numeric resources. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): guard the deliberately-unmapped commits/workflow filters The pack argues at length (module docs, guide) that commits must NOT map committed_at to the endpoint's `since` — GitHub documents it as strictly-after, so a pushed `>=` would drop the boundary commit unrecoverably — and that workflow_runs must not map `status`, whose provider parameter also matches conclusion values. Neither decision had a regression test, so a future accidental mapping would land silently with Exact classification and wrong rows. Two guards now run the real scan engine against a stub gateway and assert the request bodies carry no `since` / no `status` while DataFusion filters locally (the boundary commit stays; only completed runs return) — the same shape as the mock pack's gteq_is_not_pushed_to_strict_gt_input precedent. A shared setup_table helper registers any single non-issues table for these and future per-table tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): scan the total_count-bearing envelope end to end workflow_runs is the pack's one structurally different response shape — the row array sits beside a sibling total_count, GitHub's actual envelope — but every end-to-end scan used the unwrapped issues shape (the fixture test covered extraction only, and the status-filter test was single-page without the sibling). A 150-run scan now paginates through the wrapped envelope: two pages at per_page=100, the short page terminates, and the sibling key stays inert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): pin the GitHub binding's required-resource contract A binding without `repo` must fail registration with MissingResourceInput naming the binding and key — asserted here for the first time on the registration path at all: the only prior MissingResourceInput assertion was the UDTF planning path (mock pack), so the pack contract AND the foundation's registration-time check were both riding untested. The test also pins ordering: enforcement fires after the health check but before any action-discovery request leaves the process (the recorded gateway traffic is health-only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): drive empty-page termination through a pack scan The wrapped-envelope test terminates on a short page (50 < 100); the exact-boundary path — a full 100-row page that cannot signal completion, forcing one more request that returns empty — ran only in the pagination unit tests, never through a GitHub table. A 100-run scan now pins it end to end: two requests, all 100 rows emitted, the empty page 2 ends the scan. The workflow_runs stub/registration moved into a shared setup_workflow_runs(total, env) helper serving both termination modes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): classify the state pushdowns Inexact, not Exact The state = X translation is faithful only inside GitHub's enum domain (open/closed/all). Exact told DataFusion not to re-apply the predicate, so correctness for an out-of-domain literal — state = 'merged' on PRs is a natural thing to try — hinged entirely on the provider rejecting the value: a provider that silently ignored it and returned its default listing would hand back open rows as the answer to a query whose truth is empty. GitHub 422s today, but the Exact claim was leaning on provider validation the contract shouldn't assume. Both state mappings (issues, pull_requests) are now Inexact: the push still narrows the fetch identically on well-behaved providers, and the local re-check is essentially free — state is a column of every output row. A regression test drives the motivating scenario through a stub that ignores the state input: state = 'merged' returns zero rows while the push still appears in the request body. Docs and the module's filter-fidelity bullet updated to state the enum-domain rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): record the workflow_runs pushdown follow-up in place Per review: `event` and `head_branch` (GitHub's `branch` query param) are faithful narrowing candidates left unmapped — fetch-reduction only, since local filtering already gives correct results. Note them at the filters declaration so the follow-up isn't lost, with one correction to the review's framing: per the module's string-enum push rule they land as Inexact, not Exact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): state the unpinned-fingerprint failure mode and follow-up Per review: the "no fingerprint pins yet" comment now spells out the operational consequence — incompatible upstream drift surfaces only at scan time as a terminal ConversionFailed, for every query on the table (conversion builds all declared columns before projection), with no binding-side schema patch by design, so the fix is a pack version bump — and points at the tracked follow-up: validate against a live gateway (endpoint-contract check included) and pin the fingerprints so drift fails fast at registration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sources): Slack source pack — conversations, users, files Milestone 5.2 of the Open Connector integration: the second real provider pack, validating cursor pagination (Slack's cursor / response_metadata.next_cursor contract) the way the GitHub pack validated page-number pagination. Tables (bot-token visibility, no required resources): - conversations: cursor-paginated channels with the types=public_channel,private_channel pin, so the table reads as the complete collection the bot can see (Slack defaults to public-only); IMs/MPIMs deliberately excluded from a channels table. - users: cursor-paginated members incl. bots and deleted users; email is scope-gated (users:read.email) and NULL without it. - files: Slack's classic page/count pagination (that endpoint never adopted cursors); user_id -> user pushed as Inexact per the string-push rule; created >= -> ts_from deliberately NOT mapped (Slack takes epoch seconds; the filter engine renders timestamp literals as RFC 3339 only — a candidate once per-mapping value rendering exists). No message/thread tables, per the design's Slack caveat: upstream lacks complete message-cursor handling, and an incomplete message table would violate the admission gate's complete-pagination rule. Documented in the module docs and the guide. New engine support: FieldType::TimestampSecondsUtc — Slack's created/updated are epoch seconds, and the millis reader would have silently produced January-1970 dates. Strictly integers; fractional and string values fail with their kind. Verification: 32 new tests (212 open_connector total). Fixture contract tests per table (nested topic.value/profile.*, scope-gated email, deleted users, empty channel lists, Slack's empty-string convention, epoch-seconds columns, empty pages); end to end via the mock gateway: multi-page cursor scan (no cursor on page 1, the stub's token afterwards, limit hint + types pin on every request), BOTH termination spellings (empty-string cursor and absent response_metadata), pagination-loop detection bounded at the first repeated cursor, LIMIT early stop, empty workspace, user_id pushed and re-applied against an ignoring provider, multi-table binding with zero required resources, UDTF parity for slack.users. Docs: docs/open-connector-slack.md plus guide/README/tasks-spec updates. Stacked on feature/open-connector-github-pack (#168): depends on its Fidelity::Inexact, fixed_inputs/FixedValue, fixtures convention, and list/null conversion rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): trust Slack's paging.pages instead of the short-page heuristic The files table used PageNumber's short/empty-page termination, but Slack can legally return short non-final pages (permission filtering, deletions), which the heuristic reads as end-of-collection — silently truncating the scan, against the no-partial-result invariant. Slack's envelope carries an authoritative total, so use it. PageNumber gains an optional total_pages_path (Slack: $.paging.pages), parsed and validated at bind time like the cursor path. When declared, only page >= pages ends the scan — short and even empty non-final pages keep going (bounded by max_pages) — and a missing or non-numeric total fails loudly (RowPathNotFound / new PaginationTotalInvalid) rather than falling back to the truncating heuristic. Without the path, the heuristic remains: it is all providers like GitHub give (mock/github pass None; their behavior is unchanged). Tests: unit coverage for short/empty middle pages continuing, pages=0 stopping, missing and non-numeric totals failing, malformed paths rejected at bind; an end-to-end scan drives the motivating scenario — three pages of sizes 2/1/2 with paging.pages=3 return all five rows in exactly three requests. Docs updated (module bullet, guide, tasks spec). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sources): per-mapping ValueFormat; push files.created as ts_from scalar_to_json rendered every timestamp literal as RFC 3339, which is why files.created >= stayed unmapped — Slack's ts_from takes epoch seconds. Per review, that global assumption was also a latent hazard: a future pack mapping a timestamp to an epoch-seconds input would have silently sent the wrong spelling with no compiler or test to catch it. FilterMapping gains value_format (Rfc3339 / EpochSeconds), so the provider's spelling is declared where the mapping is declared. EpochSeconds floors sub-second precision, which widens a LOWER bound (a superset, trimmed back by Inexact re-filtering) but would narrow an upper bound — the enum docs pin epoch-seconds mappings to ts_from-style lower-bound inputs only. Existing mappings take Rfc3339; behavior unchanged. The workaround this replaces is gone rather than recorded as tech debt: files.created >= now maps to ts_from (Inexact + EpochSeconds; Slack documents ts_from as inclusive, so >= holds a superset even before flooring). Unit tests pin whole-second rendering across all four timestamp granularities plus the flooring case; an end-to-end scan asserts ts_from goes out as epoch seconds — never RFC 3339 — against a stub that ignores it, with the boundary row kept by the local re-filter. Docs updated (module bullet, guide, tasks spec). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): diagnose out-of-range epoch seconds precisely TimestampSecondsUtc collapsed a checked_mul overflow into the same None as "not an integer", so the error read found: "number" against expected: "epoch-seconds timestamp" — contradictory, since the value is a number; it is simply beyond the millisecond-representable range. collect_cells now delegates to collect_cells_described, whose converter returns the found-description itself; the seconds arm distinguishes the two failure modes ("an epoch second out of range for millisecond timestamps" vs the JSON kind). The other arms keep the Option-based API unchanged. Test extended with an i64::MAX case pinning the distinct message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): skip pagination advance once a LIMIT completes the scan After a LIMIT-satisfied page set done, the scan still called pagination.advance() on that final envelope. Beyond the wasted work the review flagged (a next_token that is never used, a re-parsed cursor), advance() validates continuation state — so a repeated cursor (loop detection) or a missing/malformed total-pages entry ON THE FINAL PAGE failed a scan whose result was already complete for its cache key. Advance now runs only while the scan is still going. A regression test drives the sharpened case end to end: a stuck-cursor gateway whose repetition would trip loop detection on page 2, with LIMIT 4 satisfied exactly there — the scan returns its 4 rows in 2 requests instead of failing on continuation state it will never use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): correct the 5.2 verification test counts The blurb claimed 212 open_connector tests with 32 new; the real figures are 219 total with 20 new relative to the 5.1 branch point (the review flagged 214/13, a snapshot of the initial commit before four review-round fixes each added tests, offset by two phantom grep matches in comments). State the counting method alongside the numbers so the next update is mechanical: cargo test -p skardi --lib sources::providers::open_connector, diffed against the branch point. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): assert request inputs structurally, not by substring Per review, the negative substring check !bodies[0].contains("cursor") was brittle — any field whose value happens to contain the substring would fail it. Parse the request body and assert on input.get("cursor") instead; the cursor test's positive and pin assertions move to the same structural form. The two GitHub never-pushed guards (!contains("since") / !contains("status")) were the same brittle class with inverted stakes: a stray substring in an unrelated field would false-FAIL them, and the structural check is what their intent ("this key never reaches the provider input") actually says. Converted alongside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): assert which rows survive the Inexact re-filter Per review, the files user-filter test counted one surviving row without checking it was F0001 — a re-filter keeping the wrong row would have passed. Assert row identity instead of cardinality, via a small ids_of helper; the ts_from boundary test had the same weakness (its message claimed "boundary row F0002 stays" while only counting two rows) and now pins [F0002, F0003] explicitly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): bind all three tables where the comment claims it Per review, the multi-table binding test said "exposes all three tables" while binding only users and files. Bind conversations too — registration only needs discovery, which the stub's generic arm already serves — and assert the binding schema actually lists all three tables, so the comment's claim is now checked rather than merely stated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sources): surface Slack's in-band ok:false errors as themselves Slack reports application errors as HTTP 200 with ok:false + error, so through the gateway's output envelope a not_authed/missing_scope page used to die on row extraction as "row path '$.channels' failed: the key is missing" — the real cause buried under a shape error. Per review, a test alone could not make that meaningful; the pack needed a way to say where in-band errors live. SourcePackTable gains error_path (Slack: $.error), carried on ScanTarget, parsed and validated at bind time like the other pack-authored paths. Before each page's row extraction the scan checks it: a present, non-null value fails the scan as ProviderReportedError naming the action, page, and the provider's own code (a short identifier, bounded at 128 chars; non-string values report their JSON kind). Success envelopes carry no error key, so the happy path costs one failed map lookup. GitHub/mock stay None — their providers error at the HTTP level, which the client already surfaces. An end-to-end test drives ok:false + missing_scope through the pack: the error names missing_scope and slack.list_conversations and never mentions a row path. Guide updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): send Open Connector's camelCase perPage, not GitHub's per_page Verified against a live gateway: OC action-input schemas are camelCase and strict (additionalProperties rejected), so POST /v1/actions/github.list_repository_issues with {"per_page":5,"page":1} returns HTTP 400 ("Action input does not match the action schema"), while {"perPage":5,"page":1} returns HTTP 200 — matching the perPage/page inputs in the action's published contract. GITHUB_PAGINATION now declares perPage; page was already correct. The mock-gateway stubs paged on the snake_case key, which is exactly why CI stayed green while a live scan would 400 on page 1 — they now read perPage, and the page-1 body assertion pins the camelCase key on the wire. The demo stub gateway and module docs follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): speak Open Connector's real HTTP protocol, verified live Reconciled the client and the GitHub pack against a live Open Connector gateway (v1.3.1, run from source) and its provider code. The mock-first implementation had diverged from the real API in ways fixtures could never catch — every item below is pinned by a live probe: Client protocol: - Execute is POST /v1/actions/{id} — the /execute suffix 404s on the real gateway. Same path as discovery, different method. - Every /v1 response is a uniform envelope {success, message, data, meta, errorCode?}. Success output lives in data (the old code looked for an output field and would have rejected every real success); failures surface errorCode + message + meta.executionId, bounded, so a 400 invalid_input or 403 authorization_failed reads as itself. - Discovery metadata is camelCase and nested: data.inputSchema, data.outputSchema, data.execution.locallyExecutable. The old snake_case flat parse yielded all-None, which default-deny would have turned into 'nothing is executable' against a real gateway. - The connection-alias header is x-oo-connector-alias (was an invented x-openconnector-connection-alias the gateway ignores, silently routing to the default connection). - connection_aliases removed from the model: the real discovery response has no such field and nothing consumed it. - read_only is parsed from execution.readOnly as forward-compat; the real gateway publishes no read/write classification yet (verified in source), so raw scans stay refused by the documented default-deny gate and pack tables are unaffected. Docs now say this outright. GitHub pack contract: - repositories now binds github.list_my_repositories; the previously declared github.list_repositories does not exist on the gateway (registration would fail at discovery). - issue_comments/reviews resource keys are issueNumber/pullNumber — required, camelCase, additionalProperties:false in the live schemas. - issues is pure issues: the OC action filters out the pull requests GitHub's raw endpoint mixes in, so the pull_request marker column (permanently NULL against the real gateway) is gone, with a negative-space guard pinning the decision. Demo pipelines dropped their pull_request IS NULL clauses. Tests mock the real wire shape via new testutil envelope builders (envelope_ok / envelope_err / discovery_ok); the demo stub gateway now speaks the same envelope. 200 open_connector tests, 720 lib tests. Environmental note: provider egress could not be exercised end-to-end here (no GitHub credential; the machine's fake-IP DNS lands in 198.18/15, which OC's SSRF guard always blocks) — row-level validation against live GitHub data remains open alongside fingerprint pinning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: drop accidentally committed __pycache__ artifact Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sources): scope binding resources per table to survive strict schemas Second live-gateway finding: a binding's resource map was forwarded wholesale to every bound table's action, but Open Connector action schemas reject undeclared inputs (additionalProperties: false). Binding repositories alongside issues under one owner/repo resource map made every repositories scan 400 — github.list_my_repositories declares no resource inputs at all. Verified live: before this change the scan returned invalid_input; after it, the same query passes schema validation (fails only at the expected credential wall). - SourcePackTable grows optional_resources (e.g. a future Slack channelId scoping list_files) next to required_resources, plus a declares_resource helper. - OpenConnectorTableProvider::new filters the binding resource to the keys the table declares — both the YAML path and the UDTF path construct the provider, so one enforcement point covers both. - Registration rejects a resource key that NO bound table declares (UnknownResourceKey): requests never carry it, so it is dead configuration — almost certainly a typo — and silently dropping it would hide the mistake. - open_connector_query rejects undeclared keys at planning outright: with a single table there is nothing else to consume them. - Tests: a shared repositories+issues binding against a stub that mirrors the live gateway's strictness (400 on undeclared keys), structural per-action input assertions, registration typo rejection, and the UDTF planning rejection. 202 open_connector tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): rebuild the Slack pack on Open Connector's real contract Reconciled against a live gateway (v1.3.1) and the OC provider source: unlike GitHub's raw-passthrough executors, OC's Slack executors NORMALIZE — every declaration in this pack that came from Slack's raw Web API was wrong on the wire. Contract fixes (each one live-verified: all three tables' generated inputs now pass the gateway's strict action schemas, reaching the credential wall instead of 400 invalid_input): - Row paths: conversations/users live under $.conversations/$.users (not Slack's channels/members); rows are the normalized camelCase shapes (channelId, isArchived, realName, …). Conversations and users columns rebuilt on that contract — the normalized rows carry no created/updated timestamps, no email/tz/team_id (users gain is_owner, locale, and conversations a type classification). File rows are raw Slack objects plus aliases, so the existing raw columns survive. - Cursor: top-level $.nextCursor, null at end-of-collection (was $.response_metadata.next_cursor). Both termination spellings pinned. - types on conversations is an ARRAY of enum strings in the strict schema; the comma-joined string pin would 400 every request. New const-friendly FixedValue::StrList carries it. - includeLocale pinned on users so the declared locale column is actually populated. - files.user_id pushes to the userId input (was Slack's raw user). - The created >= → ts_from push is REMOVED: the OC list_files contract declares no time input and its strict schema rejects one. A negative-space guard asserts no time key ever reaches the wire; the engine's ValueFormat stays for future packs. - files gains an optional channelId resource (per-table resource scoping from the github branch). - error_path dropped from all three tables: the OC executor consumes Slack's ok:false itself and returns a gateway failure envelope — an in-band error can never appear in action output. The engine mechanism survives on the mock pack ($.error) with its e2e moved to mod.rs; the slack e2e now pins the gateway-failure surfacing instead. Fixtures rewritten to the normalized shapes; all stubs speak the real uniform envelope on the real endpoint. Docs follow. 224 open_connector tests, 744 lib tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(spec): reconcile the 5.1 entry with the live-verified pack The live-gateway fixes made issues pure issues (the OC action filters out the PRs GitHub's raw endpoint mixes in) and removed the pull_request marker column in favor of the issues_declares_no_pull_request_marker negative-space guard, but the 5.1 entry still described the marker as nullable opaque JSON and listed a 'pull_request IS NULL' end-to-end test that no longer exists. Also refreshes per_page → perPage, the stub's real-envelope protocol, the live-reconciliation status (fingerprint pins remain the open follow-up), and the counts: 26 pack tests / 202 open_connector total, with the counting command spelled out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): add the admission gate's schema-mismatch fixture for github The gate requires empty, null-bearing, nested, AND schema-mismatch fixtures per pack; the mismatch case was covered only by engine-level json_to_arrow tests, never at the pack contract level. A new issues_type_mismatch.json page carries a valid first row and a second row whose declared-UInt64 'number' arrives as a string; the contract test pins the targeted error identity — column 'number', path '$.number', page 1, row 1 (proving the error is row-scoped, not page-wide), expected 'non-negative integer', found 'string' — and that the offending value itself never appears. Spec entry 5.1 updated (fixture list + 27 pack / 203 total counts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): add the admission gate's schema-mismatch fixture for slack Same gate clause as the github fixture: a conversations_type_mismatch.json page whose second row carries memberCount as a string where UInt64 is declared, with the contract test pinning the targeted error identity (column member_count, path $.memberCount, page 1, row 1, expected non-negative integer, found string) and distinguishing it from the legitimate NULL path (an OMITTED memberCount). Spec entry 5.2 updated (fixture list + 226 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(demo): call out that the raw-scan example is stub-only for now The stub hardcodes execution.readOnly: true, so raw_issue_scan runs in the demo — but today's real Open Connector publishes no read/write classification, so open_connector_scan is refused live under default-deny (RawActionReadOnlyUnknown). The stable-table and open_connector_query examples degrade gracefully to the credential wall; this one cannot, and the module docs were the only place saying so. The demo now says it twice: a note beside the example itself and a carries-over/doesn't list in the real-gateway section, including the advice to drop raw_action_allowlist + the pipeline from a real-gateway context. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): document the two layers handling in-band provider errors Review asked what happens if a gateway forwards Slack's HTTP-200 ok:false envelope: both suggested remedies already exist — the engine's error_path pre-check (checked before row extraction, mock-pack-modeled) and the verified fact that Open Connector's slack executor consumes ok:false and returns a failure envelope (assertSlackPayload throws, so a conforming gateway can never emit 200+ok:false). What was missing was saying so outside the slack doc: the generic error section now spells out the two layers and the consequence of declaring neither, and the exec.rs comment no longer implies the slack pack itself uses the mechanism. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): restore env vars via RAII guard in the slack pack tests The setup helper set the runtime-token variable with a bare unsafe set_var/remove_var pair: a panic between the two leaked the variable into later tests, and remove_var deletes rather than restores a pre-existing value. testutil::EnvVarGuard now saves the prior state (value or absence, as OsString) and restores it on drop — panic included — and centralizes the unsafe with its soundness argument (per-test-unique names; the guard adds restore-on-drop, not thread safety). The equivalent call sites in mod.rs/github.rs/client.rs are main-owned test code and migrate in a separate cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): pin the users cursor scan; refresh the spec's Review notes Two live gaps from the review batch (the rest was fixed in earlier rounds): the multi-page cursor path was pinned end-to-end only through conversations — users shares the strategy constant but not the wire declarations, so a drifted users declaration could hide behind conversations' coverage. A two-page users scan now asserts row identity across pages (U0001..U0003), no cursor on page 1, the stub's token on page 2, and the limit 200 hint on every request. The spec's Review notes still described the current PR as milestone 4; it now describes 5.2 and the engine extensions it carried. Counts: 227 open_connector tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): fail on non-string cursors instead of truncating the scan The Cursor advance arm collapsed every non-empty-string outcome of the cursor extraction into 'no next page': nextCursor: 123, nextCursor: {}, and even structural traversal failures all read as end-of-collection, so a drifted gateway silently truncated conversations/users scans while the query succeeded. Termination is now ONLY the three end-of-collection spellings — absent (any missing segment, matching Slack's omitted response_metadata), null, or empty string. A present non-string cursor fails as the new PaginationCursorInvalid (path, page, JSON kind — never the value, mirroring PaginationTotalInvalid), and structural failures (traversing through a non-object) propagate as themselves. Tests: unit — null termination added to the missing/empty pin, number/object/boolean cursors fail with their kinds, non-object parent propagates RowPathNotObject while a missing parent still terminates; e2e — a slack conversations scan against a nextCursor: 123 gateway fails naming '$.nextCursor' and 'not a string' instead of returning one page as success. 230 open_connector tests; guide + spec updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sources): pin the slack action-contract fingerprints from the live gateway The three slack tables carried expected_fingerprint: None, so the registration-time compatibility gate never ran for them — upstream output-schema drift would surface at scan time (or, combined with a lenient cursor read, as silently missing data), against the design's requirement that stable definitions record a fingerprint and fail at registration. The deferral reason ('pins must come from a live gateway') is gone: the contracts are live-reconciled, so the pins land. Each pin is the BLAKE3 hash of the canonicalized output schema captured from the live gateway (v1.3.1) into packs/fixtures/slack/contracts/. Three layers keep it honest: a sync test locks pin ↔ captured contract (fingerprint_schema is now pub(crate) for it); every mock registration serves the captured contracts, so the gate's pass side is exercised by the entire slack suite; and a drift e2e proves a gateway serving a different schema fails registration as ActionContractMismatch naming slack.conversations and its action — the gate's fail path was previously dead code across all packs. Docs spell out the pinning tradeoff (a hash cannot tell additive from breaking, so any schema change fails until re-captured) and that the github pack's pins remain the open follow-up. 232 open_connector tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(sources): add ValueFormat::Verbatim for non-timestamp mappings Declaring Rfc3339 on string/number mappings (slack user_id, github state x2, mock min_value, the filters test fixture) was a no-op only because non-timestamp scalars happen to render identically under every ValueFormat — semantics by coincidence, fragile if the rendering path is ever reworked. Verbatim now states the intent, and carries real semantics of its own: a timestamp literal reaching a Verbatim mapping does NOT translate (returns None -> the predicate stays local) instead of being pushed in a guessed spelling. Only the one genuine timestamp mapping (github updated_at -> since) keeps Rfc3339. New engine test pins both arms: plain scalars push their natural JSON through Verbatim, a timestamp under Verbatim never reaches the wire (Unsupported, evaluated by DataFusion). 233 open_connector / 790 full lib tests; spec entry updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): state the default scan ceilings for the slack and github packs With default bounds (max_pages 100), an unfiltered scan caps at 200 x 100 = 20k rows for the slack cursor tables, 100 x 100 = 10k for slack files and every github table — and then FAILS with ScanBoundsExceeded per the fail-don't-truncate rule, which is correct but surprising the first time a large workspace/repo hits it. Both pack docs now state the ceiling, the failure mode, and the remedies (raise max_pages/max_rows in the open_connector block, or narrow with a predicate/LIMIT), linking to the integration guide's bounds section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(sources): define built-in source packs as embedded YAML assets The design doc sketched packs as declarative YAML and explicitly left YAML-vs-Rust as an implementation choice; this moves the implementation to the design's illustrative format. mock, github, and slack are now packs/*.yaml assets compiled in with include_str!, parsed once at first registry access by the new packs/loader.rs, and leaked into the same &'static shapes the scan engine has always borrowed — the engine, bindings, UDTFs, fingerprint gate, and every scan-path type are untouched. The contract boundary does not move: packs stay inside the binary, versioned, fingerprint-gated, and never user-editable configuration (context YAML still cannot override an action, row path, pagination, or schema). What changes is that a pack is now reviewable and generatable as plain data, and the format is ready for the design's deferred second tier (user-authored packs loaded from a directory). Loader discipline mirrors the config layer: deny_unknown_fields end to end (a typo'd pagination key fails parsing instead of silently disabling the total it was meant to set), utf8_list_from_object_key is the only column type that accepts 'key', table keys are bare names with ids derived as <pack>.<table>, table order is BTreeMap-deterministic, and filter 'format' defaults to verbatim (the safe spelling). A malformed embedded asset is a build defect: the loader panics with the asset name, and builtin_assets_parse_and_validate parses AND structurally validates (row paths, converter, pagination, error paths) every shipped asset so that panic is unreachable in a released binary. The YAML was generated mechanically from the previous Rust statics and is pinned by the existing suite: fingerprint sync tests, per-table contract fixtures, and every e2e (pagination inputs, fixed-input pins, filter pushdown, drift refusal) all pass unchanged. Pack modules keep their module docs and tests; declaration-site rationale moved into YAML comments. 237 open_connector / 794 full-lib tests green; zero new clippy warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): validate pack semantics in the loader; fail without panicking Three review findings on the YAML loader, all applied: P1 - the loader now cross-validates each document before converting it to runtime objects: duplicate column names (RowConverter does not reject them), filters referencing undeclared columns, duplicate (column, operator) mappings, resources declared both required and optional, and fixed inputs colliding with declared resources or pagination inputs. Structural checks (row/error paths, converter construction, pagination paths) also moved into the parse pass so a generated asset gets one complete diagnostic instead of failing piecemeal at bind time. One table-driven test pins every rejection with its targeted message. P2 - a malformed embedded asset no longer panics: loader::builtin memoizes the parse Result, pack()/SourcePackRegistry::builtins() are fallible, and the error surfaces as the new SourcePackAssetInvalid at registration (register_open_connector_tables) and UDTF setup (register_open_connector_udtfs, now Result; server/CLI call sites propagate). This matters precisely because the refactor exists to make packs AI-generatable - a bad asset must be a startup diagnostic, not a first-use abort. P3 - source_pack.rs said 'parsed once at startup'; it is parsed at first registry access, and the doc now says so, matching loader.rs. 238 open_connector / 795 skardi, 137 server, 63 CLI tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sources): pin the github action-contract fingerprints from the live gateway Closes the documented follow-up: github's 8 tables now pin expected fingerprints the way slack's do. The output schemas were captured from a live gateway into fixtures/github/contracts/, each table's fingerprint in github.yaml is the BLAKE3 of its canonicalized capture, and the same recipe applies end to end — a sync test locks pin <-> contract through action_registry::fingerprint_schema (its mismatch output is also how pins are re-taken after an upstream upgrade), every e2e's discovery stub now serves the captured contracts via github_discovery so the fingerprint gate's pass side runs suite-wide, and a drift-refusal e2e pins the failure side (a differing schema fails REGISTRATION naming github.issues, github.list_repository_issues, and the fingerprint mismatch — never silently reshaped rows mid-query). Verified live: registration of all 8 github + 3 slack tables against a local gateway passes the pinned comparison. Docs updated (github Compatibility section, spec 5.1 follow-up closed). 240 open_connector tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): fix intra-doc links and stale fingerprint prose in pack modules The YAML refactor removed the pack modules' type imports, breaking the module docs' bare intra-doc links under -D rustdoc::broken-intra-doc-links; they now link by full path. Also refreshed the two module-doc blocks the github fingerprint pinning made stale ('No fingerprint pins yet' / 'GitHub pack's pins remain a follow-up'). cargo doc clean, 797 lib tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): state explicitly that embedded YAML is not dynamic loading Review asked for the boundary to be explicit: the YAML refactor standardizes the pack FORMAT, but adding a built-in pack still takes a Rust accessor module, a mod declaration, a registry entry, and a rebuild. Directory-loaded user packs remain the design's deliberately deferred second tier (per the design doc, so the format can stabilize while Skardi-internal); the loader doc now says so instead of implying otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): validate the whole request-input namespace in the loader Filter inputs join the same request-input namespace as resources, fixed inputs, and pagination parameters — and exec.rs applies pagination LAST, so a filter input named page/perPage/cursor would be silently overwritten after translation: an Exact pushed predicate DataFusion never reapplies, returning wrong rows. The loader now rejects filter inputs colliding with pagination parameters or declared resources (filter-vs-fixed-input overlap stays legal on purpose — a pushed predicate overriding the complete-collection pin is the feature), rejects a pagination declaration reusing one name for two parameters, and rejects zero page sizes. Five new table-driven cases pin each rejection's targeted message. 797 lib tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): reject two filter mappings sharing one input field Follow-up to the namespace validation: two filters targeting the same provider input are now rejected at load. The scan-time claimed-inputs guard already made them safe (the later predicate stays local, so no wrong rows) — but WHICH predicate pushes would depend on the query's predicate order, and that ambiguity in a pack declaration is an authoring mistake, not a choice. The other collision the review named — a filter input equal to a fixed input — stays legal on purpose: it is the override mechanism itself (a pushed predicate replacing the complete-collection pin, the github state=all pattern, exercised end-to-end by pull_requests_state_pin_and_override_run_end_to_end), and the validation comment now spells out both decisions. One more table-driven rejection case. 797 lib tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): reject non-finite fixed floats at pack parse YAML parses .nan/.inf/-.inf into f64 and the untagged FixedValueDoc preserved them, while FixedValue::to_json silently renders non-finite numbers as JSON null at query time — exactly the pack bug the FixedValue doc comment warns about, slipping past the startup diagnostic the loader promises. convert_table now rejects a non-finite fixed input during parsing, naming the table, the key, and the value; three table-driven cases cover all three YAML spellings. 797 lib tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): drop stale 'github pins pending' prose The github fingerprints landed with captured contracts under fixtures/github/contracts/, but three sentences still described the pinning as a pending follow-up: the integration guide's compatibility section (the reviewed one) plus two passages in the tasks spec found by sweeping for the same staleness. All three now state that both real packs are pinned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): handle register_open_connector_udtfs's Result in setup helpers The function became fallible when pack loading stopped panicking, but the three test setup helpers (slack, github, table_functions) still ignored the Result, emitting unused_must_use in test builds. They now expect() it, so a setup failure is explicit and the test build is warning-free again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): pin each pack's fingerprint coverage gap The fingerprint gate hashes the DECLARED output schema, and several mapped columns exist only via additionalProperties passthrough (github issues' created_at/updated_at/closed_at, most slack.files columns, more across the github tables) — their drift is invisible to the gate and surfaces at scan time instead. The captured contract cannot be widened pack-side (the pin compares against live discovery; editing the capture would fail registration), so the gap is made explicit instead of implicit: a testutil helper walks every mapped path through the captured row-item schema, and each pack pins its exact uncovered-column set — conversations/users pin EMPTY sets (the normalizing executors declare everything), proving the mechanism. Any change (upstream declaring more, or a mapping change) now fails a test and forces a conscious decision. The guide's compatibility section documents the boundary and the scan-time consequences (shape change fails loudly, a removed nullable field reads as NULL). 799 lib tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 549f928 commit 33033a3

42 files changed

Lines changed: 4809 additions & 955 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ For end-to-end walkthroughs — RAG, recommendations, an agent-native wiki, a si
330330
| S3 / GCS / Azure | Read | No | CSV, Parquet, Lance from object stores | [docs/S3_USAGE.md](docs/S3_USAGE.md) |
331331
| Apache Iceberg | Read | No | Schema evolution, partition pruning | [docs/iceberg/](docs/iceberg/) |
332332
| InfluxDB 3 | Read | No | Time-series measurements over Arrow Flight SQL | [docs/influxdb/](docs/influxdb/) |
333-
| Open Connector | Read | Yes | SaaS resources as stable SQL tables via a self-hosted [Open Connector](https://github.com/oomol-lab/open-connector) gateway; GitHub pack (repos, issues, PRs, reviews, commits, workflow runs, releases — [guide](docs/open-connector-github.md)), `open_connector_query` / `open_connector_scan` UDTFs, filter + limit pushdown, bounded TTL cache (more provider packs rolling out) | [docs/open-connector.md](docs/open-connector.md), [demo](docs/open-connector/) |
333+
| Open Connector | Read | Yes | SaaS resources as stable SQL tables via a self-hosted [Open Connector](https://github.com/oomol-lab/open-connector) gateway; GitHub pack (repos, issues, PRs, reviews, commits, workflow runs, releases — [guide](docs/open-connector-github.md)), Slack pack (conversations, users, files — [guide](docs/open-connector-slack.md)), `open_connector_query` / `open_connector_scan` UDTFs, filter + limit pushdown, bounded TTL cache (more provider packs rolling out) | [docs/open-connector.md](docs/open-connector.md), [demo](docs/open-connector/) |
334334
| Documents | Read | No | PDF/Office/ODF/image -> per-page markdown, tables, images (local directories; `documents` feature) | [docs/documents.md](docs/documents.md) |
335335

336336
---

crates/cli/src/main.rs

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ impl UrlTableFactory for SkardiUrlTableFactory {
483483

484484
/// Create a new SessionContext with custom URL table support (built-in files + Lance)
485485
/// and the `lance_knn` / `pg_knn` / Open Connector UDTFs registered.
486-
fn new_session_context() -> (SessionContext, DatasetRegistry, OpenConnectorGateways) {
486+
fn new_session_context() -> Result<(SessionContext, DatasetRegistry, OpenConnectorGateways)> {
487487
let dataset_registry: DatasetRegistry = Arc::new(RwLock::new(HashMap::new()));
488488
let open_connector_gateways = OpenConnectorGateways::default();
489489
let session_store = SessionStore::new();
@@ -522,7 +522,7 @@ fn new_session_context() -> (SessionContext, DatasetRegistry, OpenConnectorGatew
522522
register_vec_to_binary_udf(&mut ctx);
523523
// Open Connector UDTFs plan against the gateway state that
524524
// register_open_connector_tables fills in during ctx registration.
525-
register_open_connector_udtfs(&ctx, Arc::clone(&open_connector_gateways));
525+
register_open_connector_udtfs(&ctx, Arc::clone(&open_connector_gateways))?;
526526

527527
// Embedding UDFs (gated by feature flags, lazy model loading on first call).
528528
#[cfg(feature = "onnx")]
@@ -551,7 +551,7 @@ fn new_session_context() -> (SessionContext, DatasetRegistry, OpenConnectorGatew
551551
registry.register_chunk_udf(&mut ctx);
552552
}
553553

554-
(ctx, dataset_registry, open_connector_gateways)
554+
Ok((ctx, dataset_registry, open_connector_gateways))
555555
}
556556

557557
/// Resolve a path string: if relative (and not remote), resolve against cwd.
@@ -1237,7 +1237,7 @@ async fn show_schema(
12371237
table_filter: Option<&str>,
12381238
out: &mut dyn Write,
12391239
) -> Result<()> {
1240-
let (mut session_ctx, dataset_registry, oc_gateways) = new_session_context();
1240+
let (mut session_ctx, dataset_registry, oc_gateways) = new_session_context()?;
12411241
let config =
12421242
load_and_register_all(ctx_path, &mut session_ctx, &dataset_registry, &oc_gateways).await?;
12431243

@@ -1384,7 +1384,7 @@ fn source_name_for<'a>(
13841384
/// data sources first. If no context file is found, run the query in a bare session with
13851385
/// URL table support (allowing direct file/lance paths in SQL).
13861386
async fn run_query(ctx_override: Option<PathBuf>, sql: &str) -> Result<()> {
1387-
let (mut session_ctx, dataset_registry, oc_gateways) = new_session_context();
1387+
let (mut session_ctx, dataset_registry, oc_gateways) = new_session_context()?;
13881388

13891389
// Try to load context file, but don't fail if not found when no explicit --ctx was given
13901390
match resolve_ctx_path(ctx_override.as_deref()) {
@@ -1542,7 +1542,7 @@ async fn run_pipeline_with_params(
15421542
.with_context(|| format!("Failed to render SQL for pipeline '{}'", pipeline_name))?;
15431543

15441544
// 3. Build a SessionContext with ctx data sources registered, then execute.
1545-
let (mut session_ctx, dataset_registry, oc_gateways) = new_session_context();
1545+
let (mut session_ctx, dataset_registry, oc_gateways) = new_session_context()?;
15461546
if let Some(p) = &ctx_path_for_load {
15471547
load_and_register_all(p, &mut session_ctx, &dataset_registry, &oc_gateways).await?;
15481548
}
@@ -2896,7 +2896,8 @@ spec:
28962896

28972897
#[tokio::test]
28982898
async fn errors_without_connection_string() {
2899-
let (mut session_ctx, registry, oc_gateways) = new_session_context();
2899+
let (mut session_ctx, registry, oc_gateways) =
2900+
new_session_context().expect("session context");
29002901
let err = register_source(
29012902
&mut session_ctx,
29022903
&dynamodb_source(None),
@@ -2914,7 +2915,8 @@ spec:
29142915

29152916
#[tokio::test]
29162917
async fn errors_without_options() {
2917-
let (mut session_ctx, registry, oc_gateways) = new_session_context();
2918+
let (mut session_ctx, registry, oc_gateways) =
2919+
new_session_context().expect("session context");
29182920
let source = dynamodb_source(Some("http://localhost:8000"));
29192921
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
29202922
.await
@@ -2949,7 +2951,8 @@ spec:
29492951

29502952
#[tokio::test]
29512953
async fn errors_without_connection_string() {
2952-
let (mut session_ctx, registry, oc_gateways) = new_session_context();
2954+
let (mut session_ctx, registry, oc_gateways) =
2955+
new_session_context().expect("session context");
29532956
let err = register_source(
29542957
&mut session_ctx,
29552958
&clickhouse_source(None),
@@ -2970,7 +2973,8 @@ spec:
29702973
// The provider is the single enforcement point for the read-only
29712974
// invariant — the CLI must reject read_write exactly like the
29722975
// server's UnsupportedWriteMode.
2973-
let (mut session_ctx, registry, oc_gateways) = new_session_context();
2976+
let (mut session_ctx, registry, oc_gateways) =
2977+
new_session_context().expect("session context");
29742978
let mut source = clickhouse_source(Some("http://127.0.0.1:1"));
29752979
source.access_mode = Some("read_write".to_string());
29762980
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
@@ -3015,7 +3019,8 @@ bindings:
30153019

30163020
#[tokio::test]
30173021
async fn errors_without_connection_string() {
3018-
let (mut session_ctx, registry, oc_gateways) = new_session_context();
3022+
let (mut session_ctx, registry, oc_gateways) =
3023+
new_session_context().expect("session context");
30193024
let source = open_connector_source(None, Some(VALID_CONFIG));
30203025
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
30213026
.await
@@ -3031,7 +3036,8 @@ bindings:
30313036
async fn errors_with_table_hierarchy() {
30323037
// hierarchy_level defaults to Table; the CLI must reject it with
30333038
// a clear message, not the provider's wrapped error.
3034-
let (mut session_ctx, registry, oc_gateways) = new_session_context();
3039+
let (mut session_ctx, registry, oc_gateways) =
3040+
new_session_context().expect("session context");
30353041
let mut source =
30363042
open_connector_source(Some("http://localhost:3000"), Some(VALID_CONFIG));
30373043
source.hierarchy_level = HierarchyLevel::Table;
@@ -3047,7 +3053,8 @@ bindings:
30473053

30483054
#[tokio::test]
30493055
async fn errors_without_typed_config() {
3050-
let (mut session_ctx, registry, oc_gateways) = new_session_context();
3056+
let (mut session_ctx, registry, oc_gateways) =
3057+
new_session_context().expect("session context");
30513058
let source = open_connector_source(Some("http://localhost:3000"), None);
30523059
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
30533060
.await
@@ -3065,7 +3072,8 @@ bindings:
30653072
// The provider is the single enforcement point for the
30663073
// read-only invariant — the CLI must reject read_write exactly
30673074
// like the server's UnsupportedWriteMode.
3068-
let (mut session_ctx, registry, oc_gateways) = new_session_context();
3075+
let (mut session_ctx, registry, oc_gateways) =
3076+
new_session_context().expect("session context");
30693077
let mut source =
30703078
open_connector_source(Some("http://localhost:3000"), Some(VALID_CONFIG));
30713079
source.access_mode = Some("read_write".to_string());
@@ -3078,7 +3086,8 @@ bindings:
30783086

30793087
#[tokio::test]
30803088
async fn errors_when_typed_config_on_wrong_type() {
3081-
let (mut session_ctx, registry, oc_gateways) = new_session_context();
3089+
let (mut session_ctx, registry, oc_gateways) =
3090+
new_session_context().expect("session context");
30823091
let mut source =
30833092
open_connector_source(Some("http://localhost:3000"), Some(VALID_CONFIG));
30843093
source.source_type = "csv".to_string();
@@ -3096,7 +3105,8 @@ bindings:
30963105
async fn errors_when_token_env_missing() {
30973106
// With the config valid, the next failure is the unset runtime
30983107
// token — before any network call to the (unroutable) gateway.
3099-
let (mut session_ctx, registry, oc_gateways) = new_session_context();
3108+
let (mut session_ctx, registry, oc_gateways) =
3109+
new_session_context().expect("session context");
31003110
let config =
31013111
VALID_CONFIG.replace("OPEN_CONNECTOR_TOKEN", "SKARDI_CLI_TEST_OC_TOKEN_UNSET");
31023112
let source = open_connector_source(Some("http://127.0.0.1:1"), Some(config.as_str()));

crates/server/src/optimizer_registry.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ impl OptimizerRegistry {
120120
// Register Open Connector table functions
121121
if source_types.contains(&DataSourceType::OpenConnector) {
122122
tracing::info!("Registering Open Connector table functions");
123-
register_open_connector_udtfs(ctx, self.open_connector_gateways());
123+
register_open_connector_udtfs(ctx, self.open_connector_gateways())?;
124124
tracing::info!("✓ Registered open_connector_query and open_connector_scan");
125125
}
126126

crates/skardi/src/sources/providers/open_connector/action_registry.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ impl ActionRegistry {
170170
/// The schema is canonicalized first (object keys sorted recursively, so two
171171
/// semantically identical schemas with different key orders fingerprint
172172
/// equally), then hashed with BLAKE3 and hex-encoded.
173-
fn fingerprint_schema(output_schema: Option<&Value>) -> String {
173+
pub(crate) fn fingerprint_schema(output_schema: Option<&Value>) -> String {
174174
let canonical = match output_schema {
175175
Some(schema) => canonical_json(schema),
176176
None => "null".to_string(),

crates/skardi/src/sources/providers/open_connector/error.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,46 @@ pub enum OpenConnectorError {
204204
)]
205205
NonIdempotentAmbiguousFailure { operation: String, reason: String },
206206

207+
/// The provider reported an in-band error inside an otherwise
208+
/// successful response envelope — Slack's HTTP-200 `ok: false` +
209+
/// `error` pattern. The code is a short provider-authored identifier
210+
/// (`missing_scope`, `not_authed`), bounded before display.
211+
#[error(
212+
"Open Connector action '{action_id}' page {page}: the provider reported \
213+
error '{code}'"
214+
)]
215+
ProviderReportedError {
216+
action_id: String,
217+
page: usize,
218+
code: String,
219+
},
220+
221+
/// A declared total-pages location resolved to a non-numeric value, so
222+
/// the scan cannot know when the collection ends.
223+
#[error(
224+
"Open Connector pagination total at '{path}' on page {page} is {found}, \
225+
expected a non-negative integer"
226+
)]
227+
PaginationTotalInvalid {
228+
path: String,
229+
page: usize,
230+
found: String,
231+
},
232+
233+
/// A continuation cursor was present at the declared path but was not a
234+
/// string. Treating it as end-of-collection would silently truncate the
235+
/// scan, so it fails instead. Carries the JSON *kind* only, never the
236+
/// value.
237+
#[error(
238+
"Open Connector pagination cursor at '{path}' on page {page} is {found}, \
239+
not a string; refusing to treat it as end-of-collection"
240+
)]
241+
PaginationCursorInvalid {
242+
path: String,
243+
page: usize,
244+
found: String,
245+
},
246+
207247
/// Pagination failed to advance: the gateway returned an already-seen
208248
/// cursor, which would loop the scan forever.
209249
#[error(
@@ -354,6 +394,12 @@ pub enum OpenConnectorError {
354394
found: String,
355395
},
356396

397+
/// An embedded source-pack asset failed to parse or validate. A build
398+
/// defect (assets ship inside the binary), surfaced as a registration /
399+
/// UDTF-setup diagnostic instead of a panic.
400+
#[error("embedded source pack asset '{asset}' is invalid: {reason}")]
401+
SourcePackAssetInvalid { asset: String, reason: String },
402+
357403
/// A response body grew past the configured decoding bound.
358404
#[error("Open Connector {operation} response exceeded the {limit_bytes}-byte bound")]
359405
ResponseTooLarge {

crates/skardi/src/sources/providers/open_connector/exec.rs

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ pub struct ScanTarget {
4747
pub action_id: Arc<str>,
4848
/// Pagination contract.
4949
pub pagination: PaginationStrategy,
50+
/// In-band provider-error location (see `SourcePackTable::error_path`);
51+
/// `None` for raw scans and packs whose providers error at HTTP level.
52+
pub error_path: Option<&'static str>,
5053
/// Fixed action inputs sent with every request (see
5154
/// [`SourcePackTable::fixed_inputs`]); empty for raw scans, whose whole
5255
/// input is caller-supplied.
@@ -63,6 +66,7 @@ impl ScanTarget {
6366
table_id: Arc::from(table.id),
6467
action_id: Arc::from(table.action_id),
6568
pagination: table.pagination,
69+
error_path: table.error_path,
6670
fixed_inputs: table.fixed_inputs,
6771
source_pack_version,
6872
}
@@ -274,6 +278,9 @@ struct ScanState {
274278
scan_timeout: Duration,
275279
deadline: Instant,
276280
pagination: Pagination,
281+
/// Pre-parsed in-band provider-error path, checked before each page's
282+
/// row extraction.
283+
error_path: Option<RowPath>,
277284
rows_emitted: u64,
278285
/// Cached batches to replay (non-empty only on a cache hit).
279286
replay: VecDeque<RecordBatch>,
@@ -358,6 +365,7 @@ impl ScanState {
358365
scan_timeout: exec.scan_timeout,
359366
deadline: Instant::now() + exec.scan_timeout,
360367
pagination: Pagination::new(exec.target.pagination)?,
368+
error_path: exec.target.error_path.map(RowPath::parse).transpose()?,
361369
rows_emitted: 0,
362370
replay,
363371
fetched: Vec::new(),
@@ -474,6 +482,30 @@ impl ScanState {
474482
if Instant::now() >= self.deadline {
475483
return Err(self.timeout_error());
476484
}
485+
// Some gateways forward a provider's in-band application errors
486+
// unchanged (Slack-style HTTP 200, `ok: false` + `error`). Packs
487+
// targeting such a gateway declare `error_path` so the provider's
488+
// own code surfaces instead of the misleading row-path error the
489+
// missing row array would raise. (Open Connector's own executors
490+
// consume Slack's `ok:false` and return a failure envelope, so its
491+
// slack pack declares none — the mock pack models this mechanism.)
492+
if let Some(error_path) = &self.error_path
493+
&& let Ok(code) = error_path.extract(&envelope, page)
494+
&& !code.is_null()
495+
{
496+
let code = match code.as_str() {
497+
Some(text) => text.chars().take(128).collect(),
498+
None => format!(
499+
"<{}>",
500+
crate::sources::providers::open_connector::row_path::json_kind(code)
501+
),
502+
};
503+
return Err(OpenConnectorError::ProviderReportedError {
504+
action_id: self.target.action_id.to_string(),
505+
page,
506+
code,
507+
});
508+
}
477509
let rows = self.row_path.rows(&envelope, page)?;
478510
let batch = self.converter.convert(rows, page)?;
479511
// Conversion is synchronous, so it cannot be preempted by Tokio; do
@@ -529,10 +561,18 @@ impl ScanState {
529561
self.store_cache();
530562
}
531563

532-
let more = self.pagination.advance(&envelope, rows.len())?;
533-
if !more {
534-
self.done = true;
535-
self.store_cache();
564+
// Pagination advances only while the scan is still going. After a
565+
// LIMIT-satisfied page there is no next request to prepare — and
566+
// advance() also parses and validates continuation state, so a
567+
// repeated cursor or a missing/malformed page total on that final
568+
// page would fail a scan whose result is already complete for its
569+
// key.
570+
if !self.done {
571+
let more = self.pagination.advance(&envelope, rows.len())?;
572+
if !more {
573+
self.done = true;
574+
self.store_cache();
575+
}
536576
}
537577

538578
// A terminal empty page is completion, not output.
@@ -557,7 +597,7 @@ impl ScanState {
557597
#[cfg(test)]
558598
mod tests {
559599
use super::*;
560-
use crate::sources::providers::open_connector::packs::mock::MOCK_PACK;
600+
use crate::sources::providers::open_connector::packs::mock;
561601
use crate::sources::providers::open_connector::testutil::{
562602
CapturedEvent, MockGateway, MockResponse, RecordedRequest, capture_events, envelope_ok,
563603
};
@@ -571,7 +611,7 @@ mod tests {
571611
limit: Option<usize>,
572612
source_pack_version: u32,
573613
) -> OpenConnectorExec {
574-
let table = &MOCK_PACK.tables[0];
614+
let table = &mock::pack().expect("embedded asset parses").tables[0];
575615
OpenConnectorExec::new(
576616
client,
577617
cache,

0 commit comments

Comments
 (0)