What is a connector?
A connector is the platform's bridge to a third-party service a tenant uses — Scrive for e-signing, Fortnox for accounting, a BankID provider for identity. It is the one place in Substrat allowed to talk to the outside world.
Connectors are host code, never module code. A vertical cannot call an external API: the boundary lint bans fetch outright, and there is nowhere for a vertical to keep a credential. That prohibition is the whole point — it means "this tenant's Scrive account" lives in one audited place, and a vertical sees only the capability, never the secret.
Where a connector sits
Decision 18's triage rule sorts every outside dependency into three buckets, and a connector is the third:
| Bucket | What | Example |
|---|---|---|
| Kernel-owned | enforcement inputs and contracts | tenancy, permissions, the event spine, the integrations hub itself |
| Adapter | infrastructure the kernel consumes, swappable behind a pure interface | the SQLite/Cloudflare scope host, the SecretBox that seals credentials |
| Connector | a third-party capability tenants use, in the hub | Scrive, Fortnox, BankID, Swish, Peppol, Kivra, EDI |
The hub — the connection store, the runtime, the authority seam — is kernel-owned. Individual connectors are not: they accrete one at a time, as a vertical needs one, and live in connectors/* outside the kernel.
The seam a connector plugs into
A connector never invents its own machinery. Four kernel-owned pieces do the load-bearing work, and a connector is small because they exist:
The connection store. A tenant's authorization for one provider, held by one vertical, keyed (tenant, vertical, provider). Credentials are sealed at rest by a
SecretBoxadapter — the metadata is readable, the secret never is. See Permissions for how a connection becomes a subject. The hub is not the only way in: a vertical whose users have no dashboard account starts a provider's consent round itself withrequestConnectUrl, and the connection still lands in this store, stamped with the principal whose permission check authorized it.The connector runtime.
registerConnector(id, eventType, handler)binds a handler to an event. When a module emits that event, the runtime hands the handler an opened credential and afetchbound to the connection — so a timeout, egress policy, and per-connection health recording come for free, and module code still cannot reach any of it.At-least-once delivery with retry. The handler rides the same journal, backoff and dead-letter as every executor. A provider being briefly down is ordinary; the runtime retries with backoff and surfaces a dead letter rather than dropping the effect.
The inbound authority seam. A provider's callback is not a person, so it cannot hold a
PrincipalId. Instead a connection is a subject:getConnectorScope(connectionId, scopeId)opens a scope stub whose authority is the connection's own grants, narrowed by construction to that tenant and vertical. Events it causes are stamped{ connection }on the spine — the audit trail says "Scrive did this" without naming a human who did not.
What a connector must answer for itself
The seam supplies the machinery; four answers are the connector's own, and only the last is optional.
- What it does at the provider — the handler, or the sweep for a poll-only connector.
- Whether this credential is any good — a probe, and this one is not negotiable. Write it as a pair: one that checks a candidate secret at connect time, before anything is stored, and one that checks the credential a live connection already holds. Reach for the cheapest authenticated read the provider offers that names the account — Fortnox's
/companyinformation, Planima's/organizations— because the question an operator actually has is not "is this token valid" but "does it see the customer I meant". A valid token from the wrong login is the failure a syntax check cannot catch. - What this connection has been doing — activity, projected from the connector's own ledger into a declared shape, so redaction is structural.
- Which credential is loaded — identifiers whole, secrets masked by the connector's own rule.
The probe is mandatory because of what its absence looks like from the outside. Verify answers 501 for a provider with no probe registered — correct for a provider the platform does not operate, and actively misleading for one it does: the tenant pressing Test connection reads "Couldn't reach the provider", and blames the provider for our missing wiring. A connector that skips it also skips its own connect-time gate, so a mistyped credential is stored and looks healthy until the first real dispatch fails.
If a provider genuinely exposes nothing to probe with, the connector still carries a probe that says so, rather than leaving the route to 501. A stated "this provider exposes no verification read" is something a console can render and a person can act on.
What a connector is not
- Not an engine. An engine owns invariants, operations, events and domain state inside a scope. A connector owns none of those — it consumes an event and effects something outside. It has no tables, no permissions of its own, no domain model.
- Not swappable infrastructure. The KMS behind
SecretBoxis an adapter (bucket 2). A connector is a capability a tenant deliberately connects, not plumbing the kernel picks. - Not a way around the module rules. A connector cannot read a vertical's tables. If a connector needs a vertical's data, the vertical puts it in the event payload — the connector works from that and nothing else.
Available connectors
Connectors accrete per vertical need, so this list is short by design and grows one entry at a time. Status is honest: a connector can be documented and half-built, because the seam it needs may not exist yet.
| Connector | Category | Status | Provider |
|---|---|---|---|
| Scrive | E-signing & identity | Published (npm, 0.x) — both halves built and running in production; two caveats: the vertical schedules the poll, and BankID is off on the testbed | Scrive eSign (Swedish BankID) |
| Fortnox | Accounting | Live-verified (0.x) — poll-only, reads bookkeeping as SIE4; client-credentials auth, so no refresh token to rotate. The live run corrected the export charset (PC8/CP437, not latin1) | Fortnox (Swedish accounting) |
| Planima | Facility maintenance | Built, not yet live-verified (0.x) — poll-only and read-only, reads a maintenance plan and its costed actions; one static API token, so nothing to rotate. Every claim still rests on the published OpenAPI document and a mock that shares its reading | Planima (Swedish maintenance planning) |
Categories, as they will fill in (from the master plan's build list):
- E-signing & identity — Scrive, BankID, Kivra
- Accounting — Fortnox, Visma
- Facility maintenance — Planima
- Payments — Swish
- E-invoicing & EDI — Peppol, Ahlsell / Rexel / Sonepar
How these pages are organized
Every connector documents itself the same way — a different shape from an engine's five pages, because a connector is a different thing. One page, these sections:
| Section | Answers |
|---|---|
| At a glance | provider, category, status, the npm package |
| What it consumes | the event that triggers it, and what the payload must carry |
| The credential | what the connection stores, and what must never be stored |
| The flow | what it does at the provider, step by step |
| What's missing | the seams it still needs — stated plainly, because an incomplete connector that pretends otherwise is worse than none |
If a connector cannot fill "What's missing" with an empty list, it is not done, and the page says so.