Status: Accepted — backend/REST/CLI implemented; Studio UI + extra dialect drivers pending (proposed 2026-05-29 · calibrated 2026-06-12)
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0005 (one Zod source of truth per metadata type)
Consumers: @objectstack/spec, @objectstack/runtime, @objectstack/objectql, @objectstack/plugins/driver-sql, @objectstack/services/service-ai, @objectstack/cli, ../objectui
ObjectStack today owns its own database: the default datasource is a
managed Postgres/SQLite where the framework freely runs DDL
(createTable, alterTable, migrations from ISchemaDiffService).
The protocol already includes:
Datasource(packages/spec/src/data/datasource.zod.ts) — connection- driver + capabilities (transactions, queryFilters, readOnly, …).
Object.datasource(packages/spec/src/data/object.zod.ts:432) — per-object routing key, defaulting to'default'.ISchemaDiffService(packages/spec/src/contracts/schema-diff-service.ts) — introspect → diff → migration plan.ExternalLookup(packages/spec/src/data/external-lookup.zod.ts) — per-field REST/OData/GraphQL live lookup with caching, retry, JSONPath extraction. Not a table-level federation primitive.DataSync(packages/spec/src/automation/sync.zod.ts) — batch/incremental replication between systems via connector instances.- AI surface:
IDataEngine-backedquery_datatool,SchemaRetriever,data-chat-agent,metadata-assistant-agent— all consume objects uniformly throughIMetadataService+ ObjectQL.
What is missing for the "plug ObjectStack into a mature warehouse and let the AI chat with it" use case:
- Ownership semantics. Nothing in
Datasourcedistinguishes "ObjectStack owns this schema" from "this is somebody else's production database — never touch DDL." Thecapabilities.readOnlyflag only hints at query pushdown; it does not gate migrations. - Boot-time validation. No mechanism verifies that an externally bound object matches the remote table; declaring a wrong column type silently produces runtime errors at query time.
- Reverse introspection workflow.
IDataEnginedrivers canintrospectSchema(), but there is no CLI / UI / service that turns that into an ObjectStackObjectdraft. - Catalog persistence. Repeated introspection is expensive; we have no metadata type to cache the remote schema snapshot and surface drift.
- Write safety. Writes through external datasources are neither blocked nor explicitly opted into.
- AI prompt awareness.
SchemaRetrieverdoes not tell the LLM that a given object is external/read-only, so the model may attempt schema-mutating tool calls or generate unsafe writes.
This ADR proposes a single, layered solution that closes all six gaps without introducing a new metadata type for federated objects — keeping ADR-0005's "one Zod source per type" invariant intact.
Add External Datasource Federation as a first-class capability of
the existing Datasource and Object protocols, governed by three
runtime gates and supported by one new metadata type (external_catalog)
plus one new service contract (IExternalDatasourceService).
A federated object is still an Object. Its remote-ness is expressed by:
- the datasource it points to (
schemaMode !== 'managed'), and - an optional
object.externalsub-record describing the remote binding (table name, schema, column map, writability).
All downstream consumers (views, ObjectQL, REST, AI tools, permissions,
audit) continue to see a normal Object. Differences in behaviour are
enforced inside the runtime gates and the driver layer.
- Configure mature external databases (Postgres, MySQL, Snowflake, BigQuery, MongoDB, …) through CLI and Studio UI.
- Register existing remote tables as ObjectStack objects without touching the remote schema.
- Fail-fast at registration / boot time when an object definition diverges from the remote table; emit an actionable diff.
- Make all existing capabilities work transparently for federated objects: ObjectQL pushdown, REST/GraphQL auto-routes, Views, Dashboards, Reports, AI Data Chat, AI report agents, RBAC, audit, realtime (where the driver supports it).
- Default to read-only; require two explicit opt-ins (datasource level + object level) to enable writes.
- Coexist with
ExternalLookup(per-field HTTP) andDataSync(batch replication) without overlap or ambiguity.
- Cross-datasource distributed transactions.
- Reverse DDL push (ObjectStack will never alter the remote schema).
- Replacing dedicated ETL — heavy batch ingestion remains the job of
DataSync+ connector plugins. - Multi-statement stored-procedure invocation through ObjectQL.
- Cross-datasource joins (documented as a future workstream; users
should mirror dimension tables via
DataSyncfor now).
┌──────────────────────────────────────────────────────────────────────┐
│ ObjectStack Environment │
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ Datasource │◀────▶│ ExternalCatalog │◀────▶│ Federated │ │
│ │ schemaMode= │ │ (remote schema │ │ Object │ │
│ │ 'external' │ │ snapshot+drift) │ │ external: {…} │ │
│ └─────────────┘ └──────────────────┘ └───────────────┘ │
│ │ │ │
│ │ routed via Object.datasource │ │
│ ▼ ▼ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Mature External DB │ ◀── ObjectQL ──│ AI / Views / REST │ │
│ │ (Postgres, Snowflake │ pushdown │ unchanged consumers │ │
│ │ MySQL, BigQuery…) │ │ │ │
│ └──────────────────────┘ └──────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Three new protocol artefacts:
| Artefact | Kind | Purpose |
|---|---|---|
Datasource.schemaMode |
new field on existing | Declare ownership (managed / external / validate-only). |
Object.external |
new field on existing | Per-object remote binding (table name, schema, writability, column overrides). |
ExternalCatalog |
new metadata type | Cached remote schema snapshot + last validation diff, surfaced in Studio. |
One new service contract: IExternalDatasourceService (introspect,
generate draft, refresh catalog, validate).
Introducing a parallel external_object Zod schema would force every
downstream consumer (views, ObjectQL planner, permissions, AI tools,
REST generator) to special-case two object shapes — violating
ADR-0005's "one Zod source per metadata type" invariant and roughly
doubling the protocol surface.
Instead, externality is a property of the binding, not a new
metadata kind. A federated object is still an object in
DEFAULT_METADATA_TYPE_REGISTRY; the difference is encoded by the
datasource it points to and an optional external sub-record.
All schemas live in packages/spec/src/. Each change is additive and
backward-compatible (defaults preserve current behaviour).
export const SchemaModeSchema = z.enum([
'managed', // ObjectStack owns this schema: DDL + migrations allowed.
'external', // Mature external DB: DDL forbidden; mismatch fails boot.
'validate-only', // Like external, but mismatches warn instead of fail.
]).describe('Schema ownership mode');
export const ExternalDatasourceSettingsSchema = z.object({
label: z.string().optional()
.describe('Display label, e.g. "Snowflake — ANALYTICS / PROD"'),
allowedSchemas: z.array(z.string()).optional()
.describe('Whitelist of remote schemas/databases that may be exposed.'),
allowWrites: z.boolean().default(false)
.describe('Global write gate. Individual objects must also opt in.'),
validation: z.object({
onMismatch: z.enum(['fail', 'warn', 'ignore']).default('fail'),
checkOnBoot: z.boolean().default(true),
checkIntervalMs: z.number().optional()
.describe('Optional background drift-check interval.'),
}).default({}),
credentialsRef: z.string().optional()
.describe('Reference into the secrets store; never inline credentials.'),
queryTimeoutMs: z.number().default(30_000)
.describe('Hard cap on per-query execution time.'),
});
export const DatasourceSchema = lazySchema(() => z.object({
// …existing fields…
schemaMode: SchemaModeSchema.default('managed'),
external: ExternalDatasourceSettingsSchema.optional(),
}).superRefine((ds, ctx) => {
if (ds.schemaMode !== 'managed' && !ds.external) {
ctx.addIssue({
code: 'custom',
path: ['external'],
message: `schemaMode='${ds.schemaMode}' requires 'external' settings.`,
});
}
if (ds.schemaMode === 'managed' && ds.external) {
ctx.addIssue({
code: 'custom',
path: ['external'],
message: `'external' settings only apply when schemaMode != 'managed'.`,
});
}
}));export const ObjectExternalBindingSchema = z.object({
remoteName: z.string().optional()
.describe('Remote table/view name. Defaults to object.name.'),
remoteSchema: z.string().optional()
.describe('Remote schema/database qualifier.'),
writable: z.boolean().default(false)
.describe('Per-object write opt-in (also requires datasource.external.allowWrites).'),
columnMap: z.record(z.string(), z.string()).optional()
.describe('Remote column name → local field name.'),
introspectedAt: z.string().datetime().optional()
.describe('Set by `os datasource introspect`; informational.'),
ignoreColumns: z.array(z.string()).optional()
.describe('Remote columns to skip during validation (dev convenience).'),
});
export const ObjectSchema = lazySchema(() => z.object({
// …existing fields, including:
// datasource: z.string().optional().default('default'),
external: ObjectExternalBindingSchema.optional(),
}));The cross-field invariant ("object.external only when its datasource
has schemaMode !== 'managed'") is enforced at metadata-load time,
not in the Zod schema, because the datasource may live in another
artefact.
export const ExternalColumnSchema = z.object({
name: z.string(),
sqlType: z.string(),
nullable: z.boolean(),
primaryKey: z.boolean().default(false),
suggestedFieldType: z.string().optional(),
});
export const ExternalTableSchema = z.object({
remoteSchema: z.string().optional(),
remoteName: z.string(),
columns: z.array(ExternalColumnSchema),
indexes: z.array(z.object({
name: z.string(),
columns: z.array(z.string()),
unique: z.boolean(),
})).optional(),
rowCountEstimate: z.number().optional(),
});
export const ExternalCatalogSchema = lazySchema(() => z.object({
name: z.string().regex(/^[a-z_][a-z0-9_]*$/)
.describe('Catalog id, conventionally `<datasource>_catalog`.'),
datasource: z.string()
.describe('Datasource.name this catalog snapshots.'),
snapshotAt: z.string().datetime(),
tables: z.array(ExternalTableSchema),
}));Register in DEFAULT_METADATA_TYPE_REGISTRY with:
{ name: 'external_catalog', allowOrgOverride: false, schema: ExternalCatalogSchema }export type SchemaDiffEntryKind =
| 'missing_table' | 'missing_column' | 'type_mismatch'
| 'nullability_mismatch' | 'unmapped_column' | 'pk_mismatch';
export interface SchemaDiffEntry {
kind: SchemaDiffEntryKind;
remoteSchema?: string;
remoteName?: string;
column?: string;
expected?: string;
actual?: string;
severity: 'error' | 'warning';
}
export class ExternalSchemaMismatchError extends Error {
readonly code = 'EXTERNAL_SCHEMA_MISMATCH';
constructor(
readonly datasource: string,
readonly object: string,
readonly diffs: SchemaDiffEntry[],
) { super(renderDiffMessage(datasource, object, diffs)); }
}
export class ExternalWriteForbiddenError extends Error {
readonly code = 'EXTERNAL_WRITE_FORBIDDEN';
}
export class ExternalSchemaModeViolationError extends Error {
readonly code = 'EXTERNAL_SCHEMA_MODE_VIOLATION';
}export interface RemoteTable {
schema?: string;
name: string;
columnCount: number;
rowCountEstimate?: number;
}
export interface GenerateDraftOpts {
remoteSchema?: string;
rename?: Record<string, string>; // remote col → field name
primaryKey?: string[]; // override PK detection
includeColumns?: string[];
excludeColumns?: string[];
}
export interface SchemaValidationResult {
ok: boolean;
datasource: string;
object: string;
diffs: SchemaDiffEntry[];
}
export interface SchemaValidationReport {
ok: boolean;
results: SchemaValidationResult[];
}
export interface IExternalDatasourceService {
listRemoteTables(datasource: string, opts?: { schema?: string }): Promise<RemoteTable[]>;
generateObjectDraft(datasource: string, remoteName: string, opts?: GenerateDraftOpts): Promise<unknown>;
refreshCatalog(datasource: string): Promise<ExternalCatalog>;
validateObject(objectName: string): Promise<SchemaValidationResult>;
validateAll(): Promise<SchemaValidationReport>;
}A pure module packages/spec/src/data/type-compat.ts maps SQL types
(text, varchar, numeric(10,2), timestamptz, jsonb, …) to
ObjectStack field types, and exposes isCompatible(remoteSqlType, fieldType): boolean | 'lossy'. The matrix is dialect-aware
(postgres, mysql, sqlite, snowflake, bigquery, mongo). New
dialects extend the matrix; the matrix is unit-tested independently.
Three gates enforce the ownership semantics. Each gate is a single choke-point so the rules are auditable and cannot be bypassed by accident.
Where: every driver method that mutates schema
(createTable, alterTable, dropTable, plus the
ISchemaDiffService.applyMigrations implementation).
Behaviour:
if (datasource.schemaMode !== 'managed') {
throw new ExternalSchemaModeViolationError(
`DDL is forbidden on datasource '${datasource.name}' (schemaMode=${datasource.schemaMode}).`,
);
}Concretely — packages/plugins/driver-sql/src/sql-driver.ts:1064
and :1084 (current createTable / alterTable call sites) gain a
guard at the top. The applyMigrations implementation (forthcoming
in service-migration per ADR-0008) also calls this guard.
Where: a new external-validation-plugin registered in
@objectstack/runtime, subscribed to kernel:ready.
Behaviour:
const report = await externalDatasourceService.validateAll();
for (const r of report.results.filter(r => !r.ok)) {
const ds = datasources.get(r.datasource)!;
const mode = ds.external?.validation.onMismatch ?? 'fail';
if (mode === 'fail') throw new ExternalSchemaMismatchError(r.datasource, r.object, r.diffs);
if (mode === 'warn') logger.warn({ diffs: r.diffs }, 'external schema drift');
}The validator walks every object whose datasource has
schemaMode !== 'managed', runs IExternalDatasourceService.validateObject,
and aggregates the report. Catalog snapshots are reused across
validations within a boot to avoid duplicate introspection.
Background drift checks: if checkIntervalMs is set, the plugin also
schedules a periodic re-validation that emits external.schema.drift
events on the kernel event bus (consumed by audit and notification
services).
Where: IDataEngine.insert/update/delete (in
packages/objectql/src/engine.ts).
Behaviour:
if (datasource.schemaMode !== 'managed') {
const dsAllows = datasource.external?.allowWrites ?? false;
const objAllows = object.external?.writable ?? false;
if (!(dsAllows && objAllows)) {
throw new ExternalWriteForbiddenError(
`Writes blocked: datasource.external.allowWrites=${dsAllows}, object.external.writable=${objAllows}.`,
);
}
}The double opt-in (datasource and object) is intentional: an administrator enabling writes on the warehouse must still annotate each writable object explicitly.
| Gate | Error | Where surfaced |
|---|---|---|
| DDL | ExternalSchemaModeViolationError |
Migration runner, dev pnpm dev startup, CI os migrate plan. |
| Boot validation | ExternalSchemaMismatchError |
kernel:ready → process exits (mode=fail) or logs (mode=warn). |
| Write | ExternalWriteForbiddenError |
REST 403, ObjectQL mutation, AI tool error, returned to caller. |
All three error classes carry a stable code field and are mapped to
the shared error envelope in packages/spec/src/shared/error-map.ts.
Implements IExternalDatasourceService on top of the existing driver
introspectSchema() contract:
listRemoteTables(ds)→driver.introspectSchema()filtered bydatasource.external.allowedSchemas.generateObjectDraft(ds, table, opts)→ renders a TypeScriptdefineObject({ … })source using the type-compat matrix; the draft carries an// REVIEW:comment on every column whose mapping is'lossy'.refreshCatalog(ds)→ builds anExternalCatalogrecord and writes it throughIMetadataService.upsert('external_catalog', …).validateObject(name)→ loads the latest catalog (or refreshes if stale) and diffs against the object's field list using the type-compat matrix.validateAll()→ fans out across all federated objects, parallelised per datasource.
The service is registered as a kernel service so plugins, the CLI, the runtime plugin, and the REST layer can all consume it through DI.
Added under /api/v1/datasources/:name/external/...:
GET /tables → listRemoteTables
POST /tables/:remote/draft → generateObjectDraft (returns TS source)
POST /refresh-catalog → refreshCatalog
POST /validate → validateAll for this datasource
GET /catalog → latest ExternalCatalog
All routes require the metadata:write permission; reads require
metadata:read. The Studio UI consumes these.
os datasource add --name --driver --schema-mode --url [--credentials-ref]
os datasource list [--mode external]
os datasource list-tables <name> [--schema]
os datasource introspect <name> --table <remote> [--out path] [--rename a=b ...]
os datasource refresh <name>
os datasource validate [<name> | --all]
os datasource remove <name>
os datasource introspect is the canonical onboarding flow: it prints
the generated *.object.ts to stdout (or --out), already populated
with datasource: and external: { remoteSchema, remoteName }.
A new "External Data Sources" surface, served by the routes above:
- Connection wizard — driver picker, credential entry (writes to secrets store, never to git), live "Test connection" button.
- Schema browser — tree of catalog → schema → table → column with row-count estimates; "Import as Object" action per table.
- Mapping editor — side-by-side remote columns vs. object fields
with rename / type-override controls; preview pane shows the
resulting
*.object.ts. - Validation panel — green/yellow/red status per object with the diff list; "Refresh catalog" and "Re-validate" buttons.
- Drift inbox — surfaces
external.schema.driftevents from the background checker.
The UI lives in the sibling ../objectui repo per AGENTS.md; the
backend serves only the data plane.
The decisive design choice — keeping a federated object as a normal
Object — means the entire AI surface works with one line of
prompt-side awareness and zero changes to tools, agents, or
conversation services.
| Capability | Why no changes needed |
|---|---|
query_data AI tool |
Resolves the object via IMetadataService and runs ObjectQL through IDataEngine; the engine routes by object.datasource. |
SchemaRetriever schema injection |
Iterates over all registered objects regardless of datasource. |
data-chat-agent / metadata-assistant-agent |
Consume IDataEngine + IMetadataService only. |
| Reports, dashboards, charts | Defined against objects, not against tables. |
| ObjectQL filter / sort / aggregation pushdown | Driver capabilities already drive pushdown decisions. |
REST /api/v1/data/<object> auto-routes |
Generated from the object registry. |
| RBAC / row-level security | plugin-security applies the same policy graph; predicates are pushed into WHERE. |
SchemaRetriever.renderSnippet() annotates each object with its
ownership and writability so the LLM does not propose unsafe actions:
### wh_order — Warehouse Order [external, read-only, datasource=warehouse]
- order_id: text (pk)
- customer_id: text
- amount: number
- ordered_at: datetime
data-chat-agent and metadata-assistant-agent system prompts gain a
short rule:
Objects marked
[external, read-only]come from a customer's production database. You must not propose schema changes, writes, or destructive operations on them. Always include sensiblelimitand time-range filters when querying them.
To prevent a chatty LLM from melting a production warehouse:
- Default LIMIT injection.
query_datawraps every AI-generated ObjectQL query with alimit: 1000unless the user explicitly asks for more. - Per-datasource timeout.
Datasource.external.queryTimeoutMs(default 30 s) is enforced inside the driver; the AI tool returns a truncated-with-hint result on timeout. - Read replica convention. Documentation strongly recommends pointing the datasource at a read replica; this is policy, not code.
- Audit trail. Every ObjectQL execution against an external
datasource emits an
external.query.executedevent (object, SQL, rows, ms, actor) consumed byaudit.
// datasources/warehouse.datasource.ts
export default defineDatasource({
name: 'warehouse',
driver: 'postgres',
schemaMode: 'external',
config: { connectionString: process.env.WAREHOUSE_URL },
external: {
label: 'Analytics Warehouse (read replica)',
allowedSchemas: ['public', 'mart'],
allowWrites: false,
validation: { onMismatch: 'fail', checkOnBoot: true },
queryTimeoutMs: 15_000,
credentialsRef: 'secret:warehouse/readonly',
},
});os datasource list-tables warehouse --schema mart
# mart.fact_orders (14 cols, ~12.4M rows)
# mart.dim_customer ( 9 cols, ~ 280k rows)
# mart.dim_product (11 cols, ~ 42k rows)
os datasource introspect warehouse --table fact_orders --out objects/wh_order.object.ts// objects/wh_order.object.ts (generated, then human-reviewed)
export default defineObject({
name: 'wh_order',
label: 'Warehouse Order',
datasource: 'warehouse',
external: { remoteSchema: 'mart', remoteName: 'fact_orders' },
fields: [
{ name: 'order_id', type: 'text', primaryKey: true },
{ name: 'customer_id', type: 'text' },
{ name: 'amount', type: 'number' },
{ name: 'ordered_at', type: 'datetime' },
// REVIEW: remote column 'metadata jsonb' mapped lossy to 'json'
{ name: 'metadata', type: 'json' },
],
});os datasource validate warehouse
# ✓ wh_order matches warehouse.mart.fact_orders (5/14 mapped, 9 unmapped — ok)
pnpm dev:crm -- --fresh -p 38421
# [info] external-validation: 1 datasource, 1 object, 0 mismatchesUser in Studio's Data Chat: "哪些客户过去 7 天下单金额最高?前 10。"
The data-chat-agent generates ObjectQL:
{
object: 'wh_order',
select: ['customer_id', { agg: 'sum', field: 'amount', as: 'total' }],
where: { ordered_at: { $gte: '7d ago' } },
groupBy: ['customer_id'],
orderBy: [{ field: 'total', dir: 'desc' }],
limit: 10,
}IDataEngine routes to the warehouse driver, which pushes the entire
query down as a single SELECT … GROUP BY … ORDER BY … LIMIT 10 into
Postgres. The LLM summarises the result; the audit log records the
SQL, the actor, and the 412 ms execution time.
The three "external data" primitives serve different needs and may all appear in the same environment without conflict:
| Need | Use |
|---|---|
| Live federation of remote tables, AI/ad-hoc query | This ADR — Datasource(schemaMode=external) + Object. |
| Per-field live HTTP/OData/GraphQL lookup | ExternalLookup on a field. |
| Batch / incremental replication into a local table | DataSync + connector instance. |
Example: a single environment can federate a Snowflake warehouse for
analytics, use ExternalLookup to enrich CRM records from a SaaS HTTP
API, and DataSync-mirror a slowly-changing dimension into the
managed Postgres for low-latency joins.
- Credentials never appear in metadata artefacts.
credentialsRefpoints atservice-secrets; the secrets service is the only component that resolves the actual connection string. - Connection pools default conservatively for external mode
(
max: 5) to protect the remote system;poolmay be overridden. - Timeouts are enforced at the driver. AI tool calls additionally
wrap a
Promise.race(timeout)and report a graceful error to the LLM. - Audit:
driver-sqlalready emitsquery.executed;service-auditfilters bydatasource.schemaMode === 'external'to produce a dedicated external-access log. - Tenant scope: a
Datasourceis environment-scoped (per ADR-0006 v4). It is not organisation-overlay-customisable; admins of one environment cannot see another environment's external datasources. - Permissions: ACLs apply unchanged. A new optional convenience —
Datasource.external.requirePermission?: string— lets admins gate the entire datasource behind a single role without writing per-object ACLs. - Realtime: where the underlying driver supports CDC (e.g.
Postgres logical decoding), the
realtimeservice may opt-in; otherwise federated objects fall back to poll-based change feeds. This is out of scope for v1.
Each phase is independently shippable and testable.
| Phase | Scope | Estimate |
|---|---|---|
| P1 | Spec changes (schemaMode, object.external, error classes) + DDL gate in driver-sql + tests. |
1–2 d |
| P2 | IExternalDatasourceService implementation + type-compat matrix + CLI introspect / validate. |
2–3 d |
| P3 | Boot validation plugin in @objectstack/runtime + external_catalog metadata type + caching. |
1 d |
| P4 | SchemaRetriever annotation + agent prompt update + AI safety nets (LIMIT injection, timeout). |
0.5 d |
| P5 | Studio UI in ../objectui (wizard, schema browser, mapping editor, validation panel). |
~1 w |
| P6 | Write gate + allowWrites/writable double opt-in + tests. |
1 d |
| P7 | Additional drivers (Snowflake / BigQuery / MySQL adapters) — sized per driver. | 2–4 d ea |
MVP = P1–P4: after MVP, a user can connect a read-only Postgres replica, register a few tables, and let the AI Data Chat query them safely. P5 makes it a product; P6 unlocks writes; P7 expands the driver matrix.
| Risk | Mitigation |
|---|---|
| Remote schema evolves, breaking federated objects | checkIntervalMs background validator; external.schema.drift events into audit + notification. |
| AI generates a runaway query against production | Default limit: 1000, per-datasource queryTimeoutMs, recommend read replica, audit every query. |
| Type mapping is imperfect across dialects | Type-compat matrix is an explicit, unit-tested module; generated drafts carry // REVIEW: markers. |
| Introspection of huge schemas is slow | Snapshot persisted as external_catalog; refresh is explicit (os datasource refresh) or scheduled. |
| User expects cross-datasource joins | Documented limit; recommend DataSync to mirror dimension tables locally; revisit federation in vNext. |
Misconfigured schemaMode='managed' against a real warehouse |
Connection wizard defaults to external for any non-default datasource; UI surfaces a red banner. |
| Plugin author bypasses the DDL gate by calling knex directly | sql-driver wraps knex; direct knex access is a documented private API. Lint rule in P1 enforces it. |
Rejected. Doubles the protocol surface; every downstream consumer
would need to handle two object shapes; violates ADR-0005. The
object.external sub-record gives identical expressiveness with
strictly less surface.
Rejected. ExternalLookup is field-scoped and HTTP-shaped. Table
federation needs driver-level connection pooling, query pushdown,
typed schema, and ObjectQL planner integration — all things
ExternalLookup deliberately omits.
Rejected as the only option. Materialisation defeats the "live
analytics on a mature warehouse" use case and costs storage we do not
own. DataSync remains the right tool for some tables (small
dimensions, offline analysis); federation is the right tool for
others (huge fact tables, real-time freshness). They coexist.
Rejected. Conflates two orthogonal concerns: query-side pushdown
hints (already covered by capabilities.readOnly) and ownership /
DDL gating (introduced here). The enum schemaMode leaves room for a
future 'replica' mode without re-shaping the schema.
Rejected. Late failures surface deep inside the AI tool call, far from the misconfigured artefact, and only when the schema in question is exercised. Boot-time fail-fast matches the rest of the framework's ergonomics (manifest validation, plugin registration, settings validation).
- Cross-environment sharing. Can two environments register the
same physical datasource? (Tentative answer: yes, each environment
has its own
Datasourceartefact; the secrets/credentials may coincidentally point to the same DB. Out of scope to enforce exclusivity.) - Schema-evolution autofix. Should
os datasource validate --autofixrewrite the local*.object.tsto match remote drift? (Tentative: yes, behind explicit flag, for dev only.) - Granular write policy. Beyond per-object
writable: true, do we need per-field write masks? Defer until a user need surfaces. - Realtime / CDC integration. Out of scope for v1; tracked as a follow-up ADR.
Positive
- Customers can point ObjectStack at an existing warehouse in minutes and immediately get AI-powered query, charting, and reporting on it.
- The protocol gains a clear, enforceable boundary between "we own this database" and "we are a guest in this database."
- Zero-touch reuse of every existing AI capability (
query_data, agents, retrievers). - One new metadata type, three new fields, three runtime gates — the blast radius is small.
Negative
- New service (
service-external-datasource) and new CLI surface to maintain. - Per-driver introspection quality varies; some dialects need significant matrix work.
- Studio UI surface grows non-trivially in
../objectui.
Neutral
ExternalLookupandDataSyncare unchanged but gain a sibling; documentation must clarify which to use when.
The ADR is considered "delivered" when:
pnpm testpasses with new unit tests for: Zod refinements onschemaMode, the DDL gate, the boot validator, the write gate, and the type-compat matrix.os datasource introspectproduces a*.object.tsfor a real Postgres schema andos datasource validatereportsok: true.- Mutating any column type in the object file and re-running
pnpm dev:crmfails with anExternalSchemaMismatchErrorprinting the diff. - With a federated object loaded, the data-chat-agent answers a natural-language question and the audit log records the pushed-down SQL, the actor, and the execution time.
- Studio shows the datasource, its catalog, and validation status (P5 milestone).
- ADR-0003 — Package as first-class citizen.
- ADR-0005 — Metadata customization overlay (one Zod source per type).
- ADR-0006 v4 — Project / environment split (environment scoping).
- ADR-0008 — Metadata repository and change log.
packages/spec/src/data/datasource.zod.tspackages/spec/src/data/object.zod.tspackages/spec/src/data/external-lookup.zod.tspackages/spec/src/automation/sync.zod.tspackages/spec/src/contracts/schema-diff-service.tspackages/plugins/driver-sql/src/sql-driver.ts(introspectSchema, createTable, alterTable)packages/services/service-ai/src/tools/query-data.tool.tspackages/services/service-ai/src/schema-retriever.ts