Skip to content

Connector System

The connector system provides a plugin architecture for integrating external services (Canvas LMS, Outlook Mail, Outlook Calendar, OneDrive, SharePoint, Gmail, Google Calendar, Google Drive, PostgreSQL, ServiceNow, etc.) into CampusCore. It handles OAuth2 flows, encrypted credential storage, and a decorator-based action system that makes connectors self-describing.

Directory Structure

apps/connectors/
├── connectors/              # One subpackage per connector integration
│   ├── base.py              # BaseConnector abstract class
│   ├── credentials.py       # Stored-credentials models of the common connectors
│   ├── forms.py             # ConnectionForm: the manual connect form as a model
│   ├── registry.py          # Auto-discovery registry
│   ├── canvas/              # Canvas LMS connector
│   │   ├── __init__.py      #   re-exports CanvasConnector for discovery
│   │   ├── connector.py     #   CanvasConnector (OAuth flow, get_client)
│   │   ├── client.py        #   CanvasClient, the typed client get_client builds
│   │   ├── actions/                 # CanvasActions, composed from audience mixin modules
│   │   │   ├── __init__.py          #   composes + re-exports CanvasActions
│   │   │   ├── base.py              #   CanvasActionsBase (client closure constructor)
│   │   │   ├── shared_reads.py      #   reads any enrolled user can make
│   │   │   ├── instructor_reads.py  #   teacher/TA reads
│   │   │   ├── student_writes.py    #   a student's own-work writes
│   │   │   ├── instructor_writes.py #   teaching writes
│   │   │   └── admin.py             #   account search reads + enrollment writes
│   │   ├── models.py                # Pydantic output models
│   │   ├── transport.py             # pagination, origin rewrite, file download, GraphQL
│   │   ├── reshapes.py              # upstream-row -> output-model reshape fns
│   │   └── grade_projection.py      # grade projection math
│   ├── google_workspace/    # Google product connectors (Gmail, Google Calendar, Google Drive)
│   │   ├── __init__.py      #   re-exports the three connector classes for discovery
│   │   ├── connector.py     #   shared Google OAuth base + the three product classes
│   │   ├── client.py        #   GoogleClient and its MeCache
│   │   ├── actions/                # one composed actions class per product
│   │   │   ├── __init__.py         #   composes + re-exports the three actions classes
│   │   │   ├── base.py             #   GoogleWorkspaceActionsBase (client closure constructor)
│   │   │   ├── identity.py         #   per-product get_user_info probes + the Gmail whoami cache
│   │   │   ├── mail_reads.py       #   search/read mail, conversations, open-loop views, attachment bridge
│   │   │   ├── mail_writes.py      #   drafts (create, update, delete, attach), send, organize (all user_confirm)
│   │   │   ├── people_reads.py     #   find_outlook_people: a name to an email address
│   │   │   ├── calendar_reads.py   #   events, free/busy, slot search, conflicts
│   │   │   ├── calendar_writes.py  #   create/update/cancel/respond (all user_confirm)
│   │   │   ├── drive_reads.py      #   Drive browse/search/recent/sharing + import contract
│   │   │   └── drive_writes.py     #   documents, organize, share, trash (all user_confirm)
│   │   ├── models.py               # Pydantic output models
│   │   ├── mime.py                 # RFC 2822 draft builders (reply/forward/new)
│   │   ├── slot_search.py          # free-slot computation over merged busy windows
│   │   ├── transport.py            # pageToken pagination over the Gmail/Calendar/Drive bases
│   │   └── reshapes.py             # upstream-row -> output-model reshape fns
│   ├── microsoft_365/       # Microsoft product connectors (Outlook Mail, Outlook Calendar, OneDrive, SharePoint)
│   │   ├── __init__.py      #   re-exports the four connector classes for discovery
│   │   ├── connector.py     #   shared Entra OAuth base + the four product classes
│   │   ├── client.py        #   MicrosoftClient and its MeCache
│   │   ├── actions/                # one composed actions class per product
│   │   │   ├── __init__.py         #   composes + re-exports the four actions classes
│   │   │   ├── base.py             #   Microsoft365ActionsBase (client closure constructor)
│   │   │   ├── identity.py         #   get_user_info probe + whoami cache, in every product
│   │   │   ├── mail_reads.py       #   search/read mail, conversations, open-loop views, attachment bridge
│   │   │   ├── mail_writes.py      #   drafts (create, update, delete, attach), send, organize (all user_confirm)
│   │   │   ├── people_reads.py     #   find_outlook_people: a name to an email address
│   │   │   ├── calendar_reads.py   #   events, free/busy, findMeetingTimes, conflicts
│   │   │   ├── calendar_writes.py  #   create/update/cancel/respond (all user_confirm)
│   │   │   ├── drive_reads.py      #   OneDrive browse/search/recent/sharing + import contract
│   │   │   ├── drive_writes.py     #   documents, organize, share, trash (all user_confirm)
│   │   │   ├── sharepoint_reads.py #   SharePoint sites/libraries/files over id tokens, link loading + import contract
│   │   │   ├── sharepoint_search.py #  search_sharepoint across every site the user can see
│   │   │   └── sharepoint_pages_lists.py # site pages read as text, lists read row by row
│   │   ├── models.py               # Pydantic output models
│   │   ├── transport.py            # @odata.nextLink pagination with host pinning
│   │   └── reshapes.py             # upstream-row -> output-model reshape fns
│   ├── postgres/            # PostgreSQL connector
│   │   ├── __init__.py
│   │   ├── client.py
│   │   ├── connector.py
│   │   └── actions.py
│   ├── s3_bucket/           # Amazon S3 Bucket connector
│   │   ├── __init__.py
│   │   ├── client.py
│   │   ├── connector.py
│   │   ├── actions.py
│   │   └── helpers.py       #   boto3 client builder, MIME + key utils
│   ├── servicenow/          # ServiceNow Table API connector
│   │   ├── __init__.py
│   │   ├── client.py        #   ServiceNowClient
│   │   ├── connector.py     #   OAuth flow, get_client closure
│   │   ├── actions.py       #   10 @action methods + validation constants
│   │   ├── models.py        #   Pydantic output models (IncidentSummary, etc.)
│   │   ├── helpers.py       #   reshape fns, URL builders, query builder
│   │   └── IMPLEMENTATION_PLAN.md
│   └── banner/              # Planned (doc only - no code yet)
│       └── IMPLEMENTATION_PLAN.md
├── actions/                 # Decorator-based action system
│   ├── decorator.py         # @action() decorator and ActionMeta
│   └── introspect.py        # Schema introspection (get_action_schemas, has_tag)
├── errors.py                # ConnectorError taxonomy + boundary translator
├── health.py                # record_connection_failure (auth errors → Connection status)
├── services/                # Shared services
│   ├── secrets.py           # Fernet encryption/decryption
│   ├── oauth.py             # OAuth2 session builder (authlib)
│   ├── http.py              # httpx client with tenacity retry
│   ├── token_updater.py     # Reusable OAuth2 token refresh callback
│   ├── audit.py             # Action audit logging
│   └── rate_limit.py        # Cache-based rate limiting
├── management/commands/     # includes check_connector_credentials (read-only parse of every stored row)
├── models.py                # Connector, ConnectorConfig, Connection, ConnectorAuditLog
├── views.py                 # OAuth2 browser-redirect endpoints (start/callback)
├── urls.py                  # URL routing
├── admin.py                 # Django admin configuration
└── tests/                   # Test suite

Core Concepts

Three-Model Pattern

Model Purpose
Connector Catalog entry (slug, name, kind, icon, is_available, is_enabled, admin_only, restricted_to_roles, restricted_to_users). One per integration type. The first two switches are the availability axis described below; admin_only and the audience (restricted_to_roles plus restricted_to_users) are the per-viewer narrowings on top of it. kind is ConnectorKind - oauth2 or manual - typed on the model, checked by a database constraint, and carried to the SPA as an enum rather than a boolean.
ConnectorConfig Admin-managed key-value config (CLIENT_ID, CLIENT_SECRET, etc.). The flag and the stored text have to agree: a row marked is_secret is Fernet-encrypted on save, and a row that is not refuses text that is already a Fernet token.
Connection A user's authenticated link to a connector. Stores encrypted OAuth tokens or manual credentials. Tracks status and last_used_at.

Every ConnectorConfig row is scoped to exactly one connector - there is no shared storage tier. Vendor families (products that can authenticate against one shared OAuth application) share credentials only through explicit copy links: an admin consents on the origin connector (Connector.offers_config_copy), a sibling copies from it, and a ConnectorConfigLink row (one origin per copier, enforced by a OneToOne on copier) records the relationship. A connector opts into the family mechanics with two class attributes:

class Microsoft365ConnectorBase(
    BaseConnector[MicrosoftConfigKey, MicrosoftCredentials, MicrosoftClient, NoConnectionForm]
):
    config_family = "microsoft"
    copyable_config_keys = frozenset({"CLIENT_ID", "CLIENT_SECRET", "TENANT_ID"})

config_family names the sibling group - who may be offered a copy, and whose connections are revocation candidates. copyable_config_keys names what a copy carries; every other key (REDIRECT_URI, SCOPES) is genuinely per-connector and stays editable during and after a copy. copyable_config_keys must be a subset of the keys workspace_config_schema() declares - the connector contract test pins this.

Copying duplicates the origin's copyable rows onto the copier ciphertext-verbatim (ConnectorConfig.save() passes an already-encrypted value through untouched); the link row is provenance plus propagation routing, never a read-through. While a link stands, the copier's copyable rows are rewritten only by the link operations: an origin edit prompts the admin to propagate to all linked copiers or detach them, and a copier edits locally only after breaking its link. The workspace list reports each schema field's copy_state (ConfigField in workspace_connectors_api.py): none outside the copyable set, copyable inside it, and linked on a copier whose link stands, and the Settings form locks the copyable set while a copy is chosen or a link stands. The adapter's schema decides whether a key is secret: ConnectorConfig.save() stores a key the schema calls secret encrypted whatever the caller set (schema_calls_secret in services/connector_config.py), and ConnectorConfig.shows_masked masks a value when the row or the schema calls it secret, so a secret stored plain before that rule is still never shown in full. get_display_value() is the one masked preview, and ConfigField.from_spec, the ad-hoc rows and the Django admin all show it. Links never chain - a copier cannot be an origin and an origin cannot itself be a copier. Declining or revoking consent only stops new copy offers; standing links keep working until broken. apps/connectors/services/connector_config.py is the single owner of the storage rule - read_value, write_value, copy_config, break_link, propagate_config - and every application reader and writer goes through it, including the workspace API and BaseConnector.get_config_value. The three link operations are audited (connector_config_copied, connector_config_link_broken, connector_config_propagated), and so is the consent flag that allows a copy at all (connector_config_sharing_updated). The config rows themselves are audited from every door: a Settings edit writes connector_config_created, connector_config_updated or connector_config_deleted, and a Django admin edit writes connectorconfig_admin_created, connectorconfig_admin_updated or connectorconfig_admin_deleted. Each row names the connector, the key and the secret flag, and none of them carries the stored value - see audit-logging.md.

Break-glass caveat: the Django admin still allows raw edits of ConnectorConfig rows - both through the standalone ConnectorConfigAdmin and through the Connector inline - which bypasses the link protections and can silently diverge a linked copier from its origin. Such an edit is recorded as connectorconfig_admin_*, and the form refuses both ways a config row can end up holding ciphertext it does not call secret: turning a stored secret into a plain value without retyping it, because the model encrypts on save and never decrypts, and pasting a Fernet token into a row that is not secret. ConnectorConfigLink itself is read-only in the Django admin; links are created and broken only through Settings -> Workspace Connectors so they carry the audit trail.

Connector availability

A catalog row counts only while its adapter is loaded, and then it has two independent switches, the same split FeatureState uses (rbac-and-feature-flags.md).

Connector.objects.installed() is the first gate: the rows whose adapter this process loaded from the packages institutions.toml lists for its institution (see "Which connectors a deployment carries"). available() and offered() build on it, so a row whose adapter is not loaded shows on no list a person or the agent reads, and every door behind those lists answers 404 for it. The one exception is the connection test door, which answers 200 with "This connector is disabled." for a connection a person already holds on such a row. Only the Django admin's connector changelist still lists such a row, for operators.

Connector.is_available is CampusCore's: whether this deployment carries the connector at all. An operator flips it in Django admin, on the Connector change form, which stamps availability_updated_by / availability_updated_at and writes a connector_admin_updated audit row. Connector.is_enabled is the institution's: whether the workspace has turned the connector on for its people. An admin flips it in Settings -> Workspace Connectors, which stamps enablement_updated_by / enablement_updated_at, writes a connector_toggled audit row, and refuses to turn on a connector that is not ready (readiness is defined below). A switch that cannot be turned on says why in its tooltip, which is also its accessible description. The page works that sentence out from the connector's listed settings, so it names the likely fix rather than repeating the server's reason; Test configuration returns the server's own reason. The Django admin renders is_enabled read-only precisely because that gate lives on the workspace surface.

A connector is offered when it is installed and both switches are on. The rule has two spellings that a test pins together, also for a row whose adapter is not loaded: Connector.objects.offered() for querysets, Connector.is_offered for a row, plus Connection.objects.on_offered_connectors() for the connection joins. Every user-facing and agent-facing surface reads through them: the connectors list and detail, both OAuth legs, the cloud-import drives list and its browse and import doors, list_my_connections, load_connector_actions, action execution, the prompt's connected-services section, and the pending-action approve door. The Connectors list additionally applies readiness (below); the agent, cloud-import and export paths do not. The workspace admin list reads available() instead - an available-but-disabled connector is exactly what that page manages - and every mutating workspace endpoint resolves its row the same way, so an unavailable or uninstalled connector 404s there too.

Offered is not the same as ready. Offered is a loaded adapter and the two switches; ready is the configuration behind them: the adapter can describe its configuration schema, and every required key has a stored non-blank value. A connector with no required keys, such as PostgreSQL or S3 whose credentials are per person, needs no stored value. apps/connectors/services/readiness.py::not_ready_reason is the one spelling, answering with the reason or None. The Workspace Connectors page shows that reason when it refuses to enable a connector, and the same reason is what keeps a connector off the user Connectors page, which lists only offered connectors that are ready. So when a connector is missing from a person's page, the reason is in two places. For a connector that is off, the enablement block under Workspace Connectors shows it. For one that is enabled, that block does not show, but the admin panel's Test configuration answers with the same reason, and the web log carries a warning naming the connector and the reason each time the list hides it. A connector that stops being ready after it was enabled, such as when a required row is removed through the Django admin, leaves the page even for people who hold a connection on it, and comes back once the configuration does.

Unavailability is dormancy, not deletion, and so is an adapter a deploy no longer loads. The Connector row, its ConnectorConfig rows, its Connection rows, and its ConnectorActionPolicy rows all stay, so flipping availability back on, or loading the adapter again, restores the connector exactly as it was. One visibility limitation: a config-link peer keeps rendering by name on its sibling's card even when that peer is unavailable, because the link is admin-facing provenance rather than an offer, and hiding it would leave a locked field unexplained.

Each adapter declares default_available (BaseConnector.default_available, True unless overridden), and PostgresConnector and VsuDataGatewayConnector set it to False - they ship unavailable and an operator turns them on per deployment.

Catalog rows come from the registry at boot. run_boot_sequence runs sync_connector_catalog after the feature registry and before the action policy sync, which reads every catalog row. The sync creates the row a loaded adapter lacks, with its display_name, kind and icon_url, is_available from default_available and is_enabled off, and never edits a row that exists, so an operator's and an institution's switches stand. It names each row whose adapter is not loaded with a ? line and leaves it alone, and --dry-run shows what a run would do. The rows the earlier seed migrations wrote stay, and a test in apps/connectors/tests/test_connector_availability.py fails when a row disagrees with its adapter's declaration after the sync.

Admin-only connectors

Connector.admin_only is a third switch, and the only one that depends on who is asking. An offered connector marked admin-only is shown and usable to operators alone on the surfaces a user reaches: the connectors list and detail, both OAuth legs, the cloud-import drives list, and the available-to-connect half of the agent's list_my_connections. Each of those applies the flag against the person making the request, which is why it is not part of offered() or is_offered - those answer the per-deployment question and say nothing about the viewer.

Two surfaces deliberately do not narrow on it. Settings -> Workspace Connectors reads available() with no audience filter, so any workspace admin sees the row and configures it there; the flag governs who may connect and use the connector, not who administers the deployment. And the agent's list_my_connections keeps a connection its owner already holds in the connected list, so marking a connector afterwards never hides someone's own integration from the agent; the Connectors page, which lists only what offered_to returns, does hide it.

An operator flips it in Django admin, in the Audience fieldset of the Connector change form, which writes a connector_admin_updated audit row recording the before and after. s3-bucket is the one connector that ships marked. The flag is a column, not a metadata key, and the admin form refuses both admin_only typed into the metadata JSON and any metadata value that is not a JSON object, so it cannot regrow a second home.

Connector audience

Connector.restricted_to_roles and Connector.restricted_to_users are the institution's own narrowing: the roles and the people an offered connector is offered to. Both empty means everyone the connector is offered to; otherwise a person reaches it by holding one of the listed active roles or by being named while active, the same union FeatureState and ConnectorActionPolicy use for their scopes. admin_only still applies on top, so a person in the audience also has to be an operator when the connector is operators-only. It is an audience, not an enablement scope: it is not under the "pick at least one role or person" rule the two scopes share, because an empty audience is a valid and common state. An inactive listed role admits nobody and does not empty the audience, exactly as a deactivated named person does not. Connector.audience_is_empty() is the one row-level statement of "both halves empty", and the agreement test pins it to the queryset's spelling.

The per-person question has two spellings that apps/connectors/tests/test_connector_availability.py pins together: Connector.objects.offered_to(user) for querysets and Connector.is_offered_to(user) for a row. A connection read has no per-person spelling on purpose: a door fetches the held connection through on_offered_connectors() and then asks is_offered_to on its connector, so "not connected" and "not offered to you" keep their own messages. offered() stays the deployment question and knows nothing about the viewer; offered_to starts from it and applies admin_only and the audience against the person asking. An unauthenticated caller takes neither the operator branch nor the member branch.

Every door a user reaches asks the per-person question: the connectors list and detail (a 403 reading "This connector is not available to you." on the detail and connect endpoints), both OAuth legs (a 404), the connection test, the cloud-import drives list and its browse and import doors, the available-to-connect half of the agent's list_my_connections, load_connector_actions, live action execution, and the pending-action approve door. The agent's refusal is OFFER_REFUSAL in apps/connectors/services/action_policy.py, and the live gate and the approve door record it as a denied audit row whose details.source is audience.

A connection the person already holds is not deleted when they leave the audience. The Connectors page stops showing it, because the page lists only the connectors offered_to returns; the agent's list_my_connections keeps it in the connected list; and loading its actions, running one, approving a held proposal, testing it, and browsing or importing through it are all refused until the audience names them again. Deactivating a named person keeps their row in the audience and stops it admitting them, and the audience keeps naming them - marked deactivated in Settings -> Workspace Connectors - until an administrator removes them; deleting the account drops the row. Deactivating a listed role behaves the same way: the row stays, admits nobody, and is shown muted until an administrator removes it. This is the rule every role grant follows, an action's scope, a feature's scope and a skill's audience included (rbac-and-feature-flags.md).

The connector's row in Settings -> Workspace Connectors states the audience in its second line: "Everyone" when both lists are empty, otherwise the counts ("2 roles, 3 people"), and "audience reaches no one" when every role it names admits nobody and every person it names is deactivated. A workspace admin edits it on the Audience tab of the connector's drawer, which holds the role checklist and the people picker, each of which names an unreachable role, a deactivated role and a deactivated person where they can be removed. The role checklist is the shared RoleChecklist (web/src/components/ui/RoleChecklist.tsx) that the action drawer, the feature drawer and the skill deployment drawer also use, so a role reads the same on all four. The audience is a rarer decision than the credentials, so it stays out of the configuration form, which is the credentials alone. The tab's Save posts both halves through connector_audience_save in one request and stays open with the server's message on a refusal. connector_audience_save replaces both lists wholesale. It refuses an id that is not a nameable active account, an unknown or inactive role slug, and a role nobody can currently be assigned, the same reachability rule an action policy applies. That rule is resolve_scope_roles in apps/auth/services/scope_roles.py, shared by every role grant, which lets a role already on the audience stay whatever its state. The save writes a connector_audience_updated audit row carrying role slugs and user ids only. The Django admin renders both halves read-only, and the admin form refuses restricted_to_roles or restricted_to_users typed into the metadata JSON for the same reason it refuses admin_only.

BaseConnector

Every connector extends BaseConnector and must define:

MyConfigKey: TypeAlias = Literal["CLIENT_ID", "CLIENT_SECRET", "REDIRECT_URI"]


class MyConnector(BaseConnector[MyConfigKey, MyCredentials, MyClient, NoConnectionForm]):
    slug = "my-service"          # Unique identifier, matches Connector.slug in DB
    kind = ConnectorKind.OAUTH2  # or ConnectorKind.MANUAL
    display_name = "My Service"
    icon_url = "https://..."     # Optional
    actions_class = MyActions    # Class with @action-decorated methods
    credentials_model = MyCredentials  # What every stored row parses into
    client_type = MyClient       # What get_client builds and the actions class takes
    connection_form = None       # A manual connector names its ConnectionForm here
    default_available = True     # False ships the connector dormant per deployment

BaseConnector takes four type arguments: the workspace-config key type, the stored-credentials model, the client type, and the manual connect form. The first is what ty checks every config read against, so get_config_value(connector, "CLIENT_IDD") does not compile. A connector with no workspace configuration - one whose credentials all arrive at connection time - names NoConfigKey there, where no string is a key at all and any config read is a type error. The connector contract suite pins that every adapter names one. The credentials model and the client type are covered under Credentials and clients, and credentials_model and client_type repeat them as class attributes so the base class can check them at run time. The form is covered under The manual connect form; a connector a person signs in to names NoConnectionForm, which, like NoConfigKey, no value is.

Every subclass implements get_client(credentials, token_updater), which builds the adapter's typed client from its parsed credentials model.

A manual adapter also declares its form and tests it: - connection_form - the ConnectionForm subclass a person fills in, repeated as a class attribute like the other type arguments - test_and_normalize_manual(form) - use the parsed form against the real system and return what to store, as the adapter's credentials model

An OAuth2 adapter also implements the two legs of its sign-in: - authorization_url(state) - the provider's consent address for this sign-in, carrying state - exchange_code(code) - trade the returned code for a token through exchange_authorization_code, and return it as the adapter's credentials model, built by TokenResponse.credentials(model, **adapter_keys)

BaseConnector gives these methods and test_and_normalize_manual a body that raises NotImplementedError, so an adapter defines only the ones its kind uses. The connector contract suite fails an OAuth2 adapter that leaves out either sign-in leg or declares a form, and a manual adapter that leaves out its form or test_and_normalize_manual. Neither method sees the request or the sign-in state store: the platform issues the state, reads it back, and saves the connection.

Every adapter, of either kind, may also override one person check: - check_person_can_connect(user) - refuse the connect when the person connecting lacks something the connector needs from them, such as an identity link on their profile

The base body does nothing, so a connector with nothing to check about the person leaves it alone. Both POST /api/connectors/connect and POST /api/connectors/authorize call it after the one-connection check and before any credential is parsed or tried or any sign-in starts. A refusal is Django's ValidationError (or a ConnectorError) with one sentence that says what is missing and who can fix it, never a value, and the person reads that sentence as a 422 on the form or the card. A refusal about the person's own record names who fixes that record, such as the IT help desk. Every other failure the person cannot fix reads CANNOT_CONNECT from apps/connectors/connectors/base.py, "We could not connect your account. Contact an administrator.", and never asks them to try again; the log says why. Any other exception is an adapter bug: it is logged with its traceback, and the person reads that same sentence. A refusal with no text reads "You cannot connect this service. Ask an administrator." Replacing a saved connection's credentials and repairing a sign-in do not call it, because neither can create a connection.

allows_multiple_connections (default False) says whether one person may hold more than one connection to the connector; see One connection per connector.

The manual connect form

A manual adapter declares the form a person fills in as one Pydantic model, a subclass of ConnectionForm in apps/connectors/connectors/forms.py, named on connection_form. The one model is both what the form shows and how what the person submits is parsed, so the two cannot drift.

connection_schema() renders it as the flat JSON Schema the SPA reads (web/src/lib/render/jsonSchemaForm.ts): each field's title, description and default, enum for a Literal, format: password for a masked input and is_secret for a field the Sentry coverage test holds to the scrubber's list, in declaration order, which is the order the form shows. The form's required_fields lists the fields it marks required, because a field can be required and still start with a default (PostgreSQL's port); a form that leaves a field Pydantic requires off that list fails when its class is defined. A field declared str | None = None renders with no default, and a default a field reads from settings (S3's region) is rendered on each call, so it follows the setting. A rule the stored row needs too lives once, in credentials.py, and the form takes it from there: PostgreSQL's SSL modes (PostgresSslMode) and default timeout, and S3's empty region, which S3BucketCredentials reads as the deployment's own, and empty endpoint, which it reads as none.

parse_connection_form(submitted) parses what the person posted into the model. A field that does not parse reads as the sentence of the Refusal in its Annotated metadata ("Port must be a number.", "Bucket name is required"), a check across fields raises form_error(sentence) from a model validator (S3 refuses an access key ID without its secret, or the reverse), and any other failure reads "Invalid connection parameters:" and the failing field names. The refusal is Django's ValidationError, the auth-flow contract, and never quotes a value; secret fields are repr=False, so a parsed form is safe to print. The connect endpoint runs its checks in this order: the one-connection limit, the adapter's check_person_can_connect, the form parse, then test_and_normalize_manual(form). The replace-credentials endpoint runs the last two only. So a person the adapter refuses never has a form parsed, and a malformed form is refused before the adapter tries anything. The connect form asks for a name only for a connector that allows several connections; see One connection per connector.

Credentials and clients

Each adapter declares what its connections store as one Pydantic model, and what its actions receive as one frozen dataclass, so neither is a dict read by string key.

A common connector's credentials model lives in apps/connectors/connectors/credentials.py, and an institution's own package keeps its model inside the package, so an image pruned for another institution drops it with the rest. OAuthCredentials holds an OAuth2 token: only access_token is required, because Google often issues no refresh token and providers send scope as a string, a list or not at all. It keeps provider fields it does not declare (such as id_token) and stores them back unchanged, because authlib receives the whole token. CanvasCredentials and ServiceNowCredentials add the required provider_base, Canvas also adds split_horizon (false on rows made before it existed), and MicrosoftCredentials adds tenant_id, stored under the key _tenant_id. Google uses OAuthCredentials as it is. PostgresCredentials and S3BucketCredentials hold what the adapter stores from a tested manual form, with the defaults their readers apply declared once on the model. A model marks as required only the keys every stored row carries, so rows written before a field existed still parse.

BaseConnector.read_credentials(connection) is the one read of a stored row: it decrypts the row and parses it into the adapter's credentials_model. A row that no longer parses raises ConnectorAuthError, so the health path marks the connection expired and the person is asked to reconnect. The ERROR log line names the failing field locations only, which tells a model bug (every row of one connector failing on one field) apart from one bad row. client_for, every revoke_token and S3's prepare_s3_import read through it.

Connection.store_credentials(creds) is the one writer. It encrypts store_payload(creds), the model dumped under its stored key names with no nulls, so the ciphertext holds the same JSON keys a row has always held. For an OAuth model it also mirrors the token's scope onto granted_scopes (see Token refresh and granted scopes). PersonConnections.create and .reconnect take the credentials model and refuse, with TypeError, any model whose type is not exactly the connector's adapter's credentials_model, so a Canvas model can never be stored on a Google connection.

Every adapter family has one client dataclass in its own client.py: CanvasClient, GoogleClient, MicrosoftClient, ServiceNowClient, PostgresClient and S3BucketClient. get_client builds it, and the family's actions base class takes it in its constructor and reads its attributes (self.client.api, self.client.base_url). The clients are frozen, with one exception declared on purpose: the Google and Microsoft clients carry a MeCache as me, a small mutable holder for the connected account's own address, read once by the whoami helper and reused by every later action in the turn. Google's holds email, and Microsoft's holds addresses, an OwnAddresses pair of mail address and sign-in name.

BaseConnector.client_for(ctx, connection) is the one read of a client. It returns the client cached on the ActionContext for that connection, and raises TypeError when the cached object is not the adapter's client_type. On a miss it calls get_client with what credentials_for_client(connection) returns, and caches the result. credentials_for_client reads the credentials through read_credentials, writes a connector_credentials_accessed audit row, and builds a token updater when the credentials are an OAuthCredentials. An adapter that needs a client built differently builds it from the same two values, as Canvas does for its approval-card names. ActionContext.client_cache is typed dict[int, object] for that reason: no code reads it but client_for.

Workspace configuration

An adapter declares its workspace-config fields once, as a mapping from its key type to a ConfigFieldSpec:

def workspace_config_schema(self) -> Mapping[MyConfigKey, ConfigFieldSpec]:
    fields: dict[MyConfigKey, ConfigFieldSpec] = {
        "CLIENT_ID": ConfigFieldSpec(
            title="Client ID",
            description="OAuth2 Client ID from the provider console",
            is_secret=True,
            required=True,
        ),
        "REDIRECT_URI": ConfigFieldSpec(
            title="Redirect URI", description="OAuth2 callback URL", is_secret=False, required=True
        ),
        "SCOPES": ConfigFieldSpec(
            title="Scopes", description="Space-separated scopes", is_secret=False, default=self.DEFAULT_SCOPES
        ),
    }
    return fields

The schema is built as an annotated local and then returned, because a dict literal returned directly widens to dict[str, ...] and ty refuses it.

is_secret has no default, so every field states whether it is a secret and a forgotten flag is a type error rather than a credential stored in the clear. required is a flag on the field, so a required key that is not in the schema cannot be written. A required field cannot carry a default - ConfigFieldSpec raises on one - because the enablement gate reads stored rows only, and a default on a required field would be a value the gate ignores and the form still shows as missing.

The reads that go with it:

  • required_config_keys() - the keys marked required, in schema order. It does not swallow a failing schema call: a caller deciding whether a connector may be enabled must not read a broken adapter as "requires nothing".
  • validate_required_config(connector) - raises naming every required key with no stored, non-blank value.
  • require_config_value(connector, key) - the stored, non-blank value, never a default. Typed str. This is what follows validate_required_config in an OAuth flow, which is why the missing keys are named together first.
  • get_config_value(connector, key) - the stored value, or the field's default when no row exists. For optional keys; a required key never reaches a default.
  • config_field(key) - the field a posted string names, or None. The one place a str crosses into the key type: an admin's form post is a string until the schema recognizes it.
  • config_value_problem(key, value) - the sentence that refuses a value, or None. connector_config.write_value, which the Settings form and seed_canvas_connector write through, asks it before every write and raises ConfigValueRefused with the sentence, and the Django admin form asks it too. The default refuses a REDIRECT_URI whose path is not exactly callback_path() (/api/connectors/<slug>/callback, no trailing slash) on a whole http or https address, because the callback reads the connector from its path. REDIRECT_URI is never copyable, which the contract suite pins, so a copy never writes one.

The contract suite holds three lines here: every adapter names a key type, its schema keys are exactly the ones its Literal names, and an OAuth2 adapter marks at least one field required. The Django admin's key hint and seed_canvas_connector both read the schema too, so a key renamed there is renamed everywhere in the same edit. seed_canvas_connector builds REDIRECT_URI from its --app-url and callback_path(), the same address scripts/canvas_bootstrap.sh gives Canvas's developer key.

Optional capability hooks, where support is signaled through the return value rather than by inspecting the class: - prepare_s3_import(connection, file_id) - Return an S3ImportHandle (source bucket and key, filename, size and MIME type) for a server-side copy import, or None (the default) to fall back to download-and-reupload - revoke_token(connection) - Revoke the connection's token upstream on disconnect; return "revoked" after revoking, "unsupported" (the default) when there is nothing to revoke, "deferred" when a sibling still needs the grant, and raise on failure. Implemented by the google_workspace products (Google's revoke endpoint kills the whole (user, client id) grant, so revocation defers while the user holds a sibling Google connection whose connector stores the same decrypted CLIENT_ID - a divergent or unreadable sibling client id runs on its own grant and never defers), canvas (DELETE /login/oauth2/token through the refreshing session), and servicenow (each token live at disconnect - the stored refresh token, the live access token, and any refresh token the instance rotated in - revoked in its own RFC 7009 POST to oauth_revoke_token.do, unlike Canvas's single grant-killing DELETE)

Authorization scoping

Connectors fall into two authorization models, and the model determines how much enforcement work CampusCore has to do itself. See connectors-philosophy.md §13 for the full discussion; this table is the quick reference.

Connector Model How scoping is enforced
Canvas upstream-scoped Per-user OAuth2 token stored on Connection.credentials; Canvas enforces "Alice can only see Alice's courses"
Gmail / Google Calendar / Google Drive upstream-scoped Per-user OAuth2 token, one per product; Gmail, Google Calendar and Google Drive enforce mailbox, calendar and file scoping
Outlook Mail / Outlook Calendar / OneDrive / SharePoint upstream-scoped Per-user OAuth2 token via Microsoft Graph delegated permissions; Exchange Online, OneDrive and SharePoint enforce mailbox, calendar, file and site scoping
PostgreSQL locally-scoped (user-supplied) Each user enters their own DB credentials; upstream DB grants enforce scoping
S3 Bucket locally-scoped (user-supplied) Each user enters their own AWS credentials or IAM role; AWS enforces scoping
ServiceNow upstream-scoped Per-user OAuth2 token via the ServiceNow OAuth Application Registry; native ACLs (itil, knowledge, table-level rules) enforce read and write scoping. See the ServiceNow setup guide
VSU Data Gateway locally-scoped (institution-wide) One gateway key stored in workspace settings; VsuDataGatewayAuthorizationMiddleware lets a person read their own records only, named by the V-number on UserProfile.external_id. See the VSU Data Gateway setup guide
Banner (Ethos) - planned locally-scoped (institution-wide) Institution-level ETHOS_API_KEY shared across users; CampusCore must enforce scoping via a BannerAuthorizationMiddleware before the action runs

Upstream-scoped connectors require no custom authorization logic - the vendor does the enforcement. Locally-scoped connectors split into two sub-flavors: user-supplied (each user brings their own credentials, so upstream scoping still works but at the connection level instead of the OAuth level) and institution-wide (one service-account credential is shared across all users, so CampusCore must enforce identity mapping + scoping middleware itself). The institution-wide flavor is the highest-responsibility: it requires an explicit authorization middleware that enforces identity mapping and scoping before every action runs. The VSU Data Gateway connector is the shipped example; Banner will follow the same shape.

Before adding a new connector, always decide which model it uses and document the choice in the connector's class docstring. The connector assistant skill will prompt for this explicitly.

Note on ServiceNow

Connectors philosophy §2 names ServiceNow as the one sanctioned exception for reaching for a vendor MCP server instead of hand-rolling. The v1 connector ships as kind="oauth2" Table API anyway - a deliberate, documented deviation to avoid greenfield platform work on kind="mcp" infrastructure. The full rationale lives in the connector's class docstring. Revisit once kind="mcp" transport lands.

VSU Data Gateway

The connector reads one student's records from Virginia State University's gateway API, a FastAPI service VSU runs inside its own VPC and that our deployment reaches over AWS PrivateLink. The gateway trusts one institution-wide key and answers for whichever V-number a request names in the X-VSU-Test-VNumber header, so it is the first shipped connector of the institution-wide flavor above.

Three pieces make it safe. vsu_datagw/identity.py::student_vnumber is the one reader of the person's V-number, which single sign-on writes to UserProfile.external_id; the connector stores no copy, and a blank value raises a ConnectorPermissionError whose sentence tells the model the account is not linked, so the answer says so, while the trace row reads the platform's one sentence for every permission refusal. VsuDataGatewayAuthorizationMiddleware runs before every action and refuses a caller who is not the connection's owner or who has no V-number, so the only person a connection can ask about is the one who connected it. Every action then names that V-number on its own request, read fresh from the profile.

The connect form has no fields: the key is the workspace's and the identity is the profile's, so Connect is one click and the connection row stores an empty credentials model. Before anything is stored, the connector's check_person_can_connect reads the person's V-number and sends one profile request that names it, which proves the key, the network path and that VSU knows this student. It refuses with one of three sentences, and no sentence or log line carries the V-number or the key: - No V-number on the account reads the same not-linked sentence the actions use, and no request is sent. - No record for the V-number reads that VSU has no student record for it and the IT help desk must check the V-number linked to the account. - Every other failure reads CANNOT_CONNECT, "We could not connect your account. Contact an administrator.": a missing workspace value, a stored address that is not an address, a gateway that does not answer, a refused key, any other gateway failure and an unreadable answer. The web log's warning "VSU Data Gateway person check failed: " tells an operator which one, and for a connector error it adds that error's own message, which our code writes and which names neither secret.

The credentials hook test_and_normalize_manual then sends nothing, because the person check already proved the path. A 401 or 403 from the gateway means the workspace key or the deployment was refused, which reconnecting cannot fix, so it raises ConnectorConfigError and leaves the connection active, and the chat says the connector is not set up correctly and an administrator must check its settings, the wording failure_reasons.py gives every configuration error. A null profile means VSU has no record for the V-number and raises ConnectorNotFoundError with the help-desk sentence. Its unlisted test_connection action, which the person's Test connection button runs on every manual connector, reads the profile and fails with that same sentence when VSU holds no record, so Test connection and Connect never disagree. The connector holds the V-number in a frame local named vnumber, and the Sentry scrubber's PERSON_ID_NAMES names it, so an untyped failure on either path does not ship it. The package is named only under [institutions.vsu] in institutions.toml, so only VSU's environments load it, and on those its catalog row is created dormant (default_available = False). It is the one connector whose middleware chain is not empty; vsu_datagw/tests/ pins its chain, and the shared test pins every connector from a common package to an empty one. Locally, the vsu_datagw_mock compose service under the vsu profile serves the committed openapi.json through Prism with generated answers, so a developer can connect without a path to VSU.

The action surface has one read per gateway endpoint, 15 in all, so every list the gateway keeps can be read on its own. This is a recorded exception to connectors-philosophy.md §5, written in the connector's class docstring: each endpoint answers one student question, and a bundle as the only way to a list led the model to work out totals the gateway does not hold. Two bundles read related lists at once: get_my_account_summary reads charges, payments and card account balances, and get_my_financial_aid reads aid awards and aid disbursement rows. A bundle returns each list as an EndpointRows part that names the gateway path it came from and says in one sentence what its rows are, so the JSON the model reads labels every part. That gives 17 student-facing actions, plus the unlisted test_connection. Neither bundle carries a total, because the gateway returns no amount due and no aid total, and a figure the model worked out from the rows is not the official one; the bundle's own contains sentence and the connector's agent_hint both say so.

Microsoft product connectors

The microsoft_365 package ships four connectors - outlook-mail, outlook-calendar, onedrive, and sharepoint - over one Entra app registration (family microsoft), replacing the former microsoft-365 suite connector. Each product runs its own OAuth flow with only its product's scopes, so a mail token cannot touch files; admins enable, test, and govern each product independently. Every call runs on the connected user's own delegated Graph token, so Exchange Online, OneDrive, and SharePoint enforce all scoping. Every write is user_confirm and ships policy-disabled per role. Action names and their capability tags carry the product - search_outlook_messages under outlook-mail, create_outlook_calendar_event under outlook-calendar, list_onedrive_files under onedrive - so the governance screen and confirmation cards read unambiguously. get_user_info stays product-neutral and is composed into every product from the IdentityReads mixin: it is the account-level probe the connection Test button executes on every oauth2 connector. list_onedrive_files carries the file_browse tag and download_onedrive_file the file_download tag - the pair the cloud-import picker discovers and resolves by tag. Both return the shared import models from apps/connectors/actions/files.py: the browse returns a FileBrowsePage and the download a DownloadedFile, which the import pipeline reads by attribute. download_onedrive_file refuses agent-context calls and points to load_onedrive_file_into_chat, the bounded read-into-chat door. Two curated open-loop reads (list_outlook_threads_awaiting_my_reply, list_outlook_sent_awaiting_response) merge inbox and Sent Items pages and group by conversationId - they are candidate lists for triage, not judgments. Mail attachments flow into chat through the standard chat_file_ingestor bridge with the metadata read as the access proof. Pagination follows @odata.nextLink with the host pinned to graph.microsoft.com/v1.0 (a foreign link is refused, never followed). A 403 from one shared mailbox or one SharePoint site, library, list, file or link the user may not open is not a token failure, so the client closure translates it (transport.translate_graph_403) before the boundary classifier would mark the whole connection expired. It reads the Graph error code, ErrorAccessDenied from Exchange or accessDenied from SharePoint, and because each code also covers a token missing the scope, it translates only when the token's own granted scopes include the one the call needs; the agent then gets a sentence naming the missing access, and the connection stays active. A 403 on the user's own /me paths, on tenant-wide calls such as /search/query, with any other code (such as the directory's Authorization_RequestDenied), or on a token without the needed scope stays an auth failure, so reconnecting still repairs it. Exchange Online answers a shared mailbox the user has no Full Access to with a 404 ErrorItemNotFound, not a 403 ("Default folder Inbox not found" on a read, "The process failed to get the correct properties" on a draft). That is word for word what it says for a message that no longer exists, so the closure also translates a named mailbox's ErrorItemNotFound (transport.translate_graph_404) into one sentence naming both causes, and ErrorInvalidUser into a sentence saying the address is no mailbox in the organization. Without it the agent would get the not-found coaching that blames a guessed id. A 404 is never an auth failure, so the connection stays active either way. The batch organizing writes (archive_outlook_messages, set_outlook_messages_read) record such a message as failed and go on; when every message fails, they raise the first failed message's translated sentence instead of a bare count. A 400 is translated too (transport.translate_graph_400): the agent gets one sentence naming Graph's error code but not its message, which can quote the values sent, and saying to check the values passed. When the request's own path carries an item id (a message, folder, list, item, drive or site id), the sentence adds that an id must be passed exactly as a previous result returned it; a Microsoft Search call or a $search or $filter query gets no id advice, since its own text is the likelier cause.

Outlook Mail

The mail connector reads and writes the user's own mailbox and any shared mailbox Exchange lets them open. Every mail read and write takes an optional mailbox address; left empty it means the user's own mailbox (/me), and an address routes the call to /users/{address}. Exchange decides whether the user may read or send as that mailbox, from the Full Access and Send As permissions an Exchange admin grants; the connector adds no check of its own. The address is validated and percent-encoded in one helper, transport.mailbox_root, so it can never add a path segment or a query.

find_outlook_people turns a name into an email address. It merges the people the user works with (/me/people), the organization directory (/users with $search), and the user's Outlook contacts, one row per address, and says where each row came from. A source Graph refuses with 403 is listed in unavailable_sources rather than read as "nobody matched".

Long content reads in windows. get_outlook_message returns at most 4,000 characters of body per call, with body_offset to start later and body_next_offset to say where the next window begins. read_outlook_conversation returns a whole thread in one call, oldest first, spending a 12,000-character budget on the newest messages; older messages past the budget come back as previews with their ids. A conversation query spans every folder of the mailbox, so both conversation reads look up the Deleted Items folder by its well-known name first and leave out drafts and anything in Deleted Items - neither is part of the thread the participants exchanged. It reads at most two pages of 50 messages, and its complete field is false when more remained. search_outlook_messages returns a MessagePage whose next_page_token fetches the next page when passed back on its own. The token is the path and query of Graph's continuation link, checked both when it is issued and when it comes back: only the message paths the search itself calls are accepted, with the mailbox and folder written either as path segments or as OData keys (/users('{address}')).

Drafts can be edited after they are created. create_outlook_draft takes Bcc recipients and an HTML body, and reply and forward drafts can add To and Cc recipients to the ones Graph chose. An added address that is already on the reply - on To, Cc or Bcc, compared without case - is not added again, and a reply whose added addresses are all there already gets no second write; a forward names each address once, To before Cc. update_outlook_draft patches only the fields it is given, delete_outlook_draft moves a draft to Deleted Items, and attach_file_to_outlook_draft attaches a chat file (inline up to 3.1 MB, through an upload session up to 30 MB). Each of the three refuses a message that is not a draft, and each is user_confirm. create_outlook_draft, create_outlook_reply_draft and create_outlook_forward_draft also take attachment_file_refs, up to 10 chat files and 30 MB together, so a draft and its files are one request and one confirmation card. A ref named twice attaches once, and the receipt lists each attached file by name. Every file is resolved and size-checked before the draft is created, and no file is read once its declared size would take the total past 30 MB. Any failure after the draft exists is reported with the draft's id, the files that attached, the file that failed and the files never tried, and a pointer to attach_file_to_outlook_draft, so the agent finishes that draft rather than creating another. An auth, rate-limit or transient failure keeps its class there; any other classified failure is raised as ConnectorPartialWriteError, because the draft landed and the rest did not. The refusal keeps the failure's class, so an expired token or failed refresh still expires the connection, a 429 still reads as rate limited, and a 5xx or timeout stays transient. After a 5xx or timeout the file may have attached anyway, so the refusal says to check the draft before attaching it again; a refusal Graph explained keeps its sentence. The draft writes describe their own confirmation card through describe_pending_write (microsoft_365/write_cards.py): a summary, plain labels (To, Cc, Subject, Message, Attachments), and files by name. The draft and send writes also describe their receipt through describe_mail_receipt in the same module, such as Created the Outlook draft "Budget" with "To dana@x.edu, with Budget 2026.docx attached." and an "Open it in Outlook" link; a reply, reply-all or forward draft is named by its kind. A write on an existing draft or message names it by subject and sender from one small read ($select of the subject, sender and recipients, through the same mailbox root and client), and shows the raw fields if that read fails. reply_all must be a true or false: the reply write refuses anything else, and the card reads the flag through the same function (reply_all_flag), so the card never names an audience the write would not reach. Every recipient address is validated before any write, so a reply or forward is never created with a malformed recipient; if adding recipients to a reply draft fails after the draft was created, the refusal names the draft so the agent fixes it with update_outlook_draft rather than creating a second one. A chunk the attachment upload address refuses is reported as a refused upload (or a transient failure for a 5xx), never as an auth failure, because that address carries no user token. A file from OneDrive or SharePoint reaches a draft by being loaded into the chat first.

An attachment that is a link to a SharePoint or OneDrive file (a reference attachment) cannot be loaded through mail; the refusal names load_sharepoint_link_into_chat.

SharePoint

sharepoint is read-only (Sites.Read.All; SharePoint enforces the connecting user's own site permissions per call) and folds the site -> document library -> folder hierarchy into a prefixed id-token space, because both file-surface consumers pass a single opaque id: site:{site-id} and lib:{drive-id} render as pseudo-folders, and item:{drive-id}:{item-id} names a folder or file (the drive id percent-encoded so the parse is total). An unknown or foreign id raises ConnectorArgumentError instead of reaching Graph, and the token is a persisted wire format - it keys ConnectorFileCopy.upstream_file_id - so changing the encoding needs a migration; golden-literal tests pin it. Its browse pair is list_sharepoint_files/download_sharepoint_file; search_sharepoint_files searches one library (/drives/{id}/root/search). The sites tier returns one deterministic page of at most ~200 name-sorted sites (the picker UI cannot page), so a tenant beyond that bound reaches missing sites by search, not scrolling. list_followed_sharepoint_sites lists the sites the user follows.

The same token scheme covers site pages and lists: page:{site-id}:{page-id}, list:{site-id}:{list-id}, and row:{site-id}:{list-id}:{item-id}, the site id percent-encoded as in item:. These three are not stored anywhere, so only item: is a persisted format.

search_sharepoint searches every site the user can see through POST /search/query over files and list items, and emits citations, so an answer built from it cites SharePoint in the Sources panel. It needs no scope beyond Sites.Read.All: Microsoft's search documentation lists Sites.Read.All as sufficient for both driveItem and listItem, and Files.Read.All only as an alternative for driveItem. Graph returns a file as a driveItem whose parentReference carries siteId, driveId and sharepointIds, and a site page as a listItem whose own id is the page id the pages API takes. The request names its fields, because a default hit carries no title: a page or a row is then named by its title field, and a file keeps its file name. A hit's summary ends where its text ends: search index values that end the text, bare numbers and then a GUID in braces that Graph may cut short, are trimmed before the summary is cut to 300 characters. The values are trimmed only when the GUID fragment is the last thing in the summary, so page text that follows them is kept. Naming any field makes Graph return only the named ones, so the list is every property the hit is read from. Search writes a hit's webUrl path raw - spaces, % and # as the file is named - so a search hit's link goes through quote_raw_link_path, which percent-encodes every character of the path but / and leaves the scheme, host and query as written. A search hit's link to a Word, Excel or PowerPoint file also gets ?web=1 from office_web_link, because a bare library path makes a signed-in browser download the file instead of opening it in Office for the web; a link that already has a query, such as the Doc.aspx form the file reads return, is left as it is. The site item endpoints write webUrl already encoded, so a list row's link, a page listing's and a page read's go through requote_encoded_link instead, which keeps every escape and encodes only a stray raw space or non-ASCII letter. The other SharePoint links an action returns (sites, libraries, lists, files and sharing links) are passed on as Graph writes them. Each hit carries the token for what it is: item: for a file, page: for a site page, and row: for another list item. It keeps OneDrive out of SharePoint: a hit on a -my.sharepoint.com host, or in a drive whose type is personal or business, is dropped. The file reads check the same drive types on the item's own metadata, because an item: token names a drive by id and could be composed by hand. list_sharepoint_files and search_sharepoint_files read the drive's type before they list or search a lib: or folder token, and refuse a OneDrive drive with the same sentence. Its next_page_token is the offset of the next page, and the query must be passed again with it.

load_sharepoint_link_into_chat loads a SharePoint link the user pastes, or one taken from an email, the same way load_sharepoint_file_into_chat loads a file by id. It accepts only an https link on the tenant's own *.sharepoint.com host and resolves it through /shares/u!{base64url}/driveItem; a OneDrive link (a -my host) is refused with a sentence naming the OneDrive connector.

get_sharepoint_file_details reads a file's library columns (the custom metadata a library adds, such as an owner or an effective date) under their display names, with hidden and system columns left out. list_sharepoint_pages and read_sharepoint_page read a site's pages as text, taken from their text web parts in reading order and returned in the same 4,000-character windows as mail bodies. list_sharepoint_lists lists a site's lists (document libraries and hidden lists left out), list_sharepoint_list_items pages through a list's rows, and get_sharepoint_list_item reads one row, each row's fields under their display names. Graph's webUrl for a row is an item address (.../Lists/Policy%20Tracker/3_.000) that SharePoint only redirects a signed-in browser from, and a row inside a folder carries the folder in it, so a row's link is its display form (.../Lists/Policy%20Tracker/DispForm.aspx?ID=3). list_row_url builds that link from the list's own webUrl and the row's id, and each list-row action and search_sharepoint reads the list's webUrl once per list per call. If that read fails during a search, the row hit keeps its own webUrl and the failure is logged with its status only. After the first failed read, the search reads no more list addresses in that call, and every later row hit keeps its own webUrl, so a failing read never adds to the wait once per list. Setup lives in microsoft-connectors-setup.md.

Google product connectors

The google_workspace package ships three connectors - gmail, google-calendar, and google-drive - over one Google Cloud OAuth client (family google), replacing the former google-workspace suite connector. They mirror the Microsoft product action vocabulary so the agent's habits transfer between the two. The reborn google-drive carries the cloud-import browse/download contract as list_google_drive_files/download_google_drive_file, resolved by the file_browse/file_download tags. Every call runs on the connected user's own delegated Google token under that product's scope alone (gmail.modify, calendar, or drive), so Gmail, Calendar and Drive enforce all scoping; gmail.modify deliberately excludes permanent deletion, and the file surface trashes, never deletes. Each product's identity probe (get_user_info, the connection Test) stays inside its own scope: Gmail reads its profile, Calendar reads the primary calendar, Drive reads its about user. Every write is user_confirm and ships policy-disabled per role. Each connector pins its own api_version (Gmail v1, Calendar v3, Drive v3); the client closure refuses any URL outside the product's own bases, so a gmail token cannot even be asked to call Drive. Google reports several failures that leave the grant working as a 403, which the boundary classifier would read as an auth failure and mark the connection expired. The client closure runs transport.translate_google_403 on every 403 of every product, with the subclass's own refusal table, so the known reasons are translated before they reach the classifier. It translates rate and quota limits, a domain policy that disables the product, Drive's export, sharing, quota and file-permission reasons, and Calendar's refusal of a guest's change to an event only its organizer may change. A full Drive storage quota is a ConnectorLimitError, and the organizer-only refusal is a ConnectorPermissionError, as is the connector's own check that refuses to cancel an event the account does not organize. Any other 403 reason, such as the missing scope insufficientPermissions, still expires the connection, because reconnecting repairs it. Google-native files (Docs, Sheets, Slides) have no downloadable bytes; they export as PDF, and the stored filename gains a .pdf suffix because the chat-file host refuses extensions it does not know. Drive omits nextPageToken from a response whose fields parameter does not name it, so every listing's fields includes it - dropping that silently caps results at one page. Drafts are built locally as RFC 2822 messages (mime.py) and posted base64url-encoded with threadId nested in the message resource - that nesting is what makes a reply land on its thread. Gmail listings return ids only, so every listed item costs its own metadata GET; the curated views cap at 15 threads to stay inside the deployed ALB's 180s idle timeout. Action names and their capability tags carry the product here too - search_gmail_messages under gmail, create_google_calendar_event under google-calendar, list_google_drive_files under google-drive - with get_user_info staying product-neutral as the connection Test probe. Calendar has no native meeting-time finder, so find_google_calendar_meeting_slots computes free slots locally (slot_search.py) from freeBusy responses, reporting attendees whose availability could not be checked instead of silently assuming them free. events.patch replaces array fields wholesale, so respond_to_google_calendar_invitation PATCHes the full fetched attendee array with only the user's own entry changed - a payload carrying just that entry would wipe every other attendee. Setup lives in google-connectors-setup.md.

Which connectors a deployment carries

campuscore_app/institutions.toml is the one statement of which connector packages a deployment carries. Its [common] table lists the packages every deployment gets, and each [institutions.<name>] table lists the GitHub Environments that belong to the institution and the packages it adds. A manifest names packages, the directory names under apps/connectors/connectors/, not slugs, because one package can register several slugs. campus_core/institutions.py is the one parser, and it imports nothing from Django.

Settings.INSTITUTION says which institution a container serves. The pipeline derives it from the environment's entry in the file, and it is never set by hand. Empty means every manifest, which is what a local stack, the test suite and CI run, and a value the file does not name stops the container at settings.

The deploy workflow's first job, resolve-env, runs python3 -I campuscore_app/campus_core/institutions.py resolve <environment> and fails, naming the file, when no institution lists the environment, so an unmapped environment never reaches a build. The institution it prints goes two places in the same run: the INSTITUTION build argument of both app images, and TF_VAR_institution, which Terraform writes into every container as INSTITUTION. Both come from one resolution and the image tag is the commit, so an image and its task definition always agree. cc deploy prints the same institution and its packages before it asks for confirmation (see cc-command-line-tool.md).

The file drives these gates: - The image build deletes every connector package the institution does not carry, in the app-src stage of Dockerfile and Dockerfile.processing, and then fails when the packages left on disk are not exactly the listed ones, so another institution's package is in no layer of the image. INSTITUTION has no default there, so a build without it fails. - The registry imports only the packages packages_for(INSTITUTION) names (see "Auto-Discovery Registry"). - Connector.objects.installed() keeps every row whose adapter is not loaded off every list (see "Connector availability"). - apps/connectors/tests/test_connector_manifest.py fails when a package on disk is named in no manifest, when a listed package is missing, and when a shared module imports an institution package.

A local stack runs Dockerfile__local with the source bind-mounted, so nothing is pruned there and the registry allowlist is the gate.

Auto-Discovery Registry

Connectors are discovered on import, from an allowlist. The registry imports each package institutions.toml lists for this deployment's institution and registers every BaseConnector subclass with a non-empty slug that the package exports from its __init__.py. A package the manifest does not list for the institution is never imported, and a listed package missing from disk, or a plain .py module other than credentials, forms and presentation, is recorded in BROKEN_CONNECTORS, which the conformance harness fails on.

from apps.connectors.connectors.registry import get_connector, list_connectors

adapter = get_connector("google-drive")   # Get a specific connector
all_connectors = list_connectors()        # Get all registered connectors

Action System

@action Decorator

Actions are defined as decorated methods on an Actions class. The decorator captures tags for capability discovery, the action's human label, its verb and, for a read that returns a set, its result noun. Type hints and docstrings provide schema information.

Schema discovery walks the class's method resolution order, so a connector whose surface outgrows one module can split its actions into mixin modules composed into a single actions class. The Canvas actions/ package is the reference shape: one mixin module per stakeholder audience over a shared constructor base. Smaller connectors keep a single actions.py.

from apps.connectors.actions import ActionVerb, ResultNoun, action

from apps.connectors.actions.files import DownloadedFile, FileBrowsePage

class MyActions:
    def __init__(self, client: MyClient):
        self.client = client

    @action(
        "file_browse", label="list a folder's files", verb=ActionVerb.LIST,
        result=ResultNoun(singular="file or folder", plural="files and folders", shows_names=True),
    )
    def list_files(self, folder_id: str | None = None) -> FileBrowsePage:
        """List files and folders."""
        ...

    @action("file_download", label="download a file", verb=ActionVerb.DOWNLOAD)
    def download_file(self, file_id: str) -> DownloadedFile:
        """Download a file by ID."""
        ...

Every action returns a Pydantic model, a list of models, or a list[str]: the ActionResult alias in apps/connectors/actions/results.py. test_returns_are_typed in apps/connectors/tests/test_connector_contract.py refuses any other return annotation, so a dict or a bare str never reaches the agent or a caller. The same suite pins that a file_browse action returns FileBrowsePage, a file_download action returns DownloadedFile, and an action declaring emits_citations returns a CitingResult.

Action labels

Every @action declares label, the action's human name, and it is the one home for it. The approval card, the chat trace, the receipt and the admin governance page read it, so no surface a person reads shows an action's code name. label is a required keyword with no default, so an action without one fails at import.

A label is an imperative phrase that starts in lowercase, such as "create a draft", "search your email" or "list your open incidents". It is at most 48 characters, has no underscore, says "your" and never "my", and never names the product, because every frame already names the service beside it. So Gmail and Outlook Mail both "search your email", and the trace tells them apart by the service. test_connector_contract.py enforces these rules, including a fixed list of product words, for every registered action.

One verb means one kind of action across every connector:

  • "get" is one item's details, such as "get an assignment's details".
  • "read" is one item's content, such as "read an email".
  • An action that loads a file into the chat is "get a file" or "get an attachment", so its row never reads as a second read beside the read_file row that follows it.
  • "list" is a set, "search" takes a query, "find" is an answer worked out from data, such as "find a time to meet", and "check" is one fact, such as "check who a file is shared with".
  • "change" is an update, because "update" is the API's word.
  • A recoverable trash says where the item goes, such as "move an email to the trash", and "delete" is kept for a real delete.
  • Mail is "email" in every label, and "message" is kept for the Canvas inbox.

Action verbs and result nouns

Every @action also declares verb, from the closed ActionVerb set: search, list, find, get, get_file (a file loaded into the chat), read, check, download and write. The chat trace words each connector row and each round's line from it, so a person who has never heard the word "connector" reads what was done, not that a service was asked. A read's label starts with its verb's word (get_file starts with "get"), and the row puts the verb in its tense before the rest of the label: "Listing a site's libraries in SharePoint", "Got a file from SharePoint". A search names its query in an argument called query, search_term or query_text, and its row quotes it: "Searching SharePoint for "policy documents"". Only a write declares write, and the decorator refuses a verb that disagrees with side_effect.

A read that returns a set, a list or a page model with next_page_token and one list field, declares result, a ResultNoun of singular and plural. Its row counts in that noun, "3 files" or "No libraries", and never "3 items", and a noun of two things reads "or" at zero: "No files or folders". A round of listings sums its counts by noun across calls of one action: "Found 6 libraries and 3 lists in SharePoint". shows_names=True lets the row open its count to the names of the first five records. Set it only on a listing of files, folders, sites, libraries, lists or pages, never on one that can return people, mail, coursework or a free-text record such as a SharePoint list row, so a name a person did not ask to see never reaches the trace. test_connector_contract.py fails for an action without a verb, a read label that does not start with its verb's word, a search with no query argument, and a read that returns a set without a noun or declares one for a single record. It also pins the exact set of actions that show names in NAMED_ACTIONS, so adding one is a visible decision.

The label is written once and read in fixed frames, so it must read well in all of them:

  • The trace, in progress, finished and failed, worded from the verb: "Listing your open incidents in ServiceNow", "Listed your open incidents in ServiceNow" and "Couldn't list your open incidents in ServiceNow". A user_confirm write sends nothing while it runs, so its in-progress row reads "Asking for your approval to create a draft", and any other write reads "Preparing to create a draft in Gmail". A row stored before actions declared a verb keeps "Asked Gmail to create a draft".
  • The title of a held write whose connector writes no presentation: "Create a draft".
  • A held write's trace row: "Asked for your approval to create a draft".
  • The receipt of a write whose connector describes none: "Asked Gmail to create a draft".

action_display_label(slug, action_name) in connectors/registry.py is the one reader, built on find_action_schema, which finds an action by name and then by alias from the declarations alone. It matches the action name first, then each action's aliases, so a renamed action keeps its label on old rows, and it falls back to the action name made readable when the connector or the action is gone.

A label names one thing a person recognises. An action that does a different job by its arguments declares wordings, a tuple of CallWording, each with an applies test on the arguments the model passed, a label, and a result exactly when the action declares one. The first wording that applies words that call's trace row and the title of its held write when the connector writes no presentation, and a call none fits reads the declared label. SharePoint's list_sharepoint_files is "list files and folders", and a call with no folder reads "Listed sites in SharePoint" and counts "5 sites", while a call on a site reads "Listed a site's libraries in SharePoint". A write that goes either way by a parameter, as in "publish or unpublish a module", keeps that label for the admin governance page and words each call with argument_is, such as "Asking for your approval to unpublish a module". test_connector_contract.py fails for a label that names alternatives, with a comma or "or", on an action without wordings, and for a wording that breaks a label rule. action_call_label(slug, action_name, arguments) in connectors/registry.py reads the label for one call.

A held write's card can say more than a label, so a connector may still write a describe_pending_write hook that names the direction with the details, such as "Mark 3 emails read". Six writes have one today: set_module_published on Canvas, set_gmail_messages_read, star_gmail_message and set_gmail_message_labels on Gmail, and set_outlook_messages_read and flag_outlook_message on Outlook Mail.

Tags

Tags are used for capability discovery without isinstance checks:

Tag Meaning
file_browse Connector can list files/folders (used by cloud import UI)
file_download Connector can download files (used by import pipeline)
read General read operations
from apps.connectors.actions.introspect import has_tag, get_action_schemas

# Check if a connector supports file browsing
if has_tag(adapter.actions_class, "file_browse"):
    ...

# Get all action schemas for a connector
schemas = get_action_schemas(adapter.actions_class)

Executing Actions

# One front door: a live Connection, an action name, params.
result = adapter.execute_action(connection, "list_files", {"folder_id": "root"})

# The agent bridge passes its per-run ActionContext so clients are cached
# across the run's action calls:
result = adapter.execute_action(connection, "list_files", params, ctx=ctx)

The door parses params first, through BaseConnector.parse_action_args, against the action's args model (ActionSchema.args_model). That model is built from the method's resolved annotations and defaults, and it refuses an unknown argument, keeps argument values out of its errors, and lets a number reach a str parameter as text. A call that does not parse raises ConnectorArgumentError, a ConnectorInvalidRequestError subclass, naming each failing argument and never its value, before any credential is read or audited. The person reads that nothing was sent, not that the service refused the request, because the service never saw it. The method then receives the arguments the caller sent as parsed values, so a model-typed parameter arrives as a model instance, and a parameter left out keeps the method's default.

An argument's own shape is declared on its annotation, so the parse refuses it and the tool schema shows it to the model: a range is Annotated[int, Field(ge=1, le=3)], a fixed set of values is a Literal, a structured value is a Pydantic model (Canvas's QuizAnswerOption), and text that must not be blank is NonBlankStr from apps/connectors/actions/arguments.py. A check stays in the method body when it relates two arguments, reads what the service returned, or says more than the constraint's own message would, such as how to find a valid value.

Reading the credentials, wiring the token updater and building the client happen inside the door, after the parse, in BaseConnector.client_for (see Credentials and clients); callers never handle credentials.

Cloud Drive Import Pipeline

When a user imports files from Google Drive, OneDrive, or SharePoint into a knowledge folder (SharePoint's picker tree starts at sites and document libraries, encoded as pseudo-folders in the same browse contract):

The person picks files in the SPA
    → POST /api/folders/<folder_id>/cloud-import/selected (cloud_import_api.py)
    → CloudFileImportService.start_background_import()
        → _validate_and_load checks the connection and the folder access gate
        → creates an ImportBatch and returns its id to the SPA
        → starts a daemon thread running _run_batch_import, which per file:
            → adapter.execute_action(<file_download-tagged action>, ...)  # or a server-side S3 copy
            → stages the bytes in the knowledge folder bucket
            → folder_file_registrar.reserve + register creates the SourceFile and calls trigger.enqueue
            → increments the batch counters
    → the SPA polls GET /api/folders/<folder_id>/cloud-import/batch for progress

The request returns as soon as the batch row exists, so nobody waits on a connector download. The import service reuses the existing document processing pipeline - imported files are indistinguishable from directly uploaded files after the initial download.

Endpoints

All four are typed JSON routes under api/, registered in apps/main_app/apis/urls/cloud_import.py.

Endpoint Purpose
GET /api/folders/<folder_id>/cloud-import/drives The cloud drives this person can import from, each connected, disconnected or expired
GET /api/folders/<folder_id>/cloud-import/browse One page of a connected drive's files and folders
POST /api/folders/<folder_id>/cloud-import/selected Starts the background import of the selected files and answers with the new batch's progress
GET /api/folders/<folder_id>/cloud-import/batch The progress of one batch: running, completed or failed

Import Failure Semantics

An ImportBatch keeps one error_details entry per failed file, uncapped, and error_count is that list's length rather than a stored counter that can drift from it. A failure that kills the whole batch is not a file failure, so its reason lands in failure_reason and the import panel shows it. The progress poll sends only the first 50 detail entries, because no client reads the list and the panel polls every two seconds; errors is always the exact count.

Rate Limits

Action Limit
Cloud file browsing 30 requests/minute per user
Cloud file import 10 imports/hour per user

OAuth2 Flow

Person presses Connect under Settings -> Connectors
    → POST /api/connectors/authorize {slug}
    → refused with 409 if the person already holds the connector
    → OAuthStateStore issues a state for a NewConnectionIntent
    → adapter.authorization_url(state) builds the consent address
    → the SPA opens a tab and sends it to that address
    → the person consents at the provider
    → the provider redirects to /api/connectors/<slug>/callback
    → OAuthStateStore spends the state and returns its intent
    → adapter.exchange_code(code) makes the one token request
    → PersonConnections creates the connection, or repairs the one a ReconnectIntent names
    → the tab lands on /settings/connectors/sign-in with the outcome in the query
    → that page reports the outcome to the Connectors page and closes

There is no start hop: authorize answers with the consent address as JSON, and the page points the tab it opened at it. The tab is opened by the click itself, before the request is made, because a browser lets a click open a tab only while the click is running; its opener is cut so the provider's pages get no handle on the Connectors page. A refusal closes the tab again and shows inline where the click was, and a browser that blocks the tab makes the sign-in run in the page's own tab. The callback is the only server-rendered leg, because it is the provider, not the SPA, that sends the browser there. It answers 404 for a connector not offered to the person, the same rule the typed API applies.

Sign-in state

OAuthStateStore (services/oauth_state.py) issues a 32-character state for one connector and one person, and is the only code that knows its cache key. The entry lives 10 minutes and holds what the sign-in is for, as a Pydantic union: NewConnectionIntent, or ReconnectIntent with the connection_id to repair. It is validated when it is read back, so an entry of any other shape is refused rather than defaulted.

The callback spends the state before it looks at anything else, and only the caller whose delete removed the entry goes on. A state that is malformed, already spent, expired, issued to someone else or issued for another connector is refused with "This sign-in could not be matched to your session. It may have taken longer than 10 minutes, or it finished in a different browser session. Start it again." The provider is never asked for a token after a refusal. A declined consent (error=access_denied) spends the state too.

The state is tied to the person's session, and a session cookie does not cross hosts. The host in a connector's REDIRECT_URI must therefore be the host people open CampusCore on, or every return is refused.

Token exchange

exchange_authorization_code (services/oauth_exchange.py) makes one request with a 10-second limit and no retry. A code works once, and resending a spent one can make the provider revoke the grant it has just issued. The answer counts as a token only when it holds a non-empty access_token, parsed by TokenResponse, which keeps every other key the provider sent. Anything else becomes " did not issue a token. Ask an administrator to check the connector's Redirect URI.", and a timeout or dropped connection becomes " did not answer in time. Try again." The log line carries the HTTP status and the provider's error code only, never the body.

Reconnecting

An expired connection is repaired in place, and keeps its id, its name and its creation time. For an OAuth2 connector, POST /api/connections/reauthorize {connection_id} issues a ReconnectIntent for one of the person's own connections, on a connector still offered to them, and answers with the consent address. For a manual connector, POST /api/connections/credentials {connection_id, credentials} tries the new credentials first and answers with the refreshed connectors list.

Both end in PersonConnections.reconnect, which locks the connection row, writes the new credentials, marks the connection active and writes a connector_connection_reconnected audit entry. In the same transaction it expires the connection's pending writes, each audited with source reconnect, because a reconnect can come back as a different account at the provider and a write proposed under the old one must not run under the new one. A write already executing is left alone. A callback whose connection was removed while the person was signing in, or which names someone else's connection, reports "This connection was removed while you were signing in. Connect again to add it." and changes nothing.

One connection per connector

A person holds one connection per connector unless the adapter sets allows_multiple_connections. PersonConnections (services/connections.py) is the only reader of that attribute, and the only code that creates or reconnects a Connection: the callback, the manual connect form and the credentials form all go through it. create locks the person's user row for the check and the insert, so two tabs connecting at once cannot both pass the check.

A second connect is refused with "You are already connected to . Remove that connection first to connect a different account." authorize and the manual connect answer it with 409, and the callback reports it as a failed connect. Each card in GET /api/connectors carries can_add_connection, so the page offers Connect only when the server would accept it. A catalog row with no registered adapter answers false, because no connect can be served for it. A person who already held several connections to one connector before this rule keeps them, can remove any, and cannot add another; the drive picker and the agent's tools both use the one with the lowest id.

A connection's name follows the same rule. The manual connect form has a Name row only for a connector whose card says allows_multiple_connections, and for every other connector the server stores the default label, " Connection". The SPA shows a single connection by its state rather than its label, because the connector's own name already says which one it is. connectionName(card, connection) in web/src/components/settings/connectors/connectorView.ts is the one place that decides: it returns the label for a connector that allows several or a card holding more than one connection, and null otherwise, and the drawer item, the Connectors page row, the attention banner and the reconnect form all read it. So a single connection's row reads "Connected · Not used yet", its drawer item has no headline, and its Remove dialog names the connector. The Remove button's screen-reader name keeps the label either way. A person who still holds several connections from before the one-connection rule sees each one's name, so they can tell them apart.

Callback outcomes

The callback redirects to /settings/connectors/sign-in?connector=<slug>&outcome=<outcome>, with one of four outcomes:

Outcome When The page shows
connected A new connection was saved "Connected to ."
reconnected An expired connection was repaired "Reconnected to ."
failed A new connection was not saved "Could not connect to . "
reconnect_failed A repair did not complete "Could not reconnect to . "

Only the two failures carry message, capped at 200 characters; parseCallbackOutcome in web/src/lib/api/connectors.ts holds the same cap and strips control and format characters. The sign-in page (web/src/routes/SignInOutcomeRoute.tsx) shows the outcome as one sentence, posts it on the connector-sign-in BroadcastChannel (reportSignInOutcome) under an id of its own, and closes its tab once a Connectors page answers shown with that id, so a tab left open on an unanswered failure is not closed by a later sign-in. The Connectors page listens (subscribeToSignInOutcomes), shows the outcome as it shows a returned one, closes its drawer so the notice is not behind the scrim, reads the list again, and answers; a report it has already shown is answered but not shown again. With no Connectors page open the tab stays on the sign-in page, whose link back to Connectors carries the same query, and the Connectors page reads that query on arrival. Either way the page names the connector from its own list, never from the query; on the query path it also replaces the URL with a clean /settings/connectors, so a reload or the back button does not replay the outcome. The next action the person takes on the page clears it.

An unexpected exception in the callback is logged at warning with its traceback and reported as "The connection could not be saved. Try again." It is never logged at error, because Sentry turns error records into events, and this frame holds the token.

Connection API

The person-facing routes, all session-authenticated and scoped to the requesting person:

Route Purpose
GET /api/connectors The connectors offered to the person and ready (not_ready_reason), each with its connections, the adapter's one-line description, allows_multiple_connections and can_add_connection
GET /api/connectors/detail?slug= One connector's manual credential form schema and the adapter's action lines this person may use
POST /api/connectors/authorize Start the sign-in for a new OAuth2 connection; answers with the consent address
POST /api/connectors/connect Save a new manual connection after trying its credentials; refuses an OAuth2 connector
POST /api/connections/reauthorize Start the sign-in that repairs an OAuth2 connection in place
POST /api/connections/credentials Give a manual connection new credentials in place
POST /api/connections/test Try a saved connection; answers success, expired or failure
POST /api/connections/delete Remove a connection, revoking its grant best-effort

The Connectors page

Settings -> Connectors (web/src/components/settings/ConnectorSettings.tsx, with its parts under settings/connectors/) lists what the person has connected as rows, and everything else as cards with one Connect. Rows holding an expired connection sort first and wear "Needs attention". A banner above the lists names the connector of the first expired connection, and the connection too where it has a name, and its Reconnect repairs that connection. A card's Connect opens the provider's consent screen in a new tab for an OAuth2 connector, and opens the drawer on the credentials form for a manual one. A manual connect or reconnect that works closes the drawer and shows the same notice a sign-in does, "Connected to ." or "Reconnected to .", through the page's one outcome state; a refusal stays in the form. The page never shows how a connector signs in.

A row opens the connector's drawer: its Connection section (headed "Connections" when the adapter sets allows_multiple_connections), one item per connection with Test or Reconnect and Remove, then the Actions section, which reads the detail endpoint's action lines, reads before writes. "Add another connection" shows only while can_add_connection is true. Removing the last connection leaves the drawer open on its connect state.

The open drawer lives in the address as ?open=<slug>, so a reload reopens it and Back closes it. Opening pushes a history entry and closing replaces it, and every write touches open alone, so the callback's keys and any others survive. A slug the list does not offer is dropped from the address once the list has loaded. Closing gives the keyboard back to whatever opened the drawer, or to the connector's row or card Connect when that control has left the page.

Token refresh and granted scopes

OAuth2 tokens are refreshed on use by authlib, which calls the token updater that make_token_updater(connection, base) builds. client_for hands it to get_client, and the adapter passes it to build_oauth_session(creds, ...), which gives authlib the stored token as store_payload(creds) dumps it. TokenUpdater (services/token_updater.py) is a Protocol for the two ways authlib calls it: with the refresh token it spent when it refreshes before a call, and with the access token it replaced when a call finds the token expired.

The updater merges the new token over the stored one and parses the result into the same model class as base, so the adapter's own keys a refresh response never carries (provider_base, split_horizon, _tenant_id) stay in the row. It stores the result through Connection.store_credentials and marks the connection active. If the merge fails to parse, the updater still stores it, through Connection.store_unparsed_credentials, because the provider may have rotated the refresh token and the stored one is then dead. It logs at ERROR with the failing field locations, leaves the connection's status as it was, and the next read_credentials of the row reports the failure through the health path. Any other failure to save is logged at ERROR by exception type only and does not fail the call in flight, which already holds the new token.

A connection also records what the person actually consented to: the token response's RFC 6749 scope is stored on Connection.granted_scopes (normalized by services/oauth.py::normalize_scope), and a refresh whose token carries scope keeps the column current. Connection.store_credentials is the one writer of the credentials and the scope together, used by the callback, by a reconnect and by a refresh; a token with no scope, or an empty one, leaves the recorded scope as it was, and so does an unparsed store. Scope names are not secrets - they appear in the provider's consent URL - so the column is plaintext, the connections API serializes it as a list, and the connection in the Settings -> Connectors drawer folds them under "N permissions granted", one raw scope per line.

Disconnecting

Disconnecting reverses the grant, best-effort: the delete endpoint calls the adapter's revoke_token hook before removing the row and records the outcome (revoked, deferred, failed, unsupported, or adapter_missing) on the connector_connection_deleted audit entry. deferred means the adapter left the upstream grant alive on purpose: Google revokes a whole (person, client id) grant, so while the person holds another Google connection on the same OAuth client, removing one deletes only its local credentials. A failed revocation never blocks the delete - the user's intent to disconnect always wins - and revocation implementations make a single short-timeout attempt so an unreachable provider cannot stall the request.

Credential Security

All credentials are encrypted at rest with Fernet symmetric encryption, and for Connection.credentials that guarantee is a model contract, not a convention: Connection.save() rejects any value that is not structurally Fernet ciphertext, so a plaintext write raises instead of persisting.

  • Encryption key stored in the APP_FERNET_KEY environment variable (generate via python manage.py generate_fernet_key); settings validation rejects an unusable key at boot, so the cipher here is built once and is never None
  • Connection.credentials is written through store_credentials(), or store_unparsed_credentials() for a refreshed token whose merge failed to parse, and adapters read it through BaseConnector.read_credentials()
  • decrypt_credentials() is the raw, raising read that read_credentials() parses, and get_credentials() is the tolerant one that returns None on an undecryptable row, used by the admin display
  • A credentials model is safe to print: repr shows no secret field and no provider extra, and a parse failure's ValidationError carries no input value (hide_input_in_errors), so a stored token cannot reach a log line, an error message or a Sentry frame through the model
  • manage.py check_connector_credentials parses every stored row through its adapter's model and prints counts per connector and failing field, never a value; it writes nothing, and it exits non-zero when any row fails to parse, so run it before a deploy that narrows a model
  • The same command counts, without failing, S3 connections holding one half of their access key pair (the model still accepts them, and they run on the deployment's own S3 settings) and config rows stored plain under a key the schema calls secret
  • ConnectorConfig.save() enforces the pairing both ways: a value marked is_secret=True, or under a key the adapter's schema calls secret, is encrypted on save, and a value that is structurally Fernet ciphertext on a row that is not secret raises instead of persisting
  • SSOProvider enforces the same save-time invariant on its x509_cert and oidc_client_secret fields
  • Every screen masks a config value through ConnectorConfig.get_display_value(), which calls mask_sensitive_value() when shows_masked

Error Handling

Failure is part of the connector contract - the full rationale is connectors-philosophy.md §14; this is the mechanics.

apps/connectors/errors.py declares the taxonomy: ConnectorError with subclasses ConnectorAuthError, ConnectorRateLimitedError, ConnectorTransientError, ConnectorInvalidRequestError and ConnectorNotFoundError. ConnectorInvalidRequestError is the parent of a family with one subclass per cause: - ConnectorArgumentError: an argument failed the args model or an action's own check, before anything was sent. - ConnectorPermissionError: the connected account may not make the request. - ConnectorLimitError: a size, type, count or quota limit refused it, CampusCore's or the service's. - ConnectorRecordStateError: the record is the wrong kind, or in the wrong state, for the action, such as a folder where a file was needed or an archived file. - ConnectorUnreadableResponseError: the service answered without what the connector needs, such as a created draft without an id, so a write may have landed. - ConnectorPartialWriteError: a batch write landed in part; it is never raised when nothing landed. - ConnectorPolicyError: a domain policy refused it while the account's grant still works.

The plain ConnectorInvalidRequestError is raised only inside the functions that classify a service's 4xx answer, for a refusal no subclass names, such as a malformed request. A ConnectorPermissionError is a role, mailbox grant or file sharing setting that refuses the account, so reconnecting cannot fix it: Canvas's role denial, Graph's refusal of a named mailbox or a SharePoint site, list or file, Drive's insufficientFilePermissions and fileNeverWritable reasons, Calendar's forbiddenForNonOrganizer reason, and S3's AccessDenied raise it. S3's InvalidObjectState, a read of a file in an archive storage class that nobody has restored, raises ConnectorRecordStateError. ServiceNow's 403 stays a plain invalid request, because it mixes access rules with data policies. A HEAD response has no body, so S3's bare 403 code from a HEAD cannot be told apart from a wrong key and stays an auth failure; the S3 download reads the file's size from the GET, whose refusal carries AccessDenied. The single action entry point (execute_action) wraps the invocation in translated_connector_errors, which maps raw library failures into the taxonomy by exception type and status code, preserving the original as __cause__. Action methods raise taxonomy types directly only for domain cases (an empty result, a bad parameter, or another cause the family above names); the conformance harness rejects stdlib raises (ValueError, RuntimeError, ...) in actions modules. Unrecognized exceptions escape raw - that is a contract violation, and CloudFileImportService fails the whole batch on one instead of counting it as a per-file error.

Consumers classify by type: the import service records classified failures per-file, the agent tool factory turns them into guidance the model can act on ("ask the user to reconnect"), and the browse/test APIs map them to honest statuses. The person-facing test and manual-connect doors show a message only from a typed failure - a ConnectorError, or the Django ValidationError a manual adapter raises on purpose. An untyped escape is logged at ERROR with the connection id or slug, and the person reads a fixed sentence instead of the exception's text. Auth-classified failures go through apps/connectors/health.py::record_connection_failure, which marks the Connection expired; the next successful use (mark_active, including a passing connection test) clears it. A connection is either active or expired, and mark_expired is the only writer of expired - disconnecting deletes the row rather than parking it in a third state. A check constraint enforces the pair: an active connection carries no error_message and an expired one always carries a reason.

The stored reason is shown to the connection's owner, so mark_expired cleans it before it is written. Web addresses go, because a request URL can carry a person's search text in its query: the clauses HTTP libraries append (requests' for url: <address> and with url: <path>, httpx's for url '<address>' and its For more information check: line) are removed whole, and any other http:// or https:// address becomes [address removed]. The <slug>: prefix the error boundary adds goes too. A reason with nothing left becomes "The provider no longer accepts this connection's credentials." The Connectors page shows the reason on the expired connection's card, under a sentence that says how to repair it. The behavioral pins live in apps/connectors/tests/test_connector_errors.py; the static conformance check lives in test_connector_contract.py.

Agent Integration

Connector actions reach the agent through progressive disclosure (philosophy §9): the agent always sees the two bootstrap tools (list_my_connections, load_connector_actions), and loading a connector generates typed {slug}__{action} tools via main_app/services/agent/tools/connector_tool_factory.py, each with the action's args model as its args schema, so the schema the model is shown is the one every call is parsed against. The result comes back as a model too, and the model reads it as JSON keyed by field name, rendered by to_json_value (philosophy §6). An action that declares emits_citations returns a CitingResult (or a list of them), whose web_citations() gives the web sources the factory turns into Sources-panel citations; the factory checks that with isinstance, so a result that is not a CitingResult cites nothing.

Write Confirmation Runtime

The permission field on @action is enforced at the agent boundary (philosophy §10), in _execute_action_tool:

  • auto (reads) executes immediately.
  • user_confirm never executes from the agent's call. The arguments are parsed first with the same parse_action_args execution uses, so a malformed write is refused before any card appears. The proposal is persisted as a PendingConnectorAction row (user, connection, action, params, 15-minute expiry), where params holds the parsed arguments the model sent and no defaulted ones. The stream then emits an action_confirmation SSE event, and the SPA renders a card with Approve and Deny buttons. The card also re-renders from the saved transcript (an ActionConfirmationEvent in the stored PersistedTurnTrace.events). The transcript read carries each held write's current state in AssistantMessage.held_writes, built by current_outcomes in services/pending_action_outcomes.py, so an already-decided or expired proposal shows its outcome at once instead of buttons. That read also expires a proposal whose window closed, as a decision on it would.
  • admin_approve is refused from chat with a clear message - no admin approval queue exists yet.

Every held write gets a WritePresentation: a sentence summary and a list of LabeledFields. It is the connector's when its describe_pending_write hook answers, and otherwise the tool factory builds it on the server, titled with the action's declared label with a capital first letter, such as "Post an announcement". When a presentation has no fields, the server lays out each non-empty parameter under a plain label, and a file_ref or an id in attachment_file_refs shows as the file's name. A connector whose ids are numbers the person cannot check overrides name_write_parameters, which returns the record's name for each id parameter, and a named id shows under its label without "id", as "Course: Biology 101". Canvas reads each course, assignment, quiz, module, user, group, topic and conversation back once, and caches the name on ctx.scratch under the connection's id (canvas/write_names.py). All the reads for one card share one 5-second deadline, and the naming stops when it passes. The reads use their own client whose session timeout is that deadline, because an expired token is refreshed inside the session, where only the session timeout bounds it. A 404 or Canvas's "unauthorized" 403 leaves only that id as a number, and any other failure, such as a timeout, a 401, a throttling 403 or a 5xx, leaves that id and the ones after it as numbers. The conversation read passes auto_mark_as_read=false. This cache is separate from the agent run's RunNames book (see agent-retrieval.md), which learns only string ids from what the model's reads returned. A card must name a Canvas id the model never read, and a Canvas number is unique only within one kind of record, so the cache is keyed by the read's path instead. The factory hands the loop a typed PendingProposal, and the loop builds the ActionConfirmationEvent from it, with the connector's display name in connector_name. The row's summary stores the same sentence.

The model gives every user_confirm write a proposal_reason, a required argument in the tool's schema, and the card shows it as the line saying why the write was proposed. A reason longer than 300 characters is cut to 300, as the card's title is cut to 500. The factory takes it out of the parameters first, before the arguments are parsed, so it never reaches the connector, the row's params, the audit log, the call event's args or the spans; the confirmation event is its one stored home. A call with a missing or blank reason is refused back to the model, after the audience, administrator, policy and ungated-write gates, so a write the institution turned off is refused for that reason instead. No action may declare a parameter named proposal_reason, and the connector contract test enforces it.

Approval (POST /api/connectors/pending-actions/approve) executes the action server-side from the stored row - the client sends only the id, so what the user saw is exactly what runs - then records the JSON result on the row, or the person's sentence for a failure. The replay parses the stored params again, so a row held before a deploy that changed the action's arguments fails before anything runs, and the row records that the change no longer matches what the service expects, that nothing was sent, and to ask the assistant again. The row's error_message never holds the exception's text, because the card shows it and the next turn's decision note hands it to the model. person_reason in connectors/services/failure_reasons.py writes the sentence for a classified failure, and the connector tool factory uses the same function for a failed tool call, so a failure reads the same on the trace row and on the card. Each class has its own sentence, and none holds the exception's text. A permission failure reads "Your {name} account isn't allowed to do that." on a read and a write alike. An argument failure reads "CampusCore could not build this request, so nothing was sent to {name}.", and a write adds "Ask the assistant again.". A limit failure reads "This is over a size, type or count limit, so it could not be read from {name}." on a read, and "... so it did not go through to {name}." on a write, which holds both for a service's limit and for CampusCore's own per-call caps, where nothing was sent. A record-state failure reads "That item on {name} is not the kind, or in the state, this action needs.". An unreadable answer reads "{name} answered in a form CampusCore could not read.", and a write adds that the change may have landed and to check {name} before trying again. A partial write reads "Part of this change reached {name} and part did not. Check {name} before trying again.". A policy failure reads "A policy on {name} does not allow this.", and a plain invalid request reads "{name} refused this request.". A write's sentence differs from a read's in two places: a not-found write reads "{name} could not find what this change points at.", and a transient failure such as a timeout reads "The change may have reached {name}, but CampusCore could not confirm it. Check {name} before trying again.", because the change may have landed. An unexpected failure after execution was attempted records that same sentence and logs the exception by class. Denial records the decision and executes nothing. An approval that the re-check at decision time refuses ends as refused with the refusal's sentence, and executes nothing. A row is terminal after one decision; expiry is applied lazily at status/decide time. Check constraints hold the row's companion fields to its status: a row that has left pending carries a decided_at, only an executed row carries a result, a failed or refused row always carries a reason while pending/executing/executed/denied/expired carry none, and only a decided row can be marked outcome_reported. So a denied row is always the person's decision, and a refused row always says why the write was stopped. The API names a refusal's sentence reason, and a denial carries no message field.

The card is the call to action, and the receipt that replaces it after a decision is the outcome. Both _WRITES_CONFIRMATION_RULE in the system prompt and the held result's detail (HELD_DETAIL in connector_tool_factory.py) tell the model to say in one sentence what it prepared, then stop. So the answer above the card never asks the person to approve, deny or review it, and never repeats what the card shows. The held result's summary, which the model reads, is "Held for your approval." The card's buttons read "Approve" and "Deny" on every card, because the title already says what will happen, and nothing a connector returns becomes button text.

The receipt is composed on the server from the stored result, so the person reads what happened without a new model turn. An executed row's status and approve responses carry a WriteReceipt: a headline, a detail sentence, an optional link and the result's fields. build_write_receipt in connectors/services/write_receipts.py asks the connector's describe_write_receipt hook first, and ServiceNow and Outlook Mail answer it, as in "Created INC0010023 on ServiceNow" with the record's link. Otherwise it builds the generic receipt: "Asked {name} to {label}" with the detail "Done.", read through the declared label, so an old row reads the same as a new one. The hook takes the stored JSON result and makes no network call, so a status probe days later recomputes the same receipt, and a hook that raises is logged by class and replaced by the generic receipt. A row whose connector is no longer installed gets the generic receipt, named by the catalog Connector.name. The generic link is the first non-empty url, web_link, web_url or html_url of a dict result, labeled "Open it in {name}", and WebLink accepts only https and http addresses, so a result can never place a script link in the card. The fields are every top-level result key under a plain label, sorted by key because the row's JSONB column does not keep the order, with lists joined by commas and empty values left out. result_fields leaves out the key the link opens, so the address never shows twice. ServiceNow's receipt lists only what a person reads: the number, the short description and urgency the write asked for, and the state, never the record's id, its address or record_change. The browser never parses a result: it lays out the receipt, and composes the failed, denied, refused, expired and executing rows from the status, the summary and the connector name, plus the failed row's error_message and the refused row's reason. A card saved before the confirmation event carried connector_name is completed by the transcript on read (see agent-retrieval.md), so it names the connector by its display name and never shows a slug or the old machine line.

Decided outcomes flow back to the agent exactly once: chat.cross_turn_notes renders the conversation's undelivered decisions into the next run's dynamic context, so a later turn knows an approved action ran (with its result) and never re-proposes a denied one unprompted. Each note line names the sentence summary, then the connector slug, the action and the parameters as compact JSON cut at 300 characters, as in DENIED by the user: Post an announcement [canvas.create_announcement {"course_id": 1, "title": "T"}], so the model can tell two similar proposals apart. A refused row's line says the person approved and the institution's rules refused the write, and quotes the reason, so the model never tells the person they declined it. The audit trail records the whole lifecycle. The proposal is written as pending_confirmation with outcome pending, because nothing has run yet; execution writes success or error with source: user_confirmation; a refusal writes a denied audit row; and a card that lapses unanswered writes expired, which is also a denial.

Action Governance

Which connector actions each person may use is an institution decision, stored per action in ConnectorActionPolicy (apps/connectors/models.py): one row per (connector, action_name) with is_enabled plus an enabled_for_roles scope and an enabled_for_users scope, mirroring feature management's semantics. The two scopes are a union: a person may use an enabled action when they hold one of its active roles or are named on it while their account is active, and an enabled action scoped to nobody is usable by nobody. That rule has one home in code, apps/auth/services/enablement_service.py, shared with FeatureState; rbac-and-feature-flags.md lists every writer of both models.

sync_action_policies (apps/connectors/services/action_policy.py) converges the table with the actions each registered connector declares. Its creation defaults are the safety contract: read actions are created enabled for every active role their excluded_roles denylist does not name (disabled with no roles when the denylist covers every active role), write actions are created disabled with no roles - so a connector upgrade can never silently grant a write capability.

An action can declare excluded_roles on its @action decorator: system-role slugs that must never be in its scope. The scope save refuses newly adding an excluded role, the role drawer disables it with the reason, and "All eligible" skips it. A scope that already holds an excluded role (every deployment synced before the exclusion existed starts this way) is kept - nothing changes behind the admin's back - but the drawer and the row's scope pill flag it, and once removed it cannot be re-added. Runtime evaluation still reads only the policy table, so a deliberately kept violation keeps working until an admin removes it. Existing rows are never re-seeded (an admin's scoping stands), rows whose action vanished from the code are pruned, and permission="admin_approve" actions get no row at all - they stay invisible to the agent, the admin UI, and this table until an admin-approval mechanism exists. A rename is not a vanishing: an action that declares its old name in aliases keeps the row it already has, and a run that looks like a rename nobody declared is refused rather than performed (see "Renaming an action" below). The sync runs at boot from run_boot_sequence (after the role registry sync, which it depends on) and via manage.py sync_connector_action_policies. A policy write can name declared actions whose rows were never created, which means the boot sync did not run. That write is refused with plain admin copy carrying no action names, and one ERROR log record names the connector, the connector-wide gap, and the missed sync_connector_action_policies step. The endpoints never self-heal: a skipped boot step must surface, not be papered over.

Evaluation is fail-closed - no row, a disabled row, a person neither holding a scoped role nor named on the row, or an unauthenticated caller all mean no - and enforcement covers all three doors:

  1. load_connector_actions filters the advertised schemas to the caller's allowed set (computed once per load by resolve_loadable_actions, carried on run state in loaded_actions_by_slug), so a denied action is never even visible to the agent. A connector a recent turn of the conversation loaded is loaded again at the start of the next turn through the same resolve_loadable_actions, so its allowed set is computed again for that turn.
  2. _execute_action_tool re-checks is_action_allowed live on every call, so a forced call is refused with the institution message.
  3. The pending-action approve endpoint re-checks at decision time, so a held proposal whose action was disabled after it was proposed is REFUSED instead of executed.

A person reads POLICY_REFUSAL and OFFER_REFUSAL as they stand, as one sentence each. The model reads them with FIND_CONTACT_GUIDANCE from apps/connectors/services/failure_reasons.py added, which tells it to search the knowledge base for the office or help desk that handles the request and give the person that contact. Most people cannot see the workspace settings, so no refusal the model reads sends the person to a page only an administrator sees. The loader's "no actions enabled" error and the guidance for a ConnectorConfigError carry the same sentence, and the configuration guidance leaves out the exception's own text, which is written for an administrator. The admin_approve refusal still tells the model to send the person to an administrator, because an administrator is the one who approves that change.

Every deliberate write outside an agent turn goes through one gate, apps/connectors/services/execution.py. check_action_allowed(user, connection, action_name, source=) asks the three questions in order - is the connector offered, does its audience admit this person, has the institution enabled this action for them - and each refusal audits outcome="denied" naming the gate in details.source and the caller's own surface in details.surface before raising ActionRefused. run_connector_action(...) is that check plus the call: it executes through the adapter, audits the success with whatever extra details the caller passes, marks the connection active, and on a ConnectorError audits the error, records the failure against the connection and re-raises for the caller to interpret. The approve endpoint, the Canvas course list behind the export dialog and an artifact export sent to a connector destination all call run_connector_action, and both cloud-import doors call check_action_allowed before running their own import sequence, so none of them can drift from one another, and the surface each one passes (user_confirmation, cloud_import, artifact_export) is what tells the audit stream where a refusal was asked from. The agent's own path keeps its live re-check in _execute_action_tool, because it refuses inside a turn rather than raising.

An artifact export uses four Canvas writes the agent can also propose in a chat turn: create_course_page, which now makes a draft page unless published is passed true, upload_course_file, and the new pair create_quiz and add_quiz_question, which build a Classic quiz unpublished, one question per call. Google Drive gained upload_google_drive_file, whose convert_to says what Drive makes of the upload - google_doc for a Word file to become a Doc, google_slides for a PowerPoint file to become a Slides deck, and file to store it as it is - and OneDrive upload_onedrive_file; all three uploads name their file by file_ref, below.

Admins manage all of it from Settings -> Workspace Connectors: each connector's actions render as governed rows grouped by topic (the action's first non-read/write tag) with read/write badges, scope pills ("Everyone", "No one", the one role's name or "N roles", then a people count), inline switches, a scope editor holding the role list with reachability guardrails and a people picker beneath it, and bulk "Enable all reads" / "Disable all writes" controls. Hovering a narrowed scope's pill shows the whole scope as a sentence, such as "Academic Advisor, Student and 1 person", and on a touch screen the scope editor lists every role. A scope pill that does not fit beside its action's title moves under it, right-aligned with the pills that stayed beside, and a role name too long for its line ends in an ellipsis while the people count stays whole. The list is the Actions tab of the connector's drawer, open as soon as the tab is, with the enabled count on the tab itself and each topic heading carrying its own "n of m on". The toolbar sits on two rows so it fits the panel: the kind group and the topic list on the first, the search box and the bulk menu on the second. Saves go through workspace_connectors_api (action_policy_save, action_policies_bulk_save), which takes role_slugs and user_ids as the same tri-state (omitted leaves that scope alone, a list replaces it), refuses enabling with nobody in scope through enablement_service, refuses newly scoping an inactive, excluded or unreachable role through the same resolve_scope_roles while keeping one already in scope, refuses a person who is not a nameable active account, and audits every change with role_slugs and user_ids (ids only). A bulk enable never touches scopes and skips an action with nobody in scope, naming it in the confirm dialog. A role deactivated after it was scoped stays in the action's scope and counts for nobody, so an action scoped only to deactivated roles is skipped too; the scope pill leaves such a role out, and the drawer lists it muted until an administrator removes it. Changes apply on each person's next agent run; there is no live invalidation.

People see the same decisions on their own Connectors page. Each adapter declares action_lines: a few plain sentences, each naming the actions it stands for, such as ServiceNow's "Search the knowledge base" over search_knowledge_base and get_knowledge_article. GET /api/connectors/detail sends a line only when allowed_action_names admits at least one of its actions for the person, in the adapter's order, with the line's side_effect and asks_first (true when an action behind it is user_confirm). So turning off both knowledge-base actions takes that line out of everyone's drawer, and turning off one of them leaves it. The lines are for people only; the agent keeps reading the action docstrings through load_connector_actions. The conformance harness holds the lines to the code: every action that is not admin_approve sits under exactly one line or in unlisted_actions, every name is a real action, and one line never mixes reads and writes.

Renaming an action

Renaming an @action method is a rename of the key its policy row is stored under, so the sync has to be told about it or the admin's decision is lost. Declare the old name on the renamed method - @action("read", aliases=["old_name"]) - and the next sync moves the existing row to the new name. The row keeps its primary key, its is_enabled switch, its role scope, and its updated_by/updated_at stamp, because that stamp records the last person's edit and a rename is not one. The sync reports the pair as old_name -> new_name in the command's output and in its log line.

An action renamed without an alias looks exactly like one action being added and another being deleted, and the sync refuses that run instead of guessing. The refusal is an UndeclaredActionRename from the service, which reaches an operator as a CommandError from manage.py sync_connector_action_policies and as a stopped boot from run_boot_sequence. Its text names the connector, the names that would be created and the names that would be pruned, and gives the two ways out. The first is to add the alias and redeploy, which is what a real rename needs. The second, when the old action really is gone, is to run manage.py sync_connector_action_policies --prune-orphans as a one-off task against the deployment - that flag deletes every orphan policy row in the deployment, not only the one the message named. manage.py sync_connector_action_policies --dry-run prints what a run would do, including a refusal it would raise, and writes nothing; like sync_feature_registry --dry-run it still exits 0, so read its output rather than its exit code.

An alias the sync cannot act on raises AmbiguousActionRename and writes nothing. That covers an alias naming an action that still exists, which would hand the live action's row to the renamed one and re-create the live action with default scoping; two actions declaring the same old name, where the old row has no single heir; and a connector holding rows under both the old and the new name, where only a person can say whose scoping survives. The conformance harness catches the first two at CI time, before any deployment runs the sync.

The approve gate resolves an old name the same way execution does, so a proposal made under the old name before the deploy is still decided by the one row the admin sees, as long as the container handling the approval is running the new code. A container that has not restarted yet is running the old code, whose gate looks the old name up exactly, finds the re-keyed row under its new name, and denies - fail-closed, and the user can retry once the rollout finishes. Nothing re-keys PendingConnectorAction.action_name or the audit log: a proposal keeps the name it was proposed under, and history keeps the name that ran.

Chat-Attachment File Bridge

Actions that consume a chat file (the three uploads upload_course_file, upload_google_drive_file and upload_onedrive_file, the three Outlook draft writes through attachment_file_refs, and attach_file_to_outlook_draft) get it through an injected capability, never by importing main_app: ActionContext.file_resolver maps a file_ref - a string only the host can answer for - to a ResolvedFile (the ref, name, MIME type, size, lazy bytes), and BaseConnector.execute_action binds the context onto the actions instance so an action can reach it. main_app implements the resolver in services/connector_attachments.py and injects it on both execution paths (the live gate and the approve endpoint). It answers only for the SourceFile id of a file the person attached to this conversation and may read, which is the id every agent surface hands out, and for no other id. Bytes come from read_original_bytes, which covers both the original FileField and the presigned-upload s3_key store. The confirmation card resolves file_ref to the file's display name, so the user approves a named file. A card that lists several files names each through ActionContext.file_namer, which answers a ref's display name without reading its bytes. The resolved file's type comes from media_type_for_path, whose explicit map names the Office formats itself, so a .docx goes upstream as a Word document even on a container with no MIME table of its own. It is the bare media type: a text file goes upstream as text/plain, while the file server's mime_type_for_path serves the same file as text/plain; charset=utf-8. An artifact export uses the same seam from the worker: make_export_file_resolver answers for that one export's id and nothing else, so the connector still never reads storage, and it declares the export's type bare the same way (an HTML export goes up as text/html).

The reverse direction - a connector file loaded INTO the chat (Canvas's load_course_file_into_chat, Outlook Mail's load_outlook_attachment_into_chat) - runs through the second injected capability, ActionContext.chat_file_ingestor, implemented by apps/main_app/services/connector_file_ingest.py and injected only on the agent path. The action proves the acting user's upstream access with its own authenticated metadata read and describes the file (identity, declared size, a deferred fetch callable); the hook owns everything on our side: guards against the chat allow-list and the 30 MB cap, versioned dedupe, S3 staging, row creation, enqueue, and a bounded same-turn wait.

The primitive behind the hook is the shared connector-file copy: one processed SourceFile (access_scope="connector") per (connector slug, upstream file id, version key), registered in ConnectorFileCopy with a DB uniqueness constraint. "Loading into a conversation" creates only a ConversationAttachment link row, and the link is the access grant - KnowledgePolicy grants a user exactly the connector files they hold a live link to. An unchanged file is processed once, ever: later loads by any conversation, or by another user whose own connector read of the file succeeds, link the existing copy instantly with no new ingestion run. The version key is the upstream updated_at when the connector supplies one, else a content hash, so a changed upstream file gets a fresh copy: the asking conversation is re-linked to the new version (replace-on-change) while other conversations keep the old one, and a copy is reaped - chunks, S3 object, file row - only when its last link goes. Both columns hold 255 characters, and a connector's key can run longer - an Outlook attachment's message and attachment ids together are over 300. The hook fits each identity once, on the way in, through stored_identity: a value that fits is stored exactly as given, and a longer one as sha256-of-id: plus the SHA-256 of the whole value. Every lookup, replace, processing message and chunk provenance then carries that stored form, and nothing reads it back as an upstream id - a refetch always goes through the connector's own action. A database error in any connector action reaches the agent as one plain sentence, never as the database's own text. Connector-loaded files display in the conversation's Context panel only, never as message chips.

Adding a New Connector

  1. Create the package apps/connectors/connectors/my_connector/, whose __init__.py exports the connector class
  2. Define an Actions class with @action-decorated methods, each declaring a label that follows "Action labels"
  3. A write whose direction comes from a parameter gets a describe_pending_write hook that names the direction in the card's title.
  4. Define a BaseConnector subclass with slug, a kind from ConnectorKind, display_name, actions_class, and default_available when the connector should ship dormant
  5. description is the one line of at most 60 characters a person reads on the Connectors page card.
  6. action_lines are the plain sentences the person's drawer shows (see "Action Governance"), and unlisted_actions names the internal actions a person never asks for by name.
  7. Name its config key type - a Literal of its workspace-config keys, or NoConfigKey when it has none - parametrize BaseConnector with it, and return the typed ConfigFieldSpec schema from workspace_config_schema()
  8. Implement the required auth methods, reading required values with require_config_value after one validate_required_config(connector)
  9. Name the package in campuscore_app/institutions.toml, under [common] or under the institution that owns it; the boot sequence's sync_connector_catalog creates its catalog row, so no migration writes one
  10. Run makemigrations and migrate when the package adds models

The registry discovers the connector from the manifest, so no imports change.

Audit Logging

Every action execution is logged to ConnectorAuditLog with: - Which connection was used - Action name - Success/error status - Request details (params, error info) - Timestamp