Motivation
Through v8.19 a tenant could connect exactly one account per connector: one IMAP mailbox, one Google Drive, one Notion workspace. The data model enforced it with aUNIQUE(tenant_id, connector_name) on connector_installations, and the
ingested documents landed in a synthetic connector-<key> project rather than a
project the operator actually uses.
That breaks the moment a real team shows up. A helpdesk has a support@ and a
sales@ mailbox. A consultancy has one Drive per client. A platform team wants
the engineering Notion in the engineering KB project and the HR Notion in
acme-hr — not both dumped into connector-notion.
v8.20 lifts that ceiling: N labelled accounts per connector, each optionally
bound to a real KB project (empty = the tenant default). It is delivered
tri-surface (PHP + HTTP + MCP) over one core service (R44), tenant-scoped on
every query (R30).
Theory — what actually had to change
The connectors were already multi-account-capable where it counts: every connector’s sync logic is keyed oninstallationId, the credential vault stores
one secret per installationId, the on-disk path includes installation-%d, and
the scheduler already iterates all active installations. The blockers were
three narrow assumptions:
- the
UNIQUE(tenant_id, connector_name)constraint; - the per-connector
project_key ?? 'connector-<key>'fallback scattered across all eight connectors; - the host UI/service treating “one installation per connector” as an invariant.
askmydocs-connector-base package plus host adoption — not a rewrite.
Design
Two collaborating core services, one per capability (R44):ConnectorInstallationService— the read summary (shared verbatim by the HTTPindex, theconnectors:listcommand and the MCP tool), OAuth-account creation (find-or-rearm by label), metadata edits, and deletion. Concurrency is handled withlockForUpdateinside a transaction on the re-arm/edit paths (R21).ConfigureConnectorService— credential-account creation, owning the secret → encrypted-vault round-trip.labelandproject_keyare written as first-class columns, never intoconfig_json.
state token issued at install time is cached against its
installation id, so a callback resolves the exact account it belongs to even
when several accounts on one connector are PENDING at once.
Data model
Theaskmydocs-connector-base v1.3 migration extends connector_installations:
| Column | Type | Notes |
|---|---|---|
label | string(64) default 'default' | account discriminator; back-fills existing rows to 'default' |
project_key | string(120) nullable | optional KB project binding; null = tenant default |
- Unique relaxes
UNIQUE(tenant_id, connector_name)→UNIQUE(tenant_id, connector_name, label)— still tenant-first (R30/R31), now label-disambiguated (R28-style). - A second migration moves any legacy
config_json['project_key']into the new column so the column becomes the single source of truth. BaseConnector::resolveProjectKey($installation)resolvesproject_key column → config_json legacy → kb.ingest.default_project → 'default'— one place, replacing the scatteredconnector-<key>fallback.
Decision rationale
See ADR 0017 for the full record. The load-bearing choices:- Relax the unique, don’t drop it.
(tenant_id, connector_name, label)keeps the tenant boundary and makes the account identity explicit. The DB unique is the authority for duplicate-label rejection (the request-level rule is best-effort UX); the create-race surfaces a friendly 422, never a 500 (R21/R14). project_keyis a real column, validated against the real registry. The admin dropdown and theexistsrule both derive fromGET /api/admin/projects(R18) — never a hard-coded list. Empty binds to the tenant default.- OAuth install is find-or-rearm by label; credential configure is
create-only. Re-granting an OAuth scope re-arms the same labelled account;
adding a credential account with an existing label is rejected. Editing an
account (rename / rebind) is a separate
PATCH.
Worked example — two IMAP mailboxes
acme-hr, Sales’s in the tenant
default. Adding a third account labelled Support is rejected by the
(tenant, imap, label) unique.
Gotchas
labelis required in the UI but defaults to'default'at the API. A single-account install that omits the label gets'default', preserving the pre-v8.20 behaviour; a second omitted-label install collides on'default'.- Clearing a binding vs leaving it. On a
PATCHedit, an emptyproject_keyclears the binding (inherit the tenant default); on an OAuth re-grant, a blankproject_keyleaves the existing binding untouched (the controller keys onfilled(), nothas()). - Deleting an account cascades its vault row. The
connector_credentialsFK iscascadeOnDelete(R28) — removing the account removes its secret. - Re-enabling a disabled account. There is no “enable” endpoint yet; re-add the account with the same label to re-arm it.