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:
| Concept | User term | Technical form |
|---|---|---|
| Installable capability package | Extension | a bundle manifest |
| Extension that connects an external provider | Integration | a bundle carrying the integration catalog facet |
| A configured provider account | Connection | an accountconfig-trait entity |
| Future installable with its own UI | App | reserved |
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:
- disable (reversible): the extension's triggers stop delivering (cursors stand still, losing no position) and its functions and agents refuse to run on every path. Its config and account entities freeze; its schema and data otherwise stay untouched, so its types keep appearing in the type list and its other data stays readable and writable. enable reverses it, and the backlog delivers.
- uninstall: the extension goes. The owned group's schema is torn down (the bundle, its types, functions, agents, actors, traits, property types, and mappings) through the same admission an apply uses, and the delivery wiring (every trigger pointing at the group's callables) goes with it in the same transaction. Afterwards the types no longer appear in the type list, the callables no longer run, and a read of one is a 404. Uninstall is governed by the same refuse-with-instances rule a type drop is: while live data entities of the group's types still exist it refuses with a guard error carrying the count. Purge first. Uninstall is not reversible; re-applying the closure is a fresh install of an empty group.
- purge: the explicit destructive verb, refused while the extension is live (disable it first). It tombstones every live data entity of the owned group's types through the ordinary soft-delete path, connected accounts first so their OAuth finalizers run against the still-live config, config last. It never touches schema rows; it clears the data so a following uninstall passes the refuse-with-instances guard. The destructive order is disable, then purge, then uninstall.
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:
- Provider endpoints and scopes are trusted manifest metadata, never config-entity properties. A bundle's
oauth2:block carriesauthorizationEndpoint,tokenEndpoint, an optionalrevocationEndpoint,featureScopes(a map from a declared boolean toggle to the scopes it requests), and an optionalemailEndpoint/emailPropertypair, the profile call the facility reads the account'semailfrom. The facility reads endpoints only from this compiled metadata, so a config-row edit can never point a token exchange at an attacker's server. Endpoints are validated https (http only for a loopback test provider).
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
- The requested scope set is derived per consent from the account's enabled toggles, unioned through
featureScopes. A toggle that maps to no scope requests nothing, which is exactly how a declared-but-unwired feature stays off the wire. The scopes actually granted persist on the account asgrantedScopes.
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.