Extensions

An extension is an installable capability package: a closure of schema and behavior that teaches the substrate something new, applied as one unit and removable as one unit. Installing the URL harvester adds functions and agents; installing a provider like Google adds account access and sync; installing a governance package adds vocabulary and rules. All three are extensions.

The product word is Extension; the wire word is bundle, and the two never diverge. The vocabulary, stated once:

ConceptUser termTechnical form
Installable capability packageExtensiona bundle manifest
Extension that connects an external providerIntegrationa bundle carrying the integration catalog facet
A configured provider accountConnectionan accountconfig-trait entity
Future installable with its own UIAppreserved

Everywhere else this page says Extension, Integration, and Connection, while schema, the REST collection (core.teild.dev/bundles), and ssctl keep saying bundle. The schema kind is not renamed.

What an extension ships

A bundle document declares the one group it owns (<name>.bundles.teild.dev, a categorized group, which is what marks it installed rather than built in) and lists in installs: the exact identity of everything the closure ships: entity types, traits, property types, entity mappings, functions, and agents. A minimal declaration:

group: core.teild.dev
type: bundle
metadata:
  id: web.web.bundles.teild.dev
data:
  group: web.bundles.teild.dev
  configType: config.web.bundles.teild.dev
  installs:
    - page.web.bundles.teild.dev
    - fetchpage.web.bundles.teild.dev

The bundle document never travels alone: the group's own schemagroup document and every member installs: names belong to the same apply, and a bundle applied by itself is refused (group …: no schemagroup manifest declares it).

Two more fields matter, both covered below: configType (the extension's one configuration entity type) and an optional oauth2: block (trusted provider endpoints and scopes). A bundle may also carry modules:, shared library source its functions import (Functions).

The installed callables react through the ordinary machinery: an extension's functions and agents run on the same changelog every other write lands in, its mappings fold provider records onto your people and tasks, and its writes are attributed to its own actors (authentication).

Install and lifecycle

Installing is one atomic apply of the whole closure: the same admission as any schema batch, every document admitted or none, active on commit. The loader holds installs: equal to what the group actually declares, both ways, so nothing is smuggled in or orphaned. Upgrade is the same verb, re-applying the full new closure, and it refuses breakage inside that same transaction, all problems at once: a dropped type with live entities, a narrowing type change that would strand live entities (a dropped or renamed property, a changed property kind, a removed enum value or state, a newly required property), or a dropped callable a live trigger still names. Additive upgrades (a new type, a new optional property, a new enum value or state) admit freely, so ship those without ceremony; schema evolution is the full contract an extension author designs against. Accounts, triggers, and cursors persist by identity across upgrades.

After that, three lifecycle verbs act on an installed extension, each guarded by one dataset-wide fence so in-flight work drains first and nothing new admits after:

Status is computed, never stored: ssctl bundle list (every extension) and ssctl bundle status <id> (one) answer whether an extension is installed, enabled, and configured, and how many accounts, functions, types, and live entities it owns. The CLI faces are ssctl bundle list/status/disable/enable/uninstall/purge, plus ssctl bundle connect for the consent flow below; install and upgrade stay ssctl apply of the closure.

Integrations

An integration is an extension whose job includes an ongoing connection to an external provider. It is a facet, not the umbrella: the harvester and a governance package are extensions and not integrations, because network access alone does not make one. Saying "install the Google integration" and "install the harvester extension" both stay true.

The facet is explicit catalog metadata, curated per extension, not inferred: it is not derived from the presence of an OAuth block, from account types, or from the group name. A token or webhook integration may declare no OAuth, and an account-shaped extension is not necessarily a provider integration, so the classification is stated rather than guessed.

A provider integration ships, on top of the usual closure, the pieces the substrate's OAuth facility recognizes by trait: an accountconfig type (the Connection, one entity per account), a bundleconfig configuration type named by configType, and, when it speaks OAuth, an oauth2 type plus the trusted oauth2: endpoint-and-scope block on the bundle.

A provider's records mirror in as ordinary entities of the extension's own types, under ids composed from the provider's own identifiers (host.ids.external), so a re-sync is an idempotent upsert rather than a duplicate: syncing the same page twice writes the same rows.

The OAuth facility

The substrate holds the OAuth engine and the credential store itself, as a host facility, and bundles declare auth rather than implementing it. Two rules keep a stored row from ever redirecting a credential:

oauth2:
  authorizationEndpoint: https://accounts.google.com/o/oauth2/v2/auth
  tokenEndpoint: https://oauth2.googleapis.com/token
  revocationEndpoint: https://oauth2.googleapis.com/revoke
  featureScopes:
    enabledContacts:
      - https://www.googleapis.com/auth/contacts.readonly

Per-property writer: ownership backs this on the row, in three declared roles: oauth, only the facility's actor, holding tokenRef, tokenStatus, grantedScopes and the email it read from the grant; connector, only installed extension code, holding a sync's own state (syncToken, lastSyncedAt, syncStatus); and owner, only an owner-tier actor, holding the feature toggles, syncFrequency and backfillDepth. The rule is enforced in the write path for REST, GraphQL, and CLI alike, not just in the console.

The flow itself is two endpoints. POST …/core.teild.dev/oauth/start takes the accountconfig entity's id as entity and answers the consent URL as url (and refuses while the bundle still needs configuration); the provider redirects the browser to GET …/core.teild.dev/oauth/callback, which is unauthenticated because the signed one-time state is the authentication, and which exchanges the code and stores the token reference. The callback answers HTML rather than JSON: a small self-contained page that posts the outcome (the connected entity id, or a correlation id on failure) back to the console that opened it and closes. ssctl bundle connect is the same start endpoint from the command line.

Connections

A Connection is one configured provider account: an entity of an accountconfig-trait type. Its operational health lives on the entity, held there by the OAuth facility rather than by hand: the token reference, a tokenStatus (pending, connected or erroring), and the grantedScopes the account actually holds. A single provider can back several accounts, so a Connection is one account, not one provider. Creating a second live entity of a bundleconfig-trait type is refused with a guard error: there is exactly one configuration entity per extension.

group: google.bundles.teild.dev
type: account
metadata:
  id: george-work
data:
  properties:
    email: george@example.com
    tokenStatus: connected
    grantedScopes:
      - https://www.googleapis.com/auth/contacts.readonly

The Connections view in the console is a cross-extension operational surface over every such account, one row per account, read from the native accountconfig entities that integration extensions ship. It pages every implementor of the accountconfig trait, which is a plain query (GET …/traits/{id}/entities), because implementing a trait is queryable.

The catalog

The catalog lists the extensions shipped in the binary, ready to install. It is a read model over the bundle closures baked into the substrate: each entry carries id, name, group, description, version, configType, the integration facet above, and resources, which previews the types, functions, agents, and triggers the closure installs, so the console can show what an install will add before it runs. GET …/catalog lists every shipped bundle under a catalog key, each flagged installed for this tenant; GET …/catalog/{id} is one entry with its resources, and an unknown id is a 404 not_found.

Installing from the catalog is a thin wrapper over the ordinary apply, never a parallel path: POST …/catalog/{id}/install (owner-only) applies the entry's closure exactly the way ssctl apply -f bundle.yaml -f triggers.yaml does, and it is idempotent, so a second install changes nothing. Uninstall, disable, enable, and purge are the lifecycle verbs above; the catalog does not duplicate them.

The shipped extensions, one by one (what each declares, its functions, its triggers, and whether it is an integration or a capability bundle), are the Extensions catalog.

Next: functions, the callables an extension ships, and the full host SDK.