# 03 — Connectors: connection paths, the `connection` umbrella, and the dialable kinds

*Measured against `/home/datashield/library` at v0.22.188 (tag `2b5f46a9c`, 2026-09-21). Every claim below
carries a `file:line` you can re-read; where a value was obtained by evaluating the config module under
`tsx` rather than by reading a literal, the section says so. Normative keywords per RFC 2119.*

Audience: developers and AI agents connecting a data source to the Library/Ontology. Read §2 (paths),
§3 (the `connection` umbrella) and §5 (your kind's row) before creating anything; read §9 before you
trust a probe verdict; read §11 for the two registrations that were scripted on 2026-09-21 and what the
ledger says actually ran.

---

## 1. Vocabulary

| Term | Meaning | Where it is defined |
|---|---|---|
| **connection** | An encrypted credential row (`registry.connections`), AES-256-GCM at rest, owned by the caller's account, optionally shared | `scripts/mcp-server/umbrellas/connection.ts:54-57` |
| **`connection_type`** | The *auth shape* of the stored config: `oauth2 \| api_key \| basic \| bearer \| custom` | `connection.ts:92` |
| **`platform`** | *Which provider the credential dials* — a closed vocabulary equal to the connection-backed `PROVIDER_KINDS` ids (`z.enum(CONNECTION_BACKED_KIND_IDS)`); orthogonal to `connection_type` | `connection.ts:101` |
| **provider kind** | A `PROVIDER_KINDS` row in `config/data-catalog.config.ts:483` with a `path` (`A \| B \| ingest`), a harvest transport and lane flags | `config/data-catalog.config.ts:483`, `:734` |
| **probe kind** | The wire protocol a liveness verdict rests on (`postgres_dial`, `tds_dial`, `saas_get`, …) | `config/connection.config.ts:771` |
| **lane** | One of `discover \| profile \| extract` (`LANE_CAPABILITIES`) run by `data_catalog.scan_run` over a provider | `config/data-catalog.config.ts:1967` |

A connection is not a provider. The sequence is always: `connection.create` (credential) → `data_catalog.provider_create` (bind
the credential to a kind) → `data_catalog.provider_test` / `connection.test` (liveness) → `data_catalog.scan_run` (lanes).

---

## 2. STANDARD-010 — exactly two connection paths, and who owns what

Source: `docs/standards/STANDARD-010-connection-paths-and-ownership.md` (operator ruling 2026-09-15, amended for the
catalog-elt-1 wave).

| Path | For | Mechanism (STANDARD-010 §1) | Kinds at v0.22.188 (measured, §5 below) |
|---|---|---|---|
| **A — native dial** | databases and object stores that matter to customers | vendor SDK loaded at *dial* time (`NO_LOADTIME_RESOLUTION`), `DIALERS` material table, one dialability predicate, dialect-keyed read-only SQL guard, metadata-only harvest, typed dial verdicts | `postgresql`, `snowflake`, `databricks`, `bigquery`, `sqlserver`, `mysql`, `s3` (7) |
| **B — REST + vault** | every SaaS / API-shaped source | `saas_api` (or `rest_api`) transport, credentials in the Ontology vault, one OAuth2 acquirer, one SaaS metadata-catalog harvester contract | `salesforce`, `dynamics365`, `rest_api`, `iceberg_rest`, `kafka` (5 — the last two are declared only, §6) |
| **`ingest`** (not a path) | pre-existing file/feed ingestion lanes | never dials a provider *from the catalog*; a kind that gains a dial moves to A or B | `azure_blob`, `gcs`, `sftp`, `ftp`, `remote_storage`, `imap`, `socrata`, `tyler`, `ckan`, `arcgis`, `opendatasoft`, `dcat`, `filesystem`, `other` (14) |

Rules that bind a connector author (STANDARD-010 §1, §5):

- A third path (ODBC/JDBC driver sets, generic sockets, bespoke transports) MUST be refused at groom. The correct move is a
  new Path-A thin client or a Path-B kind. Native binaries only when no thin client exists AND the operator rules.
- Gate `audit:catalog-config-agreement` leg T30: every `PROVIDER_KINDS` row declares `path` + `pathWhy`; a row with a
  `driverModule` MUST be A; a `saas_api`/`rest_api` transport MUST be B.
- **Path B kinds MAY dial.** Dialability is answered by the transport's `objectSurface`
  (`HARVEST_TRANSPORT_CAPABILITIES`, `config/data-catalog.config.ts:656`: `source_pool → engine_catalog`,
  `saas_api → metadata_api`, both `dials: true`; `local_projection → platform_registry`, `dials: false`), not by the letter.

**Ownership (STANDARD-010 §2).** Ontology (this Library) is the loaded home of every *data* connection — all Path-A and
Path-B kinds, their credential shapes, dial probes, lanes and vault entries. **Auth** is the exclusive home of *security*
connectors (Entra ID, Okta, logging/security tooling, signing keys, MCP service registrations) and the passthrough gateway.
Both MUST expose the full connector list read-only. Until the shared `@datashield/connection-contracts` package exists (§3),
`audit:auth-connection-type-parity` keeps the two registries honest; a kind Auth does not carry declares
`library_only` with a reason (e.g. `BEARER_CREDENTIAL_SHAPE.authParity`, `config/data-catalog.config.ts:1730`).
Commander and third-party MCP clients are *consumers* through Auth entitlements, never a third runtime (§6).

---

## 3. The `connection` umbrella

File: `scripts/mcp-server/umbrellas/connection.ts` (623 lines). Description text at `:54-86`; Zod command union at `:88-122`;
handler cases at `:183` (create), `:229` (list), `:264` (get), `:288` (delete), `:297` (test).

| Command | Params (from the Zod schema) | Behaviour measured in the handler |
|---|---|---|
| `create` | `name`, `connection_type` (`oauth2\|api_key\|basic\|bearer\|custom`), `config` (object), optional `description`, `provider_name`, `base_url`, `scopes`, `tags`, `expires_at`, `platform`; `is_shared` admin-only | Owned by the caller. `platform` is validated against `registry.provider_kinds WHERE connection_backed` via `assertKindUsable` (`:202-210`, LIB-BUG-1350 K1) |
| `list` | filters `provider_name`, `connection_type`, `tag`; paginated | Metadata only — **never returns `config`**. Returns `platform` and the latest probe outcome (`:246-259`) |
| `get` | `id` | Same metadata shape as `list` (`:273-282`) |
| `test` | `id` | Probe chosen by the row's `platform` through `PLATFORM_PROBES` (`:354`); `null` platform → legacy classification (`:348-350`) |
| `delete` | `id`, `confirm: true` (literal) | **Soft**-delete — deactivates the row, owner/admin only (`:288-296`, `:117-121`) |

**Config is write-only.** `create` stores it encrypted; `list`/`get` return metadata only and the credential can never be
read back (`connection.ts:55`). The *latest probe outcome* is deliberately readable (LIB-BUG-556 #2, `:250`, `:278`).

**`connection_type` vs `platform`.** `connection_type` says how the secret is shaped for auth; `platform` says what it
dials and therefore which probe and which `data_catalog` kind can bind it (`:101`, `:85`). A row created without `platform`
is "unplatformed"; `data_catalog.provider_create` can stamp it later. Note the 2026-09-21 fresh-client prompt
(`docs/audit/v0.22.188-fresh-client-test-prompt.md:125`) passes `connection_type:'mysql'`, which is **not** in the
`CONNECTION_TYPES` enum — the prompt itself says "if `connection_type` does not accept `mysql`, that disagreement is the
finding" (`:129-130`). Use `connection_type:'basic'` (or the shape your kind's material declares) and `platform:'mysql'`.

### 3.1 Probe kinds per platform — `PLATFORM_PROBES`

`config/connection.config.ts:1011-1103` (kind vocabulary `CONNECTION_PROBE_KINDS` at `:771`; resolver-dial subset at `:963`).

| `platform` | probe kind | What the probe does |
|---|---|---|
| `postgresql` | `postgres_dial` | opens a real session; `SELECT current_setting('server_version')` (`PLATFORM_DIAL_PROBES`, `config/data-catalog.config.ts:2110`) |
| `sqlserver` | `tds_dial` | real session; `SELECT @@VERSION` |
| `snowflake` | `snowflake_dial` | real session; `SELECT CURRENT_VERSION()` |
| `mysql` | `mysql_dial` | real session; `SELECT VERSION()` |
| `bigquery` | `bigquery_dial` | `sql_dry_run` of `SELECT 1` |
| `databricks` | `databricks_dial` | `SELECT current_version().dbsql_version` |
| `dynamics365`, `salesforce` | `saas_get` | ONE kind for both: a credentialed HTTPS GET of a *declared* metadata path — `/WhoAmI` and `/limits` respectively (`connection.config.ts:1065-1069`, LIB-BUG-1433) |
| `rest_api` | `http_head` | SSRF-guarded HEAD of `base_url`, no credential (`connection.ts:73`) |
| `s3`, `azure_blob`, `gcs`, `sftp`, `ftp`, `remote_storage`, `imap` | `storage_dial` | provider's own `testConnection()`; for `s3` the dial probe is `object_store_list` with `maxKeys: 1` |
| `iceberg_rest`, `kafka` | `not_applicable` | typed `test_not_applicable` naming the platform — never a fabricated `unreachable` (`connection.ts:70, 366-370`) |

The probe kinds are also rows of `registry.probe_kinds` (migration 0239 FK), seeded from this tuple as an unconditional
post-step of `release:migrate` — which is why `release:migrate` MUST run before any pdl-mcp/pdl-job-worker reload
(`connection.config.ts:1054-1061`; confirmed at the v0.22.188 event, `docs/audit/v0.22.188-release-event.md:23`).
Probe timeout: `CONNECTION_TEST_TIMEOUT_MS = 10_000` (`connection.config.ts:448`).

---

## 4. Credential shapes — `PLATFORM_CREDENTIAL_FIELDS` and `BEARER_CREDENTIAL_SHAPE`

`config/data-catalog.config.ts:1253` (evaluated under `tsx`; `*` = required, `(s)` = secret). These are the members of the
`config` object you pass to `connection.create`.

| kind | fields |
|---|---|
| `postgresql` | `host*`, `port`, `database*`, `username*`, `password*(s)`, `sslmode` |
| `mysql` | `host*`, `port`, `database*`, `username*`, `password*(s)`, `ssl` |
| `sqlserver` | `host*`, `port`, `database*`, `username*`, `password*(s)`, `encrypt`, `trustServerCertificate` |
| `snowflake` | `account*`, `username*`, `password(s)` **or** `privateKey(s)` + `privateKeyPassphrase(s)`, `warehouse`, `database`, `schema`, `role` |
| `bigquery` | `project_id*`, `client_email*`, `private_key*(s)`, `location` |
| `databricks` | `server_hostname*`, `http_path*`, `access_token*(s)`, `catalog`, `schema` |
| `s3` | `bucket*`, `region`, `endpoint`, `access_key_id(s)`, `secret_access_key(s)`, `force_path_style` |
| `salesforce` | `instance_url*`, `client_id*`, `client_secret*(s)`, `token_url` (derived from the my-domain host — omit), `scope` (never invent) |
| `dynamics365` | `org_url*`, `tenant_id*`, `client_id*`, `client_secret*(s)`, `token_url`, `scope` |

`BEARER_CREDENTIAL_SHAPE` (`data-catalog.config.ts:1730`) is the *material* both Path-B kinds claim
(`PLATFORM_MATERIAL_KIND.salesforce = "bearer"`): `token_url*`, `client_id*`, `client_secret*(s)`, `scope`, `audience`. A
module-load check (`:1794`) refuses a platform whose credential fields omit a field its material requires. Its `authParity`
is `library_only`: Auth's registry declares no client-credentials grant shape, so no parity is claimed (`:1744-1769`).

S3-compatible endpoints (`S3_COMPATIBLE_ENDPOINTS`, `connection.config.ts:104`) and server-side-encryption decisions
(`decideServerSideEncryption`, `:368`) are declared as data; `resolveS3Endpoint` (`:259`) picks the endpoint from the
provider name.

---

## 5. Per-kind capability table (measured)

`runnableLanes(kind)` and `KIND_HARVEST_TRANSPORT` (`config/data-catalog.config.ts:2028`, `:734`) evaluated under `tsx` at
v0.22.188. `kindCanRun` (`:1915`) derives these from each kind's `implemented` flags — no hand-maintained list exists.

| kind | path | transport | driver (`driverModule`) | discover | profile | extract | credential read-only probe |
|---|---|---|---|---|---|---|---|
| `postgresql` | A | `source_pool` | `pg` | yes | yes | yes | yes — SQL over `information_schema.role_table_grants` (`CREDENTIAL_READONLY_PROBES`, `:3914`) |
| `snowflake` | A | `source_pool` | `snowflake-sdk` | yes | yes | yes | `null` (declared) |
| `sqlserver` | A | `source_pool` | `mssql` | yes | yes | yes | `null` |
| `mysql` | A | `source_pool` | `mysql2` (`:349`; pinned exactly at `3.24.4` in `package.json:383`) | yes | yes | yes | `null` |
| `bigquery` | A | `source_pool` | `@google-cloud/bigquery` | yes | yes | yes | `null` |
| `databricks` | A | `source_pool` | `@databricks/sql` | yes | yes | yes | `null` |
| `s3` | A | `source_pool` | `@aws-sdk/client-s3` | yes | no | no | `null` |
| `salesforce` | B | `saas_api` | — | yes | no | no | `null` (declared, `v0.22.187-task-elt1-b2-salesforce.md` §1 row 8) |
| `dynamics365` | B | `saas_api` | — | yes | no | no | `null` |
| `rest_api` | B | `local_projection` | — | yes | no | yes (scoped assets) | — |

What the lanes mean:

- **discover** — harvests the provider's *catalog* (schemas, tables/objects, columns, types, keys) into `catalog.assets` /
  `catalog.bindings`. For `source_pool` kinds this reads the engine's own `information_schema`-class views through a pooled
  session; for `saas_api` kinds it walks the metadata API only (§7).
- **profile** — per-column statistics and (where the sample policy permits) samples. Governed by `applySamplePolicy` and
  the acknowledgement rungs in `lib/data_catalog/policy.ts:114, :373`.
- **extract** — a FULL extract per binding (M3) into a Library dataset (`lib/data_catalog/extract/handler.ts:6`). See §8.

---

## 6. Declared-only kinds — stated honestly

`iceberg_rest` and `kafka` are `PROVIDER_KINDS` rows with `path: "B"`, transport `source_pool`, **no `driverModule`, no
credential shape, no dial probe and no runnable lane** (`runnableLanes` → `[]`). `connection.test` returns
`test_not_applicable` for them by name (`connection.config.ts:1101-1102`; `connection.ts:70`). LIB-BUG-1043 was narrowed
to exactly `mysql`, `iceberg_rest`, `kafka` at the v0.22.188 event (`v0.22.188-release-event.md:170`); `mysql` then shipped
in the same release, so the residue is these two. `azure_blob`, `gcs`, `sftp`, `ftp`, `remote_storage`, `imap` are
`ingest` kinds: they have a `storage_dial` liveness probe through the legacy provider registry but **no catalog lanes**
(`runnableLanes` → `[]`); the open-data portal kinds and `filesystem`/`other` have neither transport nor lanes. A
`data_catalog.provider_create` over any of these is a typed refusal, not a silent empty scan.

---

## 7. Path-B specifics: metadata-only allowlists, budgets, ceilings

### 7.1 Salesforce — `config/salesforce.config.ts`

- API version `67.0` (`:64`), prefix `/services/data/v67.0` (`:67`).
- **Exact-shape allowlist, not a prefix** (`:25-35`, `:174-192`): the admitted shapes are `describe_global` (`/sobjects`),
  `sobject_describe` (`/sobjects/{Name}/describe`) and `limits` (`/limits` — the liveness path of the bullet below). The reason is load-bearing: `/sobjects/Account/001xx…` is a customer
  *record* and `/sobjects/Account/listviews` names filter *values*; a prefix rule admits both. `assertMetadataOnlyPath()`
  (`:561`) refuses anything else and names the segment at which the path left the allowlist (`:199-202`).
- The object name interpolated into the next path is org-controlled input and is held to `SALESFORCE_OBJECT_NAME.pattern`
  (`:72-89`); unnormalised path markers are refused (`:124`).
- Liveness: `/limits` — requires a session and reads no record; `/services/data/` answers 200 unauthenticated and would
  report a credential healthy without presenting one (`SALESFORCE_LIVENESS`, `:228-235`). `reportsServerVersion: false`.
- Object filter: skips `queryable: false` and `retrieveable: false` sObjects (`:286-290`). Measured on the target org
  2026-09-16: 1700 sObjects, 1433 queryable, 2 custom (`:270-271`).
- **`SALESFORCE_BUDGET = { maxDescribesPerScan: 1000, describeConcurrency: 1 }`** (`:335-350`; `describeConcurrency` at `:349`). One describe per admitted
  object against a 15 000-call daily allocation shared with the customer's other integrations; 1000 ≈ 6.7 % of a day
  (`:310-330`). `scope.max_entities_per_scan` on the crawler outranks it; `SAAS_API.paging.maxEntities` bounds both.
- Governance evidence (`SALESFORCE_GOVERNANCE`, `:427-435`): `field_encryption_declared` written for every field;
  `evidenceStrength: "provider_declared"`, `scoreOnly: true`. It names *enrolment in Shield Platform Encryption*, not an
  effective read restriction — a reader MUST NOT render it as "masked".

### 7.2 Dynamics 365 (Dataverse) — `config/dynamics365.config.ts`

- API `v9.2`, prefix `/api/data/v9.2` (`:41-44`).
- **Prefix allowlist** (`DYNAMICS365_ADMITTED_PATH_PREFIXES`, `:159`): `/EntityDefinitions`, `/RelationshipDefinitions`,
  `/GlobalOptionSetDefinitions`, `/WhoAmI`. A prefix suffices here because Dataverse metadata collections are disjoint
  from record collections by URL (`:20-23`); traversal probes (`/EntityDefinitions/../accounts`, `%2e%2e`) are refused at
  module load (`:545`). Refusal code `path_not_in_metadata_allowlist` (`:445`) — the sentence is value-free by construction.
- `$select` projections (`DYNAMICS365_SELECT`, `:186`), entity filter `IsPrivate eq false` (`:237`), page-size header
  `odata.maxpagesize`, per-request timeout `PT60S` (ISO 8601, `:364`), credential statuses `[401, 403]` (`:380`).
- Governance evidence (`DYNAMICS365_GOVERNANCE`, `:352`): `field_level_security_declared` from `IsSecured`;
  `provider_declared`, `scoreOnly` — enrolment in field-level security, not an effective restriction.

### 7.3 The shared SaaS contract and the entity-ceiling ladder

`config/saas-api.config.ts` `SAAS_API` (evaluated): `paging = { defaultPageSize: 200, maxPages: 250, maxEntities: 5000 }`;
`rateLimit = { maxAttempts: 4, baseBackoff: PT1S, maxBackoff: PT30S, jitterRatio: 0.2, maxRetryAfter: PT2M,
retryStatuses: [429, 503], credentialStatuses: [400, 401, 403], transientStatuses: [500, 502, 504, 408, 425] }`. RFC 9110
§10.2.3 `Retry-After` is honoured in both syntaxes; a credential-fact status is never retried
(`HARVEST_TRANSPORT_CAPABILITIES.saas_api.rateLimitPosture`, `data-catalog.config.ts:687-691`).

The **ceiling ladder** (`SAAS_ENTITY_CEILING`, `lib/data_catalog/harvest/saas.ts:211-246`, resolved at `:249-266`): rung 1 the crawler's own
`scope.max_entities_per_scan`; rung 2 the kind's declared per-scan ceiling — `SAAS_KIND_ENTITY_CEILING_DEFAULTS`
(`data-catalog.config.ts:726`): `salesforce: 1000` (= `SALESFORCE_BUDGET.maxDescribesPerScan`), `dynamics365: null`; rung
3 the contract's `maxEntities: 5000`. The driver enforces the winner and records which rung supplied it, so a partial
scan is diagnosable (`salesforce_objects_truncated`, `dynamics365_relationships_truncated`).

---

## 8. Guards every dial passes through

| Guard | Where | What it does |
|---|---|---|
| **Read-only SQL guard** | `config/sql-readonly-guard.config.ts` — `SQL_GUARD_DIALECT_IDS = ["snowflake","sqlserver","bigquery","databricks","mysql"]` (`:56`), `SQL_GUARD_DIALECTS` (`:275`), `terminalVerb` (`:1024`), `hasStatementSeparator` (`:951`) | Dialect-keyed lexer that strips preambles/comments and refuses any statement whose terminal verb is not a read, or that carries a second statement. Postgres is covered by the credential read-only probe in §5 instead |
| **Host policy** | `HOST_POLICY`, `config/data-catalog.config.ts:2731` (evaluated): `blockMetadataRanges: true`, `blockSelfDatabase: true`, `allowPrivateRanges: false` (per-connection key `allow_private_ranges`), `allowLoopback: false` (env `PDL_CATALOG_ALLOW_LOOPBACK`), `allowUnixSockets: false`, `dsnQueryAllowlist: [sslmode, application_name, connect_timeout]` | Refuses cloud-metadata ranges, the Library's own database, private ranges, loopback and unix sockets before any socket opens |
| **Pinned dial** | `config/host-pinning.config.ts` `HOST_PINNING` (`:61`) declares a rule for every dialable kind, in **two strategies**: `socket_address` for `mysql` (`:66`) and `sqlserver` (`:75`), and `recheck` for `postgresql` (`:81`), `snowflake` (`:90`), `s3` (`:97`), `bigquery` (`:104`) and `databricks` (`:128`); `saas_api`/`dynamics365`/`salesforce` share `SAAS_API_HOST_PINNING` (`:138-159`), `socket_address` via a DNS-pinned undici Agent (`:54-55`) | On a `socket_address` row the address that passed policy is the address dialled — no DNS rebinding between check and connect. A `recheck` row does **not** close that window and says so in its own `residual` field ("the window is narrowed to the recheck-to-connect interval, not closed"): those drivers take a host NAME and resolve it themselves |
| **Cleartext refusal** | `SAAS_CLEARTEXT_REFUSAL_CODE = "cleartext_refused"` (`config/saas-api.config.ts:261`); raised in `lib/data_catalog/resolver/index.ts:430` (LIB-BUG-1435) | An `http://` token or instance URL is refused before a bearer secret is sent |
| **Sample policy** | `lib/data_catalog/policy.ts:114, :373, :530` | Values leave the customer's engine only under an acknowledged rung |

### 8.1 The extract lane on the shared loop

`EXTRACT_LANE_UNIT` (`data-catalog.config.ts:2939`, evaluated): `source_pool → "bindings"`, `local_projection →
"scoped_assets"`, `saas_api → "scoped_assets"`. `lib/data_catalog/extract/handler.ts:133` reads the unit from the
transport (close-fix L3: no silent literal, `:125`); for `bindings` it iterates `catalog.bindings` joined to assets
(`:171`), stops at `HARVEST_BUDGETS.maxExtractedObjectsPerScan` (`:190`), skips a binding with no dataset (`:194`) and
runs `FULL_EXTRACT_PROFILE.mode` (`:216`). Row egress goes through `lib/data_catalog/extract/sharedLoop.ts`; MySQL became
the fifth engine on it with "one spelling row, one `KIND_EXTRACT` row, one `EXTRACTORS` slot" and zero loop changes
(`docs/audit/v0.22.188-task-elt2-a-mysql.md:21`).

---

## 9. Dial verdicts — the classifier and the typed refusals

`classifyDialError` (`config/connection.config.ts:707`) maps `err.code` / `err.name` / `err.cause.code` (undici wraps the
socket error) to a registered wire code, first match wins, fallback `unreachable` (`:450-467`). Messages are fixed author
templates; raw upstream text is logged server-side only.

| code | retryable | `retry_after_ms` (`DIAL_RETRY_AFTER_MS`, `:681`) | class |
|---|---|---|---|
| `dns_failure` | yes | 30 000 | unreachable |
| `connect_timeout` | yes | 60 000 | timeout |
| `unreachable` | yes | 30 000 | unreachable (the honest generic) |
| `credential_rejected` | no | — | auth |
| `config_rejected` | no | — | config |
| `response_malformed` | no | — | — |
| `dial_failed_unclassified` | no | — | unclassified |

A module-load self-check refuses a retryable class without a positive budget, or a budget on a non-retryable class
(`:1105-1122`).

Refusals raised *before* or *around* a dial are not dial verdicts and are re-thrown, not flattened (G1 close-vet F-V1,
`:850-870`): `host_form_refused` (`resolver/index.ts:523`), the host-policy family (loopback / private range / metadata /
self-dial / unix socket), `credential_shape_invalid`, `credential_expired`, `driver_unavailable`, `cleartext_refused`, and
the SaaS token family `token_acquire_failed`, `token_endpoint_unreachable`, `token_response_too_large`
(`resolver/saasToken.ts:103-140`). The probe result then carries a `probe_not_run` reason selected by *phase*
(`CONNECTION_PROBE_NOT_RUN_REASONS`, `:829`: `host_unresolvable`, `resolve_refused`, and the third row for refusals raised
after preparation succeeded) so the caller is never told "the host did not resolve" about a host that was never queried.

Reading a verdict: `ok:false` + a code from the table = the provider was reached or attempted; `test_not_applicable` = the
platform has no probe (§6); `probe_kind: 'no_declared_probe'` = a configuration gap in `PLATFORM_PROBES`, not a statement
about the credential (`connection.ts:355-362`).

---

## 10. Governance evidence harvested per kind

The classification kernel (`config/classification-evidence.config.ts`) admits provider governance facts as the
`provider_declared` signal: role `score` (`:420`), weight `0.10` (`:438`), strictly below `fingerprint` (`:1259-1262`),
`evidence: "external_claim"`, `mayCorroborate: false` (`:490`) — a provider tag is a customer-authored *claim about* the
column, never an observation of it, so it can raise a score but never gate, veto or corroborate.

| kind | fact harvested | key | source |
|---|---|---|---|
| `salesforce` | Shield Platform Encryption enrolment per field | `field_encryption_declared` | `config/salesforce.config.ts:427-435` |
| `dynamics365` | field-level-security enrolment (`IsSecured`) | `field_level_security_declared` | `config/dynamics365.config.ts:352` |
| `mysql` | governance **absence** declared explicitly | see `config/mysql-harvest.config.ts` ("governance-ABSENCE declarations", `v0.22.188-task-elt2-a-mysql.md:31`) |
| `bigquery`, `postgresql`, `sqlserver`, `snowflake`, `databricks` | engine-catalog facts (keys, nullability, native type family via `NATIVE_TYPE_FAMILY_RULES`) | — | per-kind `harvest/*.ts` |

STANDARD-009 applies to every artefact about the data: value-free, `unclassified ≠ public`, masking OFF by default.

---

## 11. Worked example — registering Salesforce and MySQL (2026-09-21)

What the ledgers record, verbatim in disposition: the v0.22.188 event deployed both kinds
(`docs/audit/v0.22.188-release-event.md:55-74`: `mysql.path = A`, `mysql` lanes `{discover, profile, extract}` all true,
`salesforce` `{discover:true, profile:false, extract:false}`, four new probe kinds in `registry.probe_kinds` and in the
served bundle, `mysql2@3.24.4` installed additively in the primary inside the lock, node_modules 839 → 847). **Deviation 12
(`:239-240`): "No post-deploy live acceptance was run"** — the MySQL fixture pass and the Salesforce rescan were left to
the orchestrator. The registrations below are therefore the *scripted* acceptance
(`docs/audit/v0.22.187-task-elt1-b2-salesforce.md` §6, `:314-349`; `docs/audit/v0.22.188-fresh-client-test-prompt.md:121-133`),
not a recorded production run. Do not cite this section as proof a customer org was catalogued.

### 11.1 Salesforce (Path B, `saas_get`, discover only)

Prerequisite in the org: an External Client App with the client-credentials flow enabled **and a run-as user**; without
the run-as user the grant fails and `provider_test` answers `provider_dial_auth_failed`, which is the correct and
diagnosable answer (§6 step 1).

```jsonc
// 1. credential — token_url and scope OMITTED (derived / never invented)
connection {command:'create', name:'acme-salesforce', connection_type:'oauth2', platform:'salesforce',
  config:{instance_url:'https://acme.my.salesforce.com', client_id:'<consumer key>', client_secret:'<consumer secret>'}}
// 2. provider over that connection
data_catalog {command:'provider_create', kind:'salesforce', connection_id:'<id from step 1>', name:'Acme CRM'}
// 3. liveness
data_catalog {command:'provider_test', provider_id:'<pid>'}
//    expect ok:true, probe_kind:'saas_get', server_version:null (an org reports none)
// 4. discover
data_catalog {command:'scan_run', provider_id:'<pid>', purpose:'discover'}
```

Expected discover outcome (§6 step 4): `saas_object` assets > 0 with `salesforce_objects_seen ≈ 1700` and
`salesforce_objects_filtered` as denominators; `salesforce_api_usage_used` / `_allocation` (≈ 15 000) present; `fk` on
reference columns and `field_encryption_declared` on every column; `salesforce_objects_truncated: 1` means the describe
ceiling (§7.1, now 1000) bound a *partial, resumable* pass. **The first check to run**: grep the whole result for any
15/18-character Salesforce id outside a `key_prefix` attr — there must be none. Then read the job-worker log for the tick:
no `durable-tick DEGRADED`, no `assertHostPolicy` refusal on the my-domain host (§6 step 6).

### 11.2 MySQL (Path A, `mysql_dial`, discover + profile + extract)

```jsonc
connection {command:'create', name:'acme-mysql', connection_type:'basic', platform:'mysql',
  config:{host:'db.acme.example', port:3306, database:'sales', username:'catalog_ro', password:'<secret>', ssl:true}}
connection {command:'test', id:'<id>'}
//   expect probe_kind:'mysql_dial'; a rejected credential → credential_rejected with driver_code + stage
data_catalog {command:'provider_create', kind:'mysql', connection_id:'<id>', name:'Acme Sales DB'}
data_catalog {command:'provider_test', provider_id:'<pid>'}      // SELECT VERSION() over a pinned, policy-checked session
data_catalog {command:'scan_run', provider_id:'<pid>', purpose:'discover'}
data_catalog {command:'scan_run', provider_id:'<pid>', purpose:'profile'}
data_catalog {command:'scan_run', provider_id:'<pid>', purpose:'extract'}   // bindings unit on the shared loop, §8.1
```

The fresh-client prompt's local fixture (`127.0.0.1:13306`) will be refused by `HOST_POLICY.allowLoopback: false` unless
`PDL_CATALOG_ALLOW_LOOPBACK` is set — that refusal is `resolve_refused`, not a dial verdict (§9). Every session statement
runs through `SQL_GUARD_DIALECTS.mysql` (§8), and the four pinned session statements plus TLS policy live in
`config/mysql-session.config.ts`.

---

## 12. Adding a kind — the checklist the config enforces

Module-load validators in `config/data-catalog.config.ts` (`:4253-4416`) and `connectionProbeConfigProblems`
(`connection.config.ts:975`) refuse a process whose kind is half-declared. A new dialable kind needs, at minimum: a
`PROVIDER_KINDS` row with `path`/`pathWhy` (T30); `KIND_HARVEST_TRANSPORT`; `PLATFORM_CREDENTIAL_FIELDS`;
`PLATFORM_MATERIAL_KIND`; `PLATFORM_DIAL_PROBES`; `PLATFORM_PROBES` + a `RESOLVER_DIAL_PROBE_KINDS` member if it dials
through the resolver; `HOST_PINNING`; `CREDENTIAL_READONLY_PROBES` (a declared `null` with a reason is valid);
`AUTH_CONNECTION_TYPE_PARITY`; a `DIALERS` factory and `HARVESTERS` entry; for Path A a `SQL_GUARD_DIALECTS` row and an
exact-pinned driver; for Path B an allowlist config in the shape of §7. No migration is required for the probe kind —
the seeder fills `registry.probe_kinds` from config (`connection.config.ts:1054-1056`).
